From 2952be00df427f78c7747d1e2a584622f21b1465 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 23 Oct 2026 17:25:24 +0800 Subject: [PATCH 01/39] feat(e2b): support verified template compute profiles --- .github/workflows/e2b-template.yml | 38 +++++- services/core/deploy/e2b/README.md | 6 +- services/core/deploy/e2b/build-template.py | 8 +- .../core/deploy/e2b/build_template_test.py | 110 +++++++++++++++++- 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2b-template.yml b/.github/workflows/e2b-template.yml index 692a11cbc..6537b838a 100644 --- a/.github/workflows/e2b-template.yml +++ b/.github/workflows/e2b-template.yml @@ -21,6 +21,13 @@ on: default: creative required: true + compute_profile: + description: Template CPU and memory allocation + type: choice + options: [2c2g, 2c8g, 4c8g] + default: 2c2g + required: true + resume_build: description: Existing unsubmitted templateID:build_UUID after an upload failure; requires ref type: string @@ -181,6 +188,7 @@ jobs: env: RESUME_BUILD: ${{ inputs.resume_build }} SOURCE_COMMIT: ${{ steps.source.outputs.revision }} + COMPUTE_PROFILE: ${{ inputs.compute_profile }} E2B_API_KEY: ${{ secrets.E2B_API_KEY }} E2B_API_URL: ${{ vars.E2B_API_URL }} run: | @@ -191,9 +199,16 @@ jobs: printf '%s' "$E2B_API_KEY" > "$key" unset E2B_API_KEY mkdir -p "$RUNNER_TEMP/e2b-result" + compute_args=() + case "$COMPUTE_PROFILE" in + 2c2g) ;; + 2c8g|4c8g) compute_args=(--compute-profile "$COMPUTE_PROFILE") ;; + *) echo '::error::Unknown compute profile.'; exit 1 ;; + esac "$RUNNER_TEMP/e2b-sdk/bin/python" - source/services/core/deploy/e2b/build-template.py \ --image "$(cat "$HOME/.oac/build/e2b-runtime/runtime-image.id")" \ --name "sandbase-oac-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \ + "${compute_args[@]}" \ --api-key-file "$key" --output "$RUNNER_TEMP/e2b-result/template.json" <<'PY' import hashlib, json, os, pathlib, runpy, sys from types import SimpleNamespace @@ -252,7 +267,22 @@ jobs: result = original_request(client, **kwargs) receipt = {'template': result.template_id + ':' + result.build_id, 'source_commit': os.environ['SOURCE_COMMIT'], - 'api_url': os.environ['E2B_API_URL'], 'resumed': bool(selector)} + 'api_url': os.environ['E2B_API_URL'], 'resumed': bool(selector), + 'resources_verified': False} + (output / 'build-request.json').write_text(json.dumps(receipt, indent=2) + '\n') + response = client.get_httpx_client().get(f'/templates/{result.template_id}') + response.raise_for_status() + detail = response.json() + builds = [build for build in detail.get('builds', []) + if build.get('buildID') == result.build_id] + if (detail.get('templateID') != result.template_id or len(builds) != 1 + or type(builds[0].get('cpuCount')) is not int + or type(builds[0].get('memoryMB')) is not int + or builds[0]['cpuCount'] != kwargs['cpu_count'] + or builds[0]['memoryMB'] != kwargs['memory_mb']): + raise RuntimeError('Template build CPU/memory does not match the requested compute profile') + receipt.update(cpu_count=builds[0]['cpuCount'], memory_mb=builds[0]['memoryMB'], + resources_verified=True) (output / 'build-request.json').write_text(json.dumps(receipt, indent=2) + '\n') return result sdk.request_build = request @@ -271,12 +301,18 @@ jobs: import json, os, pathlib report = pathlib.Path(os.environ['RUNNER_TEMP']) / 'e2b-result/template.json' value = json.loads(report.read_text()) + receipt = json.loads(report.with_name('build-request.json').read_text()) + if (receipt.get('resources_verified') is not True + or receipt.get('template') != value['template']): + raise RuntimeError('Build report does not match a verified allocation receipt') + value.update(cpu_count=receipt['cpu_count'], memory_mb=receipt['memory_mb']) value.update(source_commit=os.environ['SOURCE_COMMIT'], source_branch=os.environ['SOURCE_BRANCH'], api_url=os.environ['E2B_API_URL'], harnesses=['codex', 'claude_sdk', 'mcode'], qualification='image identity and native package checks; no live Session/Turn') report.write_text(json.dumps(value, indent=2) + '\n') with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out: out.write(f"Source: `{value['source_commit']}`\n\nTemplate: `{value['template']}`\n\nImage: `{value['image']}`\n\n") + out.write(f"Compute: `{value['cpu_count']} vCPU / {value['memory_mb']} MiB`\n\n") out.write('One combined Runtime template built. No Core configuration or production deployment was changed.\n') PY - uses: actions/upload-artifact@v6 diff --git a/services/core/deploy/e2b/README.md b/services/core/deploy/e2b/README.md index 5e2e3c31c..99d05c3be 100644 --- a/services/core/deploy/e2b/README.md +++ b/services/core/deploy/e2b/README.md @@ -21,9 +21,11 @@ python -m venv "$HOME/.oac/build/e2b-sdk" --output "$HOME/.oac/build/e2b-template.json" ``` -[`build-template.py`](build-template.py) requires a `sha256:` image ID, a Linux amd64 image and the Runtime layout (`OAC_RUNTIME_WORKSPACE=/environment/workspace`). It copies the image's `/usr/local/bin`, `/opt` and, when present, `/usr/local/codex-resources` and `/etc/codex`, together with its `HOME` and `OAC_*` environment, onto a digest-pinned `node:22.23.1-bookworm-slim` base with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip`, `ripgrep` and `util-linux`. It installs [`init.py`](init.py) and [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, runs the daemon as UID/GID 1000 (`runtime`, home `/home/runtime`) and builds with 2 vCPUs and 2048 MiB. Only the `usr`, `usr/local` and `etc` archive ancestors it creates get traversable modes; Runtime file modes, private build contexts, key inputs and the output's umask stay unchanged. +[`build-template.py`](build-template.py) requires a `sha256:` image ID, a Linux amd64 image and the Runtime layout (`OAC_RUNTIME_WORKSPACE=/environment/workspace`). It copies the image's `/usr/local/bin`, `/opt` and, when present, `/usr/local/codex-resources` and `/etc/codex`, together with its `HOME` and `OAC_*` environment, onto a digest-pinned `node:22.23.1-bookworm-slim` base with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip`, `ripgrep` and `util-linux`. It installs [`init.py`](init.py) and [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, runs the daemon as UID/GID 1000 (`runtime`, home `/home/runtime`) and defaults to 2 vCPUs and 2048 MiB. Select `--compute-profile 2c8g` for 2 vCPUs and 8192 MiB or `--compute-profile 4c8g` for 4 vCPUs and 8192 MiB; only these profiles and the default `2c2g` are accepted. Only the `usr`, `usr/local` and `etc` archive ancestors it creates get traversable modes; Runtime file modes, private build contexts, key inputs and the output's umask stay unchanged. -The output file records `template` (the immutable `templateID:build_UUID`), the source `image`, the packaged `runtime_sha256` and the `base`. Use that exact `template` value. The template must qualify every Harness its image advertises. Install system dependencies into the image; the daemon runs as UID/GID 1000. No E2B account key, executor key or model credential belongs in a build, template environment, metadata, command argument or log. +The output file records `template` (the immutable `templateID:build_UUID`), the source `image`, the packaged `runtime_sha256`, the `base`, `compute_profile`, `cpu_count` and `memory_mb`. Use that exact `template` value. The template must qualify every Harness its image advertises. Install system dependencies into the image; the daemon runs as UID/GID 1000. No E2B account key, executor key or model credential belongs in a build, template environment, metadata, command argument or log. + +The `e2b-template-build` workflow exposes the same `compute_profile` choices and keeps `2c2g` as its default. The default omits the optional builder flag, allowing an exact historical source revision to retain its default allocation; 8 GiB selections require a source revision supporting `--compute-profile` and never fall back. It verifies the registered build’s CPU and memory before uploading, including when `resume_build` is supplied; missing or mismatched resource metadata stops the build. The build receipt records the verified allocation, and the final report takes its CPU and memory from that receipt after checking the immutable template identity. Building a separate 8 GiB template does not change the deployment’s default template. ## Launch an application-managed Runtime diff --git a/services/core/deploy/e2b/build-template.py b/services/core/deploy/e2b/build-template.py index 7d7be1af8..985c628d1 100644 --- a/services/core/deploy/e2b/build-template.py +++ b/services/core/deploy/e2b/build-template.py @@ -12,12 +12,15 @@ from e2b import Template BASE = 'node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27' +COMPUTE_PROFILES = {'2c2g': (2, 2048), '2c8g': (2, 8192), '4c8g': (4, 8192)} parser = argparse.ArgumentParser() parser.add_argument('--image', required=True, help='Qualified linux/amd64 Runtime image digest') parser.add_argument('--name', required=True) parser.add_argument('--api-key-file', type=Path, required=True) parser.add_argument('--output', type=Path, required=True) +parser.add_argument('--compute-profile', choices=COMPUTE_PROFILES, default='2c2g') args = parser.parse_args() +cpu_count, memory_mb = COMPUTE_PROFILES[args.compute_profile] if not args.image.startswith('sha256:') or not args.output.is_absolute() or not args.api_key_file.is_absolute(): parser.error('Use an image digest and absolute private key/output paths') image = json.loads(subprocess.check_output(['docker', 'image', 'inspect', args.image]))[0] @@ -88,12 +91,13 @@ # Reinstall their system dependencies in E2B using the image's own installer. if creative_tools: template = template.run_cmd('sh /opt/oac-creative/install.sh --system-only', user='root') - result = Template.build(template, name=args.name, cpu_count=2, memory_mb=2048, + result = Template.build(template, name=args.name, cpu_count=cpu_count, memory_mb=memory_mb, on_build_logs=lambda entry: print(entry.message, flush=True), api_key=args.api_key_file.read_text().strip()) report = {'template': result.template_id + ':' + result.build_id, 'image': image['Id'], 'runtime_sha256': hashlib.sha256(bundle.read_bytes()).hexdigest(), 'base': BASE, - 'creative_tools': creative_tools} + 'creative_tools': creative_tools, 'compute_profile': args.compute_profile, + 'cpu_count': cpu_count, 'memory_mb': memory_mb} args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(report, indent=2) + '\n') print(json.dumps(report)) diff --git a/services/core/deploy/e2b/build_template_test.py b/services/core/deploy/e2b/build_template_test.py index 91e387419..5a3849e48 100644 --- a/services/core/deploy/e2b/build_template_test.py +++ b/services/core/deploy/e2b/build_template_test.py @@ -1,4 +1,6 @@ """Exercise real Runtime tar metadata without Docker or provider calls.""" +import argparse +import ast import contextlib import io import itertools @@ -7,9 +9,11 @@ from pathlib import Path import runpy import stat +import subprocess import sys import tarfile import tempfile +import textwrap from types import SimpleNamespace import unittest from unittest.mock import Mock, patch @@ -17,7 +21,8 @@ class BundlePermissionsTest(unittest.TestCase): def test_public_parents_and_runtime_modes_under_both_umasks(self): - for mask, creative in itertools.product((0o022, 0o077), (False, True)): + for mask, creative, profile in itertools.product((0o022, 0o077), (False, True), (None, '2c8g', '4c8g')): + expected_cpu, expected_memory = (4 if profile == '4c8g' else 2), (8192 if profile else 2048) with self.subTest(umask=oct(mask), creative=creative), tempfile.TemporaryDirectory() as temporary: root = Path(temporary) key = root / 'key' @@ -65,6 +70,7 @@ def run(argv, **kwargs): def build(instance, **kwargs): self.assertIs(instance, template) + self.assertEqual((kwargs['cpu_count'], kwargs['memory_mb']), (expected_cpu, expected_memory)) context = Path(factory.call_args.kwargs['file_context_path']) self.assertEqual(stat.S_IMODE(context.stat().st_mode), 0o700) with tarfile.open(context / 'runtime.tar.gz') as archive: @@ -93,7 +99,8 @@ def build(instance, **kwargs): with patch.dict(sys.modules, {'e2b': SimpleNamespace(Template=factory)}), \ patch.dict(os.environ, {'OAC_DEV_HOME': str(root / 'state')}), \ patch.object(sys, 'argv', ['build-template.py', '--image', 'sha256:fixture', - '--name', 'fixture', '--api-key-file', str(key), '--output', str(output)]), \ + '--name', 'fixture', '--api-key-file', str(key), '--output', str(output)] + + (['--compute-profile', profile] if profile else [])), \ patch('subprocess.check_output', side_effect=check_output), \ patch('subprocess.run', side_effect=run), contextlib.redirect_stdout(io.StringIO()): runpy.run_path(str(Path(__file__).with_name('build-template.py')), run_name='__main__') @@ -103,9 +110,108 @@ def build(instance, **kwargs): self.assertEqual(key.read_text(), 'fixture-only') self.assertEqual(json.loads(output.read_text())['creative_tools'], creative) factory.build.assert_called_once() + report = json.loads(output.read_text()) + self.assertEqual((report['cpu_count'], report['memory_mb']), (expected_cpu, expected_memory)) + self.assertEqual(report['compute_profile'], profile or '2c2g') finally: os.umask(original_mask) +class ComputeProfileTest(unittest.TestCase): + def test_invalid_profile_fails_before_docker_or_provider(self): + for profile in ('', '4c2g', '8c8g', '4c8g;command'): + factory = Mock() + with self.subTest(profile=profile), patch.dict(sys.modules, {'e2b': SimpleNamespace(Template=factory)}), \ + patch.object(sys, 'argv', ['build-template.py', '--image', 'sha256:fixture', + '--name', 'fixture', '--api-key-file', '/private/key', '--output', '/private/result', + '--compute-profile', profile]), patch('subprocess.check_output') as docker, \ + contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error: + runpy.run_path(str(Path(__file__).with_name('build-template.py')), run_name='__main__') + self.assertEqual(error.exception.code, 2) + docker.assert_not_called() + factory.assert_not_called() + + def test_workflow_checks_fresh_and_resumed_build_resources(self): + workflow = Path(__file__).resolve().parents[4] / '.github/workflows/e2b-template.yml' + source = workflow.read_text() + start = source.index(' def request(client, **kwargs):') + end = source.index(' sdk.request_build = request', start) + function = compile(ast.parse(textwrap.dedent(source[start:end])), str(workflow), 'exec') + cases = ( + ('valid', [{'buildID': 'build', 'cpuCount': 4, 'memoryMB': 8192}]), + ('wrong-resource', [{'buildID': 'build', 'cpuCount': 2, 'memoryMB': 2048}]), + ('missing-memory', [{'buildID': 'build', 'cpuCount': 4}]), + ('non-integer', [{'buildID': 'build', 'cpuCount': True, 'memoryMB': 8192}]), + ('missing-build', []), + ('different-build', [{'buildID': 'other', 'cpuCount': 4, 'memoryMB': 8192}]), + ('duplicate', [{'buildID': 'build', 'cpuCount': 4, 'memoryMB': 8192}] * 2), + ('default', [{'buildID': 'build', 'cpuCount': 2, 'memoryMB': 2048}]), + ) + for resumed, (name, builds) in itertools.product((False, True), cases): + with self.subTest(resumed=resumed, case=name), tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) + client = Mock() + status = {'templateID': 'template', 'buildID': 'build', 'status': 'waiting', 'logs': []} + detail = {'templateID': 'template', 'builds': builds} + responses = [Mock(json=Mock(return_value=detail))] + if resumed: + responses.insert(0, Mock(json=Mock(return_value=status))) + client.get_httpx_client.return_value.get.side_effect = responses + original = Mock(return_value=SimpleNamespace(template_id='template', build_id='build')) + namespace = dict(os=os, json=json, SimpleNamespace=SimpleNamespace, + original_request=original, output=output) + exec(function, namespace) + resources = (2, 2048) if name == 'default' else (4, 8192) + with patch.dict(os.environ, {'RESUME_BUILD': 'template:build' if resumed else '', + 'SOURCE_COMMIT': 'fixture', 'E2B_API_URL': 'https://fixture.invalid'}): + if name in ('valid', 'default'): + namespace['request'](client, cpu_count=resources[0], memory_mb=resources[1]) + receipt = json.loads((output / 'build-request.json').read_text()) + self.assertEqual((receipt['cpu_count'], receipt['memory_mb']), resources) + else: + with self.assertRaisesRegex(RuntimeError, 'CPU/memory'): + namespace['request'](client, cpu_count=4, memory_mb=8192) + self.assertFalse(json.loads((output / 'build-request.json').read_text())['resources_verified']) + self.assertEqual(original.call_count, 0 if resumed else 1) + + def test_default_omits_optional_flag_for_historical_source_parser(self): + workflow = Path(__file__).resolve().parents[4] / '.github/workflows/e2b-template.yml' + source = workflow.read_text() + start = source.index(' compute_args=()') + end = source.index(' esac', start) + len(' esac') + script = textwrap.dedent(source[start:end]) + '\nif (( ${#compute_args[@]} )); then printf "%s\\0" "${compute_args[@]}"; fi' + for profile in ('2c2g', '2c8g', '4c8g'): + result = subprocess.run(['bash', '-c', script], env={**os.environ, 'COMPUTE_PROFILE': profile}, + capture_output=True, check=True) + args = result.stdout.decode().split('\0')[:-1] + old_parser = argparse.ArgumentParser() + if profile == '2c2g': + self.assertEqual(vars(old_parser.parse_args(args)), {}) + else: + self.assertEqual(args, ['--compute-profile', profile]) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + old_parser.parse_args(args) + + def test_report_uses_verified_receipt_for_old_builder_and_rejects_mismatch(self): + workflow = Path(__file__).resolve().parents[4] / '.github/workflows/e2b-template.yml' + source = workflow.read_text() + start = source.index(" value = json.loads(report.read_text())") + end = source.index(' value.update(source_commit=', start) + code = compile(textwrap.dedent(source[start:end]), str(workflow), 'exec') + for verified, template in itertools.product((True, False), ('template:build', 'template:other')): + with self.subTest(verified=verified, template=template), tempfile.TemporaryDirectory() as temporary: + report = Path(temporary) / 'template.json' + report.write_text(json.dumps({'template': 'template:build', 'image': 'fixture'})) + report.with_name('build-request.json').write_text(json.dumps({ + 'template': template, 'resources_verified': verified, 'cpu_count': 2, 'memory_mb': 2048})) + namespace = {'json': json, 'report': report} + if verified and template == 'template:build': + exec(code, namespace) + self.assertEqual((namespace['value']['cpu_count'], namespace['value']['memory_mb']), (2, 2048)) + else: + with self.assertRaisesRegex(RuntimeError, 'verified allocation receipt'): + exec(code, namespace) + + if __name__ == '__main__': unittest.main() From b54754823f3494492303830ce9700932707f39a9 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 02:25:03 +0800 Subject: [PATCH 02/39] Remove obsolete and duplicate documentation (#830) Delete text that no code supports (OAC_E2B_STATE_DIR, scheduled-run and older-product-name notes, the Core metrics 404 path, README.ja.md), replace restated facts with links to their owners, correct the compatibility ledger (Files default, Vault archive, text.verbosity, Codex service_tier fast, programmatic tool calling with multi_agent), and drop filler and process wording. Chinese mirrors and source hashes are updated with each change. --- README.ja.md | 87 ------------------- README.md | 2 +- README.zh-CN.md | 2 +- apps/web/DESIGN.md | 18 ++-- apps/web/PRODUCT.md | 14 +-- contracts/agents-api/admin-api.md | 6 +- contracts/agents-api/core-metrics.md | 2 +- .../environment-executor-credentials.md | 2 +- contracts/agents-api/environment-files.md | 4 +- contracts/agents-api/environments.md | 7 +- contracts/agents-api/harness-capabilities.md | 4 +- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/index.md | 12 +-- contracts/agents-api/sandbox-deployment.md | 4 +- contracts/agents-api/subagents.md | 2 +- contracts/agents-api/wire-semantics.md | 2 +- contracts/agents-api/zh/admin-api.md | 4 +- contracts/agents-api/zh/core-metrics.md | 4 +- .../zh/environment-executor-credentials.md | 2 +- contracts/agents-api/zh/environment-files.md | 2 +- contracts/agents-api/zh/environments.md | 9 +- .../agents-api/zh/harness-capabilities.md | 6 +- contracts/agents-api/zh/harness-onboarding.md | 2 +- contracts/agents-api/zh/index.md | 10 +-- contracts/agents-api/zh/sandbox-deployment.md | 6 +- contracts/agents-api/zh/subagents.md | 2 +- contracts/agents-api/zh/wire-semantics.md | 4 +- deploy/README.md | 10 +-- deploy/node/README.md | 2 +- docs/concepts.md | 2 +- docs/configuration.md | 4 +- docs/getting-started/install.md | 2 +- docs/getting-started/nodes.md | 2 +- docs/getting-started/operations.md | 7 +- docs/getting-started/public-address.md | 2 +- docs/maintainers.md | 11 ++- docs/sandbox-provider.md | 2 +- docs/web/console-api-usage.md | 4 +- docs/web/console-server.md | 2 +- docs/web/index.md | 4 +- docs/zh/concepts.md | 4 +- docs/zh/configuration.md | 4 +- docs/zh/getting-started/install.md | 4 +- docs/zh/getting-started/nodes.md | 4 +- docs/zh/getting-started/operations.md | 9 +- docs/zh/getting-started/public-address.md | 4 +- docs/zh/maintainers.md | 7 +- docs/zh/sandbox-provider.md | 2 +- docs/zh/web/console-api-usage.md | 4 +- docs/zh/web/console-server.md | 4 +- docs/zh/web/index.md | 6 +- example/parsar/README.md | 4 +- packages/claude-sdk-adapter/README.md | 6 +- services/core/deploy/claude/README.md | 2 +- services/core/deploy/codex/README.md | 4 +- services/core/deploy/mcode/README.md | 2 +- services/core/tools/e2b-provider/README.md | 2 +- .../tools/microsandbox-provider/README.md | 2 +- website/README.md | 6 +- 59 files changed, 130 insertions(+), 225 deletions(-) delete mode 100644 README.ja.md diff --git a/README.ja.md b/README.ja.md deleted file mode 100644 index e12d93c55..000000000 --- a/README.ja.md +++ /dev/null @@ -1,87 +0,0 @@ -
- - - - OpenAgentCore - - -オープンソースでセルフホストできる OpenAI Agents API。公式 OpenAI SDK から、自分のマシンと自分のモデルで Codex、Claude Code、MiniMax Code を実行できます。 - -[![Release](https://img.shields.io/github/v/release/MiniMax-AI/OpenAgentCore)](https://github.com/MiniMax-AI/OpenAgentCore/releases/latest) [![License](https://img.shields.io/github/license/MiniMax-AI/OpenAgentCore)](LICENSE) - -[公式サイト](https://openagentcore.dev/) · [ドキュメント](https://openagentcore.dev/docs/getting-started/) · [クイックスタート](#クイックスタート) · [コントリビューション](CONTRIBUTING.md) - -[English](README.md) · [简体中文](README.zh-CN.md) · **日本語** - -
- -- **OpenAI と同じ API。** `OPENAI_BASE_URL` を設定すれば、[公式 OpenAI SDK](https://developers.openai.com/api/docs/libraries)または直接の HTTP リクエストがそのまま動作します。 -- **自分のエージェント、自分のモデル。** 各 Session は Codex、Claude Code、MiniMax Code のいずれかを手元のマシンと同じようにネイティブに実行し、設定したモデルプロバイダーを使用します。詳しくは [Harness の機能](https://openagentcore.dev/contracts/agents-api/harness-capabilities)をご覧ください。 -- **計算資源は休止し、ワークスペースは残る。** microsandbox と独立したワークスペースストレージの組み合わせでは、アイドル状態のサンドボックスが計算資源を解放し、互換性のあるノードでファイルをそのまま再開します。E2B、Docker、自分の Linux、macOS、Windows マシンでも Session を実行できます。詳しくは[Sandbox Provider protocol](https://openagentcore.dev/docs/sandbox-provider)をご覧ください。 -- **置き換え可能なコンポーネント。** サンドボックスプロバイダー、ワークスペースファイルシステム、Harness、モデルプロバイダーは、[定義されたプロトコル](AGENTS.md#protocols-at-every-boundary)を通じて接続されます。 - -![デプロイの概要](docs/assets/console-overview-en.webp) - -## クイックスタート - -[前提条件](https://openagentcore.dev/docs/getting-started/install#prerequisites)に従って Docker をセットアップした Linux または macOS で、Core、Web、PostgreSQL をインストールします。 - -```sh -curl -fsSL https://openagentcore.dev/install.sh | bash -``` - -Windows では PowerShell で実行します。 - -```powershell -irm https://github.com/MiniMax-AI/OpenAgentCore/releases/latest/download/install.ps1 | iex -``` - -Web で、インストーラーが表示する Core key でサインインし、パブリックアドレスを設定し、デフォルトモデルを設定し、Project API key を発行して、サンドボックスの容量(ノードまたは E2B)を追加します。各手順は[Core と Web のインストール](https://openagentcore.dev/docs/getting-started/install)をご覧ください。 - -続いて OpenAI SDK から呼び出します。`OPENAI_BASE_URL` に API のベース URL(`https://core.example/v1`)、`OPENAI_API_KEY` に Project API key を設定してください。 - -```python -from openai import OpenAI - -client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY -session = client.beta.agents.sessions.create( - environment={"type": "openai_hosted"}, - input="Create /workspace/hello.txt with a short greeting, then describe it.", - extra_body={"agent": {"x_agents_core": {"harness": "codex"}}}, -) -print(session.id) -``` - -[最初の Session を実行](https://openagentcore.dev/docs/getting-started/quickstart)では、結果を待って読み戻します。 - -## 仕組み - - - - OpenAgentCore のアーキテクチャ - - -アプリケーションと管理者は、次の Core API を使用します。 - -| API | パス | 利用者 | -| --- | --- | --- | -| **[Agents API](https://openagentcore.dev/docs/api/public-agent-api)** | `/v1` | アプリケーション。[OpenAI の Agents API](https://developers.openai.com/api/docs/guides/agents-api/overview) と同じプロトコルを使用 | -| **[Core API](https://openagentcore.dev/contracts/agents-api/admin-api)** | `/core/v1` | 管理者。Web 経由で利用 | - -Core は実行状態を永続化し、Runtime は Environment 内で選択した Harness を実行します。各コンポーネントは定義されたプロトコルで接続されているため、個別に置き換えられます。詳しくは[アーキテクチャ](https://openagentcore.dev/docs/architecture)をご覧ください。 - -## ドキュメント - -| 目的 | 参照先 | -| --- | --- | -| インストールと運用 | [Core と Web のインストール](https://openagentcore.dev/docs/getting-started/install)、続いて[運用](https://openagentcore.dev/docs/getting-started/operations) | -| API を使ったアプリケーション開発 | [最初の Session を実行](https://openagentcore.dev/docs/getting-started/quickstart)、続いて [Agents API ガイド](https://openagentcore.dev/docs/api/public-agent-api) | -| 完成したアプリケーションの確認 | [サンプル](https://openagentcore.dev/docs/examples) | -| 自分のマシンでエージェントを実行 | [セルフホスト実行](https://openagentcore.dev/docs/getting-started/self-hosted) | -| Harness の機能と制限の確認 | [Harness の機能](https://openagentcore.dev/contracts/agents-api/harness-capabilities) | -| 設計の理解 | [アーキテクチャ](https://openagentcore.dev/docs/architecture) | -| 新しいサンドボックス、Harness などのコンポーネントの追加 | [OpenAgentCore の開発](https://openagentcore.dev/docs/development) | - -## コントリビューションとライセンス - -コードを変更する前に [CONTRIBUTING.md](CONTRIBUTING.md) をお読みください。バグ報告と質問は [GitHub Issues](https://github.com/MiniMax-AI/OpenAgentCore/issues) へお寄せください。[MIT License](LICENSE) で公開しています。 diff --git a/README.md b/README.md index 83ef3704b..070d59327 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The OpenAI Agents API, open source and self-hosted. Run Codex, Claude Code or Mi [Website](https://openagentcore.dev/) · [Documentation](https://openagentcore.dev/docs/getting-started/) · [Quickstart](#quickstart) · [Contributing](CONTRIBUTING.md) -**English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) +**English** · [简体中文](README.zh-CN.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 535f2252e..e4cb85dbd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,7 @@ [官网](https://openagentcore.dev/zh/) · [文档](https://openagentcore.dev/zh/docs/getting-started/) · [快速开始](#快速开始) · [参与贡献](CONTRIBUTING.md) -[English](README.md) · **简体中文** · [日本語](README.ja.md) +[English](README.md) · **简体中文** diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 4ce53087f..f288d1905 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -227,7 +227,7 @@ The data contract is part of the look. Core reports only what it observes, so th - One list grammar on every resource page: project filter, search, count, name with compact ID, creator, row actions. - Tabular numerals everywhere a number can line up. - Status is always a dot plus a plain-language label. -- Task instructions and empty-state next steps stay visible. Supplementary definitions belong in help tips. Errors, warnings and safety notices stay visible. +- Task instructions and empty-state next steps stay visible. Errors, warnings and safety notices stay visible. ## Colors @@ -419,14 +419,14 @@ A single rollout row opens a details dialog for Core's target generation, previo A generation-only update within the same installation/backend lifecycle retains compatible previous node/allocation evidence while refreshing. Failed or pending reads visibly qualify those observations; never replace them with fabricated zeros. Reset, backend and installation lifecycle changes discard incompatible data. The shared deployment query and write ownership below govern navigation, late reads, explicit retries and login isolation. ### Sandbox reset -Sandbox configuration offers an explicit reset through its panels and confirmation dialogs. The confirmation keeps to one concise consequence paragraph, two mode choices, the auto deadline and footer actions. Put cleanup sequencing and preservation details in help tips. Auto clear is selected first, with a one-hour deadline editable from 5 minutes to 24 hours; Force clear and escalation require destructive confirmation. State directly that hosted work is archived, remaining active work may be cancelled, archived Sessions cannot resume and unpersisted workspace contents may be lost. Details about preserved histories, Files/Artifacts and unaffected self-hosted execution live behind the reset impact help tip. Cancelling an active reset stops further clearing but cannot undo completed archives. +Sandbox configuration offers an explicit reset ([semantics](../../contracts/agents-api/sandbox-deployment.md#reset)) through its panels and confirmation dialogs. The confirmation keeps to one concise consequence paragraph, two mode choices, the auto deadline and footer actions. Put cleanup sequencing and preservation details in help tips. Auto clear is selected first, with a one-hour deadline editable from 5 minutes to 24 hours; Force clear and escalation require destructive confirmation. State directly that hosted work is archived, remaining active work may be cancelled, archived Sessions cannot resume and unpersisted workspace contents may be lost. Further consequences live behind the reset impact help tip. A persistent progress panel uses Core's busy, idle and cleanup counts, deadline and named offline-node blockers. Bring blocked nodes online for confirmed cleanup; never offer a browser-side force-release shortcut. Poll the deployment every five seconds only while its reset is non-null. A passed deadline does not establish force or completion; only a Core response does. Completion opens the setup flow, with a new explicit save using the generation read from Core, including zero on a fresh install. Reset and online configuration rollout have independent authoritative progress; neither automatically replays configuration. Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; recovery uses the separate read-and-review dialog. Observation retries preserve applicable non-secret configuration drafts. A changed installation, owner epoch, backend, mode or generation discards the prior draft and confirmation. Logout clears this connection-scoped state. ### Settings page -Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes and metrics open the owning tab, and the Sandbox and scheduling header links back to Nodes. Forms save independently through their resource APIs, never through one cross-resource Save all action. +Settings is the single platform configuration entry, with one tab per [group](../../docs/configuration.md#runtime-settings-web). Each tab has a stable address; links from Nodes and metrics open the owning tab, and the Sandbox and scheduling header links back to Nodes. Forms save independently through their resource APIs, never through one cross-resource Save all action. The hierarchy is page, tab, section, row. The page header loses its rule to an underline tab row: 40px text tabs in Graphite 13px/500, the current one Ink with a 2px Ink underline. Below 640px the tabs wrap onto a second row rather than scroll out of sight. The body is a left-aligned column at most 880px wide with 32px between sections. A section is the shared Section heading (14px/600, help tip, section-wide outline actions on the right) over one card of label/value rows (`settings.css`): a 200px Graphite label column with its help tip and an Ink value column, 8px by 16px padding, 32px minimum line height, Faint Rules between rows; below 640px the label sits above the value. An action that changes one value sits at the end of its row (Edit tenant capacity; Save beside the idle-seconds field, whose error appears beneath it). Read-only status is a row too (Configuration rollout, Snapshot retention); tables sit in one framed card. Settings never repeats the KPI strips of Nodes or the metrics pages. The irreversible operation closes the page in its own section: a red-ringed card with one Ink line stating the consequence and a red-text outline button. @@ -434,12 +434,12 @@ Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Workspace storage shows the selected configuration's adapter and tenant capacity (in bytes units, or No tenant capacity limit) under Storage configuration, with Select new configuration in the section header and Edit tenant capacity on the capacity row. The configuration ID and parameters JSON sit in a closed Configuration details disclosure at the end of the card. Select new configuration accepts an adapter identifier, configuration ID, parameters JSON object and optional write-only credential JSON object. Edit tenant capacity keeps the current configuration identity. Explain adapter preparation and existing-workspace behavior behind help tips, with a link to the owning configuration document; keep required form labels and validation visible. Never prefill credentials or persist their drafts across navigation. A failed read is not an unconfigured filesystem, and an uncertain write is not a safe automatic retry. -Models and credentials stacks one default-model card per Harness. Its header carries the Harness name, its readiness (Ready in green; Model provider not set in amber, with Set as the primary button; Disabled in neutral, with a View startup settings text action), a Default pill, a help tip and the Set, Replace and Clear actions; rows hold model ID, provider protocol, base URL, key presence, token limits when set and update time, and a foot holds Usage details. Set and Replace open one form dialog. The adapter declaration supplies supported protocols and native JSON support without harness-specific branches. Native settings use an optional JSON object editor with formatting and inline syntax errors. Changing model identity clears native JSON; replacing only the required write-only API key does not. Keys are never prefilled and are forgotten when the form closes. Core rejections remain visible in the form; Clear uses ConfirmDialog. Usage details opens Core's observations separately. +Models and credentials stacks one default-model card per Harness. Its header carries the Harness name, its [readiness](../../docs/configuration.md#runtime-settings-web) (Ready in green; Model provider not set in amber, with Set as the primary button; Disabled in neutral, with a View startup settings text action), a Default pill, a help tip and the Set, Replace and Clear actions; rows hold model ID, provider protocol, base URL, key presence, token limits when set and update time, and a foot holds Usage details. Set and Replace open one form dialog. The adapter declaration supplies supported protocols and native JSON support without harness-specific branches. Native settings use an optional JSON object editor with formatting and inline syntax errors. Changing model identity clears native JSON; replacing only the required write-only API key does not. Keys are never prefilled and are forgotten when the form closes. Core rejections remain visible in the form; Clear uses ConfirmDialog. Usage details opens Core's observations separately. -Service and deployment shows installation addresses, an Installation details disclosure, closed by default, with the installation ID and source commit, existing Domain and HTTPS actions, and the startup settings Core loaded. Process settings remain read-only here; their source stays the installation configuration. Each row's label is the setting's environment variable in code style (OAC_LOG_LEVEL), as Core reports it, wrapping after an underscore so a phone keeps all three columns; values show as is, with arrays joined, On or Off for booleans and None for an empty list. How to change a setting in the section header links to the configuration guide. Sensitive settings show only Configured or Not set. Default uses a neutral pill. +Service and deployment shows installation addresses, an Installation details disclosure, closed by default, with the installation ID and source commit, Domain and HTTPS actions, and the startup settings Core loaded. Process settings remain read-only here; their source stays the installation configuration. Each row's label is the setting's environment variable in code style (OAC_LOG_LEVEL), as Core reports it, wrapping after an underscore so a phone keeps all three columns; values show as is, with arrays joined, On or Off for booleans and None for an empty list. How to change a setting in the section header links to the configuration guide. Sensitive settings show only Configured or Not set. Default uses a neutral pill. ### One place for each task -Each configuration has exactly one editor in Settings; other pages link to it. Resource operations such as Project keys, Session executor credentials and node removal stay on their resource pages. Keep the configuration page flat: resource editors are dialogs and rollout is one status row with a details action. Explanations belong in question-mark help tips, not small-print helper paragraphs. Errors, uncertain outcomes and current usage exceeding a new limit remain visible without duplicating the workflow. +Each configuration has exactly one editor in Settings; other pages link to it. Resource operations such as Project keys, Session executor credentials and node removal stay on their resource pages. Keep the configuration page flat: resource editors are dialogs and rollout is one status row with a details action. Explanations go in help tips, not small-print helper paragraphs. Errors, uncertain outcomes and current usage exceeding a new limit remain visible without duplicating the workflow. ### Diagnostic observations Failure reasons belong beside the failed Session or Turn status. On the Session page the Status fact stays short, and the reason and its next step read as one block under the facts, at reading width. Their first read uses a skeleton; an unavailable reason names that state and puts the read retry beside its help tip. Technical classifications are translated through one catalogue, not shown as raw error codes. Trace Timing labels Core receipt times separately from tool-reported duration; explanations of batched delivery, clock differences and historical gaps live in help tips. @@ -454,7 +454,7 @@ Motion reports state and never makes anyone wait: the navigation chip and segmen ## Do's and Don'ts ### Do: -- **Do** keep next steps visible and put supplementary definitions behind a circled "?" help tip; report errors in a dialog or a toast; keep warnings and safety notices (deletion consequences, a key shown once) visible. +- **Do** keep next steps visible; report errors in a dialog or a toast; keep warnings and safety notices (deletion consequences, a key shown once) visible. - **Do** start every project-scoped toolbar with the project filter, then search, with the count on the right. - **Do** end every resource table with the Creator column and then the row actions. - **Do** confirm every deletion in ConfirmDialog. @@ -482,6 +482,6 @@ Motion reports state and never makes anyone wait: the navigation chip and segmen Use Lucide throughout. Navigation icons are 18px with a 1.75px stroke; shared tool icons use the same stroke at their existing compact sizes. Default navigation icons use secondary ink, selected icons use primary ink. The selected row has a neutral background and a narrow indigo marker. -Getting started emphasizes the first confirmed incomplete step with the primary button while leaving every step independently available. Only authoritative, successful zero-Session reads replace the activity chart with first-use guidance. Missing and failed reads retain their existing uncertainty and recovery controls. +Getting started emphasizes the first confirmed incomplete step with the primary button while leaving every step independently available. Only authoritative, successful zero-Session reads replace the activity chart with first-use guidance. Missing and failed reads retain their uncertainty and recovery controls. -Configuration controls show concise labels, values and actions. Put necessary explanatory policy text behind a `HelpTip` question mark; do not add persistent small-print helper text or note paragraphs. Validation and request failures remain visible beside the affected action or field. +Configuration controls show concise labels, values and actions. Validation and request failures remain visible beside the affected action or field. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index e7320e85a..98a0c9709 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -31,22 +31,22 @@ The console runs beside the administrator's own Core, with execution, files and - **Monitor**: Overview (service status, running Sessions, sandbox slots, the Needs attention count for the last 24 hours, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, where Core opens Core metrics, a node opens its page on Nodes and E2B opens Sandbox metrics, the Needs attention table, whose rows name each Session, its project, its one-line reason and last activity and open it, with older ones left to the Session log, usage by project), Core metrics (whether Core can run Turns: execution slots, the Turn queue, its p95 wait and interrupted Turns, connected daemons, database latency, the Core process's CPU and memory, and background jobs; Go heap, goroutines and the database pool sit in a collapsed Diagnostics area), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (hosted Runtimes across projects over time, active against total; a sandbox opens in a dialog with its figures and CPU and memory charts; an E2B deployment adds what Core holds in E2B's cloud), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. An empty list says, in one sentence, that applications create these resources through the Agents API, and links to that resource's section of the API guide; Agent metrics does the same for Sessions. -- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes offers Recommended or Custom limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each Harness's default model and shows whether each Harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. +- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes offers Recommended or Custom limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each Harness's default model and shows its [readiness](../../docs/configuration.md#runtime-settings-web), with Set as the primary action when the model provider is not set and a link to the startup settings when it is disabled. Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. - A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, a host too small) and its fix in the help tip beside its status, wherever that status shows. - A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes list and its page its status is Old address, with "Remove and add again", never Available. -- **Sandbox reset** is an explicit administrator operation in Settings → Sandbox and scheduling. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment credentials. A new configuration is then a separate deliberate save. +- **Sandbox reset** is an explicit administrator operation in Settings → Sandbox and scheduling. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Its semantics are defined in [Reset](../../contracts/agents-api/sandbox-deployment.md#reset); Web shows only Core's progress and completion, and a new configuration is a separate deliberate save. - **Online sandbox configuration** changes the same backend's resources or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. - **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use. -- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. Necessary policy explanations belong in help tips, not persistent helper paragraphs. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. +- **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. - **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment whose template build is not reported as not ready), a default model provider on the default Harness (on any enabled Harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. - Terminology: API terms stay in English in the Chinese UI and docs (Agent, Session, Turn, Item, Runtime, Environment, Skill, Vault, Credential, Harness); providers are 提供商 (沙箱提供商, 模型提供商). The sign-in credential is the Core key ("Core 密钥"); keys issued in a project for applications are project API keys ("项目 API 密钥"). Empty lists read "No … yet" ("还没有…"). Sandbox provider readiness is "Provider not ready / 沙箱提供商未就绪"; revoked credentials and keys use "Revoked / 已撤销". A default model provider is a service address and write-only key; the application's Agent `model` chooses the provider-supported model name. A sandbox is Core-managed compute; Runtime names Core's execution observations, and Environment is the Session's API execution environment. These are distinct counts and resources, not synonyms. ## Capabilities and Constraints - **Projects and keys.** A project owns an isolated set of assets shared by all of its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. The list shows active projects by default; the Archived filter, with its count, shows the rest. Archiving a project revokes every key and keeps its assets viewable and deletable; it can't be undone, so its confirmation says so, counts the active keys it revokes and, when there are any, asks for the project's name. Key plaintext is shown once, at issuance, and never stored by the console. Beside it, and without the key on an active project's page, the console tells developers to set `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (a key of the project), with curl and Python samples that list Agents and create a Session. -- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures. Each fact has one page: Nodes owns the node list, per-node capacity and host figures; Overview summarizes capacity and links to Nodes; Sandbox metrics owns Runtime time series and activity; Core metrics owns the Core process. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. -- **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the existing storage API. It shows the adapter and tenant capacity; the configuration ID and adapter parameters stay under Configuration details. Adapter parameters and optional write-only credentials are JSON objects validated by Core, with no vendor-specific Web fields or capability discovery by name. Selecting a new configuration does not migrate existing workspaces; changing tenant capacity does not resize them. Credentials are never prefilled, stored in browser persistence or included in error output. Uncertain writes require an authoritative read before another save. +- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures. Each fact has one page: Nodes owns the node list, per-node capacity and host figures; Overview summarizes capacity and links to Nodes; Sandbox metrics owns Runtime time series and activity; Core metrics owns the Core process. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs. +- **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the storage API. It shows the adapter and tenant capacity; the configuration ID and adapter parameters stay under Configuration details. Adapter parameters and optional write-only credentials are JSON objects validated by Core, with no vendor-specific Web fields or capability discovery by name. Selecting a new configuration does not migrate existing workspaces; changing tenant capacity does not resize them. Credentials are never prefilled, stored in browser persistence or included in error output. Uncertain writes require an authoritative read before another save. - **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. - **Secrets stay write-only.** Credential tokens, Template environment variables and setup commands are never returned, to the administrator included. An Agent's saved model provider shows its protocol, base URL, limits and whether a key is configured, never the key. @@ -62,7 +62,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Typed errors.** Every failure the console shows, read or write, follows one rule. Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Other codes show Core's own message in English and name the code in Chinese, since Core's messages are English; a failure without a code names its HTTP status, and one that never reached Core says so. Uncertain write outcomes stay form-level and are never retried automatically. - **Read failures.** A read is tried again once after a 5xx answer or a failed connection, never after a 4xx answer. Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; a first read that fails never mentions earlier data; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. - **Local-only address.** Overview, Nodes and Settings warn when Core reports `local_only` and lead to Settings → Service and deployment to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. -- **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. +- **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. - Runtime CPU and memory exist only for Core-managed hosted sandboxes. - Preserve workflow safety: confirmed deletion, no automatic retry of uncertain writes, no secrets in browser storage. @@ -87,4 +87,4 @@ The console runs beside the administrator's own Core, with execution, files and ## Accessibility & Inclusion -Keyboard navigation with visible focus, reduced-motion support, and bilingual Chinese/English copy through the existing i18n modules. +Keyboard navigation with visible focus, reduced-motion support, and bilingual Chinese/English copy through the i18n modules. diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 907d9e989..6a8bb075c 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -63,7 +63,7 @@ A sensitive setting has null `value` and `default` and a boolean `configured` in | `address_bindings` field | Meaning | | --- | --- | | `nodes` | Enrolled nodes that are not removed | -| `nodes_on_other_address` | Nodes enrolled with an address other than `public_url`. They receive no new sandboxes; remove and add them again. At most `nodes` | +| `nodes_on_other_address` | Nodes enrolled with an address other than `public_url`. They receive no new sandboxes; see [Change the public URL](../../docs/getting-started/public-address.md#changing-the-public-url). At most `nodes` | | `hosted_sandboxes` | Retained and pending hosted sandboxes, which run with the address current when they started | | `self_hosted_executors` | Unrevoked executor credentials, whose executors were installed with the `remote_url` then advertised | @@ -223,13 +223,13 @@ The response is `{data, has_more, next_cursor}`. Each row has `project_id`, null `GET /core/v1/request-logs` is a Core-key-only diagnostic read for Web's **Request log** and operator scripts. It observes HTTP requests that reach Core's `/v1` and `/core/v1` APIs, including rejected authentication and unsuccessful responses. The log read itself, health and documentation endpoints, Web sign-in/proxy failures before Core, machine transports and background execution are outside this view. It is not an audit archive or a distributed execution trace. -Each API instance retains the most recent 1,000 started requests for each surface independently: `public` for `/v1`, `admin` for `/core/v1`. Administration polling cannot evict application requests. Older entries, including long-lived requests, are overwritten when their surface fills. Restart clears the rings. `instance_id` and `instance_started_at` identify the serving API instance; a load balancer can return another instance's window. Core also emits each request's completion metadata through its existing structured logger; retention of that output belongs to the operator's log system, not this endpoint. +Each API instance retains the most recent 1,000 started requests for each surface independently: `public` for `/v1`, `admin` for `/core/v1`. Administration polling cannot evict application requests. Older entries, including long-lived requests, are overwritten when their surface fills. Restart clears the rings. `instance_id` and `instance_started_at` identify the serving API instance; a load balancer can return another instance's window. Core also emits each request's completion metadata through its structured logger; retention of that output belongs to the operator's log system, not this endpoint. Query parameters are `surface=public|admin` (default `public`), `limit` (1–200, default 100), optional exact `request_id`, `outcome=all|errors|active` (default `all`) and optional `method`. Supported method values are `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT`, `TRACE` and `OTHER`; other incoming methods are recorded as `OTHER`. Unsupported, repeated or malformed query parameters return 400. `errors` includes HTTP status 400 or above and aborted requests. Filtering scans only the retained window and returns newest-started first. `retained_count` describes that surface's retained window, `total_matching` counts retained matches, and `has_more` means the response limit omitted matches; it does not promise older evicted history is available. The response has `object: "core.request_log.list"`, instance identity, `surface`, `capacity`, coverage counts and `data`. Each entry contains `request_id`, `trace_id`, `started_at`, `method`, `route`, `state`, `status`, `duration_ms`, `response_bytes` and nullable `error_code`. `route` is a registered route template, a partially matched authentication prefix or `(unmatched)`, never the raw resource path or query. `status` is null until final response headers are committed. `state` distinguishes `active`, `completed` and `aborted`; duration and bytes describe the observation so far for active requests. `response_bytes` counts bytes accepted by the response writer, excluding headers and transport framing; it is not a network transfer measurement. An SSE response remains active until its handler ends. HTTP status and completion do not establish the outcome of an asynchronous Agent Turn; use [Session diagnostics](./session-diagnostics.md) and the Session's recorded history for that outcome. -Bodies, header values, credentials, prompts, file contents, raw paths, query values and error messages are not captured. Only the API's safe error code is retained. The returned request ID corresponds to `X-Request-Id` and can be used to find related existing process log entries. Trace IDs are correlation metadata, not proof of a complete component trace. +Bodies, header values, credentials, prompts, file contents, raw paths, query values and error messages are not captured. Only the API's safe error code is retained. The returned request ID corresponds to `X-Request-Id` and can be used to find related process log entries. Trace IDs are correlation metadata, not proof of a complete component trace. ## Audit log diff --git a/contracts/agents-api/core-metrics.md b/contracts/agents-api/core-metrics.md index dba1d1b7e..088ae46e9 100644 --- a/contracts/agents-api/core-metrics.md +++ b/contracts/agents-api/core-metrics.md @@ -33,7 +33,7 @@ The response has `object: "core.metrics"`, `range`, `service`, `execution`, `dat | `service.revision` | The full source commit injected at build time; null for builds without one | | `service.started_at` | When the process initialized | | `service.execution_owner` | Whether this process holds the execution worker's database lease | -| `execution.slots_in_use`, `execution.slots_total` | Active Session reservations of the execution worker, and its capacity: [`OAC_EXECUTION_CONCURRENCY`](../../docs/configuration.md#settings), 4 by default. Environment input, Turns and file work share the slots; native Harness subprocesses are not counted | +| `execution.slots_in_use`, `execution.slots_total` | Active Session reservations of the execution worker, and its capacity: [`OAC_EXECUTION_CONCURRENCY`](../../docs/configuration.md#settings). Environment input, Turns and file work share the slots; native Harness subprocesses are not counted | | `execution.queued_turns`, `execution.in_progress_turns` | Root Turns in those states, including Turns of deleted Sessions. Subagent Turns and input reserved for a preparing Environment are not counted | | `execution.waiting_for_daemon` | Queued Turns whose Session's device is not connected | | `execution.oldest_queued_seconds` | Age of the oldest queued Turn, from its `created_at` | diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 06863c4e4..cf0febd48 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -73,7 +73,7 @@ Issue, rotate and revoke each record an administrator audit entry (`resource_typ ## Connection status -The list's required `connection` object contains `status` (`never_enrolled`, `connected` or `disconnected`), `bound_key_id`, `enrolled_at` and `last_seen_at`. All three binding fields are null before enrollment. Once enrolled, the bound key and enrollment time describe the existing device; a null `last_seen_at` means no authenticated heartbeat has been recorded. Issuing another key does not change the binding. Rotation or revocation can make the binding disconnected while its history remains visible. Expired Environments remain readable under the existing list rules but cannot have current executor authority. +The list's required `connection` object contains `status` (`never_enrolled`, `connected` or `disconnected`), `bound_key_id`, `enrolled_at` and `last_seen_at`. All three binding fields are null before enrollment. Once enrolled, the bound key and enrollment time describe the device; a null `last_seen_at` means no authenticated heartbeat has been recorded. Issuing another key does not change the binding. Rotation or revocation can make the binding disconnected while its history remains visible. Expired Environments remain readable under the existing list rules but cannot have current executor authority. Connected means the Environment is connected, its device and executor key still have current Core authority, and the process-local gateway has an open peer that authenticated with that current key. Core rechecks authority after observing the peer. A former key's live socket, a device timestamp or a ready-looking Environment alone is insufficient; without a gateway, Core never returns connected. These facts are an observation, not a reservation of connectivity or of native or model readiness. `last_seen_at` may lag by a heartbeat interval. diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md index 20ef28246..67fcc1345 100644 --- a/contracts/agents-api/environment-files.md +++ b/contracts/agents-api/environment-files.md @@ -12,7 +12,7 @@ The routes follow the SDK pinned in [upstream.json](https://github.com/MiniMax-A - Public paths start at `/workspace`, which stands for the Environment's workspace directory wherever it is on the machine. - The Environment is looked up in the caller's Project first; a missing or foreign ID returns 404. - On an `openai_hosted` Environment that is still `pending`, both operations return 400 `the hosted environment is still provisioning; wait until it is connected before accessing files`. This check runs after request validation and before any source File lookup. Any other state proceeds, and an unreachable Runtime returns 503. -- File operations wait within their existing request deadline for Environment initialization to complete; `connected` alone does not imply readiness, and failed initialization or exhausted execution capacity still returns 503. +- File operations wait within their request deadline for Environment initialization to complete; `connected` alone does not imply readiness, and failed initialization or exhausted execution capacity still returns 503. - Listing and writing never start a Turn or send input to the model. ## List files @@ -78,7 +78,7 @@ Unless the table names a code, the 400 errors have type and code `invalid_reques - Before sending any bytes, Core records the write under the Session lock. While input is pending, a Turn is running or an earlier write is unsettled, a new write returns 409 `turn_conflict`. An unsettled write also makes new messages to the Session return 409. - The Runtime checks the complete body against its digest before creating anything, so incomplete input creates nothing. A write that fails later can leave newly created empty parent directories. -- Only a definite receipt from the Runtime settles a write, as committed or rejected. A rejected write publishes no destination file and releases the Session; newly created empty parent directories can remain. Storage or quota exhaustion confirmed before publication returns 503, while malformed input and destination conflicts retain their existing errors. If the connection drops, the request times out or no receipt arrives, the request returns 503 and the write stays unsettled, across Core restarts. Core never resends it and has no automatic recovery, so the Session accepts no further writes or messages. Reads still work. +- Only a definite receipt from the Runtime settles a write, as committed or rejected. A rejected write publishes no destination file and releases the Session; newly created empty parent directories can remain. Storage or quota exhaustion confirmed before publication returns 503, while malformed input and destination conflicts retain their errors. If the connection drops, the request times out or no receipt arrives, the request returns 503 and the write stays unsettled, across Core restarts. Core never resends it and has no automatic recovery, so the Session accepts no further writes or messages. Reads still work. - Deleting the source File after its bytes were read does not affect the copy. ## Artifacts diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 03dc68551..267de102c 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -318,7 +318,7 @@ For comparison, an inline `openai_hosted` Session without a Template treats omit ### Preparation reuse -Core may start a hosted Environment from the result of an earlier preparation in the same Project whose effective configuration, after Template composition, is identical, for up to 24 hours after that preparation. Each Session still gets its own copy. Setup commands and package installation then do not run again for this Session. Only `/environment` is reused (see the workspace filesystem scope); HOME and other compute-local changes are not. Setup must not create per-Session unique values, perform external side effects that must happen per Session, or fetch credentials that expire within 24 hours; configurations that need this must differ, for example in `env` or `files`. A Provider that does not declare the [preparation cache](../../docs/sandbox-provider.md#explicit-operation-contracts) always prepares in full. +Core may start a hosted Environment from the result of an earlier preparation in the same Project whose effective configuration, after Template composition, is identical, for up to 24 hours after that preparation. Each Session still gets its own copy. Setup commands and package installation then do not run again for this Session. Only `/environment` is reused (see [Lifetime and filesystem scope](../../docs/workspace-provider.md#lifetime-and-filesystem-scope)); HOME and other compute-local changes are not. Setup must not create per-Session unique values, perform external side effects that must happen per Session, or fetch credentials that expire within 24 hours; configurations that need this must differ, for example in `env` or `files`. A Provider that does not declare the [preparation cache](../../docs/sandbox-provider.md#explicit-operation-contracts) always prepares in full. ### Restricted network @@ -357,7 +357,7 @@ template = client.beta.agents.environments.templates.create( ) ``` -The pinned `/v1/skills` resource, version and content routes use the Project API key without the Agents beta header. ZIP uploads use `files` and directory uploads repeated `files[]`. The pinned SDK 3.13.0 drops a single file tuple during multipart extraction, so upload a single ZIP with raw HTTP. An upload holds at most 500 regular files and exactly one `SKILL.md`, 5 MiB compressed and 20 MiB expanded. [File resource semantics](./source-files.md#versions-and-metadata) owns default-version and deletion rules. +The pinned `/v1/skills` resource, version and content routes use the Project API key without the Agents beta header. ZIP uploads use `files` and directory uploads repeated `files[]`. [Upload a bundle](./source-files.md#upload-a-bundle) owns the bundle format, upload limits and the SDK single-file limitation; [versions and metadata](./source-files.md#versions-and-metadata) owns default-version and deletion rules. A reference with an omitted or null version selects the default at Session creation, `"latest"` the latest version, and a positive version string that version. Template responses keep the unresolved selector (`version: null` for the default); Session metadata shows `{type, skill_id, version, name, description}` with a concrete version. A Session freezes the selected version's bytes and metadata in its creation transaction; later default changes, source deletion or Template updates cannot change it. @@ -380,8 +380,7 @@ A Plugin is an inline ZIP with type, name and description whose single archive r Skills and their immutable versions are Project resources, independent of Sessions and native installations. -- Version allocation and pointer changes serialize on the owning Skill row. The top-level name and description follow the default version in the same transaction; non-default uploads keep them. Version identities stay unique across concurrent uploads and deletion. -- Bundles are encrypted with the service cipher, bound to Project, Skill and version. Metadata reads never load or decrypt bundles. Deleting a Skill reclaims its versions without affecting frozen Sessions. +- Version allocation, default-version changes and serialization are defined in [Versions and metadata](./source-files.md#versions-and-metadata), and bundle encryption in [Upload a bundle](./source-files.md#upload-a-bundle). Metadata reads never load or decrypt bundles. Deleting a Skill reclaims its versions without affecting frozen Sessions. - References resolve inside the Session creation transaction, after the upsert establishes ownership. Referenced resources are locked in a stable order, and the version, metadata and bytes freeze together. Public reference metadata, unresolved Template intent and the resolved Runtime bundle stay distinct; a reference is never reported as inline. Inline and referenced Skills use the same confidential snapshot and installer. The Runtime installs them under `skills/` in its capability root before setup and native execution. Adapters register only the selected Skill roots: Codex as explicit extra roots, MiniMax Code through its native catalog, and Claude as user Skills under its private state path, so native lists the bare names a local `~/.claude/skills` gives. Claude rejects a Skill that contains `.claude-plugin`, because native would load it as a plugin with its hooks and MCP servers. Skill roots activate no MCP servers; only [Plugin MCP](#plugin-mcp) does. Codex scans each Skill root recursively as it does locally, so a nested `SKILL.md` registers another native Skill. Native Harness configuration is never passed through wholesale. diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index 9385052b6..ad446300e 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -24,7 +24,7 @@ Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted | [Message images](./message-content.md#images) | PNG and JPEG data URLs: verified on all placements; other data URLs: admitted; remote URLs: rejected | PNG and JPEG data URLs: verified on all placements; remote URLs and GIF and WebP data URLs: admitted | Rejected | | `reasoning.effort` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`: admitted | `low`, `medium`, `high`, `xhigh`, `max`: admitted | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`: admitted | | `reasoning.summary` | `concise`, `detailed`, `auto`: admitted | `auto`: admitted | Rejected | -| `service_tier` other than `auto` | `default`, `flex`, `priority`: admitted | Rejected | Rejected | +| `service_tier` other than `auto` | `default`, `flex`, `priority`, `fast`: admitted | Rejected | Rejected | | `text.verbosity` other than `medium` | Admitted; the native model decides | Rejected | Rejected | | [Public token usage](./sessions-events.md) | Measured counters | Null | Null | @@ -39,7 +39,7 @@ Native model parameters and provider protocols per Harness are in [model executi | [Structured output](./execution-tools.md#structured-output) | Admitted on all placements | Verified on all placements | Rejected | | [Deferred function discovery](./execution-tools.md#deferred-function-discovery) | Rejected | Verified: `none`, self-hosted; admitted: hosted | Rejected | | [Disabled web search and programmatic tool calling](./execution-tools.md#web-search-and-programmatic-tool-calling) | Verified: `none`; admitted: hosted, self-hosted | Verified: `none`; admitted: hosted, self-hosted | Verified: `none`; admitted: hosted, self-hosted | -| Web search `cached` or `live` and programmatic tool calling `enabled` | Admitted on all placements | `live` admitted on all placements; `cached` and `enabled` rejected | Rejected | +| Web search `cached` or `live` and programmatic tool calling `enabled` | Admitted on all placements; `enabled` is rejected together with `multi_agent` | `live` admitted on all placements; `cached` and `enabled` rejected | Rejected | | [Service-origin HTTP MCP](./environments.md#public-mcp-connection-origin) | Verified: `none`; rejected elsewhere | Verified: `none`; rejected elsewhere | Verified: `none`, with `allowed_tools` null and `required` false only; rejected elsewhere | | [Environment-origin HTTP MCP](./environments.md#public-mcp-connection-origin) | Verified: hosted, self-hosted | Verified: hosted, self-hosted | Verified: hosted, self-hosted; `allowed_tools` null and `required` false only | | [Stdio MCP](./execution-tools.md#mcp) | Admitted: hosted, self-hosted | Admitted: hosted, self-hosted; `cwd` must be the workspace directory and `allowed_tools` null | Rejected | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index df391130f..a90b8b86e 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -190,7 +190,7 @@ The declaration holds: - `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`; - optional pure restrictions, `ValidateConfiguration`, `ValidateTools`, `ValidateMessageInput` and `ValidateFunctionResult`, that add native limits to the common checks. `ValidateMessageInput` runs only for messages that carry images, after the `MessageImages` capability check and before anything is stored, and decides which `image_url` values the Harness accepts; Core checks only that the reference is not blank. A nil validator means the common checks suffice. They cannot query business data, decrypt credentials or control native processes. Configuration restrictions run first, and a tool-decoding error precedes `ValidateTools`. -`harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, and bearer support requires an MCP origin, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. +`harnessconfig.NewRegistry` validates every declaration and panics on an invalid or duplicate one: kinds and labels must be nonempty, placements must list at least one supported placement, MCP origins must be a non-nil list, unknown or duplicate choices and origins without a corresponding placement are rejected, bearer support requires an MCP origin, and `Execution` must declare text verbosity `medium`. Shared dispatch checks capability combinations, never engine names. The Runtime binding in `apps/daemon/internal/agent//declaration.go` embeds the same declaration and adds `Discover`, which owns native availability probes and factories and only narrows the declared capabilities to what the installation verified. The binding test `TestDiscoveryOnlyNarrowsDeclaration` checks that discovery never exceeds the declaration. `harnessconfig/harnesstest.Declaration` builds a synthetic declaration for tests. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 69e35d493..f0ef5ea4b 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -36,7 +36,7 @@ Evidence for a status comes from the pinned official SDK and raw HTTP against th | Environments | retrieve | Implemented; Session-scoped ([ownership](./environments.md#resources-and-states)). The pinned contract rejects `environment.environment_id`; existing hosted Environments cannot be attached across Sessions. Workspaces shared across Sessions belong to the application ([Projects own assets](../../docs/concepts.md#projects-own-assets)) | [Environments](./environments.md) | | Environment files | create, list | Implemented; the list is not recursive | [Environment files and Artifacts](./environment-files.md) | | Environment Templates | create, retrieve, update, list, delete | Implemented; execution limits are listed under [known gaps](#known-gaps) | [Environment Templates](./environments.md#templates) | -| Vaults | create, retrieve, list, delete | Implemented; no archive operation | [Vaults and Credentials](./vaults.md) | +| Vaults | create, retrieve, list, delete | Implemented | [Vaults and Credentials](./vaults.md) | | Vault Credentials | create, retrieve, update, list, delete | Implemented for `static_bearer` and `mcp_oauth` | [Vaults and Credentials](./vaults.md) | | Files | create, retrieve, list, delete, content | Implemented for `purpose=user_data`; content download is rejected | [Files and Skills](./source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | Implemented | [Files and Skills](./source-files.md) | @@ -80,7 +80,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Core rejects an inline `authorization` on a Session MCP transport, an `Authorization` entry in `headers` and a nonempty stdio `env`: credentials come only from Vaults (`credential_id`) and environment values only from the Environment (`env_vars`) ([MCP](./execution-tools.md#mcp)). - A `stdio` MCP server needs a workspace placement and the origin `environment`. Claude Code starts the server in its workspace directory, so `cwd` must equal it, with its whole process environment, which also holds the model provider key and other servers' Vault bearer tokens, and it needs `allowed_tools` null. MiniMax Code starts the server in its private home directory, so it rejects every `stdio` server ([MCP](./execution-tools.md#mcp)). - Claude rejects an MCP `server_url` or `headers` value containing `${`, because the native client expands `${VAR}` in both from a process environment that holds Core-held credentials. -- Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returned 200. +- Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returns 200. **Input, events and history** ([Sessions, events and history](./sessions-events.md), [message content](./message-content.md)) @@ -98,7 +98,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Files, Skills, Environment files and Artifacts** ([Files and Skills](./source-files.md), [Environment files and Artifacts](./environment-files.md)) -- File uploads hold up to 512 MiB; the official limit is 512 MB. The Files list returns up to 10,000 Files by default, and a `purpose` filter other than `user_data` returns an empty page. +- File uploads hold up to 512 MiB; the official limit is 512 MB. A `purpose` filter other than `user_data` returns an empty page. - Skill version numbers are never reused, and uploads and deletions of one Skill run one at a time. - Environment files work on `self_hosted` Environments, which the official service refuses. - Creating an Environment file over an existing regular file returns the "must not traverse symlinks or overwrite existing files" message. A parent symbolic link that stays inside the workspace is followed, and a parent that escapes the workspace or is a regular file gets a generic 400; the official service rejects symbolic-link parents. @@ -118,7 +118,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Configuration and tools** -- A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting or `programmatic_tool_calling` value that the selected Harness does not declare is saved but rejected at Session admission ([Harness capabilities](./harness-capabilities.md#execution-and-input)). +- A `reasoning.effort`, `reasoning.summary`, `service_tier`, `text.verbosity`, `web_search` mode or setting or `programmatic_tool_calling` value that the selected Harness does not declare is saved but rejected at Session admission ([Harness capabilities](./harness-capabilities.md#execution-and-input)). - Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no image input. - Model-derived reasoning defaults are not resolved. @@ -135,7 +135,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Environments and Templates** -- Hosted workspace admission is limited by the [tenant capacity policy](../../docs/configuration.md#tenant-capacity) and the existing [input reservation deadline](./environments.md#reservations). +- Hosted workspace admission is limited by the [tenant capacity policy](../../docs/configuration.md#tenant-capacity) and the [input reservation deadline](./environments.md#reservations). - MiniMax Code does not declare `retained_native_history`, so it cannot run on microsandbox, which keeps every Environment on workspace storage; Core rejects the Session ([Harness capabilities](./harness-capabilities.md)). - Only `enabled` networking runs, so Sessions that need `disabled` or `restricted` are rejected ([restricted network policy](./environments.md#restricted-network)). - `packages.system` is rejected; system packages must be preinstalled. @@ -148,7 +148,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Vaults and Credentials** -- There is no archive lifecycle, storage-key rotation or re-encryption. +- There is no storage-key rotation or re-encryption. - OAuth refresh happens only at dispatch: there is no refresh on a provider 401, no mid-Turn replacement and no withdrawal of a token already sent to a Runtime. - Input sent to a Session whose selected Credential was deleted is admitted and then fails at dispatch. - Credential creation takes no `Idempotency-Key`. diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 26db1bfd2..2068ea9d6 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -39,7 +39,7 @@ POST and PUT take the same complete selection and require `expected_generation` | `configuration` | The provider's public selectors. E2B: the immutable `template` build and the optional paired `api_url` and `domain`. Docker and microsandbox accept only `{}` or omission | | `credential` | The provider's write-only credential. E2B: `{api_key}`, required at first setup and omitted on PUT to keep the current key; a null or empty key is invalid. Docker and microsandbox reject it | -The request has no Core address. Core derives the deployment's `core_url` from the installation public URL (`public_url` in `config.json`, `OAC_PUBLIC_URL` for Core): the origin nodes and sandbox guests use to reach Core. A request that contains `core_url` is rejected with 400 `invalid_request` like any other unknown member. E2B guests reach Core from E2B's cloud, so an E2B selection is rejected with 409 `sandbox_configuration_error` while the public URL is loopback. Docker and microsandbox selections accept a loopback public URL, which serves only local development because a guest's loopback address does not reach its host. Changing the public URL is an installation change: nodes enrolled with the old address receive no new sandboxes and must be removed and added again. +The request has no Core address. Core derives the deployment's `core_url` from the installation public URL (`public_url` in `config.json`, `OAC_PUBLIC_URL` for Core): the origin nodes and sandbox guests use to reach Core. A request that contains `core_url` is rejected with 400 `invalid_request` like any other unknown member. E2B guests reach Core from E2B's cloud, so an E2B selection is rejected with 409 `sandbox_configuration_error` while the public URL is loopback. Docker and microsandbox selections accept a loopback public URL, which serves only local development because a guest's loopback address does not reach its host. Changing the public URL is an installation change; see [Change the public URL](../../docs/getting-started/public-address.md#changing-the-public-url). ### Resources @@ -192,7 +192,7 @@ One mutation gate serializes setup, PUT, reset, cancellation and finalization. A Node capacity is set separately from the deployment specification. `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and `max_retained` and returns `{token, expires_at, enrollment_id}`; `enrollment_id` is a public handle of that command, never a credential. Core stores the token with the limits as sent, and an omitted limit stays unset. When the node enrolls, Core copies each explicit limit to it unchanged and sets each unset limit to the [recommended capacity](#recommended-capacity) of the host measurements in the enrollment request and the current specification; an unset limit with no host measurements fails with 409 `sandbox_node_host_unmeasured` and leaves the token unused. When only `max_retained` is set, the derived `max_active` never exceeds it; when only `max_active` is set, the derived `max_retained` is raised to at least `max_active`. microsandbox uses both limits. Docker never suspends, so Core replaces its `max_retained` with `max_active`, here and in PATCH. E2B has no nodes and answers 409 `sandbox_deployment_conflict`. A node cannot submit limits, but an unset limit follows the host measurements the node reports at enrollment, bounded by 1 and 1000000. Its local checks may refuse a deployment it cannot run but never raise the limits. Explicit values and later PATCH edits bind: once a node is enrolled, Core derives nothing again. [Node capacity](../../docs/configuration.md#node-capacity) explains the limits for operators. -`GET /core/v1/sandbox/nodes` returns `{data: [...]}` with, for each node: `id`, `name`, `provider`, `online`, `last_seen_at`, `created_at`, `max_active`, `max_retained`, the counts `active`, `reserved`, `running`, `retained`, `snapshots` and `cleanup_pending`, `provider_ready`, the host measurements `cpu_count`, `available_memory_bytes` and `available_disk_bytes`, `rollout`, `recommended_capacity`, `enrollment_id` and `core_url`. `recommended_capacity` is `{max_active, max_retained}`, the [recommended capacity](#recommended-capacity) of the node's latest health and the current specification, or null until the node has reported a CPU count or effective cores and its total memory, and while the current specification does not validate. `enrollment_id` is the handle of the command that registered the node, or null when Core has none. `core_url` is the installation public URL at enrollment; a node whose `core_url` differs from the current public URL receives no new placements. Work already placed on it finishes there, including a placed Environment that has no allocation yet, and its retained sandboxes can still resume while the old address reaches Core. Remove it and add it again. +`GET /core/v1/sandbox/nodes` returns `{data: [...]}` with, for each node: `id`, `name`, `provider`, `online`, `last_seen_at`, `created_at`, `max_active`, `max_retained`, the counts `active`, `reserved`, `running`, `retained`, `snapshots` and `cleanup_pending`, `provider_ready`, the host measurements `cpu_count`, `available_memory_bytes` and `available_disk_bytes`, `rollout`, `recommended_capacity`, `enrollment_id` and `core_url`. `recommended_capacity` is `{max_active, max_retained}`, the [recommended capacity](#recommended-capacity) of the node's latest health and the current specification, or null until the node has reported a CPU count or effective cores and its total memory, and while the current specification does not validate. `enrollment_id` is the handle of the command that registered the node, or null when Core has none. `core_url` is the installation public URL at enrollment; a node whose `core_url` differs from the current public URL receives no new placements. Work already placed on it finishes there, including a placed Environment that has no allocation yet, and its retained sandboxes can still resume while the old address reaches Core. See [Change the public URL](../../docs/getting-started/public-address.md#changing-the-public-url). A node manages generations, so its `provider_ready` follows its serving generation and its fixed code for the target generation appears in `rollout.diagnostic`. The node classifies the first failed readiness check and sends only its code; a frame with any other value is invalid, and Core never stores or returns probe text or host paths. The codes are Provider-neutral classes: `provider_unavailable`, `host_unsupported`, `artifacts_unavailable`, `runtime_image_unavailable` and `capacity_insufficient`. [Readiness codes](../../docs/getting-started/nodes.md#readiness-codes) gives causes and operator actions. Core and nodes must come from the same distribution. diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index 7fb2bd8e4..54eafb74e 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -32,7 +32,7 @@ Active includes idle. A successful close records the native time; a successful r ## Adapter contract -Adapters report Subagent facts through `internal/agentdaemon/proto/subagents.go` over the existing authenticated Run and execution journal. There is no separate transport, scheduler or model and tool loop. +Adapters report Subagent facts through `internal/agentdaemon/proto/subagents.go` over the authenticated Run and execution journal. There is no separate transport, scheduler or model and tool loop. | Fact | Adapter obligation | | --- | --- | diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index 1520547ee..5b961639e 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -38,7 +38,7 @@ Every Agents API response, including errors and event streams, carries: ### Authentication -`/v1` accepts only a Project API key as `Authorization: Bearer `. All keys of one Project act as the same caller: they share its resources and its Session creation retries. Core resolves the key and its Project in the database on every request, with no credential cache and a five-second timeout. Revoking a key or archiving its Project takes effect on the next request. All keys of a Project act as subject `service_account/project:`. [Projects and keys](./admin-api.md#projects-and-keys) describes key management. +`/v1` accepts only a Project API key as `Authorization: Bearer `. Core resolves the key and its Project in the database on every request, with no credential cache and a five-second timeout. Revoking a key or archiving its Project takes effect on the next request. All keys of a Project act as subject `service_account/project:`. [Projects and keys](./admin-api.md#projects-and-keys) describes key management. The optional `OpenAI-Organization` and `OpenAI-Project` headers must, when sent, appear once and equal `core` and `proj_`; any other value rejects the key. diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index 9e550d491..66d71c265 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: da7f631aea81c3b934e61e8749de577f2dc458d5b45ede3f5492a3f184affe08 +source_hash: 0b0be1a0a0d85119e047330c178258481e71f645d577554b5619d3a9b201a794 --- Core 管理 API(`/core/v1`)用于管理安装实例:项目及其 API 密钥、项目资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -65,7 +65,7 @@ Core 管理 API(`/core/v1`)用于管理安装实例:项目及其 API 密 | `address_bindings` 字段 | 含义 | | --- | --- | | `nodes` | 已注册且未移除的节点 | -| `nodes_on_other_address` | 使用了 `public_url` 以外地址注册的节点。它们不会获得新的沙箱;请移除后重新添加。最多为 `nodes` 个 | +| `nodes_on_other_address` | 使用了 `public_url` 以外地址注册的节点。它们不会获得新的沙箱;参见[更改公开 URL](../../../docs/zh/getting-started/public-address.md#changing-the-public-url)。最多为 `nodes` 个 | | `hosted_sandboxes` | 保留的待处理托管沙箱;它们启动时使用当时有效的地址运行 | | `self_hosted_executors` | 未吊销的执行器凭据;对应执行器安装时使用的是当时公布的 `remote_url` | diff --git a/contracts/agents-api/zh/core-metrics.md b/contracts/agents-api/zh/core-metrics.md index 0bf738757..1921f3526 100644 --- a/contracts/agents-api/zh/core-metrics.md +++ b/contracts/agents-api/zh/core-metrics.md @@ -1,7 +1,7 @@ --- title: "Core 指标" source: contracts/agents-api/core-metrics.md -source_hash: 5843b943903b0a288e941998d365ed1d01fd282ea71feacadb3fe5f1bbb576a7 +source_hash: bb6125b122a9cb46664db9d9536c0e799a3e1b789daed0f62941c0bc2bed6405 --- `GET /core/v1/metrics?range=1h|6h|24h|7d` 报告 Core 自身的健康状况:进程、执行队列与槽位、PostgreSQL 和后台任务。它要求 Core 密钥([Core 管理 API](admin-api.md))。 @@ -35,7 +35,7 @@ source_hash: 5843b943903b0a288e941998d365ed1d01fd282ea71feacadb3fe5f1bbb576a7 | `service.revision` | 构建时注入的完整源代码提交;未注入时为 null | | `service.started_at` | 进程初始化时间 | | `service.execution_owner` | 此进程是否持有执行 worker 的数据库租约 | -| `execution.slots_in_use`, `execution.slots_total` | 执行 worker 的活动 Session 预留数量及容量:[`OAC_EXECUTION_CONCURRENCY`](../../../docs/zh/configuration.md#settings),默认为 4。Environment 输入、Turn 和文件工作共享槽位;不统计原生 Harness 子进程 | +| `execution.slots_in_use`, `execution.slots_total` | 执行 worker 的活动 Session 预留数量及容量:[`OAC_EXECUTION_CONCURRENCY`](../../../docs/zh/configuration.md#settings)。Environment 输入、Turn 和文件工作共享槽位;不统计原生 Harness 子进程 | | `execution.queued_turns`, `execution.in_progress_turns` | 处于相应状态的根 Turn,包括已删除 Session 的 Turn。不统计 Subagent Turn 和为准备中 Environment 预留的输入 | | `execution.waiting_for_daemon` | Session 设备未连接的排队 Turn | | `execution.oldest_queued_seconds` | 最早排队 Turn 自 `created_at` 起的年龄 | diff --git a/contracts/agents-api/zh/environment-executor-credentials.md b/contracts/agents-api/zh/environment-executor-credentials.md index b711a247f..c7449e9f7 100644 --- a/contracts/agents-api/zh/environment-executor-credentials.md +++ b/contracts/agents-api/zh/environment-executor-credentials.md @@ -1,7 +1,7 @@ --- title: "执行器凭据" source: contracts/agents-api/environment-executor-credentials.md -source_hash: bd7758cbe88d35995dcdffafc381f4a1d6b36ef70f9d0932f88d4d3b67ec1ab6 +source_hash: bc6bfa4cdaa4a82694fc3b2936aa50f1e00ae7feecea05f7c67401bb5816f185 --- 执行器凭据允许 `oac-daemon` 为一个 `self_hosted` Environment 注册并连接。它只授权该 Environment 的私有 daemon 传输(`/api/v1/agent-daemon/*`),不授权 `/v1`、`/core/v1`、sandbox node 注册或项目资源。项目的 principal 是其执行 principal。Core 只保存密钥摘要。 diff --git a/contracts/agents-api/zh/environment-files.md b/contracts/agents-api/zh/environment-files.md index bff81f7cc..e9264a806 100644 --- a/contracts/agents-api/zh/environment-files.md +++ b/contracts/agents-api/zh/environment-files.md @@ -1,7 +1,7 @@ --- title: "Environment 文件与 Artifact" source: contracts/agents-api/environment-files.md -source_hash: 957cca18ef63a95a01ff682ff8adffb0d63d34e520c197ed9f6c8d08c4fd6c5d +source_hash: b6c51bd066cf70b6cce4c38ba1403dc3463574fcbc254bf64f8c98b6aeca9b93 --- Session 工作区保存由 agent 及其工具修改的实时文件。`/agents/environments/{environment_id}/files` 列出一个工作区目录,并在其中创建文件。Turn 完成时,Core 将工作区 `outputs/` 目录中的文件复制为不可变 Artifact,通过 `/agents/sessions/{session_id}/artifacts` 读取。Artifact 的生命周期长于 Environment;工作区文件则不是。 diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 3c0fd8c29..a663bb140 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: f50217dd1fc465626ac7135a46b69cea0ee40fc14fd30141e23813018192cb5c +source_hash: 6b609956acde1748bfc1762ba5428959a88de8b42cbcaa9297fb89c1c2c013bb --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -320,7 +320,7 @@ Session 会先应用模板,然后应用自身字段。组合仅在加密 Sessi ### 准备复用 {#preparation-reuse} -Core 可以用同一 Project 中较早一次准备的结果启动托管 Environment,前提是两者在 Template 组合之后的有效配置完全相同,且距该次准备不超过 24 小时。每个 Session 仍然得到自己的副本。此时设置命令和包安装不会为该 Session 再次运行。只复用 `/environment`(见工作区文件系统范围),HOME 和其他与计算实例本地相关的改动不会复用。设置过程不得产生每个 Session 唯一的值,不得执行必须按 Session 发生的外部副作用,也不得获取 24 小时内就会过期的凭据;需要这些行为的配置必须有所不同,例如在 `env` 或 `files` 上。未声明[准备缓存](../../../docs/zh/sandbox-provider.md#explicit-operation-contracts)的提供商始终完整准备。 +Core 可以用同一 Project 中较早一次准备的结果启动托管 Environment,前提是两者在 Template 组合之后的有效配置完全相同,且距该次准备不超过 24 小时。每个 Session 仍然得到自己的副本。此时设置命令和包安装不会为该 Session 再次运行。只复用 `/environment`(见[生命周期与文件系统范围](../../../docs/zh/workspace-provider.md#lifetime-and-filesystem-scope)),HOME 和其他与计算实例本地相关的改动不会复用。设置过程不得产生每个 Session 唯一的值,不得执行必须按 Session 发生的外部副作用,也不得获取 24 小时内就会过期的凭据;需要这些行为的配置必须有所不同,例如在 `env` 或 `files` 上。未声明[准备缓存](../../../docs/zh/sandbox-provider.md#explicit-operation-contracts)的提供商始终完整准备。 ### 受限网络 {#restricted-network} @@ -359,7 +359,7 @@ template = client.beta.agents.environments.templates.create( ) ``` -固定版本的 `/v1/skills` 资源、版本及内容路由使用项目 API 密钥,且不使用 Agents beta 标头。ZIP 上传使用 `files`,目录上传则重复使用 `files[]`。固定版本 SDK 3.13.0 在多部分内容提取过程中会丢弃单文件 tuple,因此应使用原始 HTTP 上传单个 ZIP。一次上传最多包含 500 个常规文件,并且必须恰好包含一个 `SKILL.md`;压缩后为 5 MiB,展开后为 20 MiB。[File resource semantics](source-files.md#versions-and-metadata) 负责默认版本和删除规则。 +固定版本的 `/v1/skills` 资源、版本及内容路由使用项目 API 密钥,且不使用 Agents beta 标头。ZIP 上传使用 `files`,目录上传则重复使用 `files[]`。[Upload a bundle](source-files.md#upload-a-bundle) 负责 bundle 格式、上传限制和 SDK 单文件限制;[versions and metadata](source-files.md#versions-and-metadata) 负责默认版本和删除规则。 省略或传入 null 版本的引用会在 Session 创建时选择默认版本,`"latest"` 会选择最新版本,正版本号字符串则选择该版本。模板响应会保留未解析的选择器(默认版本为 `version: null`);Session 元数据会显示 `{type, skill_id, version, name, description}`,其中版本为具体值。Session 会在其创建事务中冻结所选版本的字节和元数据;之后默认版本发生变化、源被删除或模板被更新,都无法改变这些内容。 @@ -382,8 +382,7 @@ Plugin 是带有 type、name 和 description 的内联 ZIP,其单个归档根 Skills 及其不可变版本是项目资源,独立于 Session 和原生安装。 -- 版本分配和指针变更会在所属 Skill 行上串行化。顶层 name 和 description 在同一事务中跟随默认版本;非默认上传不会更改它们。在并发上传和删除过程中,版本身份始终唯一。 -- Bundle 使用服务密码进行加密,并绑定到项目、Skill 和版本。元数据读取绝不会加载或解密 Bundle。删除 Skill 会回收其版本,但不会影响已冻结的 Session。 +- 版本分配、默认版本变更和串行化由 [Versions and metadata](source-files.md#versions-and-metadata) 定义,Bundle 加密由 [Upload a bundle](source-files.md#upload-a-bundle) 定义。元数据读取绝不会加载或解密 Bundle。删除 Skill 会回收其版本,但不会影响已冻结的 Session。 - 引用会在 Session 创建事务中、upsert 建立所有权之后解析。被引用的资源按稳定顺序锁定,版本、元数据和字节会一并冻结。公开引用元数据、未解析的模板意图和已解析的 Runtime Bundle 始终彼此独立;引用绝不会报告为内联内容。 内联和引用的 Skills 使用相同的机密快照与安装程序。Runtime 会在设置和原生执行之前,将它们安装到能力根目录下的 `skills/`。适配器只注册所选的 Skill 根目录:Codex 将其注册为显式额外根目录,MiniMax Code 通过其原生目录进行注册,Claude 则将其作为私有状态路径下的用户 Skills,使原生列出与本地 `~/.claude/skills` 相同的裸名称。Claude 拒绝包含 `.claude-plugin` 的 Skill,因为原生会将其作为插件加载,连同其 hooks 和 MCP 服务器。Skill 根目录不会启用 MCP 服务器,只有 [Plugin MCP](#plugin-mcp) 会启用。Codex 与本地运行一样递归扫描每个 Skill 根目录,因此嵌套的 `SKILL.md` 会注册另一个原生 Skill。原生 Harness 配置绝不会整体透传。 diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index 2cb49062e..0470a3c18 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: d79690e618d82c6b858dc40342198fc5b091ee56505816f551da80d100ef2c2e +source_hash: 507c77c74145cac386ac8dd0e231d5701821c00490c0c5db2d25f052911a3107 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -26,7 +26,7 @@ source_hash: d79690e618d82c6b858dc40342198fc5b091ee56505816f551da80d100ef2c2e | [消息图像](message-content.md#images) | PNG、JPEG data URL:所有位置已验证;其他 data URL:已准入;远程 URL:已拒绝 | PNG、JPEG data URL:所有位置已验证;远程 URL 及 GIF、WebP data URL:已准入 | 已拒绝 | | `reasoning.effort` | `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`:已准入 | `low`、`medium`、`high`、`xhigh`、`max`:已准入 | `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`:已准入 | | `reasoning.summary` | `concise`、`detailed`、`auto`:已准入 | `auto`:已准入 | 已拒绝 | -| 非 `auto` 的 `service_tier` | `default`、`flex`、`priority`:已准入 | 已拒绝 | 已拒绝 | +| 非 `auto` 的 `service_tier` | `default`、`flex`、`priority`、`fast`:已准入 | 已拒绝 | 已拒绝 | | 非 `medium` 的 `text.verbosity` | 已准入;由原生模型决定 | 已拒绝 | 已拒绝 | | [公开 token 用量](sessions-events.md) | 测量计数器 | Null | Null | @@ -41,7 +41,7 @@ source_hash: d79690e618d82c6b858dc40342198fc5b091ee56505816f551da80d100ef2c2e | [结构化输出](execution-tools.md#structured-output) | 所有位置已准入 | 所有位置已验证 | 已拒绝 | | [延迟函数发现](execution-tools.md#deferred-function-discovery) | 已拒绝 | 已验证:`none`、自托管;已准入:托管 | 已拒绝 | | [禁用 Web 搜索与程序化工具调用](execution-tools.md#web-search-and-programmatic-tool-calling) | 已验证:`none`;已准入:托管、自托管 | 已验证:`none`;已准入:托管、自托管 | 已验证:`none`;已准入:托管、自托管 | -| Web 搜索 `cached` 或 `live`,以及程序化工具调用 `enabled` | 所有位置已准入 | `live` 在所有位置已准入;`cached` 和 `enabled` 已拒绝 | 已拒绝 | +| Web 搜索 `cached` 或 `live`,以及程序化工具调用 `enabled` | 所有位置已准入;`enabled` 与 `multi_agent` 同时出现时被拒绝 | `live` 在所有位置已准入;`cached` 和 `enabled` 已拒绝 | 已拒绝 | | [服务端来源 HTTP MCP](environments.md#public-mcp-connection-origin) | 已验证:`none`;其他位置拒绝 | 已验证:`none`;其他位置拒绝 | 已验证:`none`,仅 `allowed_tools` null、`required` false;其他位置拒绝 | | [Environment 来源 HTTP MCP](environments.md#public-mcp-connection-origin) | 已验证:托管、自托管 | 已验证:托管、自托管 | 已验证:托管、自托管;仅 `allowed_tools` null、`required` false | | [stdio MCP](execution-tools.md#mcp) | 已准入:托管、自托管 | 已准入:托管、自托管;`cwd` 须为工作区目录,`allowed_tools` 须为 null | 已拒绝 | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3d7db2da5..2bc0770c5 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 8beb6af27e99a4bf25a2e828d1b2c1c51adcd7b597f3cd64a211396a506a09c0 +source_hash: 735078df56cfbe89dc569d36a87d31c7eccd69848f19465d3bf66ac55e5f2238 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index a5017f184..7cb9b2d1c 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 2936067de4c8baa0ff06ff5175d23d3c92a65de758bd656f4bbffd60529f41b6 +source_hash: 4431b17e74c51ece10dc606c8c2078aeb5e974259cf954868f79b797c91d63a7 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -39,7 +39,7 @@ Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源 | Environments | retrieve | 已实现;以 Session 为范围([归属](environments.md#resources-and-states))。固定版本契约会拒绝 `environment.environment_id`;已有 hosted Environment 无法跨 Session 关联。跨 Session 共享的工作区属于应用([项目拥有资产](../../../docs/zh/concepts.md#projects-own-assets)) | [Environments](environments.md) | | Environment files | create, list | 已实现;列表不会递归 | [Environment files and Artifacts](environment-files.md) | | Environment 模板 | create, retrieve, update, list, delete | 已实现;执行限制列于 [known gaps](#known-gaps) | [Environment 模板](environments.md#templates) | -| Vaults | create, retrieve, list, delete | 已实现;没有归档操作 | [Vaults and Credentials](vaults.md) | +| Vaults | create, retrieve, list, delete | 已实现 | [Vaults and Credentials](vaults.md) | | Vault Credentials | create, retrieve, update, list, delete | 已支持 `static_bearer` 和 `mcp_oauth` | [Vaults and Credentials](vaults.md) | | Files | create, retrieve, list, delete, content | 已支持 `purpose=user_data`;内容下载会被拒绝 | [Files and Skills](source-files.md) | | Skills and Skill versions | create, retrieve, update, list, delete, content | 已实现 | [Files and Skills](source-files.md) | @@ -101,7 +101,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **Files、Skills、Environment files 和 Artifacts**([Files and Skills](source-files.md)、[Environment files and Artifacts](environment-files.md)) -- 文件上传上限为 512 MiB;官方上限为 512 MB。Files 列表默认最多返回 10,000 个 Files,`purpose` 过滤值不是 `user_data` 时会返回空页面。 +- 文件上传上限为 512 MiB;官方上限为 512 MB。`purpose` 过滤值不是 `user_data` 时会返回空页面。 - Skill 版本号绝不复用,并且针对某个 Skill 的上传和删除会串行执行。 - Environment files 可用于 `self_hosted` Environments,而官方服务会拒绝。 - 在已有常规文件上创建 Environment file 会返回 "must not traverse symlinks or overwrite existing files" 消息。若父级符号链接仍位于工作区内,则会跟随该链接;若父级逸出工作区或父级本身是常规文件,则会返回通用 400;官方服务会拒绝父级符号链接。 @@ -121,7 +121,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **配置和工具** -- 所选 Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值,会被保存,但在 Session 准入时被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 +- 所选 Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`text.verbosity`、`web_search` 模式或设置、`programmatic_tool_calling` 取值,会被保存,但在 Session 准入时被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 - Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不支持图像输入。 - 由模型推导出的推理默认值不会被解析确定。 @@ -151,7 +151,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **Vaults 和 Credentials** -- 没有归档生命周期、存储密钥轮换或重新加密。 +- 没有存储密钥轮换或重新加密。 - OAuth 刷新仅在派发时执行:提供商返回 401 时不会刷新,不会在 Turn 中途替换令牌,也不会撤回已发送给 Runtime 的令牌。 - 发送到所选 Credential 已被删除的 Session 的输入会先通过准入,随后在派发时失败。 - 创建 Credential 时不接受 `Idempotency-Key`。 diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 72ecae899..4596ede7e 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 385786135904715e0b92b5978cade8a4313d4004610b4ea5a1310d0b6ff30900 +source_hash: 57d2c630e22ee6e6b7333c36bb042af74e820c1205bb9fb6d41cb8f54acd605d --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -41,7 +41,7 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 ` | `configuration` | 提供商的公开选择器。E2B:不可变的 `template` 构建以及可选且配套的 `api_url` 和 `domain`。Docker 和 microsandbox 仅接受 `{}` 或省略 | | `credential` | 提供商的只写凭据。E2B:`{api_key}`,首次设置时必填,在 PUT 中省略以保留当前密钥;null 或空密钥无效。Docker 和 microsandbox 拒绝该字段 | -请求中没有 Core 地址。Core 根据安装公开 URL(`config.json` 中的 `public_url`,Core 对应 `OAC_PUBLIC_URL`)派生部署的 `core_url`:这是节点和沙箱客户机访问 Core 时使用的源地址。包含 `core_url` 的请求会像包含任何其他未知成员一样被拒绝,并返回 400 `invalid_request`。E2B 客户机从 E2B 云访问 Core,因此当公开 URL 为回环地址时,E2B 选择会被拒绝,并返回 409 `sandbox_configuration_error`。Docker 和 microsandbox 选择接受回环公开 URL,但这仅适用于本地开发,因为客户机的回环地址无法访问其主机。更改公开 URL 属于安装变更:使用旧地址注册的节点不会收到新沙箱,必须移除后重新添加。 +请求中没有 Core 地址。Core 根据安装公开 URL(`config.json` 中的 `public_url`,Core 对应 `OAC_PUBLIC_URL`)派生部署的 `core_url`:这是节点和沙箱客户机访问 Core 时使用的源地址。包含 `core_url` 的请求会像包含任何其他未知成员一样被拒绝,并返回 400 `invalid_request`。E2B 客户机从 E2B 云访问 Core,因此当公开 URL 为回环地址时,E2B 选择会被拒绝,并返回 409 `sandbox_configuration_error`。Docker 和 microsandbox 选择接受回环公开 URL,但这仅适用于本地开发,因为客户机的回环地址无法访问其主机。更改公开 URL 属于安装变更;参见[更改公开 URL](../../../docs/zh/getting-started/public-address.md#changing-the-public-url)。 ### 资源 {#resources} @@ -197,7 +197,7 @@ stateDiagram-v2 节点容量独立于部署 specification 设置。`POST /core/v1/sandbox/enrollment-tokens` 接受可选的 `max_active` 和 `max_retained`,并返回 `{token, expires_at, enrollment_id}`;`enrollment_id` 是该命令的公共句柄,绝不是凭据。Core 将令牌与发送的限制一同存储,省略的限制保持未设置。节点注册时,Core 把每个显式限制原样复制给节点,并把每个未设置的限制设为注册请求中的主机测量值与当前 specification 的[推荐容量](#recommended-capacity);限制未设置且没有主机测量值时,返回 409 `sandbox_node_host_unmeasured`,令牌保持未使用。只设置 `max_retained` 时,推导出的 `max_active` 不会超过它;只设置 `max_active` 时,推导出的 `max_retained` 会被提高到至少等于 `max_active`。microsandbox 会使用两个限制。Docker 绝不暂停,因此 Core 会在此处和 PATCH 中用 `max_active` 替换其 `max_retained`。E2B 没有节点,并返回 409 `sandbox_deployment_conflict`。节点不能提交限制,但未设置的限制取决于节点在注册时报告的主机测量值,范围限定在 1 到 1000000。其本地检查可以拒绝其无法运行的部署,但绝不会提高限制。显式值和之后的 PATCH 修改具有约束力:节点注册之后,Core 不再推导。[节点容量](../../../docs/zh/configuration.md#node-capacity)为操作员说明了这些限制。 -`GET /core/v1/sandbox/nodes` 返回 `{data: [...]}`,其中每个节点包含 `id`、`name`、`provider`、`online`、`last_seen_at`、`created_at`、`max_active`、`max_retained`,计数项 `active`、`reserved`、`running`、`retained`、`snapshots` 和 `cleanup_pending`,以及 `provider_ready`、主机测量值 `cpu_count`、`available_memory_bytes` 和 `available_disk_bytes`、`rollout`、`recommended_capacity`、`enrollment_id` 和 `core_url`。`recommended_capacity` 是 `{max_active, max_retained}`,即节点最新健康状态与当前 specification 的[推荐容量](#recommended-capacity);在节点报告 CPU 数量或有效核数以及总内存之前,以及当前 specification 未通过校验时,为 null。`enrollment_id` 是注册该节点的命令的句柄;如果 Core 没有该句柄,则为 null。`core_url` 是注册时的安装公开 URL;`core_url` 与当前公开 URL 不同的节点不会收到新放置。已放置到该节点的工作会继续在那里完成,包括已放置但尚未获得分配的 Environment;只要旧地址仍能访问 Core,其保留沙箱仍可恢复。请移除该节点并重新添加。 +`GET /core/v1/sandbox/nodes` 返回 `{data: [...]}`,其中每个节点包含 `id`、`name`、`provider`、`online`、`last_seen_at`、`created_at`、`max_active`、`max_retained`,计数项 `active`、`reserved`、`running`、`retained`、`snapshots` 和 `cleanup_pending`,以及 `provider_ready`、主机测量值 `cpu_count`、`available_memory_bytes` 和 `available_disk_bytes`、`rollout`、`recommended_capacity`、`enrollment_id` 和 `core_url`。`recommended_capacity` 是 `{max_active, max_retained}`,即节点最新健康状态与当前 specification 的[推荐容量](#recommended-capacity);在节点报告 CPU 数量或有效核数以及总内存之前,以及当前 specification 未通过校验时,为 null。`enrollment_id` 是注册该节点的命令的句柄;如果 Core 没有该句柄,则为 null。`core_url` 是注册时的安装公开 URL;`core_url` 与当前公开 URL 不同的节点不会收到新放置。已放置到该节点的工作会继续在那里完成,包括已放置但尚未获得分配的 Environment;只要旧地址仍能访问 Core,其保留沙箱仍可恢复。参见[更改公开 URL](../../../docs/zh/getting-started/public-address.md#changing-the-public-url)。 节点管理代次,因此其 `provider_ready` 取决于服务代次,目标代次的固定代码会出现在 `rollout.diagnostic` 中。节点会对首次失败的就绪检查进行分类,并且只发送代码;携带任何其他值的帧均无效,且 Core 绝不存储或返回探测文本或主机路径。这些代码是与提供商无关的类别:`provider_unavailable`、`host_unsupported`、`artifacts_unavailable`、`runtime_image_unavailable` 和 `capacity_insufficient`。[就绪代码](../../../docs/zh/getting-started/nodes.md#readiness-codes)给出了原因和操作员应采取的措施。Core 和节点必须来自同一发行包。 diff --git a/contracts/agents-api/zh/subagents.md b/contracts/agents-api/zh/subagents.md index c4346f1aa..761b4fc9e 100644 --- a/contracts/agents-api/zh/subagents.md +++ b/contracts/agents-api/zh/subagents.md @@ -1,7 +1,7 @@ --- title: "子 Agent" source: contracts/agents-api/subagents.md -source_hash: 6c4fe5212d8473ff319fea4a6abf30d8038b75dd663dd9612ddad023adb918c7 +source_hash: d8341bfd7747152dea23afc930cf5fe07ff1ac4a58f503cb0e2ec5fe86060cb9 --- 启用 `multi_agent.enabled` 后,Harness 可以启动原生子 Agent。Core 通过锁定版本的 SDK(见 [`upstream.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))中的六项 Subagent 读取操作公开它们,并根据适配器观察结果记录它们。当 `multi_agent.enabled=false` 时,Runtime 会移除原生子 Agent 工具。[Harness capabilities](harness-capabilities.md) 列出各 Harness 在哪些组合中支持子 Agent。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index b9c0810c5..2c6c53efe 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: 1f374322fcfd4a9fda8162004342162b1c3b8e3b5f12c2f2871f68bd593751da +source_hash: ba9c6565567c1b4dcffb0c8f18731372b9e3c95e25f84dc0c3c2782c94161eb2 --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -40,7 +40,7 @@ Beta 路由要求 `OpenAI-Beta` 标头中恰好有一个值,且该值必须等 ### 身份验证 {#authentication} -`/v1` 仅接受项目 API 密钥,形式为 `Authorization: Bearer `。同一项目的所有密钥都视为同一调用方:它们共享该项目的资源和 Session 创建重试。Core 在每次请求时都在数据库中解析密钥及其项目,不缓存凭据,超时时间为五秒。撤销密钥或归档其项目会在下一次请求时生效。项目的所有密钥都使用主体 `service_account/project:`。[项目与密钥](admin-api.md#projects-and-keys) 介绍了密钥管理。 +`/v1` 仅接受项目 API 密钥,形式为 `Authorization: Bearer `。Core 在每次请求时都在数据库中解析密钥及其项目,不缓存凭据,超时时间为五秒。撤销密钥或归档其项目会在下一次请求时生效。项目的所有密钥都使用主体 `service_account/project:`。[项目与密钥](admin-api.md#projects-and-keys) 介绍了密钥管理。 可选的 `OpenAI-Organization` 和 `OpenAI-Project` 标头在发送时必须各出现一次,并分别等于 `core` 和 `proj_`;任何其他值都会导致密钥被拒绝。 diff --git a/deploy/README.md b/deploy/README.md index 642beaee9..397a74409 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -12,17 +12,17 @@ `install.sh` and `install.ps1` select a native `oac` release binary, verify its checksum and invoke `oac install`. That Go command owns the same installation lifecycle on Linux, macOS and Windows: validate Docker, verify release configuration, prepare `.env` and the host command, pull images, publish the installation directory, initialize the data volume and start Compose. [Installation](../docs/getting-started/install.md#prerequisites) owns platform prerequisites; [Configuration](../docs/configuration.md) owns settings and storage. -Preparation happens in `.staging`. On entry, the installer removes an empty stage or one with the empty ownership directory `.oac-installer-`; it preserves unrecognized directories. Ordinary failures remove owned staging immediately. The ownership marker is created atomically before downloading files. A killed process leaves staging for the next attempt. The release's `compose.yaml` is verified against its published checksum in the stage and must name the installer's own release in `OAC_REVISION`. Publication happens only after downloads, checks and pulls succeed. Later failures preserve saved configuration, containers and the Docker data volume. `.lock` is a persistent lock directory shared by installation and mutating operator commands; the existing `runtimefs` platform adapter owns locking. +Preparation happens in `.staging`. On entry, the installer removes an empty stage or one with the empty ownership directory `.oac-installer-`; it preserves unrecognized directories. Ordinary failures remove owned staging immediately. The ownership marker is created atomically before downloading files. A killed process leaves staging for the next attempt. The release's `compose.yaml` is verified against its published checksum in the stage and must name the installer's own release in `OAC_REVISION`. Publication happens only after downloads, checks and pulls succeed. Later failures preserve saved configuration, containers and the Docker data volume. `.lock` is a persistent lock directory shared by installation and mutating operator commands; the `runtimefs` platform adapter owns locking. An existing directory runs the release named by `OAC_REVISION` in its `compose.yaml`. When that is the installer's release, the installer resumes: it keeps the saved settings, pulls only missing images and starts the services the way `oac apply` does. The port check applies only to new directories. Another release is refused. -`install.py` resumes a source-bundle installation of its own release under the same lock; another release is refused, and `--public-url` is required only for a new directory. +`install.py` resumes a source-bundle installation of its own release under the same lock; another release is refused, and `--public-url` is required only for a new directory ([Offline hosts](../docs/getting-started/install-options.md#offline-hosts)). -`install.py` prepares a new installation in a `.staging-*` directory inside the installation directory: the node payload, `.env`, `oac` and both Compose files. After `docker compose config` accepts them, it moves them into place, `compose.yaml` last. An earlier failure removes the stage, and the directory too when the run created it, so the same command can be rerun; the next run also removes a stage left by a killed run. A directory with other contents but no `compose.yaml` is refused. +`install.py` prepares a new installation in a `.staging-*` directory inside the installation directory: the node payload, `.env`, `oac` and both Compose files. After `docker compose config` accepts them, it moves them into place, `compose.yaml` last. A failed or killed run leaves nothing to clean up by hand; rerun the same command ([Offline hosts](../docs/getting-started/install-options.md#offline-hosts)). A directory with other contents but no `compose.yaml` is refused. -The host `oac` command also implements `apply`, `core-key`, `rotate-core-key` and `uninstall`. `apply` runs init, which finishes a first start that stopped before it and refuses a data volume committed to another release, then checks Core configuration before starting services and recreating those that changed. Reading the key executes `oac-web core-key` in Web. Rotation runs in the initialization container, where Linux file ownership is identical on every host, then restarts Core and Web. Start, stop and logs use `docker compose`; `oac uninstall` removes the installation, its data volume, the lock and the sidecar. The initialization image verifies and copies bundled node metadata without network access. Its receipt authenticates immutable metadata and credentials; the operator-rotatable Core key and its digest remain mutable. No service receives a Docker socket. Initialization emits structured step logs without credential values; view them with `docker compose logs --timestamps init`. +The host `oac` command also implements `apply`, `core-key`, `rotate-core-key` and `uninstall`. `apply` runs init, which finishes a first start that stopped before it and refuses a data volume committed to another release, then checks Core configuration before starting services and recreating those that changed. Reading the key executes `oac-web core-key` in Web. Rotation runs in the initialization container, where Linux file ownership is identical on every host, then restarts Core and Web. Start, stop and logs use `docker compose`; `oac uninstall` removes the installation, its data volume, the lock and, on Windows, the lock file beside the removed directory. The initialization image verifies and copies bundled node metadata without network access. Its receipt authenticates immutable metadata and credentials; the operator-rotatable Core key and its digest remain mutable. No service receives a Docker socket. Initialization emits structured step logs without credential values; view them with `docker compose logs --timestamps init`. -Web serves the console and forwards `/v1` and `/api/v1` to Core, so it is the only published service. HTTPS is terminated by the operator's reverse proxy or hosting platform, which routes to `web:8080`; `OAC_PUBLIC_URL` records that origin. +Web is the only published service; [Web server](../docs/web/console-server.md#forwarding-to-core) defines what it serves and forwards. HTTPS is terminated by the operator's reverse proxy or hosting platform, which routes to `web:8080`; `OAC_PUBLIC_URL` records that origin. ## Native daemon installer diff --git a/deploy/node/README.md b/deploy/node/README.md index 5307ff177..28ceb9616 100644 --- a/deploy/node/README.md +++ b/deploy/node/README.md @@ -23,7 +23,7 @@ The node installer runs as root and prepares the host for one node per installat - Root never runs a file the service account can write, opens a URL it wrote, or follows a link in its home. Capture the trusted bootstrap bytes before dropping to the service account and pass them through the fork; the service account writes its own retained generation helper. Never open the caller's private download directory to it or let root write into service-owned state. - The generated bootstrap passes only the six standard HTTP/HTTPS proxy and bypass variables through sudo and gives both spellings the lowercase value when present, even if empty, so curl, urllib and the Go registration command follow the same rules. The installation child keeps just those names beside its fixed environment. Proxy values stay out of arguments, unit files and diagnostics; never use broad sudo environment inheritance. The installation child uses them for downloads. The node service keeps the settings of the latest run in `/etc/oac-node/.env` (root, mode 0600, read through `EnvironmentFile=`; removed when a run has no proxy and by uninstall), with loopback added to `NO_PROXY`. - `--uninstall` removes a node only after Core rejects its credential, except for a node that never registered and with `--force`, which Web offers when the old Core address no longer responds. It never touches sandboxes, volumes or Docker images (the Runtime image stays, with the command that removes it). Once Core confirmed the removal it also deletes the node's microsandbox store, which `--force` never does (it prints the command instead). It deletes the account only when the installer created it and no node remains, and otherwise removes only the groups it added. -- Refuse resources of an older product name for the same installation ID; never adopt them or remove another installation's resources. +- Never adopt or remove another installation's resources. ## Download contract diff --git a/docs/concepts.md b/docs/concepts.md index da0cf2866..51cc6d65a 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -18,7 +18,7 @@ Application reads, writes and references are scoped to the key's Project. A reso ## What administrators can and cannot do -Administrators manage Projects and keys, sandbox deployments, nodes, executor credentials and deployment default models. They inspect resources, execution history, operational counts and usage, and delete resources under their deletion rules. Archiving a hosted Session requests cancellation and sandbox reclamation; see [Session archive](../contracts/agents-api/admin-api.md#session-archive). +Administrators manage Projects and keys, sandbox deployments, nodes, executor credentials and deployment default models. They inspect resources, execution history, operational counts and usage, and delete resources under their deletion rules. Archiving a hosted Session is defined in [Session archive](../contracts/agents-api/admin-api.md#session-archive). Creating or editing application assets, starting Sessions and submitting input require a Project API key. The Core key gives no application identity and cannot read stored secrets. The [Core administration API](../contracts/agents-api/admin-api.md) defines its operations; the [Console server](./web/console-server.md) owns Web sign-in and credential handling. diff --git a/docs/configuration.md b/docs/configuration.md index 5d84f9394..b8d89e049 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -130,7 +130,7 @@ The installer creates `~/.oac/core` by default (`$HOME/.oac/core` on Windows). I | `compose.override.yaml` | Optional local Compose entries. For a source bundle it is JSON and also holds Web's `/node-payload` bind and Core's native installer bind | You. `install.py` replaces only its own entries and requires JSON | | `oac` (`oac.exe` on Windows) | Native management command | The installer | -The sibling `.lock` directory remains for synchronization; `.staging` holds unpublished installation files. Neither contains service data. On Unix the installer creates private directories with mode `0700` and configuration files with mode `0600`. +The sibling `.lock` directory is for synchronization; `.staging` holds unpublished installation files. Neither contains service data. On Unix the installer creates private directories with mode `0700` and configuration files with mode `0600`. The Compose project is named `oac-<10 hex digits>`. Its services are `init`, `database`, `core` and `web`. Core applies database migrations when it starts. Web serves the console and is the only service with a published port, `OAC_WEB_PORT`; see [Request boundary](./web/console-server.md#request-boundary) for what it forwards. No service receives a Docker socket, and Web and Core have no KVM access. @@ -167,7 +167,7 @@ Compose sets these for Web. Set them yourself only when you run the console with | `OAC_WEB_ADDR` | `:8080` | Listener address. `oac-web healthcheck` probes its `/healthz` on `127.0.0.1` when its host is empty or unspecified | | `OAC_PUBLIC_URL` | Required | The [public URL](#settings): the exact browser-facing origin, HTTP or HTTPS, without a path. Host and origin checks use it; HTTPS makes the session cookie `Secure` | | `OAC_WEB_UPSTREAM` | `http://core:8091` | Core's origin, HTTP or HTTPS, without credentials, query or path | -| `OAC_WEB_CORE_KEY_FILE` | Required | Absolute path of a regular file with no group or other permissions, holding the Core key: at least 32 characters, no whitespace, at most 4 KiB | +| `OAC_WEB_CORE_KEY_FILE` | Required | Absolute path of a regular file with no group or other permissions, holding the [Core key](getting-started/operations.md#core-key), at most 4 KiB | | `OAC_WEB_DIST` | `/www` | Absolute directory of the built console; must contain `index.html` | | `OAC_WEB_NODE_PAYLOAD_DIR` | unset | Absolute path of the matched distribution's node payload (the installer's `node-payload/`). Unset, `/node-install/*` is not served and Add node is unavailable | diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index ca7401a54..17b8b6dc2 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -91,7 +91,7 @@ Core-hosted Sessions that bring no model provider use their Harness's default mo 1. On **Projects and keys**, choose **Create project**, then **Issue key**. The dialog shows the key once: copy it and keep it safe. Its **How to call** card shows the API base URL and sample requests. 2. Give the key and the API base URL to the application developer. They continue with [Run your first Session](./quickstart.md). -## Reach Core from other machines {#configure-the-domain-and-https} +## Reach Core from other machines This step is optional for a trial on one machine. Applications, nodes and sandboxes reach Core at one address, the public URL. HTTP is enough on the local network; beyond it, put a reverse proxy in front and set the public URL to the HTTPS origin it serves. [Configure the public address](./public-address.md) covers the reverse proxy, ports and changing the address later. diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 5e2b12f69..32f6c51e6 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -110,7 +110,7 @@ The [reset contract](../../contracts/agents-api/sandbox-deployment.md#generation ## Checkpoint recovery across nodes -For microsandbox checkpoint recovery across nodes, add `--checkpoint-root /absolute/private/shared-store` to the installer command on each participating node. Mount the same private store at those paths before installation and grant only the node service account access. The default is a private node-local store and supports recovery on that node. The checkpoint store is separate from workspace storage and must never be guest-accessible; the [checkpoint transfer contract](../sandbox-provider.md#checkpoint-transfer) owns compatibility and retention requirements. +For microsandbox checkpoint recovery across nodes, add `--checkpoint-root /absolute/private/shared-store` to the installer command on each participating node. Mount the same private store at those paths before installation and grant only the node service account access. The default is a private node-local store and supports recovery on that node. The [checkpoint transfer contract](../sandbox-provider.md#checkpoint-transfer) owns the store's isolation, compatibility and retention requirements. microsandbox includes the host kernel and CPU profile in its checkpoint execution compatibility. An OS or CPU-profile change can therefore make retained checkpoints incompatible even on their original node. Before maintenance, keep a node with the matching execution class and the same ready generation available for those checkpoints; otherwise their requests wait under the [checkpoint retention policy](../sandbox-provider.md#checkpoint-transfer). diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 5ebee95a7..3a97bcb7f 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -127,7 +127,7 @@ Core records which key made each public resource write; the retention of that hi A recoverable backup contains one consistent, stopped-write set. A database dump or Core volume export alone does not include independent workspace storage or node compute state. Restore the data volume together with the installation directory from the same backup, which holds the release that wrote it. -### Single-host backup and restore {#single-host-backup} +### Single-host backup and restore For an installation whose data all lives in its Docker volume, with no independent workspace storage and no node state to keep, the data volume and the installation directory are the whole backup. This recipe uses a Unix shell and the PostgreSQL image the installation already pulled. @@ -164,8 +164,7 @@ An installation with independent workspace storage or nodes needs the full proce - Every independent workspace namespace selected by current or retained objects, even when it is outside the installation volume. Back up its entire root, including `.oac-storage-root`, object identities, staging, live data, trash and deletion markers. Preserve numeric ownership, modes, links and extended attributes, including every `user.*` attribute. Copying only `live/data` loses lifecycle evidence and can resurrect deleted identities. [Workspace storage](../workspace-provider.md#kernel-nfs-adapter) owns the layout and ownership requirements. - For a [quota mode](../workspace-provider.md#quota-mode) namespace, the entire XFS filesystem holding the export as one block-level copy: a storage or LVM snapshot, or an image of the frozen or unmounted device. Only such a copy keeps project IDs, quota limits, inode identities, the filesystem UUID and the `oac-workspace-quota` journal consistent. A file-level copy, `tar`, `rsync` or `xfsdump` loses them; the command then refuses the restored objects instead of serving them unbounded. - Each node's state directory, `/var/lib/oac-node/.oac/nodes//`, and its provider storage: Docker volumes or microsandbox's store. Include all compute and snapshot resources still referenced by the database. See [when a node host fails](./nodes.md#when-a-node-host-fails). - -- Each private `checkpoint_root` in its entirety, including `.oac-checkpoint-store`, checkpoints, operation journals, lock files and tombstones, together with the matching Core database. It is separate from the native SDK store and independent workspace namespace. Preserve its identity and private ownership; the [checkpoint transfer contract](../sandbox-provider.md#checkpoint-transfer) owns its lifecycle. +- Each private `checkpoint_root` in its entirety, including `.oac-checkpoint-store`, checkpoints, operation journals, lock files and tombstones, together with the matching Core database. The [checkpoint transfer contract](../sandbox-provider.md#checkpoint-transfer) owns its identity, ownership and lifecycle. ### Establish a stopped-write window @@ -192,7 +191,7 @@ Keep application ingress and Core/node services stopped while restoring. Fence o Restore matching secrets and database state, the complete independent filesystem namespaces and any required node identities/provider stores before starting Core. Preserve the database's configuration and namespace UUIDs, object identities and deletion markers; do not run first-time namespace initialization over restored storage. Restore a quota mode filesystem by writing its block-level copy to the backing device and mounting it with `prjquota` at the configured mount point; never run `oac-workspace-quota init` over it, and do not mount the copy beside the original, which has the same filesystem UUID. Reestablish the configured mount paths and service UIDs, then validate the mounted filesystem, ownership and xattrs from each participating service context. [Set up workspace storage](./workspace-storage.md) owns deployment setup. -The cold released baseline resumes only qualified native history after new compute admission. A backup containing retained checkpoints also depends on the matching native runtime, node identity, provider store and external filesystem identities. File-level restore can change inode identity and prevent strict checkpoint restore even when file bytes match. This procedure does not promise portable memory snapshots or restoration onto an arbitrary replacement node; preserve unresolved ownership rather than substituting a new sandbox. +A cold restore resumes only qualified native history after new compute admission. A backup containing retained checkpoints also depends on the matching native runtime, node identity, provider store and external filesystem identities. File-level restore can change inode identity and prevent strict checkpoint restore even when file bytes match. Memory snapshots are not portable and restoration onto an arbitrary replacement node is unsupported; preserve unresolved ownership rather than substituting a new sandbox. Start the database, Core and node services only after their restored dependencies are complete; keep ingress restricted while checking them. In an isolated rehearsal, verify a retained Session's files and native continuation on fresh compute where qualified, confirm that deleted objects cannot be reopened, and check that no old writer can access the restored namespace. Record the results before relying on the backup for recovery. diff --git a/docs/getting-started/public-address.md b/docs/getting-started/public-address.md index f4defced5..e7e3fa61b 100644 --- a/docs/getting-started/public-address.md +++ b/docs/getting-started/public-address.md @@ -6,7 +6,7 @@ Set the one origin that browsers, nodes, sandboxes and applications use to reach ## Choose the public URL -`OAC_PUBLIC_URL` is the origin applications, nodes, sandboxes and self-hosted executors use. Web accepts only its host. Core derives the daemon WebSocket URL, the self-hosted `remote_url` and each sandbox's connection address from it. It is an http or https origin without a path. +`OAC_PUBLIC_URL` is the origin applications, nodes, sandboxes and self-hosted executors use. Web accepts only its host. Core derives its other addresses from it, listed under [`OAC_PUBLIC_URL`](../configuration.md#appendix-core-environment-without-the-installer). It is an http or https origin without a path. | Situation | Public URL | | --- | --- | diff --git a/docs/maintainers.md b/docs/maintainers.md index 74a1a22ef..3a9090c3b 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -10,7 +10,6 @@ A distribution is a matched set of release assets built from one commit: the con Core, Web and ingress images are published as verified Linux amd64/arm64 indexes. The arm64 control archive contains those three images; Node, hosted Runtime and offline payloads use Linux amd64. Release builders use QEMU for ARM image steps, including the E2B helper. Host `oac` binaries are built from the same command for Linux amd64/arm64, macOS amd64/arm64 and Windows amd64; launchers only select, verify and invoke them. Each version index is checked against its platform archives before floating tags move. - Build on Linux x86_64 with a glibc compatible with Debian 12, Docker, the Go version in `go.mod`, a C compiler (the microsandbox helper is a CGO build), Node, pnpm, Python 3.9 or newer, curl, tar, pigz and sha256sum. The source must be clean and committed. First prepare the pinned Codex package and MiniMax Code companion, then build: ```sh @@ -62,7 +61,7 @@ The catalog records the commit, the Runtime protocol version, each artifact's SH `make build-core-distribution` builds all of these. Build one on its own to test a Harness image or a helper. Run every command from the repository root; default outputs go under `${OAC_DEV_HOME:-$HOME/.oac}/build`. -The three maintained Runtime images share a build-time adjustment to the pinned Debian login profile: an inherited, exported `PATH` is preserved, while an unset `PATH` receives Debian’s defaults. This keeps the Runtime’s initialized package and user paths available in login and non-login tools without another package-path setting. The combined image inherits the same profile. A changed upstream profile fails the build for review; custom shell startup files can still explicitly change `PATH`. +The three maintained Runtime images share the digest-pinned base `node:22.23.1-bookworm-slim` with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip` and `ripgrep`, and a build-time adjustment to the pinned Debian login profile: an inherited, exported `PATH` is preserved, while an unset `PATH` receives Debian’s defaults. This keeps the Runtime’s initialized package and user paths available in login and non-login tools without another package-path setting. The combined image inherits the same profile. A changed upstream profile fails the build for review; custom shell startup files can still explicitly change `PATH`. **Codex Runtime image.** Extract the official npm package `@openai/codex@0.153.4-linux-x64` under `~/.oac` (for example with `npm pack --ignore-scripts` and `tar -xzf`), then: @@ -119,7 +118,7 @@ The helper is written to `~/.oac/build/microsandbox-provider/oac-microsandbox-pr ### Standalone Core builds -`make build-core` builds `oac-core`, `oac-core-device`, `oac-core-environment-key` `oac-node` and `oac` into `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core` (`OAC_DEV_CORE_BUILD_DIR` selects another absolute directory). The build copies only the source set listed in `scripts/build-core.sh` (the Core service, its contracts, the shared packages it needs and the root Go module files) into a temporary context and builds with CGO disabled, read-only modules and trimmed paths. It needs no Node, Docker or other application. When Core gains a shared dependency, add that package to the list; never copy the whole repository to make it compile. +`make build-core` builds `oac-core`, `oac-core-device`, `oac-core-environment-key`, `oac-node` and `oac` into `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core` (`OAC_DEV_CORE_BUILD_DIR` selects another absolute directory). The build copies only the source set listed in `scripts/build-core.sh` (the Core service, its contracts, the shared packages it needs and the root Go module files) into a temporary context and builds with CGO disabled, read-only modules and trimmed paths. It needs no Node, Docker or other application. When Core gains a shared dependency, add that package to the list; never copy the whole repository to make it compile. `make docker-build-core` builds the image `oac-core:dev` (`OAC_DEV_CORE_IMAGE` selects another name) from those five commands and the E2B helper. The base is the digest-pinned `debian:bookworm-slim` with CA certificates and the glibc runtime the helper needs; the default user is UID/GID 65532 and Core listens on `:8091`. This local target builds Linux amd64; the [distribution build](#build-a-distribution) builds both architectures. Changes to the image or its build need `make check-core-container` in addition to the relevant source checks: it runs the official-client suite against the image with a read-only root filesystem and needs Linux Docker, a non-root user, and the [test database and pinned SDK](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/README.md#official-client-verification) of the service checks (`OAC_TEST_DATABASE_URL` naming an `oac_*_tests` database with the migrations applied, and `OAC_TEST_OFFICIAL_SDK_PYTHON`). @@ -224,9 +223,9 @@ Use **Actions → core-check → Run workflow** for a manual full check. For a t The `CI review and Feishu notification` workflow runs once after a PR merges into main. It checks out the merged commit, reads that PR's existing checks and logs, and sends one Feishu card through `FEISHU_WEBHOOK_URL` that summarizes the change, its fit with the repository rules and the actual CI status. It does not trigger another test run, and a failed review or delivery never fails the job or blocks merges. Closing an unmerged PR does not invoke the review. The workflow uses `pull_request_target` only for the merged event and never checks out an unmerged PR head with notification credentials. -Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Native archives are built and uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. +Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Native archives are built and uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Release distribution artifacts retain their recovery policy; failed publication can reuse the original build as described above. -The local Node composite action installs the pinned pnpm and caches its package store by lockfile, OS, architecture, Node version and pnpm version. It caches downloaded packages, not `node_modules`; installs remain frozen. Go partitions retain the existing module/compiler caches described under [publication](#publish-a-version). Cache hits seed work and never replace tests. The three native platforms run independently; parallel execution reduces elapsed time without reducing total machine time. +The local Node composite action installs the pinned pnpm and caches its package store by lockfile, OS, architecture, Node version and pnpm version. It caches downloaded packages, not `node_modules`; installs remain frozen. Go partitions retain the module/compiler caches described under [publication](#publish-a-version). Cache hits seed work and never replace tests. The three native platforms run independently; parallel execution reduces elapsed time without reducing total machine time. For a local change, inspect the selected groups and choose focused checks according to [Checks for a change](../CONTRIBUTING.md#checks-for-a-change): @@ -237,7 +236,7 @@ make check-ci `make check` remains the full local entry point with an unsharded Web suite and an unsharded Core integration test package. `make check-web-unit` and `make check-web-acceptance OAC_WEB_TEST_SHARD=1/4` expose the Web parts; `make check-core-packages` and `make check-core-integration OAC_CORE_INTEGRATION_SHARD=1/3` expose the Core parts, with integration tests assigned to shards by a stable hash of their names. The selection tests cover mixed changes, shared consumers, renames/deletions, unknown inputs, shallow merge checkouts and failed/cancelled/missing results. For changes to the selection map, replay representative diffs for the affected rules. For workflow changes, run actionlint and validate the changed scheduling or partition behavior. Use real component runs only when needed to validate behavior affected by the change. -Measure completed runs with `python3 scripts/ci_metrics.py RUN_ID ...`. It reports the latest attempt's summed runner minutes, elapsed time and initial queue delay from that attempt's start, peak concurrent jobs, platform breakdown and job outcomes/failure fraction. Only jobs assigned a runner in that attempt contribute machine time and execution concurrency; jobs cancelled while queued retain their outcome and wall time. Earlier attempts are not included. Failed-job reruns can carry earlier successful results: their outcomes appear separately and their old execution time is excluded. A missing rerun start timestamp stops measurement because reused jobs cannot be separated reliably. Keep run/head/attempt identities with comparisons, and report cancellations and unfinished runs separately. Raw runner minutes are not billed minutes; use each platform's published conversion and allowance rules before estimating cost. A small successful sample is not a long-term failure-rate estimate. Scheduled full runs are outside this policy. +Measure completed runs with `python3 scripts/ci_metrics.py RUN_ID ...`. It reports the latest attempt's summed runner minutes, elapsed time and initial queue delay from that attempt's start, peak concurrent jobs, platform breakdown and job outcomes/failure fraction. Only jobs assigned a runner in that attempt contribute machine time and execution concurrency; jobs cancelled while queued retain their outcome and wall time. Earlier attempts are not included. Failed-job reruns can carry earlier successful results: their outcomes appear separately and their old execution time is excluded. A missing rerun start timestamp stops measurement because reused jobs cannot be separated reliably. Keep run/head/attempt identities with comparisons, and report cancellations and unfinished runs separately. Raw runner minutes are not billed minutes; use each platform's published conversion and allowance rules before estimating cost. A small successful sample is not a long-term failure-rate estimate. ### CI runners and free allowance diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index b8db55b7e..a46638a76 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -136,7 +136,6 @@ A new provider takes these steps: 6. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. 7. Web's setup step, deployment summary and client projection name each provider. Add the new provider to `apps/web` until the protocol declares configuration fields. - A node configuration, `sandbox.NodeConfig`, holds `provider`, `generation`, `installation_id`, `core_url`, `specification` and the adapter's opaque `native` object. `providers.Build` validates `provider`, `generation`, `installation_id` and `specification`, and passes the configuration with ephemeral `sandbox.LocalOptions` to `BuildLocal`, which decodes `native` strictly. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. ### Registration validation @@ -252,6 +251,7 @@ A Provider relies on these rules: - A retained checkpoint resumes on its original node when it is eligible, or on another eligible node through [checkpoint transfer](#checkpoint-transfer). Core owns the snapshot's retention deadline, and the daemon has no timer for it. - A consumed snapshot never rolls a running generation back. Consumed artifacts and old compute are deleted, so suspension cycles never build a chain of writable disks. - Deletion, revocation and retention expiry win over wake, up to the final database compare-and-swap. Unknown cleanup identities are kept until owned resources are confirmed absent. + ### Checkpoint transfer A checkpoint-capable generation reports `CheckpointCompatibility`: opaque `ArtifactDomain` and `ExecutionClass` tokens. Every verified `SnapshotIdentity` carries the same qualification. Core compares these values without interpreting CPU features, filesystem paths or storage implementations. A restore destination must be online and ready for the allocation's immutable deployment generation, match both tokens, and have active capacity plus a retained slot when moving from another node. The allocation, Device and Session identities remain unchanged. The Session and deployment transaction commits the destination route, placement and exact restore intent before target-side native work. No capacity or compatible destination leaves the retained snapshot owned until its configured deadline. The target must already hold that exact generation; Core does not automatically prepare historical generations on new nodes. A node keeps a superseded generation's provider until its checkpoint retention obligations end. diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 97a29c595..49103e848 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -73,7 +73,7 @@ In an archived project the section hides **Issue credential** and **Rotate** beh | Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | | Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | | Installation | `GET /core/v1/installation` | Settings → Service and deployment installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the call samples; `public_url` as the download origin and `--source-url` of the node install and uninstall commands (and the install command's `--core-url`). A sensitive setting with a value, or an unknown member, fails the read | -| Core metrics | `GET /core/v1/metrics?range=` | Core metrics page. A Core without the route (404) is shown as not reporting. [Core metrics](../../contracts/agents-api/core-metrics.md) defines every measurement | +| Core metrics | `GET /core/v1/metrics?range=` | Core metrics page. [Core metrics](../../contracts/agents-api/core-metrics.md) defines every measurement | `local_only`, or no `public_url`, stops Add node from issuing a command and Clean up the host from giving one. Overview, Nodes and Settings then show a visible warning, with Review the public address leading to Settings → Service and deployment. Nodes disables Add node with a visible reason, and Getting started leaves its sandbox step to do. @@ -89,7 +89,7 @@ Summary figures are cumulative per Session and are not billing records. Sessions | Operation | Route | Console use | | --- | --- | --- | -| List Harnesses | `GET /core/v1/harnesses` | Settings → Models and credentials default model cards: each Harness's readiness (Ready when `enabled` and its model configuration is set, otherwise Model provider not set or Disabled), `default`, its model configuration without the key, and Usage details from the configuration's `last_used_at`, `last_error_code` and `last_error_at`; the Overview's Getting started (the default Harness is Ready, or any Harness when none is default) | +| List Harnesses | `GET /core/v1/harnesses` | Settings → Models and credentials default model cards: each Harness's readiness ([readiness](../configuration.md#runtime-settings-web)), `default`, its model configuration without the key, and Usage details from the configuration's `last_used_at`, `last_error_code` and `last_error_at`; the Overview's Getting started (the default Harness is Ready, or any Harness when none is default) | | Set or replace | `PUT /core/v1/harnesses/{harness}/model-configuration` | **Set** or **Replace**: the complete model configuration with its write-only provider key, never prefilled and never retried; a 400 shows Core's message in the form; then the list is read again | | Clear | `DELETE /core/v1/harnesses/{harness}/model-configuration` | **Clear**, confirmed, then the list is read again | diff --git a/docs/web/console-server.md b/docs/web/console-server.md index 50f096fa5..8ecd5beee 100644 --- a/docs/web/console-server.md +++ b/docs/web/console-server.md @@ -80,7 +80,7 @@ The administrator signs in with the deployment's [Core key](../getting-started/o - The session cookie `core_console_session` is HttpOnly, `SameSite=Strict`, and `Secure` when `OAC_PUBLIC_URL` is HTTPS. It lasts 12 hours. - Sessions live only in the console's memory, at most 64 at a time; the oldest is dropped first. A console restart or a Core key rotation signs everyone out. - At most two sign-in checks run at once; another attempt gets 429 with `Retry-After: 1`. -- Failed attempts share a budget of 10 per minute; beyond it, a wrong key gets 429 with `Retry-After: 60`. The correct key always signs in, which is why the console refuses to start with a Core key shorter than 32 characters. +- Failed attempts share a budget of 10 per minute; beyond it, a wrong key gets 429 with `Retry-After: 60`. The correct key always signs in, which is why the console refuses to start with a Core key that breaks the [key format](../getting-started/operations.md#core-key). Sign-in errors: 400 for a malformed body, 401 `Invalid Core key`, 405 for a method other than `POST`, 415 for a body that is not JSON, 429 as above, and 503 when the console cannot create a session. diff --git a/docs/web/index.md b/docs/web/index.md index df57f68d5..80887254d 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -27,7 +27,7 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion, with the Project and the creating key of each resource | | Platform | Projects and keys | Create, rename and archive Projects; issue and revoke keys; each Project's usage, write history and how to call the API | | Platform | Nodes | Add, edit and remove microsandbox or Docker nodes; each node's readiness, active and retained limits, host CPU and memory with their history, free disk, and allocations with elapsed time in each compute phase | -| Platform | Settings | **Sandbox and scheduling**, **Workspace storage**, **Models and credentials**, and **Service and deployment**; see [configuration](../configuration.md#runtime-settings-web) | +| Platform | Settings | Platform configuration, one group per [runtime setting](../configuration.md#runtime-settings-web) | Missing data is shown as missing (—), never as zero. [Console API usage](./console-api-usage.md) lists what each page reads and how its figures are bounded. @@ -42,7 +42,7 @@ Missing data is shown as missing (—), never as zero. [Console API usage](./con | Issue, rotate or revoke a self-hosted executor's credential, or copy its install command | The Session's page in the **Session log**; see [Run self-hosted Sessions](../getting-started/self-hosted.md) | | Delete a resource, for example a leaked Credential | The resource's row in its list, or its page; Files are deleted from the Files list. The public deletion rules apply | -Web observes and administers the installation. It never starts a Session or sends input; agents run only when applications call `/v1`. Archiving a hosted Session requests cancellation and sandbox reclamation. [Concepts](../concepts.md#what-administrators-can-and-cannot-do) defines what administrators can do. +Web observes and administers the installation. It never starts a Session or sends input; agents run only when applications call `/v1`. Archiving a hosted Session is defined in [Session archive](../../contracts/agents-api/admin-api.md#session-archive). [Concepts](../concepts.md#what-administrators-can-and-cannot-do) defines what administrators can do. A new Project key is shown once, together with the calls that use it: diff --git a/docs/zh/concepts.md b/docs/zh/concepts.md index 457ec541b..03d1bce5e 100644 --- a/docs/zh/concepts.md +++ b/docs/zh/concepts.md @@ -1,7 +1,7 @@ --- title: "概念" source: docs/concepts.md -source_hash: 378b3621d64bbe1d70057c048c5bac457361dea628a30c5e60704d69d17277ca +source_hash: a0749db928e9f8767f5547dec2e6fb4887d1ace214c6fb8a08fe2e7fd5667af0 --- 项目是 OpenAgentCore 的执行租户。应用使用其 API 密钥;运维人员使用独立的 Core 密钥管理安装实例。[API 命名空间和凭据](api/index.md)将每类调用方映射到对应命名空间和凭据。 @@ -20,7 +20,7 @@ source_hash: 378b3621d64bbe1d70057c048c5bac457361dea628a30c5e60704d69d17277ca ## 管理员可以做什么 {#what-administrators-can-and-cannot-do} -管理员管理项目和密钥、沙箱部署、节点、执行器凭据及部署默认模型。他们可以检查资源、执行历史、运行计数与用量,并按删除规则删除资源。归档托管 Session 会请求取消和沙箱回收;参见 [Session 归档](../../contracts/agents-api/zh/admin-api.md#session-archive)。 +管理员管理项目和密钥、沙箱部署、节点、执行器凭据及部署默认模型。他们可以检查资源、执行历史、运行计数与用量,并按删除规则删除资源。归档托管 Session 的行为由 [Session 归档](../../contracts/agents-api/zh/admin-api.md#session-archive)定义。 创建或编辑应用资产、启动 Session 和提交输入都需要项目 API 密钥。Core 密钥不提供应用身份,不能读取已保存的秘密。[Core 管理 API](../../contracts/agents-api/zh/admin-api.md) 定义其操作;[控制台服务器](web/console-server.md) 负责 Web 登录与凭据处理。 diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index d68e58b04..c33e626cb 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 5519609be1b60dbd8c1b3aaac6aa8500f3907bebdba50a9e2ce6cb4ebf928b13 +source_hash: e9ca5912f98f2acb49373b42e256a7343ea086bb195cccb3d3612085f8aee2de --- Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类: @@ -171,7 +171,7 @@ Compose 为 Web 设置这些变量。仅在不使用 Compose 运行控制台时 | `OAC_WEB_ADDR` | `:8080` | 监听地址。主机部分为空或未指定时,`oac-web healthcheck` 在 `127.0.0.1` 上探测其 `/healthz` | | `OAC_PUBLIC_URL` | 必填 | [公共 URL](#settings):面向浏览器的准确源地址,可以使用 HTTP 或 HTTPS,且不得包含路径。Host 和源地址检查使用此值;HTTPS 会使 Session Cookie 具备 `Secure` 属性 | | `OAC_WEB_UPSTREAM` | `http://core:8091` | Core 的源地址,可以使用 HTTP 或 HTTPS,且不得包含凭据、查询参数或路径 | -| `OAC_WEB_CORE_KEY_FILE` | 必填 | 常规文件的绝对路径,该文件没有组或其他用户权限,并保存 Core 密钥:至少 32 个字符、不含空白字符、最大 4 KiB | +| `OAC_WEB_CORE_KEY_FILE` | 必填 | 常规文件的绝对路径,该文件没有组或其他用户权限,并保存 [Core 密钥](getting-started/operations.md#core-key),最大 4 KiB | | `OAC_WEB_DIST` | `/www` | 已构建控制台的绝对目录;必须包含 `index.html` | | `OAC_WEB_NODE_PAYLOAD_DIR` | 未设置 | 所匹配发行版的节点载荷(即安装程序的 `node-payload/`)的绝对路径。未设置时,不提供 `/node-install/*`,且 **添加节点** 不可用 | diff --git a/docs/zh/getting-started/install.md b/docs/zh/getting-started/install.md index ec3f614c2..46a9f1f17 100644 --- a/docs/zh/getting-started/install.md +++ b/docs/zh/getting-started/install.md @@ -1,7 +1,7 @@ --- title: "安装 Core 和 Web" source: docs/getting-started/install.md -source_hash: ec59f9762d21e486cf84108e4dda087c9640095196527bb8059c6833279df8ec +source_hash: 6f5dbf881566fe1be1c4ae0ae998066f8b6df43b6d489eb88a328a6868ab7706 --- 本指南带运维人员从一台空主机到可用的安装:在 Docker 下运行的 Core、Web 控制台和 PostgreSQL,一个沙箱提供商,一个默认模型和第一个项目 API 密钥。Web 的 **概览** 通过 **新手引导** 清单跟踪同样的步骤。 @@ -93,7 +93,7 @@ curl -fsSL https://openagentcore.dev/install.sh | bash -s -- --public-url https: 1. 在 **项目与密钥** 中选择 **创建项目**,然后选择 **签发密钥**。对话框只显示一次密钥:复制并妥善保存。**如何调用** 卡片展示 API 基础 URL 和示例请求。 2. 将密钥和 API 基础 URL 交给应用开发者,他们可以继续阅读[运行第一个 Session](./quickstart.md)。 -## 从其他机器访问 Core {#configure-the-domain-and-https} +## 从其他机器访问 Core {#reach-core-from-other-machines} 在一台机器上试用时,此步骤可选。应用、节点和沙箱通过同一个地址访问 Core,即公开 URL。局域网上用 HTTP 即可;超出局域网时,在前面放反向代理,并把公开 URL 设为它提供的 HTTPS 源地址。反向代理、端口以及之后如何修改地址,见[配置公开地址](./public-address.md)。 diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index e59860b63..0863c5a6d 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,7 +1,7 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: fef6bd64da44efaa4ab1943901667c68c749790c36974ffb15b1b2845d62a7eb +source_hash: 16211a07449e565b8b6ad8856c0060140cd0a18ee16d16300ba969193afede74 --- 节点是一台 Linux 主机,为 Core 托管 Session 运行沙箱。本指南面向在沙箱提供商(参阅[选择沙箱提供商](./sandbox-backends.md))为 microsandbox 或 Docker 时添加、修改和移除节点的运维人员;E2B 不需要节点。 @@ -112,7 +112,7 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 ## 跨节点的检查点恢复 {#checkpoint-recovery-across-nodes} -microsandbox 跨节点检查点恢复要求在每个参与节点的安装命令加入 `--checkpoint-root /absolute/private/shared-store`。安装前将同一个私有 store 挂载到这些路径,并只允许 node 服务账户访问。默认使用节点本地私有 store,只支持在该节点恢复。检查点 store 独立于 workspace 存储,绝不能允许 guest 访问;[检查点转移契约](../sandbox-provider.md#checkpoint-transfer) 定义兼容性和保留要求。 +microsandbox 跨节点检查点恢复要求在每个参与节点的安装命令加入 `--checkpoint-root /absolute/private/shared-store`。安装前将同一个私有 store 挂载到这些路径,并只允许 node 服务账户访问。默认使用节点本地私有 store,只支持在该节点恢复。[检查点转移契约](../sandbox-provider.md#checkpoint-transfer) 定义 store 的隔离、兼容性和保留要求。 microsandbox 的检查点执行兼容性包含主机内核和 CPU profile。因此,操作系统或 CPU profile 变化可能使保留的检查点不再兼容,甚至无法在原节点恢复。维护前,应为这些检查点保留具有匹配 execution class 且已就绪同一 generation 的节点;否则,相关请求会按[检查点保留策略](../sandbox-provider.md#checkpoint-transfer)等待。 diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md index 2c3171110..6467c2309 100644 --- a/docs/zh/getting-started/operations.md +++ b/docs/zh/getting-started/operations.md @@ -1,7 +1,7 @@ --- title: "运维" source: docs/getting-started/operations.md -source_hash: c61c4d11097be8e0718c6c095c55b10a47020e5a2f83f24c504eb6753ca00b5c +source_hash: 4fd70597517560cfab44ba9b10adedf7713c3a9c5441f0babd07531330174fdc --- Core 安装运维人员的日常任务:`oac` 命令、服务健康状态、Core 密钥、备份和卸载。节点有[专门的指南](./nodes.md),[故障排查](./troubleshooting.md)列出了安装程序和访问方面的提示信息。设置见[配置参考](../configuration.md)。 @@ -129,7 +129,7 @@ Core 记录每次公开资源写入所使用的密钥;历史保留策略为 [` 可恢复的备份必须是一组在停止写入期间取得的一致数据。仅数据库转储或 Core 卷导出不包含独立工作区存储及节点计算状态。恢复数据卷时,同时恢复同一份备份中的安装目录,其中保存着写入该数据的版本。 -### 单机备份与恢复 {#single-host-backup} +### 单机备份与恢复 {#single-host-backup-and-restore} 如果安装的全部数据都在其 Docker 数据卷中,没有独立工作区存储,也没有需要保留的节点状态,那么数据卷加安装目录就是完整备份。以下流程使用 Unix shell,以及该安装已拉取的 PostgreSQL 镜像。 @@ -166,8 +166,7 @@ docker run --rm -v "${project}_data:/data" -v ~/oac-backup:/backup:ro --entrypoi - 当前或保留对象所使用的每个独立工作区命名空间,即使其位于安装卷之外。备份整个根目录,包括 `.oac-storage-root`、对象标识、staging、live 数据、trash 和删除标记。保留数值所有者、权限、链接及扩展属性,包括全部 `user.*` 属性。仅复制 `live/data` 会丢失生命周期证据,并可能使已删除标识复活。[工作区存储](../workspace-provider.md#kernel-nfs-adapter)负责定义布局与所有权要求。 - 对于[配额模式](../workspace-provider.md#quota-mode)命名空间,将承载导出的整个 XFS 文件系统作为一份块级副本备份:存储或 LVM 快照,或已冻结或已卸载设备的镜像。只有这样的副本能保持 project ID、配额限制、inode 标识、文件系统 UUID 和 `oac-workspace-quota` 日志库的一致。文件级复制、`tar`、`rsync` 或 `xfsdump` 会丢失这些信息;此时该命令会拒绝恢复后的对象,而不是在无上限状态下提供它们。 - 各节点状态目录 `/var/lib/oac-node/.oac/nodes//` 及其提供商存储:Docker 卷或 microsandbox 存储。包含数据库仍然引用的全部计算资源和快照。参见[节点主机故障时](./nodes.md#when-a-node-host-fails)。 - -- 每个私有 `checkpoint_root` 的全部内容,包括 `.oac-checkpoint-store`、检查点、operation journal、lock 文件与 tombstone,并与匹配的 Core 数据库一起保留。它独立于原生 SDK store 和工作区 namespace。保留其身份和私有所有权;[检查点转移契约](../sandbox-provider.md#checkpoint-transfer)定义其生命周期。 +- 每个私有 `checkpoint_root` 的全部内容,包括 `.oac-checkpoint-store`、检查点、operation journal、lock 文件与 tombstone,并与匹配的 Core 数据库一起保留。[检查点转移契约](../sandbox-provider.md#checkpoint-transfer)定义其身份、所有权和生命周期。 ### 建立停止写入窗口 {#establish-a-stopped-write-window} @@ -194,7 +193,7 @@ docker compose -f "$HOME/.oac/core/compose.yaml" exec -T database \ 启动 Core 前,恢复相匹配的 secrets 和数据库状态、完整的独立文件系统命名空间,以及任何所需的节点标识和提供商存储。保留数据库中的配置和命名空间 UUID、对象标识及删除标记;不要对恢复后的存储执行首次命名空间初始化。恢复配额模式文件系统时,将其块级副本写入后备设备,并以 `prjquota` 挂载到配置的挂载点;绝不要对其运行 `oac-workspace-quota init`,也不要将副本与原文件系统同时挂载,二者具有相同的文件系统 UUID。重新建立配置中的挂载路径和服务 UID,然后从每个参与服务的上下文验证所挂载文件系统、所有权及 xattr。[设置工作区存储](./workspace-storage.md)负责定义部署设置。 -已释放计算资源的冷恢复基线仅在新计算资源准入后续接具备资格的原生历史。包含保留检查点的备份还依赖匹配的原生 Runtime、节点标识、提供商存储和外部文件系统标识。文件级恢复可能改变 inode 标识,即使文件字节一致,也可能阻止严格检查点恢复。此流程不承诺可移植内存快照,也不承诺恢复到任意替代节点;应保留未解决的归属,而不是替换为新沙箱。 +冷恢复仅在新计算资源准入后续接具备资格的原生历史。包含保留检查点的备份还依赖匹配的原生 Runtime、节点标识、提供商存储和外部文件系统标识。文件级恢复可能改变 inode 标识,即使文件字节一致,也可能阻止严格检查点恢复。内存快照不可移植,也不支持恢复到任意替代节点;应保留未解决的归属,而不是替换为新沙箱。 恢复依赖全部完整后才启动 database、Core 和节点服务;检查期间保持入口受限。在隔离演练中,验证保留 Session 的文件和具备资格时在新计算资源上的原生续接,确认已删除对象无法重新打开,并检查旧写入方均无法访问恢复后的命名空间。在依赖备份进行恢复前记录结果。 diff --git a/docs/zh/getting-started/public-address.md b/docs/zh/getting-started/public-address.md index d85986ecc..755afd06f 100644 --- a/docs/zh/getting-started/public-address.md +++ b/docs/zh/getting-started/public-address.md @@ -1,14 +1,14 @@ --- title: "配置公开地址" source: docs/getting-started/public-address.md -source_hash: 18cf3655776a7bd22a09023a7764abf17461570371b846ef5ce4d1caa3d13a6f +source_hash: 02eb5d34fb53a65e4cadb60ada04c98c5667e083141e56b8c5169683926a8249 --- 设置浏览器、节点、沙箱和应用访问 Core 所用的唯一源地址,并在其前面放置带 HTTPS 的反向代理。本指南面向将 Core 暴露到其所在机器之外的运维人员。 ## 选择公开 URL {#choose-the-public-url} -`OAC_PUBLIC_URL` 是应用、节点、沙箱和自托管执行器使用的源地址。Web 只接受其中的主机。Core 从中派生守护进程 WebSocket URL、自托管 `remote_url` 和每个沙箱的连接地址。它是不含路径的 http 或 https 源地址。 +`OAC_PUBLIC_URL` 是应用、节点、沙箱和自托管执行器使用的源地址。Web 只接受其中的主机。Core 从中派生其他地址,列在[`OAC_PUBLIC_URL`](../configuration.md#appendix-core-environment-without-the-installer)下。它是不含路径的 http 或 https 源地址。 | 场景 | 公开 URL | | --- | --- | diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index 013a1cc94..7fbf7b40d 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,7 +1,7 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: f66f608093cc824add3215a9df520082b1679af1852ee213b7a6adfca40221ed +source_hash: c0ce961c1457daffdada461892d2e75a44efa15efce814f535c60b98b0b7a6fc --- 本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 Core 和 Web,请按 [安装 Core 和 Web](getting-started/install.md) 操作。安装程序代码遵循的规则见 [部署](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md) 和 [节点安装程序](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md);必需检查见 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#required-checks)。 @@ -12,7 +12,6 @@ source_hash: f66f608093cc824add3215a9df520082b1679af1852ee213b7a6adfca40221ed Core、Web 和 ingress 镜像发布为经过校验的 Linux amd64/arm64 多架构索引。arm64 控制归档包含这三个镜像;Node、托管 Runtime 和离线包使用 Linux amd64。发行构建使用 QEMU 执行 ARM 镜像步骤,包括 E2B helper。主机 `oac` 从同一份实现构建为 Linux amd64/arm64、macOS amd64/arm64 和 Windows amd64 二进制;启动脚本只选择、校验并运行它们。所有版本索引校验通过后才更新浮动标签。 - 请在 Linux x86_64 上构建,所需环境包括与 Debian 12 兼容的 glibc、Docker、`go.mod` 中指定的 Go 版本、C 编译器(microsandbox 辅助程序使用 CGO 构建)、Node、pnpm、Python 3.9 或更高版本、curl、tar、pigz 和 sha256sum。源代码必须保持干净并已提交。请先准备固定版本的 Codex 包和 MiniMax Code 配套程序,然后执行构建: ```sh @@ -64,7 +63,7 @@ export OAC_NATIVE_INSTALLER_BUILD_DIR=OUTPUT_DIR `make build-core-distribution` 会构建以下全部内容。也可以单独构建其中一项,以测试某个 Harness 镜像或辅助程序。所有命令都必须从仓库根目录运行;默认输出位于 `${OAC_DEV_HOME:-$HOME/.oac}/build` 下。 -三个维护的 Runtime 镜像共用一个构建期调整:固定 Debian 登录 profile 会保留继承且已导出的 `PATH`,未设置 `PATH` 时仍使用 Debian 默认值。这样,登录和非登录工具都能使用 Runtime 初始化的包路径与用户路径,无需另一份包路径设置。组合镜像继承同一 profile。上游 profile 结构变化会使构建失败以便审查;自定义 shell 启动文件仍可以显式更改 `PATH`。 +三个维护的 Runtime 镜像共用固定摘要的基础镜像 `node:22.23.1-bookworm-slim`(含 `ca-certificates`、`bash`、`git`、`python3`、`python3-pip` 和 `ripgrep`),以及一个构建期调整:固定 Debian 登录 profile 会保留继承且已导出的 `PATH`,未设置 `PATH` 时仍使用 Debian 默认值。这样,登录和非登录工具都能使用 Runtime 初始化的包路径与用户路径,无需另一份包路径设置。组合镜像继承同一 profile。上游 profile 结构变化会使构建失败以便审查;自定义 shell 启动文件仍可以显式更改 `PATH`。 **Codex Runtime 镜像。** 在 `~/.oac` 下解压官方 npm 包 `@openai/codex@0.153.4-linux-x64`(例如使用 `npm pack --ignore-scripts` 和 `tar -xzf`),然后执行: @@ -239,7 +238,7 @@ make check-ci `make check` 仍是完整的本地入口,使用未分片的 Web 测试套件和未分片的 Core 集成测试包。`make check-web-unit` 和 `make check-web-acceptance OAC_WEB_TEST_SHARD=1/4` 用于分别运行 Web 部分;`make check-core-packages` 和 `make check-core-integration OAC_CORE_INTEGRATION_SHARD=1/3` 用于分别运行 Core 部分,其中集成测试按名称的稳定哈希分配到分片。选择测试涵盖混合变更、共享使用方、重命名/删除、未知输入、浅合并检出以及失败/取消/缺失结果。对于选择映射的更改,请针对受影响的规则重放具有代表性的差异。对于工作流更改,请运行 actionlint,并验证更改后的调度或分区行为。仅在需要验证受变更影响的行为时,才运行真实组件测试。 -使用 `python3 scripts/ci_metrics.py RUN_ID ...` 衡量已完成的运行。它会报告最近一次尝试的运行器分钟数总和、耗时、从该次尝试开始计算的初始排队延迟、并发作业峰值、平台明细以及作业结果/失败比例。只有在该次尝试中被分配了运行器的作业才计入机器时间和执行并发;排队期间被取消的作业仍保留其结果和实际耗时。更早的尝试不计入其中。失败作业的重新运行可能沿用更早的成功结果:这些结果会单独显示,且其原有执行时间会被排除。如果缺少重新运行的开始时间戳,测量会停止,因为无法可靠区分复用的作业。比较时应保留 run/head/attempt 标识,并分别报告取消和未完成的运行。原始运行器分钟数并非计费分钟数;估算成本前,应使用各平台公布的转换和配额规则。较小的成功样本不能作为长期失败率估计。定时完整运行不在此策略范围内。 +使用 `python3 scripts/ci_metrics.py RUN_ID ...` 衡量已完成的运行。它会报告最近一次尝试的运行器分钟数总和、耗时、从该次尝试开始计算的初始排队延迟、并发作业峰值、平台明细以及作业结果/失败比例。只有在该次尝试中被分配了运行器的作业才计入机器时间和执行并发;排队期间被取消的作业仍保留其结果和实际耗时。更早的尝试不计入其中。失败作业的重新运行可能沿用更早的成功结果:这些结果会单独显示,且其原有执行时间会被排除。如果缺少重新运行的开始时间戳,测量会停止,因为无法可靠区分复用的作业。比较时应保留 run/head/attempt 标识,并分别报告取消和未完成的运行。原始运行器分钟数并非计费分钟数;估算成本前,应使用各平台公布的转换和配额规则。较小的成功样本不能作为长期失败率估计。 ### CI 运行器和免费配额 {#ci-runners-and-free-allowance} diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 99173c5b2..2aae07456 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: 2e359fb0bd99974bd1077f15b3a529c80a4ff19b3d9c2f7a3882fe125aa9f8c8 +source_hash: 5cb1e7a7139fceaf0e9efb1833959b067d6a7b313484b1d6c9c652e3ab9191f5 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。Docker、microsandbox 和 E2B 实现了该协议。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 19f1330f1..54fafc2c6 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: d14848610e56678c0de35ed6796b4631c5cd42b8a1f0d25366e224a4ff878e44 +source_hash: fb035e336b49105be6cc6fb4ec72e2f62e98b7ca6ea696a907cdce2fd8824e9e --- 本页列出各控制台页面调用的 Core 路由,以及控制台如何限定读取范围。本页面向控制台开发者;运维人员请阅读 [Web 控制台](index.md)。[Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API 命名空间和凭据](../api/index.md) 定义了本文使用的术语。 @@ -91,7 +91,7 @@ source_hash: d14848610e56678c0de35ed6796b4631c5cd42b8a1f0d25366e224a4ff878e44 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 列出 Harnesses | `GET /core/v1/harnesses` | **设置 → 模型与凭据** 的 **默认模型配置** 卡片:每个 Harness 的就绪状态(`enabled` 且已设置模型配置时为 **就绪**,否则为 **未设置模型提供商** 或 **已禁用**)、`default`、不含密钥的模型配置,以及来自配置中 `last_used_at`、`last_error_code` 和 `last_error_at` 的 **使用详情**;**概览** 的 **新手引导**(默认 Harness 为 **就绪**;没有默认 Harness 时,任意 Harness 为 **就绪**) | +| 列出 Harnesses | `GET /core/v1/harnesses` | **设置 → 模型与凭据** 的 **默认模型配置** 卡片:每个 Harness 的就绪状态([就绪状态](../configuration.md#runtime-settings-web))、`default`、不含密钥的模型配置,以及来自配置中 `last_used_at`、`last_error_code` 和 `last_error_at` 的 **使用详情**;**概览** 的 **新手引导**(默认 Harness 为 **就绪**;没有默认 Harness 时,任意 Harness 为 **就绪**) | | 设置或替换 | `PUT /core/v1/harnesses/{harness}/model-configuration` | **设置** 或 **替换**:提交包含只写提供商密钥的完整模型配置;该密钥绝不预填,写入也绝不重试;400 会在表单中显示 Core 的消息;随后再次读取列表 | | 清除 | `DELETE /core/v1/harnesses/{harness}/model-configuration` | **清除**,需确认,随后再次读取列表 | diff --git a/docs/zh/web/console-server.md b/docs/zh/web/console-server.md index dd2518afa..614b67ca5 100644 --- a/docs/zh/web/console-server.md +++ b/docs/zh/web/console-server.md @@ -1,7 +1,7 @@ --- title: "控制台服务器" source: docs/web/console-server.md -source_hash: 2c8b3bfed09d29c70cfee4b3381a248befd722c8336c4964a079e20c073f1a4a +source_hash: 927c27a14bbcc394d7218053937eaea8eaf95258cfbe035d48d298568c97caa5 --- 控制台服务器(`oac-web`)提供 Web,使用 Core 密钥认证管理员,并将浏览器的 `/core/v1` 请求转发到 Core。本参考面向不使用安装程序运行 Web 的运维人员,以及评审其安全边界的人员。 @@ -82,7 +82,7 @@ flowchart LR - 会话 cookie `core_console_session` 为 HttpOnly、`SameSite=Strict`,`OAC_PUBLIC_URL` 为 HTTPS 时还设置 `Secure`。有效期 12 小时。 - 会话仅存在控制台内存中,最多同时 64 个,先移除最旧的。重启控制台或轮换 Core 密钥会让所有用户退出登录。 - 同时最多执行两次登录检查;额外尝试返回 429 和 `Retry-After: 1`。 -- 失败尝试共享每分钟 10 次预算;超出后,错误密钥返回 429 和 `Retry-After: 60`。正确密钥始终可以登录,因此控制台拒绝使用少于 32 字符的 Core 密钥启动。 +- 失败尝试共享每分钟 10 次预算;超出后,错误密钥返回 429 和 `Retry-After: 60`。正确密钥始终可以登录,因此控制台拒绝使用不符合[密钥格式](../getting-started/operations.md#core-key)的 Core 密钥启动。 登录错误:请求体格式错误为 400,错误密钥为 401 `Invalid Core key`,非 `POST` 方法为 405,非 JSON 请求体为 415,上述频率限制为 429,无法创建会话为 503。 diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md index 5b40a5c41..703063f8d 100644 --- a/docs/zh/web/index.md +++ b/docs/zh/web/index.md @@ -1,7 +1,7 @@ --- title: "Web 控制台" source: docs/web/index.md -source_hash: fed3544a6fb993db8fb8a4f6c4dc3f64894ff2a587cd6d2deb3b49b9b74d082f +source_hash: d8f267f45ad305a3c7280d98333aaa1fdfdf4d5cc5082a5781d5de7ab1bd56b4 --- Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥登录,查看健康状态和用量,检查应用创建的内容,并管理项目、密钥、节点和设置。应用使用项目 API 密钥调用 `/v1`,不使用 Web。 @@ -29,7 +29,7 @@ Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥 | 资源 | Agent、Environment 模板、Skills、文件、Vault | 检查和允许的删除操作,显示各资源所属项目及创建密钥 | | 平台 | 项目与密钥 | 创建、重命名和归档项目;签发与撤销密钥;各项目的用量、写入历史和 API 调用方法 | | 平台 | 节点 | 添加、编辑和移除 microsandbox 或 Docker 节点;各节点的就绪状态、活跃与保留上限、主机 CPU 与内存及其历史、可用磁盘,以及分配及其在各计算阶段的已用时间 | -| 平台 | 设置 | **沙箱与调度**、**工作区存储**、**模型与凭据** 和 **服务与部署**;参阅[配置](../configuration.md#runtime-settings-web) | +| 平台 | 设置 | 平台配置,每个[运行时设置](../configuration.md#runtime-settings-web)一组 | 缺失数据展示为缺失(—),不会当作零。[控制台 API 使用](console-api-usage.md)列出各页面读取内容及统计边界。 @@ -44,7 +44,7 @@ Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥 | 签发、轮换或撤销自托管执行器凭据,或复制安装命令 | **Session 日志** 中的 Session 页面;参阅[运行自托管 Session](../getting-started/self-hosted.md) | | 删除资源,例如泄露的 Credential | 资源列表行或资源页面;文件从 **文件** 列表删除。适用公开删除规则 | -Web 观察并管理安装。它从不启动 Session,也不发送输入;只有应用调用 `/v1` 时 Agent 才会运行。归档托管 Session 会请求取消并回收沙箱。[概念](../concepts.md#what-administrators-can-and-cannot-do)定义了管理员能做什么。 +Web 观察并管理安装。它从不启动 Session,也不发送输入;只有应用调用 `/v1` 时 Agent 才会运行。归档托管 Session 的行为由 [Session 归档](../../../contracts/agents-api/zh/admin-api.md#session-archive)定义。[概念](../concepts.md#what-administrators-can-and-cannot-do)定义了管理员能做什么。 新项目密钥只显示一次,并附带使用它的调用示例: diff --git a/example/parsar/README.md b/example/parsar/README.md index 72eead840..309cdf499 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -37,7 +37,7 @@ Use one server process and a dedicated Project for this local single-user exampl A few more implementation details: - **Providers and models** are saved together in one SQLite transaction. Editing reads a Provider and its models as one snapshot; changing a model advances the Provider's revision. -- **Browser calls** use `OpenAIAgentsClient`, with a small public HTTP reader for Artifacts (not yet exposed by that client); only Session creation goes through a small server adapter. Closing the browser aborts the upstream stream, never the running Session. +- **Browser calls** use `OpenAIAgentsClient`, with a small public HTTP reader for Artifacts (the client has no Artifact method); only Session creation goes through a small server adapter. Closing the browser aborts the upstream stream, never the running Session. - **Workspaces:** hosted Sessions each get their own. User-machine Sessions use the selected host directory, so the same path means shared files. - **Skills on a user machine** come only from local capability directories in this example; it rejects Agents bound to managed Skills instead of ignoring them. Core itself can deliver managed Skills to user machines through `x_agents_core.environment`. @@ -66,6 +66,6 @@ Create a user-machine runtime and start a Session with an Agent using Codex or C The selected model Provider needs an HTTPS Base URL and API key. Its protocol is Responses for Codex and Anthropic for Claude Code and MiniMax Code. Core freezes and delivers the configuration to the enrolled executor. Local pending creation requests contain that configuration but browser Session responses omit the entire pending request. -Self-hosted capability sources are local directories under the current Core API. Managed Skill bindings fail explicitly; configure local Skills/Plugins in the runtime's capability directory before connecting. Bound HTTP MCP services also fail explicitly for self-hosted Sessions; configure them in local Plugins instead. +Self-hosted capability sources are local directories under the Core API. Managed Skill bindings fail explicitly; configure local Skills/Plugins in the runtime's capability directory before connecting. Bound HTTP MCP services also fail explicitly for self-hosted Sessions; configure them in local Plugins instead. The daemon runs with the starting user's permissions and adds no sandbox. Runtime home is separate per Session; stopping it preserves local files. Credential rotation and revocation remain Core console operations. Reuse the original installation directory when reconnecting. diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index ce6fa2535..ac28cf5ae 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -66,7 +66,7 @@ Workspace deferred-function discovery uses native ToolSearch alongside the norma For unmanaged bootstrap, daemon `connect` optionally registers this adapter as `claude_sdk` when the operator sets `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT` to the absolute packaged `dist/main.js`. `OAC_RUNTIME_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). Discovery resolves Node once and checks that exact configuration before connecting; the SDK's bounded runtime check is independent of CLI version probes. A ready SDK alone is sufficient to start the daemon. No configuration means no SDK probe or descriptor; failed readiness reports an unavailable descriptor without factories. Runtime checks establish local readiness, not provider authentication. Installed daemons use `start` and their verified installation manifest for adapter selection and activation; ambient activation variables cannot extend that selection. See [the native installation contract](../../deploy/README.md#native-daemon-installer). -SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently of the replaceable runtime bundle. Both the entrypoint and managed state root must be absolute. Background re-execution inherits operator configuration; it does not persist provider credentials in credential profiles. It accepts no caller-supplied environment variables or business write authority. +The SDK state paths are in the [Claude Runtime](../../services/core/deploy/claude/README.md) guide. Both the entrypoint and managed state root must be absolute. Background re-execution inherits operator configuration; it does not persist provider credentials in credential profiles. It accepts no caller-supplied environment variables or business write authority. ### Descriptor and execution profile @@ -90,7 +90,7 @@ Each SDK result supplies one native usage snapshot, including reported failures. Active text uses the SDK's `AsyncIterable` input, with a fresh native UUID mapped to each daemon input ID. A native query may fold text into its current native turn or queue another; one daemon Run can therefore contain several native turns. Never promise Codex's same-native-turn semantics. Writes, queued notifications and user-message echoes do not confirm consumption. Only matching root assistant/partial/result `user_message_uuids` (or the singular fallback) 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 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 inputs per Run, preserving the native 64-UUID receipt bound. Durable receipt opt-in separates bounded writes from native consumption waits; calls without it keep the router's ten-second deadline. Larger input capacity and recovery of interrupted input are not supported. Daemon registration alone does not establish public acceptance. -[Harness capabilities](../../contracts/agents-api/harness-capabilities.md) and its linked operation contracts record current public qualification. Keep native execution evidence separate from local registration and packaging checks. Live adapter acceptance uses a real provider with private credentials; fixture tests cannot substitute for it. +Keep native execution evidence separate from local registration and packaging checks. Live adapter acceptance uses a real provider with private credentials; fixture tests cannot substitute for it. ## Subagents @@ -112,6 +112,6 @@ Cancellation uses an adapter-owned effect receipt only after the query owner con The build validates source manifests with the repository-pinned pnpm frozen install and compiles into fresh managed staging, never exporting incremental checkout output. It then uses `pnpm deploy` from the adapter workspace with its dedicated frozen lock. The adapter has no workspace dependencies; keep that boundary explicit. Workspace injection is configured only in this adapter project for its frozen export; do not enable it in the Web/client workspace or replace export with a custom dependency copier. Export only compiled `dist` and production dependencies; retain their package metadata, lockfile and licenses. Check dependency links stay inside the export, pinned SDK/MCP/native versions, native `--version`, and bridge startup before publishing the archive. Startup with stdin EOF is an import check, not model execution acceptance. `make check-claude-sdk` includes this artifact check. -Extract the archive into a fresh managed runtime directory on a matching host and use its absolute `dist/main.js` as the private factory entrypoint. Validate relocation and real provider cancellation/continuation before accepting an artifact. Linux x64/glibc with Node22 is the currently exercised platform; other hosts require their own native acceptance. Do not reuse a bundle across platforms or libc variants. The native installer bundles Node and owns user-managed activation; release publication remains separate. Operator-configured discovery and registration remain available for unmanaged bootstrap as specified above. +Extract the archive into a fresh managed runtime directory on a matching host and use its absolute `dist/main.js` as the private factory entrypoint. Validate relocation and real provider cancellation/continuation before accepting an artifact. Linux x64/glibc with Node22 is the tested platform; other hosts require their own native acceptance. Do not reuse a bundle across platforms or libc variants. The native installer bundles Node and owns user-managed activation; release publication remains separate. Operator-configured discovery and registration remain available for unmanaged bootstrap as specified above. The exported `dist/runtime_check.js` companion is the local readiness contract. It checks the Node version, installed SDK/MCP/native versions against the package manifest, contained dependency resolution, native startup, and the exact `dist/main.js` bridge with stdin EOF. It emits one versioned JSON report without calling a model or creating Session state. The artifact check reuses this companion and separately checks all exported links, the lockfile and source pins. `claudesdk.CheckRuntime` uses the same Node, entrypoint and environment as execution, with shared process-group ownership, bounded output and a 15-second deadline plus bounded cleanup. Both native and bridge probes have five-second limits. Return unavailable on failed or malformed probes; never forward native diagnostics or treat local readiness as provider authentication, public capability acceptance or filesystem isolation. The native installer reuses this readiness check after copying its release components. diff --git a/services/core/deploy/claude/README.md b/services/core/deploy/claude/README.md index e81d42594..73447e9d3 100644 --- a/services/core/deploy/claude/README.md +++ b/services/core/deploy/claude/README.md @@ -43,7 +43,7 @@ Claude Code `2.1.269` reports both a request that got no HTTP response, such as | Item | Value | | --- | --- | -| Base | Digest-pinned `node:22.23.1-bookworm-slim` with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip` and `ripgrep` | +| Base | The [shared Runtime base](../../../../docs/maintainers.md#runtime-images-and-helpers) | | Programs | `/usr/local/bin/oac-daemon` and the SDK bundle at `/opt/claude-sdk` | | User | UID/GID 1000 with `HOME=/home/runtime` | | Environment | `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_WORKSPACE=/environment/workspace`, `OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization`, `OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages`, `OAC_RUNTIME_STATE_DIRECTORY=/environment/runtime-state` | diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 72c7d2af6..43e776ac9 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -1,6 +1,6 @@ # Codex Runtime -The Codex adapter ([`agent/codex`](../../../../apps/daemon/internal/agent/codex)) runs the native Codex CLI through its app-server protocol inside the daemon. This page holds the Codex-specific adapter rules, the Codex Runtime image and the Docker sandbox settings that every Docker Runtime uses. [Harness protocol](../../../../contracts/agents-api/harness-onboarding.md) owns the obligations shared by all adapters. +The Codex adapter ([`agent/codex`](../../../../apps/daemon/internal/agent/codex)) runs the native Codex CLI through its app-server protocol inside the daemon. This page holds the Codex-specific adapter rules, and the Codex Runtime image. [Harness protocol](../../../../contracts/agents-api/harness-onboarding.md) owns the obligations shared by all adapters. Native tools run with the daemon user's permissions; the outer sandbox provides isolation ([Runtime and outer isolation](../../../../docs/concepts.md#runtime-and-outer-isolation)). @@ -66,7 +66,7 @@ Codex `0.153.4` reports a provider HTTP 400 as `other` and puts the provider's e | Item | Value | | --- | --- | -| Base | Digest-pinned `node:22.23.1-bookworm-slim` with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip` and `ripgrep`, the same base and package layer as the Claude and MiniMax images | +| Base | The [shared Runtime base](../../../../docs/maintainers.md#runtime-images-and-helpers) | | Programs | `/usr/local/bin/oac-daemon`, `/usr/local/bin/codex` (mode 0555) and `/usr/local/codex-resources` | | User | UID/GID 1000 with `HOME=/home/runtime` | | Environment | `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_STATE_DIRECTORY=/environment/runtime-state` | diff --git a/services/core/deploy/mcode/README.md b/services/core/deploy/mcode/README.md index c8c7f2cba..81ac8ea2a 100644 --- a/services/core/deploy/mcode/README.md +++ b/services/core/deploy/mcode/README.md @@ -45,7 +45,7 @@ In the workspace profile, the frozen installation's Skills are linked into the S | Item | Value | | --- | --- | -| Base | Digest-pinned `node:22.23.1-bookworm-slim` with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip` and `ripgrep` | +| Base | The [shared Runtime base](../../../../docs/maintainers.md#runtime-images-and-helpers) | | Programs | `/usr/local/bin/oac-daemon` and the companion at `/opt/mcode-harness` (native CLI at `native/cli.js`) | | User | UID/GID 1000 with `HOME=/home/runtime` | | Environment | `OAC_RUNTIME_HOME=/home/runtime/.oac`, `OAC_RUNTIME_MCODE_NODE`, `OAC_RUNTIME_MCODE_BIN`, `OAC_RUNTIME_MCODE_COMPANION`, `OAC_RUNTIME_WORKSPACE=/environment/workspace`, `OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization`, `OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages`, `OAC_RUNTIME_STATE_DIRECTORY=/environment/runtime-state` | diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md index b872e1f84..07243a1cb 100644 --- a/services/core/tools/e2b-provider/README.md +++ b/services/core/tools/e2b-provider/README.md @@ -34,7 +34,7 @@ The boundary has version 1. The request and credentials arrive on standard input ## Receipts and state directory -`OAC_E2B_STATE_DIR` ([configuration](../../../../docs/configuration.md#appendix-core-environment-without-the-installer)) must already exist, be owned by the helper's user and grant no group or other access. Keep it on durable private storage across Core restarts. Its receipts hold SDK connection material, ownership identities and one-shot creation claims; they are not Core execution state. Losing the directory cannot authorize recreation or successful cleanup; never delete receipts after an uncertain call. +The state directory is the fixed `/state/e2b` ([private Provider state](../../../../docs/configuration.md#compose-installations)). It must be owned by the helper's user and grant no group or other access, and it lives on durable private storage across Core restarts. Its receipts hold SDK connection material, ownership identities and one-shot creation claims; they are not Core execution state. Losing the directory cannot authorize recreation or successful cleanup; never delete receipts after an uncertain call. A helper holds its allocation's lock until the SDK operation returns, even after Core's caller times out. Core tracks the helper's actual exit, and a credential change waits for running helpers without killing them. Helper exit never proves that a remote Create settled. Core serializes lifecycle requests per allocation and never replays Create. diff --git a/services/core/tools/microsandbox-provider/README.md b/services/core/tools/microsandbox-provider/README.md index a7cb64a30..e8dec91df 100644 --- a/services/core/tools/microsandbox-provider/README.md +++ b/services/core/tools/microsandbox-provider/README.md @@ -1,6 +1,6 @@ # microsandbox Sandbox Provider helper -microsandbox runs each hosted Session in its own microVM on a Linux amd64 node with KVM, and it is the Sandbox Provider that supports idle suspension. Core forwards provider operations to the node over the [node protocol](../../../../contracts/agents-api/node-generation-protocol.md); the node's adapter ([`sandbox/microsandbox`](../../internal/sandbox/microsandbox)) runs this helper once per operation. The private helper wire version is 7; mismatches are rejected. The helper links the microsandbox Go SDK v0.7.8 with its FFI library, so Core and the node program stay CGO-free Go binaries. It implements the checkpoint operations of the provider-neutral `sandbox.SandboxProvider` with full snapshots. It has no daemon, lifecycle database, scheduler or network control plane. +microsandbox runs each hosted Session in its own microVM on a Linux amd64 node with KVM. Core forwards provider operations to the node over the [node protocol](../../../../contracts/agents-api/node-generation-protocol.md); the node's adapter ([`sandbox/microsandbox`](../../internal/sandbox/microsandbox)) runs this helper once per operation. The private helper wire version is 7; mismatches are rejected. The helper links the microsandbox Go SDK v0.7.8 with its FFI library, so Core and the node program stay CGO-free Go binaries. It implements the checkpoint operations of the provider-neutral `sandbox.SandboxProvider` with full snapshots. It has no daemon, lifecycle database, scheduler or network control plane. [Sandbox Provider protocol](../../../../docs/sandbox-provider.md) owns the provider contract. [Sandbox deployment](../../../../contracts/agents-api/sandbox-deployment.md) owns the resources and suspension policy; the [nodes guide](../../../../docs/getting-started/nodes.md) owns node installation, host requirements, the node's directories and its network policy. diff --git a/website/README.md b/website/README.md index 0b6a74f88..6fe19efa3 100644 --- a/website/README.md +++ b/website/README.md @@ -1,6 +1,6 @@ # OpenAgentCore website -The website is the landing page plus the published documentation, built with [VitePress](https://vitepress.dev) and deployed to GitHub Pages. It reads the repository's existing `docs/`, `contracts/` and `docs.json` in place: no page is copied, moved or rewritten. +The website is the landing page plus the published documentation, built with [VitePress](https://vitepress.dev) and deployed to GitHub Pages. It reads the repository's `docs/`, `contracts/` and `docs.json` in place: no page is copied, moved or rewritten. ## Run it @@ -58,6 +58,4 @@ Pull request checks build under the published `/OpenAgentCore/` path. Output tes ## Maintain bilingual documentation -English documentation owns technical facts. Translate each authored page in `docs/` and `contracts/agents-api/` into its mirrored `zh/` path in the same change. Keep code blocks, API identifiers and exact Web action names unchanged; translate explanatory prose and preserve the English heading ID with an explicit `{#id}` anchor. Resolve relative links from the Chinese file to the corresponding Chinese document or shared original asset. The Harness catalog generator produces both language editions. Extend other generators before translating their output; preserve generated regions in otherwise authored pages byte for byte, with a Chinese explanation before the region. - -Each Chinese frontmatter records `source` as the repository-relative English path and `source_hash` as its SHA-256 digest. Update that digest only after reconciling the complete translation with the source. `pnpm --dir website check-translations` checks coverage and freshness before the build. `docs.json` remains the single navigation order; titles are read from the selected language's source. +The translation rules are in [Documentation](../AGENTS.md#documentation). Resolve relative links from the Chinese file to the corresponding Chinese document or shared original asset. The Harness catalog generator produces both language editions. Extend other generators before translating their output; preserve generated regions in otherwise authored pages byte for byte, with a Chinese explanation before the region. Update `source_hash` only after reconciling the complete translation with the source. `pnpm --dir website check-translations` checks coverage and freshness before the build. `docs.json` remains the single navigation order; titles are read from the selected language's source. From 89f77044a56ba2c7418d6ea48e7bf46ad8370131 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 02:27:23 +0800 Subject: [PATCH 03/39] Delete unused Web and client code (#829) - Web: Button, AnimatedShinyText, BlurFade, EntityChip, unused Select exports, shiny-text keyframes - Web: dashboard-model, the client-side runtime sampling path, held-usage and their tests - Web: unused helpers (useSandboxProvider, formatCompactNumber, isAbortError x2, pendingPlaintext, readSkillsPage, uploadPrecheck, readFilesPage, TraceView formatters) and unused imports, locals and e2e locals - Web: @tanstack/react-table dependency, unused i18n keys and dead CSS selectors - agents-client: unused and test-only type aliases, sandboxNodeDiagnostics alias, unused index re-exports - Root package.json typecheck and test scripts --- apps/web/e2e/data/admin.mjs | 6 +- apps/web/e2e/data/routes.mjs | 1 - apps/web/e2e/fixture-console.mjs | 4 +- apps/web/package.json | 1 - apps/web/pnpm-lock.yaml | 22 -- apps/web/src/ConsoleApp.tsx | 2 +- apps/web/src/app/beautifui/foundation.css | 243 ------------ apps/web/src/components/atoms/Button.tsx | 47 --- apps/web/src/components/atoms/EntityChip.tsx | 25 -- apps/web/src/components/charts/chart-scale.ts | 4 - apps/web/src/components/console-ui.test.tsx | 2 - .../magicui/animated-shiny-text.tsx | 42 -- apps/web/src/components/magicui/blur-fade.tsx | 94 ----- apps/web/src/components/ui/select.tsx | 86 +--- apps/web/src/features/agents/AgentsPage.tsx | 4 +- .../src/features/api-keys/ProjectDetail.tsx | 1 - .../src/features/api-keys/WriteOperations.tsx | 1 - apps/web/src/features/api-keys/api-keys.css | 4 - .../src/features/api-keys/key-flows.test.ts | 5 +- apps/web/src/features/api-keys/key-flows.ts | 5 - .../dashboard/dashboard-model.test.ts | 28 -- .../src/features/dashboard/dashboard-model.ts | 32 -- apps/web/src/features/dashboard/held-usage.ts | 17 - .../src/features/dashboard/runtime-history.ts | 1 - .../features/dashboard/runtime-trends.test.ts | 369 ------------------ .../src/features/dashboard/runtime-trends.ts | 266 +------------ .../TemplatesPage.test.tsx | 2 +- .../environment-templates/TemplatesPage.tsx | 2 +- apps/web/src/features/files/FilesPage.tsx | 2 +- .../features/files/file-operations.test.ts | 50 +-- .../web/src/features/files/file-operations.ts | 28 +- .../src/features/metrics/project-sessions.ts | 4 - apps/web/src/features/overview/overview.css | 9 - .../features/sandbox/SandboxManagerView.css | 10 - .../src/features/sandbox/sandbox-queries.ts | 13 +- .../src/features/sandbox/sandbox-wizard.css | 8 - apps/web/src/features/sessions/sessions.css | 52 --- .../src/features/sessions/trace/TraceView.tsx | 18 - .../src/features/skills/SkillDetail.test.tsx | 4 +- apps/web/src/features/skills/SkillsPage.tsx | 2 +- .../features/skills/skill-operations.test.ts | 19 +- .../src/features/skills/skill-operations.ts | 15 - apps/web/src/features/system/system.css | 8 - apps/web/src/features/vaults/VaultsPage.tsx | 4 +- apps/web/src/i18n/locales/en/navigation.ts | 3 - apps/web/src/i18n/locales/en/templates.ts | 3 - apps/web/src/i18n/locales/zh-CN/navigation.ts | 3 - apps/web/src/i18n/locales/zh-CN/sandbox.ts | 1 - apps/web/src/i18n/locales/zh-CN/templates.ts | 6 +- apps/web/src/lib/sandbox-diagnostic.test.ts | 1 - apps/web/src/style.css | 71 +--- apps/web/src/styles/console.css | 33 -- apps/web/src/styles/magicui-theme.css | 6 - apps/web/src/styles/system.css | 42 +- package.json | 2 - packages/agents-client/README.md | 2 +- packages/agents-client/src/index.ts | 9 +- .../agents-client/src/protocol-types.test.ts | 21 +- .../agents-client/src/sandbox-client.test.ts | 5 +- packages/agents-client/src/sandbox-client.ts | 2 - packages/agents-client/src/types.ts | 58 +-- .../src/vault-credentials-oauth.test.ts | 4 +- 62 files changed, 56 insertions(+), 1778 deletions(-) delete mode 100644 apps/web/src/components/atoms/Button.tsx delete mode 100644 apps/web/src/components/magicui/animated-shiny-text.tsx delete mode 100644 apps/web/src/components/magicui/blur-fade.tsx delete mode 100644 apps/web/src/features/dashboard/dashboard-model.test.ts delete mode 100644 apps/web/src/features/dashboard/dashboard-model.ts delete mode 100644 apps/web/src/features/dashboard/held-usage.ts delete mode 100644 apps/web/src/features/dashboard/runtime-trends.test.ts diff --git a/apps/web/e2e/data/admin.mjs b/apps/web/e2e/data/admin.mjs index 058d35875..3d4265b08 100644 --- a/apps/web/e2e/data/admin.mjs +++ b/apps/web/e2e/data/admin.mjs @@ -44,7 +44,7 @@ export function buildAdmin(now, base, resources) { vaults: resources.vaults.filter(of("vault", projectId)), }); - const keyRef = (project, k) => ({ id: k.id, name: k.name, prefix: k.prefix, kind: "issued", revoked_at: k.revoked_at ? iso(k.revoked_at) : null }); + const keyRef = (k) => ({ id: k.id, name: k.name, prefix: k.prefix, kind: "issued", revoked_at: k.revoked_at ? iso(k.revoked_at) : null }); // Deterministic creator per resource: rotate through the project's keys; one in seven is unknown. const creators = new Map(); let turn = 0; @@ -52,7 +52,7 @@ export function buildAdmin(now, base, resources) { const cacheKey = `${type}:${id}`; if (!creators.has(cacheKey)) { turn += 1; - creators.set(cacheKey, turn % 7 === 0 ? null : keyRef(project, project.keys[turn % project.keys.length])); + creators.set(cacheKey, turn % 7 === 0 ? null : keyRef(project.keys[turn % project.keys.length])); } return creators.get(cacheKey); }; @@ -61,7 +61,7 @@ export function buildAdmin(now, base, resources) { const own = collections(project.id); const list = []; const push = (at, action, type, id, parent = "", creatorType = type) => { - const creator = action === "create" ? creatorFor(project, creatorType, id) : keyRef(project, project.keys[list.length % project.keys.length]); + const creator = action === "create" ? creatorFor(project, creatorType, id) : keyRef(project.keys[list.length % project.keys.length]); list.push({ id: `op_${project.id.slice(5)}_${list.length}`, created_at: iso(at), api_key: creator, action, resource_type: type, resource_id: id, parent_id: parent, request_id: `req_${list.length}`, trace_id: `${list.length}`.padStart(32, "0") }); }; for (const agent of own.agents) { push(agent.created_at, "create", "agent", agent.id); if (agent.updated_at > agent.created_at) push(agent.updated_at, "update", "agent", agent.id); } diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index 535af7a62..c0806d2f8 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -15,7 +15,6 @@ function daemonUrl(publicUrl) { export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "https://core.example.com") { seed = 42; - const models = ["gpt-5.1-codex", "claude-sonnet-5", "MiniMax-M2", "gpt-5.1-mini"]; const agents = agentDefinitions.map((definition, index) => ({ id: `agent_${index + 1}${uuid().slice(0, 6)}`, object: "agent", model: definition.model, name: definition.name, instructions: definition.instructions, metadata: definition.metadata, multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: definition.reasoning, service_tier: "auto", diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 1f2513802..c5a67bea7 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -533,7 +533,7 @@ const tokenLimit = (value) => value === undefined || (Number.isInteger(value) && const jsonKind = (value) => Array.isArray(value) ? "an array" : typeof value === "string" ? "a string" : typeof value === "boolean" ? "a boolean" : Number.isInteger(value) ? "an integer" : "a number"; /** The first rule a provider body breaks, in Core's order and words; null when valid. */ -function providerProblem(harness, input) { +function providerProblem(input) { if (Object.keys(input).some((key) => !PROVIDER_FIELDS.has(key)) || ["protocol", "base_url", "api_key"].some((key) => typeof input[key] !== "string") || !tokenLimit(input.context_window) || !tokenLimit(input.max_output_tokens)) return PROVIDER_SHAPE; if (!httpsBase(input.base_url)) return "model provider requires an HTTPS base_url without credentials, query or fragment"; @@ -581,7 +581,7 @@ async function harnessRoute(request, response, path) { if (typeof input.model !== "string" || !input.model.trim()) return error(response, 400, "A model ID is required.", "model_configuration_model_invalid"); if (input.harness_config !== undefined && (!input.harness_config || typeof input.harness_config !== "object" || Array.isArray(input.harness_config))) return error(response, 400, "Harness configuration must be an object.", "harness_config_invalid"); if (!support(harness).protocols.includes(input.model_provider?.protocol)) return error(response, 400, "This harness does not support this protocol.", "model_provider_protocol_unsupported"); - const problem = providerProblem(harness, input.model_provider ?? {}); + const problem = providerProblem(input.model_provider ?? {}); if (problem) return error(response, 400, problem, "invalid_request_error"); entry.provider = { object: "core.model_configuration", harness, model: input.model, harness_config: input.harness_config ?? {}, diff --git a/apps/web/package.json b/apps/web/package.json index 10e47110a..a2257a27c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,6 @@ "@number-flow/react": "^0.6.2", "@oac/agents-client": "workspace:*", "@tanstack/react-query": "^5.103.2", - "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "i18next": "^26.4.2", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 934f1cf02..d7b40b9b9 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@tanstack/react-query': specifier: ^5.103.2 version: 5.103.2(react@19.3.0) - '@tanstack/react-table': - specifier: ^8.21.3 - version: 8.21.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -541,17 +538,6 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-table@8.21.3': - resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1694,14 +1680,6 @@ snapshots: '@tanstack/query-core': 5.103.2 react: 19.3.0 - '@tanstack/react-table@8.21.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - '@tanstack/table-core': 8.21.3 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - - '@tanstack/table-core@8.21.3': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index b9ce5881e..8e55c738e 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -19,7 +19,7 @@ import { SessionPage } from "./features/sessions/SessionPage"; import { SkillsPage } from "./features/skills/SkillsPage"; import { SettingsPage } from "./features/settings/SettingsPage"; import { VaultsPage } from "./features/vaults/VaultsPage"; -import { consoleDepth, ConsoleNavigationContext, useConsoleNavigation, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; +import { consoleDepth, ConsoleNavigationContext, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; import { consoleHashForView, consoleNavParent, consoleViewFromHash, type ConsoleView } from "./lib/console-routes"; import { ProjectsProvider, useProjects } from "./lib/projects"; import { collectionQuery, collections, filesCollection, queryClient, type CollectionSpec } from "./lib/queries"; diff --git a/apps/web/src/app/beautifui/foundation.css b/apps/web/src/app/beautifui/foundation.css index eb9a3b892..8d2a26ce0 100644 --- a/apps/web/src/app/beautifui/foundation.css +++ b/apps/web/src/app/beautifui/foundation.css @@ -211,30 +211,6 @@ a { text-decoration-skip-ink: auto; } -/* theme switches swap every token at once — freeze transitions so the flip - * is one clean repaint instead of hundreds of mismatched color fades */ -.theme-switching *, -.theme-switching *::before, -.theme-switching *::after { - transition: none !important; -} - -/* touch: :hover sticks after a tap, so neutralize row/header hovers there */ -@media (hover: none) { - .records-row:hover > .records-cell { - background: transparent; - } - - .records-row:hover > td.is-colsel { - background: color-mix(in srgb, var(--accent) 4%, var(--surface)); - } - - .records-header-button:hover { - background: transparent; - color: var(--ink-2); - } -} - /* Tailwind v4 resets buttons to cursor:default — anything * clickable must invite the click */ button:not(:disabled), @@ -258,73 +234,6 @@ select:focus-visible, outline: none; } -.animated-underline { - position: relative; - display: inline-block; -} - -.animated-underline::after { - position: absolute; - right: 0; - bottom: -1px; - left: 0; - height: 1px; - content: ""; - background: currentColor; - transform: scaleX(0); - transform-origin: left center; - transition: transform 280ms var(--ease-link); -} - -a:hover .animated-underline::after, -a:focus-visible .animated-underline::after { - transform: scaleX(1); -} - -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — StreamingText / ContextCards - * Favicon source avatars. - * Copy this block only alongside StreamingText / ContextCards; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.source-avatar { - box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1); -} - -.dark .source-avatar { - box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08); -} - -/* ── primitive spacing system ───────────────────────────── - * Shared optical offsets for small demo cards. Use these - * instead of hand-tuning 10/12/14px per component. - */ - -.primitive-card-pad { - padding: 12px; -} - -.primitive-card-bar { - padding: 10px 12px; -} - -.primitive-card-footer { - padding: 10px; -} - -.primitive-table-cell { - padding: 10px 12px; -} - -.primitive-icon-button { - display: inline-flex; - width: 28px; - height: 28px; - align-items: center; - justify-content: center; - border-radius: var(--radius-control); -} - /* ── shared keyframes ─────────────────────────────────── */ @keyframes shimmer-text { @@ -347,114 +256,6 @@ a:focus-visible .animated-underline::after { } } -.home-reveal { - will-change: opacity, transform, filter; -} - - -/* ── filter table electric status colors ─────────────────── */ - -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — FilterTable - * Electric status-pill colors. - * Copy this block only alongside FilterTable; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.filter-status-todo, -.filter-status-progress, -.filter-status-done { - border: 1px solid transparent; -} - -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — InsightCards - * Liveline chart tooltip + cursor. - * Copy this block only alongside InsightCards; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.insight-chart-stage { - overflow: hidden; -} - -.insight-chart-cursor { - position: absolute; - z-index: 4; - top: 0; - bottom: 0; - width: 1px; - background: var(--ink); - opacity: 0.26; - pointer-events: none; -} - -.insight-chart-tooltip-anchor { - position: absolute; - z-index: 5; - top: 8px; - transform: translateX(-50%); - pointer-events: none; -} - -.insight-chart-tooltip { - display: flex; - align-items: center; - gap: 12px; - padding: 5px 9px; - border: 1px solid var(--line-strong); - border-radius: 8px; - color: var(--tooltip-fg); - background: var(--tooltip-bg); - box-shadow: var(--shadow-overlay); - font-size: 11.5px; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.insight-chart-tooltip-item { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.insight-chart-tooltip-dot { - width: 8px; - height: 8px; - flex: 0 0 8px; - border-radius: 50%; -} - -/* Status pills share the same base-hue → color-mix derivation as .records-tag, - * so both components stay consistent across light and dark. */ -.filter-status-todo { - --tag-base: oklch(0.757 0.153 66.401); -} -.filter-status-progress { - --tag-base: oklch(0.671 0.118 219.351); -} -.filter-status-done { - --tag-base: oklch(0.652 0.131 162.865); -} - -.filter-status-todo, -.filter-status-progress, -.filter-status-done { - color: color-mix(in srgb, var(--tag-base) 92%, var(--ink)); - background: color-mix(in srgb, var(--tag-base) 20%, var(--surface)); - border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); -} - -/* Dark mode — a richer, saturated same-hue fill - * with bright same-hue text and no visible border (light mode keeps the soft - * tinted pastels above). Applies to both the records grid and status pills. */ -.dark .records-tag, -.dark .filter-status-todo, -.dark .filter-status-progress, -.dark .filter-status-done { - color: color-mix(in srgb, var(--tag-base) 86%, white); - background: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); - border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface)); -} - @keyframes fade-in { from { opacity: 0; @@ -474,39 +275,6 @@ a:focus-visible .animated-underline::after { } } -/* ── StreamText primitive ───────────────────────────────── - * Fast character reveal: the newest few characters carry a - * soft, masked blur so the leading edge resolves smoothly as - * text flows in. The caret holds solid while streaming, then - * blinks once the string settles. */ -/* ══════════════════════════════════════════════════════════ - * COMPONENT-SPECIFIC — StreamText - * The streaming caret + tail. - * Copy this block only alongside StreamText; it is not part of the - * generic foundation above. - * ══════════════════════════════════════════════════════════ */ -.stream-tail { - filter: blur(1.6px); - -webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2)); - mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2)); -} - -.stream-caret { - display: inline-block; - width: 2px; - height: 1.05em; - margin-left: 1.5px; - translate: 0 -0.5px; - border-radius: 1px; - background: var(--ink); - vertical-align: text-bottom; - animation: caret-blink 1s step-end infinite; -} - -.stream-caret.is-streaming { - animation: none; -} - @keyframes caret-blink { 0%, 100% { @@ -517,17 +285,6 @@ a:focus-visible .animated-underline::after { } } -@media (prefers-reduced-motion: reduce) { - .stream-caret { - animation: none; - } - .stream-tail { - filter: none; - -webkit-mask-image: none; - mask-image: none; - } -} - @keyframes pop-in { from { opacity: 0; diff --git a/apps/web/src/components/atoms/Button.tsx b/apps/web/src/components/atoms/Button.tsx deleted file mode 100644 index af0bfca1b..000000000 --- a/apps/web/src/components/atoms/Button.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { ButtonHTMLAttributes } from "react"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; - -const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]"; - -/* Pill-shaped by default — the app's core button style. Explicit symmetric - * padding (not a fixed height) so the top/bottom spacing is always equal. */ -export const buttonVariants = cva( - `inline-flex items-center justify-center font-medium select-none - transition-[transform,background-color,opacity] duration-150 ease-out - active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`, - { - variants: { - variant: { - primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`, - secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover", - ghost: "bg-hover-2 text-ink hover:bg-line-strong", - accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`, - success: `bg-green text-white hover:brightness-95 ${filledShadow}`, - /* transparent until hovered — for dense toolbars/action rows */ - quiet: "text-ink hover:bg-hover", - }, - size: { - /* compact toolbar pill — fixed height, lighter weight */ - xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1", - /* canonical action pill — 27px tall, roomy sides */ - sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5", - md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2", - }, - }, - defaultVariants: { variant: "secondary", size: "md" }, - }, -); - -export type ButtonVariant = NonNullable["variant"]>; - -export function Button({ - variant, - size, - className, - ...props -}: ButtonHTMLAttributes & VariantProps) { - return }> @@ -76,7 +63,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onUpdat key={editKey} coreUrl={deployment.core_url} expectedGeneration={deployment.generation} - current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, e2bTemplate: deployment.configuration?.template, e2bAPIURL: deployment.configuration?.api_url, e2bDomain: deployment.configuration?.domain } : undefined} + current={deployment.provider ? { provider: deployment.provider, specification: deployment.specification, configuration: deployment.configuration } : undefined} disabled={disabled || !canEdit} editing onSubmit={async (input) => { if (await onUpdate(input)) setChanging(false); }} diff --git a/apps/web/src/features/sandbox/SandboxResetControls.tsx b/apps/web/src/features/sandbox/SandboxResetControls.tsx index 105157055..254a7272d 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.tsx @@ -81,7 +81,7 @@ export function SandboxResetControls({ deployment, disabled, stale, onStart, onC }>
{dialog?.action === "cancel" ?

{t("Stop the reset and allow new hosted Sessions again. Sessions already archived stay archived and cannot be resumed.")}

: <> -

{t("Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.")} {t("Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.")} {t("After cleanup, Core clears the provider and saved E2B credential, retires nodes and enrollment commands, and returns to setup.")}

+

{t("Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.")} {t("Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.")} {t("After cleanup, Core clears the provider and saved credential, retires nodes and enrollment commands, and returns to setup.")}

{dialog?.action === "force" ?

{t("Force immediately cancels remaining work. Offline resources can still block cleanup.")}

:
{t("Clear hosted work")}
{t("Idle, suspended and queued Sessions are archived immediately. Running or waiting Turns and file writes may finish until the deadline; then Core forces the rest.")}
diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts b/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts deleted file mode 100644 index 3d2b6a7d6..000000000 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validEndpoint } from "./SandboxSetupWizard"; - -describe("E2B endpoint input", () => { - it("accepts the official default and a paired compatible service", () => { - expect(validEndpoint("", "")).toBe(true); - expect(validEndpoint("https://sandbox-test.sandbase.ai", "sandbox-test.sandbase.ai")).toBe(true); - }); - - it("rejects incomplete, local and malformed selectors before submission", () => { - expect(validEndpoint("https://sandbox-test.sandbase.ai", "")).toBe(false); - expect(validEndpoint("https://localhost", "sandbox-test.sandbase.ai")).toBe(false); - expect(validEndpoint("https://127.0.0.1", "sandbox-test.sandbase.ai")).toBe(false); - expect(validEndpoint("https://bad-.example", "good.example")).toBe(false); - expect(validEndpoint("https://good.example", "-bad.example")).toBe(false); - expect(validEndpoint("https://unrelated.example", "sandbox-test.sandbase.ai")).toBe(false); - }); -}); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 45fad9877..95e0138d6 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,8 +1,8 @@ -import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, sandboxProviderSetup, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxConfigurationOption, type SandboxDiscoveryInput, type SandboxProvider, type SandboxResources, type SandboxSetupField, type SandboxSpecification } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; -import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; +import { ArrowLeft, ArrowRight, Cloud, ExternalLink, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -17,55 +17,33 @@ import type { ParseKeys } from "i18next"; import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; import { defaultSandboxResources, savedSpecification, selectedWorkspace, validSandboxResources } from "./deployment-specification"; import { workspaceStorageQuery } from "../settings/workspace-storage"; -import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; +import { credentialReady, submittedCredential } from "./sandbox-update"; import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; type Where = "nodes" | "direct"; -type Step = "where" | "backend" | "e2b" | "size" | "review" | "advanced"; +type Step = "where" | "backend" | "connection" | "size" | "review" | "advanced"; type Preset = "small" | "standard" | "large"; type Size = Preset | "current" | "custom"; -type E2BService = "sandbase" | "official" | "custom"; -const E2B_PRESETS = { - sandbase: { apiURL: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" }, - official: { apiURL: "https://api.e2b.app", domain: "e2b.app" }, -} as const; -const E2B_KEY_CONSOLES = { - sandbase: "https://www.sandbase.ai/console/keys", - official: "https://e2b.dev/dashboard?tab=api-keys", -} as const; -function e2bService(apiURL?: string): E2BService { - if (!apiURL) return "sandbase"; - if (apiURL === E2B_PRESETS.official.apiURL) return "official"; - return apiURL === E2B_PRESETS.sandbase.apiURL ? "sandbase" : "custom"; +type Values = Record; +const kinds = Object.keys(deploymentContract.providers) as SandboxProvider[]; +const providersOf = (mode: Where) => kinds.filter((kind) => deploymentContract.providers[kind].mode === mode); + +/** The service whose values the configuration holds, or -1 for a custom one. */ +function serviceOf(provider: SandboxProvider, configuration: Values): number { + return sandboxProviderSetup(provider).presets.findIndex((preset) => Object.entries(preset.values).every(([name, value]) => configuration[name] === value)); +} + +/** The values of the connection fields a service sets, empty for a custom one. */ +function serviceValues(provider: SandboxProvider, index: number): Values { + const setup = sandboxProviderSetup(provider); + return Object.fromEntries(setup.fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, setup.presets[index]?.values[field.name] ?? ""])); } const MIB = 2 ** 20; const bounds = Object.fromEntries(deploymentContract.resources.map(({ name, min, max }) => [name, `${min}–${max}`])); const resourceBounds = { cpus: bounds.cpus, memory: bounds.memory_mib, disk: deploymentContract.minimum_disk }; const EASE = [0.16, 1, 0.3, 1] as const; -// Core accepts a template ID of up to 128 characters and a canonical, non-nil build UUID. -const TEMPLATE = /^[a-zA-Z0-9_-]{1,128}:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/; -function validTemplate(value: string): boolean { - const build = TEMPLATE.exec(value)?.[1]; - return build !== undefined && /[^0-]/.test(build); -} - -export function validEndpoint(apiURL: string, domain: string): boolean { - const publicName = (host: string) => host.length <= 253 && host.includes(".") && !/^[0-9.]+$/.test(host) && - !host.endsWith(".local") && !host.endsWith(".localhost") && - host.split(".").every((label) => label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)); - if (!apiURL && !domain) return true; - if (!apiURL || !domain || apiURL.length > 512 || !publicName(domain)) return false; - try { - const url = new URL(apiURL); - return url.protocol === "https:" && url.origin === apiURL && !url.username && !url.password && - !url.port && publicName(url.hostname) && (url.hostname === domain || url.hostname.endsWith(`.${domain}`)); - } catch { - return false; - } -} - /** * Per-sandbox presets around the Provider's declared default size: half and * double of it, disks included where the default declares them. A Provider @@ -91,23 +69,24 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese /** * Hosted sandbox setup as pages, one decision each: where sandboxes run, - * which backend (own machines, microsandbox preselected) or the E2B account, - * how big each sandbox is (only for a Provider that declares a default size: - * E2B sandboxes take the template build's size), then a review. The Provider's - * declarations decide the size and disk inputs. Advanced settings hold the - * complete form. + * which backend (own machines, the recommended one preselected) or the + * Provider's connection, how big each sandbox is (only for a Provider that + * declares a default size), then a review. A Provider's setup declaration + * decides its connection fields, services, cautions and discovered options; the + * Provider's resource declarations decide the size and disk inputs. Advanced + * settings hold the complete size form. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size; another backend starts from its defaults. - * E2B updates can retain the saved key. - * Docker isolates less than microsandbox, so choosing it takes a confirmation, - * once per wizard session; a saved Docker deployment has already made it. + * An update can retain the saved credential. + * A Provider that declares cautions takes a confirmation, once per wizard + * session; a saved deployment has already made it. * Core's address is config.json's `public_url`: the review only shows it, and * a configuration Core rejects for it is explained here, where it was saved. */ type WizardProps = { coreUrl: string; expectedGeneration: number; - current?: { provider: SandboxProvider; specification?: SandboxSpecification; e2bTemplate?: string; e2bAPIURL?: string; e2bDomain?: string }; + current?: { provider: SandboxProvider; specification?: SandboxSpecification; configuration?: Values }; disabled: boolean; } & ({ editing: true; onSubmit: (input: UpdateSandboxDeployment) => Promise } | { editing?: false; onSubmit: (input: InitializeSandboxDeployment) => Promise }); @@ -118,10 +97,11 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const id = useId(); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null; - const [step, setStep] = useState(editing ? currentMode === "direct" ? "e2b" : "size" : "where"); + const [step, setStep] = useState(editing ? currentMode === "direct" ? "connection" : "size" : "where"); const [where, setWhere] = useState(currentMode); const [provider, setProvider] = useState(current?.provider ?? null); const policy = provider ? deploymentContract.providers[provider] : null; + const setup = provider ? sandboxProviderSetup(provider) : null; const saved = provider && current ? savedSpecification(provider, current.provider, current.specification) : null; // The selected storage sizes a first initialization; a saved specification takes precedence. const storage = useQuery(workspaceStorageQuery).data; @@ -129,25 +109,15 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab // Choosing a Provider that declares a default size replaces this placeholder. const [resources, setResources] = useState(current?.specification?.resources ?? { cpus: 0, memory_mib: 0 }); const [size, setSize] = useState(current?.specification ? presetOf(current.provider, current.specification.resources) ?? "current" : "standard"); - const [apiKey, setApiKey] = useState(""); + // Every declared field's value, secret ones included; a secret is cleared once a save was attempted. + const [values, setValues] = useState(current?.configuration ?? {}); const [replacementRequested, setReplacementRequested] = useState(false); - const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); - const [service, setService] = useState(e2bService(current?.e2bAPIURL)); - const [apiURL, setAPIURL] = useState(current?.e2bAPIURL ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.apiURL ?? ""); - const [domain, setDomain] = useState(current?.e2bDomain ?? E2B_PRESETS[e2bService(current?.e2bAPIURL) as keyof typeof E2B_PRESETS]?.domain ?? ""); - const keyConsoleURL = service !== "custom" && apiURL === E2B_PRESETS[service].apiURL && domain === E2B_PRESETS[service].domain - ? E2B_KEY_CONSOLES[service] : null; - const [templates, setTemplates] = useState([]); - const [builds, setBuilds] = useState([]); - const [selectedTemplate, setSelectedTemplate] = useState(current?.e2bTemplate?.split(":")[0] ?? ""); - const [discovery, setDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); - const [buildDiscovery, setBuildDiscovery] = useState<"idle" | "loading" | "ready" | "error">("idle"); - const [discoveryRetry, setDiscoveryRetry] = useState(0); + const [checking, setChecking] = useState(false); const [busy, setBusy] = useState(false); - const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker"); - const [confirmingDocker, setConfirmingDocker] = useState(false); - const keepMicrosandbox = useRef(null); - // Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url. + const [confirmed, setConfirmed] = useState(current?.provider ?? null); + const [confirming, setConfirming] = useState(null); + const goBack = useRef(null); + // Core's reason for rejecting the saved configuration, such as a loopback public_url. const [rejection, setRejection] = useState(null); const [fieldRejection, setFieldRejection] = useState(null); const fieldError = (param: string) => coreFieldError(fieldRejection, param, tCommon); @@ -156,50 +126,38 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const installation = useQuery(installationQuery); const address = coreUrl || installation.data?.public_url || null; const { navigate } = useConsoleNavigation(); + const text = (value: { readonly en: string; readonly "zh-CN": string }) => value[locale]; - useEffect(() => { - setTemplates([]); setBuilds([]); setDiscovery("idle"); setBuildDiscovery("idle"); - if (provider !== "e2b" || !apiKey.trim() || !validEndpoint(apiURL.trim(), domain.trim())) return; - const controller = new AbortController(); - const timer = window.setTimeout(() => { - setDiscovery("loading"); - void sandboxAdmin.listE2BTemplates({ api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) - .then((items) => { if (!controller.signal.aborted) { setTemplates(items); setDiscovery("ready"); } }) - .catch(() => { if (!controller.signal.aborted) setDiscovery("error"); }); - }, 500); - return () => { window.clearTimeout(timer); controller.abort(); }; - }, [provider, apiKey, apiURL, domain, discoveryRetry]); - - useEffect(() => { - setBuilds([]); setBuildDiscovery("idle"); - if (discovery !== "ready" || !selectedTemplate || !templates.some((item) => item.id === selectedTemplate)) return; - const controller = new AbortController(); - setBuildDiscovery("loading"); - void sandboxAdmin.listE2BReadyBuilds(selectedTemplate, { api_key: apiKey.trim(), api_url: apiURL.trim(), domain: domain.trim() }, { signal: controller.signal }) - .then((items) => { if (!controller.signal.aborted) { setBuilds(items); setBuildDiscovery("ready"); } }) - .catch(() => { if (!controller.signal.aborted) setBuildDiscovery("error"); }); - return () => controller.abort(); - }, [discovery, selectedTemplate, templates, apiKey, apiURL, domain, discoveryRetry]); - - function changeConnection(key: string, url: string, dataDomain: string) { - setApiKey(key); setAPIURL(url); setDomain(dataDomain); - setTemplate(""); setSelectedTemplate(""); setTemplates([]); setBuilds([]); + const fields = setup?.fields ?? []; + const secretFields = fields.filter((field) => field.secret); + const valueOf = (field: SandboxSetupField) => (values[field.name] ?? "").trim(); + // A connection change invalidates the discovered values; a secret edited while updating keeps the saved ones. + function change(field: SandboxSetupField, value: string) { + const resets = field.secret && editing ? [] : fields.filter((other) => other.discovered); + setValues((previous) => ({ ...previous, ...Object.fromEntries(resets.map((other) => [other.name, ""])), [field.name]: value })); + setFieldRejection(null); } - - // Initial setup requires a key; an update may retain the committed key. - const keyReady = e2bKeyReady(Boolean(editing), replacementRequested, apiKey); - const connectionChanged = Boolean(editing && (apiURL.trim() !== (current?.e2bAPIURL || E2B_PRESETS.official.apiURL) || domain.trim() !== (current?.e2bDomain || E2B_PRESETS.official.domain))); - const e2bReady = provider !== "e2b" || (keyReady && validTemplate(template.trim()) && validEndpoint(apiURL.trim(), domain.trim()) && (!editing || !connectionChanged || apiKey.trim().length > 0)); + const service = provider ? serviceOf(provider, values) : -1; + // Discovery waits for the credential and every required value it depends on. + const dependencies = fields.filter((field) => !field.discovered && !field.optional); + const request: Omit | null = dependencies.length > 0 && dependencies.every((field) => valueOf(field)) ? { + configuration: Object.fromEntries(fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, valueOf(field)] as const).filter(([, value]) => value)), + credential: Object.fromEntries(secretFields.map((field) => [field.name, valueOf(field)])), + } : null; + // Initial setup requires the credential; an update may retain the committed one. + const keyReady = credentialReady(Boolean(editing), replacementRequested, secretFields.every((field) => valueOf(field))); + const connectionReady = fields.every((field) => field.secret || field.optional || valueOf(field)); // A Provider without a declared default size takes it from its configuration, so the selection sends no resources. const sized = Boolean(policy?.default_resources); const sizeReady = provider !== null && (!sized || validSandboxResources(provider, resources, workspace)); - const ready = provider !== null && e2bReady && sizeReady && !disabled && !busy; + const ready = provider !== null && keyReady && connectionReady && sizeReady && !disabled && !busy; - const order: Step[] = editing ? where === "direct" ? ["e2b", "review"] : ["size", "review"] : where === "direct" ? ["where", "e2b", "review"] : ["where", "backend", "size", "review"]; + const connects = where === "direct"; + const order: Step[] = editing ? connects ? ["connection", "review"] : ["size", "review"] : connects ? ["where", "connection", "review"] : ["where", "backend", "size", "review"]; const index = Math.max(0, order.indexOf(step === "advanced" ? "review" : step)); const back = () => setStep(step === "advanced" ? "review" : order[Math.max(0, index - 1)]!); - // The saved backend keeps its size; another starts from its declared default size. + // The saved backend keeps its saved size and connection; another starts from its declared defaults. function choose(next: SandboxProvider) { if (next !== provider) { setRejection(null); @@ -207,15 +165,19 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const proposed = kept?.resources ?? defaultSandboxResources(next); if (proposed) setResources(proposed); setSize(kept ? presetOf(next, kept.resources) ?? "current" : "standard"); + setValues(kept ? current?.configuration ?? {} : serviceValues(next, 0)); } setProvider(next); } - function selectDocker() { - setConfirmingDocker(false); - setDockerConfirmed(true); - choose("docker"); - setStep("size"); + function proceed(next: SandboxProvider) { + setConfirming(null); + choose(next); + setStep(deploymentContract.providers[next].mode === "direct" ? "connection" : "size"); + } + function select(next: SandboxProvider) { + if (sandboxProviderSetup(next).cautions.length > 0 && confirmed !== next) setConfirming(next); + else proceed(next); } async function save(event?: FormEvent) { @@ -226,9 +188,10 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const selection = { provider, expected_generation: expectedGeneration, ...(sized ? { resources } : {}), + ...(fields.length ? { configuration: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.name, valueOf(field)])) } : {}), + ...submittedCredential(secretFields.map((field) => [field.name, valueOf(field)])), }; - if (editing) await onSubmit({ ...selection, ...(provider === "e2b" ? { ...e2bUpdateSelection(template, apiKey), configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); - else await onSubmit({ ...selection, ...(provider === "e2b" ? { credential: { api_key: apiKey.trim() }, configuration: { template: template.trim(), api_url: apiURL.trim(), domain: domain.trim() } } : {}) }); + await onSubmit(selection); } catch (error) { // A configuration Core rejected is explained here; the page reports every other failure. const reason = sandboxConfigurationRejection(error, locale); @@ -236,13 +199,13 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab setRejection(reason); setFieldRejection(error); if (error instanceof AgentCoreError && error.param) { - if (["credential", "configuration", "e2b.api_url", "e2b.domain"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("e2b"); + if (["credential", "configuration"].includes(error.param) && error.code !== "sandbox_credential_ownership") setStep("connection"); else if (error.param.startsWith("resources.")) setStep("advanced"); } setResetRequired(error instanceof AgentCoreError && ["sandbox_credential_ownership", "sandbox_reset_required"].includes(error.code ?? "")); setAddressRejected(error instanceof AgentCoreError && error.code === "sandbox_configuration_error"); } finally { - setApiKey(""); + setValues((previous) => ({ ...previous, ...Object.fromEntries(secretFields.map((field) => [field.name, ""])) })); setBusy(false); } } @@ -252,77 +215,69 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab let page: ReactNode; if (step === "where") { + const hosted = providersOf("direct"); + const help = [...hosted.map((kind) => `${sandboxProviderSetup(kind).label}: ${text(sandboxProviderSetup(kind).summary)}`), t("Own machines run sandboxes on hosts you add.")].join(" "); page = ( - +
- { setWhere("direct"); choose("e2b"); setStep("e2b"); }} /> - { setWhere("nodes"); setApiKey(""); if (policy?.mode !== "nodes") choose("microsandbox"); setStep("backend"); }} /> + {hosted.map((kind) => { setWhere("direct"); select(kind); }} />)} + {providersOf("nodes").length > 0 ? { + setWhere("nodes"); + setValues({}); + if (policy?.mode !== "nodes") choose(providersOf("nodes").find((kind) => sandboxProviderSetup(kind).recommended) ?? providersOf("nodes")[0]!); + setStep("backend"); + }} /> : null}
); } else if (step === "backend") { + const backends = providersOf("nodes"); page = ( - + `${sandboxProviderSetup(kind).label}: ${text(sandboxProviderSetup(kind).summary)}`).join(" ")}>
- { choose("microsandbox"); setStep("size"); }} /> - { if (dockerConfirmed) selectDocker(); else setConfirmingDocker(true); }} /> + {backends.map((kind) => select(kind)} />)}
) : null} {/* Initial setup needs the cleared key re-entered; updates may keep the saved key. */} - {provider === "e2b" && !keyReady ? ( + {!keyReady ? (

- {t("Enter the E2B key again to save.")} - + {t("Enter the key again to save.")} + {editing ? : null}

) : null} @@ -402,7 +355,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ event.preventDefault(); setStep("review"); }}> {sized ?
- {t("Each sandbox")}{t("{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.", resourceBounds)} + {t("Each sandbox")}{t("{{cpus}} CPUs, {{memory}} MiB of memory. Disks are at least {{disk}} MiB.", resourceBounds)}
{ setSize("custom"); setResources({ ...resources, cpus }); setFieldRejection(null); }} /> { setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} /> @@ -412,11 +365,6 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab : null}
: null} - {provider === "e2b" ? ( - - { setTemplate(event.target.value); setFieldRejection(null); }} autoComplete="off" spellCheck={false} /> - - ) : null}
@@ -447,19 +395,17 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {/* Portaled: the sliding page's transform would otherwise contain the fixed backdrop. */} {createPortal( setConfirmingDocker(false)} + open={confirming !== null} + title={t("Use {{provider}}?", { provider: confirming ? sandboxProviderSetup(confirming).label : "" })} + initialFocus={goBack} + onClose={() => setConfirming(null)} footer={<> - - + + } > -
    -
  • {t("Weaker isolation")}{t("Containers share the host's kernel, so a container escape reaches the host. microsandbox runs each sandbox in its own microVM.")}
  • -
  • {t("Root-equivalent access")}{t("The node's service account joins the docker group, which is equivalent to root on that host.")}
  • -
  • {t("Limited use")}{t("Docker suits only trusted workloads, or hosts without KVM.")}
  • +
      + {confirming ? sandboxProviderSetup(confirming).cautions.map((caution) =>
    • {text(caution)}
    • ) : null}
    , document.body, @@ -515,3 +461,68 @@ function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?:
); } + +type Level = "loading" | "error" | SandboxConfigurationOption[]; + +/** + * A field whose value the Provider offers for the entered credential: one select + * per level, each loaded after the option chosen above it, until an option is + * complete. The page remounts it when the connection changes. While updating + * without a new credential it keeps the saved value in an input. + */ +function DiscoveredField({ id, provider, field, label, help, request, value, editing, error, onChange, onChecking }: { + id: string; provider: SandboxProvider; field: SandboxSetupField; label: string; help?: string; request: Omit | null; + value: string; editing: boolean; error: string | null; onChange: (value: string) => void; onChecking: (checking: boolean) => void; +}) { + const { t } = useTranslation("sandbox"); + const [picks, setPicks] = useState([]); + const [levels, setLevels] = useState([]); + const [retry, setRetry] = useState(0); + const open = picks.length === 0 || !picks[picks.length - 1]!.complete; + const last = levels[picks.length]; + // The step cannot be left while the credential is still being checked, whether settling or loading. + const checking = Boolean(request) && open && (last === undefined || last === "loading"); + useEffect(() => { onChecking(checking); return () => onChecking(false); }, [checking]); + useEffect(() => { + if (!request || !open) return; + const depth = picks.length; + const controller = new AbortController(); + // Typing the credential settles for half a second before the first read. + const timer = window.setTimeout(() => { + setLevels((previous) => [...previous.slice(0, depth), "loading"]); + void sandboxAdmin.discoverConfiguration(provider, { ...request, query: { field: field.name, path: picks.map((pick) => pick.value) } }, { signal: controller.signal }) + .then((options) => { if (!controller.signal.aborted) setLevels((previous) => [...previous.slice(0, depth), options]); }) + .catch(() => { if (!controller.signal.aborted) setLevels((previous) => [...previous.slice(0, depth), "error"]); }); + }, depth === 0 ? 500 : 0); + return () => { window.clearTimeout(timer); controller.abort(); }; + // The page remounts this field when the request changes. + }, [picks, retry]); + + if (!request && editing && value) { + return onChange(event.target.value)} autoComplete="off" spellCheck={false} />; + } + const choose = (depth: number, option: SandboxConfigurationOption | undefined) => { + setLevels((previous) => previous.slice(0, depth + 1)); + setPicks((previous) => option ? [...previous.slice(0, depth), option] : previous.slice(0, depth)); + onChange(option?.complete ? option.value : ""); + }; + const selects = Array.from({ length: Math.max(1, Math.min(picks.length + 1, levels.length)) }, (_, depth) => { + const level = levels[depth]; + const options = Array.isArray(level) ? level : []; + const pick = picks[depth]; + return ( + + ); + }); + return ( + + {selects} + {last === "loading" ?

{t("Loading options…")}

: null} + {last === "error" ?

{t("Could not load options. Check the credential and provider connection.")}

: null} + {Array.isArray(last) && last.length === 0 ?

{t("No options are available for this credential.")}

: null} +
+ ); +} diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 5e6cfc596..8809d1ea9 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,4 +1,4 @@ -import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; +import { deploymentContract, type SandboxProvider, type SandboxResources, type SandboxSpecification } from "@oac/agents-client"; /** The size the Provider declares for setup to propose; null when its configuration selects the size. */ export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null { @@ -27,20 +27,3 @@ export function validSandboxResources(provider: SandboxProvider, resources: Sand export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { return provider === savedProvider && specification ? structuredClone(specification) : null; } - -/** CPU and memory of the E2B template build as Core read them when the selection was saved; null while either is unknown. */ -export function templateBuildSize(deployment: SandboxDeployment): { cpus: number; memory_mib: number } | null { - const build = deployment.metadata?.template_build?.resources; - return build && build.cpus !== null && build.memory_mib !== null ? { cpus: build.cpus, memory_mib: build.memory_mib } : null; -} - -/** Core admits only a ready build; a selection saved before Core recorded the build has no status. */ -export function templateBuildStatus(build: SandboxE2BTemplateBuild | undefined): "ready" | "notReady" | "unknown" { - if (!build?.status) return "unknown"; - return build.status === "ready" ? "ready" : "notReady"; -} - -/** Each sandbox's limits: the saved specification, else the E2B template build that an E2B selection adopts. */ -export function sandboxSize(deployment: SandboxDeployment): SandboxResources | null { - return deployment.specification?.resources ?? templateBuildSize(deployment); -} diff --git a/apps/web/src/features/sandbox/sandbox-update.test.ts b/apps/web/src/features/sandbox/sandbox-update.test.ts index 9c1a10e9d..006a828bd 100644 --- a/apps/web/src/features/sandbox/sandbox-update.test.ts +++ b/apps/web/src/features/sandbox/sandbox-update.test.ts @@ -1,20 +1,16 @@ import { describe, expect, it } from "vitest"; -import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; +import { credentialReady, submittedCredential } from "./sandbox-update"; -describe("E2B update selection", () => { - it("omits a retained key while keeping the complete template selection", () => { - expect(e2bUpdateSelection(" template:build ", " ")).toEqual({ configuration: { template: "template:build" } }); - expect(e2bKeyReady(true, false, "")).toBe(true); - expect(e2bKeyReady(false, false, "")).toBe(false); - }); - - it("always sends an explicit replacement, including repeated identical values", () => { - for (let attempt = 0; attempt < 2; attempt++) expect(e2bUpdateSelection("template:build", " test-key ")).toEqual({ configuration: { template: "template:build" }, credential: { api_key: "test-key" } }); +describe("sandbox credential update", () => { + it("omits a retained credential and always sends an explicit replacement", () => { + expect(submittedCredential([["api_key", ""]])).toEqual({}); + for (let attempt = 0; attempt < 2; attempt++) expect(submittedCredential([["api_key", "test-key"]])).toEqual({ credential: { api_key: "test-key" } }); }); it("does not silently turn a cleared replacement secret into retained-key resubmission", () => { - expect(e2bKeyReady(true, true, "")).toBe(false); - expect(e2bKeyReady(true, true, "replacement")).toBe(true); - expect(e2bKeyReady(true, false, "")).toBe(true); + expect(credentialReady(true, false, false)).toBe(true); + expect(credentialReady(false, false, false)).toBe(false); + expect(credentialReady(true, true, false)).toBe(false); + expect(credentialReady(true, true, true)).toBe(true); }); }); diff --git a/apps/web/src/features/sandbox/sandbox-update.ts b/apps/web/src/features/sandbox/sandbox-update.ts index 0fa5fa75d..c48b0bb92 100644 --- a/apps/web/src/features/sandbox/sandbox-update.ts +++ b/apps/web/src/features/sandbox/sandbox-update.ts @@ -1,12 +1,10 @@ -import type { UpdateSandboxDeployment } from "@oac/agents-client"; - -/** Omission preserves the credential; every explicit key follows Core's replacement path. */ -export function e2bUpdateSelection(template: string, apiKey: string): Pick { - const key = apiKey.trim(); - return { configuration: { template: template.trim() }, ...(key ? { credential: { api_key: key } } : {}) }; +/** Omission preserves the credential; every explicit value follows Core's replacement path. */ +export function submittedCredential(entries: readonly (readonly [string, string])[]): { credential?: Record } { + const entered = entries.filter(([, value]) => value); + return entered.length > 0 ? { credential: Object.fromEntries(entered) } : {}; } /** Clearing a submitted secret must not silently convert replacement into retention. */ -export function e2bKeyReady(editing: boolean, replacementRequested: boolean, apiKey: string): boolean { - return apiKey.trim().length > 0 || (editing && !replacementRequested); +export function credentialReady(editing: boolean, replacementRequested: boolean, entered: boolean): boolean { + return entered || (editing && !replacementRequested); } diff --git a/apps/web/src/features/sandbox/sandbox-wizard.css b/apps/web/src/features/sandbox/sandbox-wizard.css index 4810db71b..4e780dc9e 100644 --- a/apps/web/src/features/sandbox/sandbox-wizard.css +++ b/apps/web/src/features/sandbox/sandbox-wizard.css @@ -322,7 +322,7 @@ font-size: 13px; } -/* After a save attempt the E2B key is gone; this says so beside the way back to it. */ +/* After a save attempt the key is gone; this says so beside the way back to it. */ .sandbox-wizard .wizard-key-again { display: flex; flex-wrap: wrap; @@ -351,8 +351,8 @@ margin: 0; } -/* What Docker gives up against microsandbox, in its confirmation. */ -.wizard-docker-risks { +/* The risks a Provider declares, in its confirmation. */ +.wizard-cautions { display: grid; gap: 12px; margin: 0; @@ -362,13 +362,6 @@ list-style: none; } -.wizard-docker-risks li { - display: grid; - gap: 2px; +.wizard-cautions li { color: var(--ink-2); } - -.wizard-docker-risks strong { - color: var(--ink); - font-weight: 600; -} diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index 6e38ff03c..2dd7c8fe1 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -33,11 +33,11 @@ export const coreErrors = { "sandbox_in_use": "Hosted resources remain. Wait for confirmed cleanup before changing the configuration.", "runtime_node_in_use": "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.", "runtime_node_unavailable": "The selected sandbox node is unavailable or has no capacity.", - "sandbox_credential_ownership": "This E2B key cannot manage the retained deployment. Reset before changing teams.", - "sandbox_credential_invalid": "The E2B API key was rejected. The saved configuration is unchanged.", - "sandbox_configuration_invalid": "Select a ready immutable E2B template build with matching resources.", - "sandbox_verification_unconfirmed": "E2B verification could not be confirmed. Refresh before submitting again.", - "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin.", + "sandbox_credential_ownership": "This credential cannot manage the retained deployment. Reset before changing accounts.", + "sandbox_credential_invalid": "The sandbox provider credential was rejected. The saved configuration is unchanged.", + "sandbox_configuration_invalid": "Select a ready immutable provider configuration with matching resources.", + "sandbox_verification_unconfirmed": "Sandbox provider verification could not be confirmed. Refresh before submitting again.", + "sandbox_configuration_error": "This sandbox provider needs a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin.", "sandbox_deployment_conflict": "The sandbox deployment cannot change in its current state. Refresh and check its reset and resource state.", "sandbox_specification_mismatch": "The saved sandbox specification does not match the deployment. Refresh to check the configuration.", "sandbox_operation_unsupported": "The selected sandbox provider does not support this operation.", diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 059054738..e36b7d79a 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -180,7 +180,7 @@ export const metrics = { configurationGeneration: "Sandbox configuration generation", title: "Sandbox metrics", description: "The hosted Runtimes of every project over time: their state, CPU and memory. Node capacity is on Nodes.", - cloudDescription: "Sandboxes Core holds in E2B’s cloud, and hosted Runtime observations across projects.", + cloudDescription: "Sandboxes Core holds in the provider’s cloud, and hosted Runtime observations across projects.", runtimeDialog: { openLabel: "Show sandbox of {{name}}", facts: "Sandbox", @@ -188,18 +188,14 @@ export const metrics = { noSession: "The Session of this sandbox could not be read, so its history cannot be shown.", }, cloud: { - title: "E2B cloud", - counts: "The running count comes from Core’s E2B allocations. The hosted Runtime total below counts Core’s hosted observation records across projects, including every reported lifecycle state. These sources have different coverage and refresh separately.", - help: "This deployment runs its sandboxes in E2B's cloud: there are no machines to manage. Counts are Core's own records of what it placed in E2B.", + title: "Provider cloud", + counts: "The running count comes from Core’s allocations in the provider’s cloud. The hosted Runtime total below counts Core’s hosted observation records across projects, including every reported lifecycle state. These sources have different coverage and refresh separately.", + help: "This deployment runs its sandboxes in the provider's cloud: there are no machines to manage. Counts are Core's own records of what it placed there.", manage: "Sandbox backend", running: "Running sandboxes", - runningHelp: "Sandboxes Core currently holds in E2B.", + runningHelp: "Sandboxes Core currently holds in the provider’s cloud.", pending: "Starting or changing", size: "Each sandbox", - disk: "{{disk}} disk", - template: "Template build", - templateHelp: "The fixed E2B build every sandbox starts from, as Core read it when the selection was saved. Each sandbox gets its CPU, memory and disk.", - build: { ready: "Ready", notReady: "Not ready", unknown: "Unknown" }, }, node: "Node", openNode: "Open node {{name}}", diff --git a/apps/web/src/i18n/locales/en/overview.ts b/apps/web/src/i18n/locales/en/overview.ts index 4af9fe201..9986d5154 100644 --- a/apps/web/src/i18n/locales/en/overview.ts +++ b/apps/web/src/i18n/locales/en/overview.ts @@ -25,7 +25,7 @@ export const overview = { localOnly: "Set a public address to connect applications and nodes.", title: "Get sandboxes ready", body: "Save where sandboxes run, then connect a node that is online and ready.", - bodyCloud: "Save the E2B account; its template build must be ready.", + bodyCloud: "Connect the sandbox provider account.", setup: "Set up sandboxes", addNode: "Add node", nodes: "Open Nodes", @@ -68,7 +68,7 @@ export const overview = { slots: "Sandbox capacity", slotsHelp: "Active sandboxes against the active limit of online hosts.", cloudRunning: "Running sandboxes", - cloudRunningHelp: "Sandboxes Core currently holds in E2B's cloud. E2B sets no limit Core can read.", + cloudRunningHelp: "Sandboxes Core currently holds in the provider's cloud. The provider sets no limit Core can read.", attention: "Needs attention", }, tiles: { @@ -116,7 +116,7 @@ export const overview = { fleet: { previousGeneration: "Inventory was read for configuration generation {{observed}}; the current target is {{target}}. These are earlier observations.", title: "Fleet", - cloudHelp: "Core and the sandboxes it holds in E2B’s cloud. Select Core for Core metrics or E2B for Sandbox metrics.", + cloudHelp: "Core and the sandboxes it holds in the provider’s cloud. Select Core for Core metrics or the cloud for Sandbox metrics.", help: "Core and sandbox nodes, with availability and active sandboxes / limit. Select Core for Core metrics or a node for its page on Nodes. When the list is full, offline and degraded nodes appear first.", core: "Core", open: "{{name}}, {{state}}, sandboxes {{slots}}", @@ -132,8 +132,8 @@ export const overview = { setUp: "Set up sandboxes", notSetUp: "Sandboxes are not set up yet.", cloud: { - name: "E2B cloud", - open: "E2B cloud, {{running}} running sandboxes", + name: "Provider cloud", + open: "Provider cloud, {{running}} running sandboxes", running: "{{count}} running", openBackend: "Sandbox backend", }, diff --git a/apps/web/src/i18n/locales/en/sandbox-navigation.ts b/apps/web/src/i18n/locales/en/sandbox-navigation.ts index cf5c38a84..1ba8a7a03 100644 --- a/apps/web/src/i18n/locales/en/sandbox-navigation.ts +++ b/apps/web/src/i18n/locales/en/sandbox-navigation.ts @@ -5,7 +5,7 @@ export const sandboxNavigation = { "back": "Back to Settings", "nodes": "Open Nodes", "unconfigured": "Set up sandbox hosting in Settings before adding nodes.", - "cloud": "E2B runs sandboxes in its cloud. There are no nodes to manage.", + "cloud": "The provider runs sandboxes in its cloud. There are no nodes to manage.", "reset": "Node enrollment is paused during reset. View progress in Settings.", "details": "Configuration details" } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index 570ab2c25..957ee9144 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -32,11 +32,11 @@ export const coreErrors = { "sandbox_in_use": "仍有托管资源。请等待 Core 确认清理完成后再修改配置。", "runtime_node_in_use": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", "runtime_node_unavailable": "所选沙箱节点不可用或容量不足。", - "sandbox_credential_ownership": "此 E2B 密钥无法管理当前保留的部署。更换团队前请先重置。", - "sandbox_credential_invalid": "E2B API 密钥被拒绝。已保存的配置未改变。", - "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变 E2B 模板构建。", - "sandbox_verification_unconfirmed": "无法确认 E2B 验证结果。请刷新后再提交。", - "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", + "sandbox_credential_ownership": "此凭据无法管理当前保留的部署。更换账号前请先重置。", + "sandbox_credential_invalid": "沙箱提供商凭据被拒绝。已保存的配置未改变。", + "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变提供商配置。", + "sandbox_verification_unconfirmed": "无法确认沙箱提供商的验证结果。请刷新后再提交。", + "sandbox_configuration_error": "此沙箱提供商需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", "sandbox_deployment_conflict": "沙箱部署在当前状态下无法更改。请刷新并检查重置和资源状态。", "sandbox_specification_mismatch": "已保存的沙箱规格与部署不一致。请刷新检查配置。", "sandbox_operation_unsupported": "所选沙箱提供商不支持此操作。", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index c9fc0fe28..b6bf73549 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -180,7 +180,7 @@ export const metrics = { configurationGeneration: "沙箱配置代次", title: "沙箱监控", description: "所有项目的托管 Runtime 随时间的状态、CPU 和内存。节点容量见节点页。", - cloudDescription: "Core 在 E2B 云端持有的沙箱,以及各项目的托管 Runtime 观测。", + cloudDescription: "Core 在提供商云端持有的沙箱,以及各项目的托管 Runtime 观测。", runtimeDialog: { openLabel: "查看 {{name}} 的沙箱", facts: "沙箱", @@ -188,18 +188,14 @@ export const metrics = { noSession: "无法读取这个沙箱所属的 Session,因此无法显示它的历史。", }, cloud: { - title: "E2B 云端", - counts: "运行数量来自 Core 的 E2B 分配记录;下方托管 Runtime 总数统计各项目的托管观测记录,包含所有已报告的生命周期状态。两者覆盖范围不同,并分别刷新。", - help: "这个部署的沙箱在 E2B 的云端运行,没有需要管理的机器。数量是 Core 自己记录的、放在 E2B 上的沙箱。", + title: "提供商云端", + counts: "运行数量来自 Core 在提供商云端的分配记录;下方托管 Runtime 总数统计各项目的托管观测记录,包含所有已报告的生命周期状态。两者覆盖范围不同,并分别刷新。", + help: "这个部署的沙箱在提供商的云端运行,没有需要管理的机器。数量是 Core 自己记录的、放在云端的沙箱。", manage: "沙箱提供商", running: "运行中的沙箱", - runningHelp: "Core 当前在 E2B 上占用的沙箱。", + runningHelp: "Core 当前在提供商云端占用的沙箱。", pending: "启动或切换中", size: "每个沙箱", - disk: "磁盘 {{disk}}", - template: "模板构建", - templateHelp: "每个沙箱启动时使用的固定 E2B 构建,是保存选择时 Core 读到的信息。每个沙箱按它分配 CPU、内存和磁盘。", - build: { ready: "就绪", notReady: "未就绪", unknown: "未知" }, }, node: "节点", openNode: "打开节点 {{name}}", diff --git a/apps/web/src/i18n/locales/zh-CN/overview.ts b/apps/web/src/i18n/locales/zh-CN/overview.ts index c76ef271d..be938ead8 100644 --- a/apps/web/src/i18n/locales/zh-CN/overview.ts +++ b/apps/web/src/i18n/locales/zh-CN/overview.ts @@ -25,7 +25,7 @@ export const overview = { localOnly: "设置公开地址后即可连接外部应用和节点。", title: "准备好沙箱", body: "保存沙箱的运行位置,再接入一台在线且就绪的节点。", - bodyCloud: "保存 E2B 账号,并等它的模板构建就绪。", + bodyCloud: "连接沙箱提供商账号。", setup: "设置沙箱", addNode: "添加节点", nodes: "打开节点", @@ -68,7 +68,7 @@ export const overview = { slots: "沙箱容量", slotsHelp: "活跃沙箱数,相对于在线主机的活跃上限。", cloudRunning: "运行中的沙箱", - cloudRunningHelp: "Core 当前在 E2B 云端占用的沙箱。E2B 没有可供 Core 读取的上限。", + cloudRunningHelp: "Core 当前在提供商云端占用的沙箱。提供商没有可供 Core 读取的上限。", attention: "需要处理", }, tiles: { @@ -116,7 +116,7 @@ export const overview = { fleet: { previousGeneration: "资源清单读取于配置代次 {{observed}},当前目标为 {{target}}。这里保留的是较早的观测。", title: "机群", - cloudHelp: "Core 与它在 E2B 云端持有的沙箱。选择 Core 打开 Core 监控,选择 E2B 打开沙箱监控。", + cloudHelp: "Core 与它在提供商云端持有的沙箱。选择 Core 打开 Core 监控,选择云端打开沙箱监控。", help: "Core 与沙箱节点的可用状态及活跃沙箱 / 上限。选择 Core 打开 Core 监控,选择节点打开它在节点页的详情。列表显示不下时,优先显示离线和异常节点。", core: "Core", open: "{{name}},{{state}},沙箱 {{slots}}", @@ -132,8 +132,8 @@ export const overview = { setUp: "设置沙箱", notSetUp: "还没有设置沙箱。", cloud: { - name: "E2B 云端", - open: "E2B 云端,{{running}} 个运行中的沙箱", + name: "提供商云端", + open: "提供商云端,{{running}} 个运行中的沙箱", running: "{{count}} 个运行中", openBackend: "沙箱提供商", }, diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts b/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts index 8e8ab7544..0cda508ec 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox-navigation.ts @@ -5,7 +5,7 @@ export const sandboxNavigation = { "back": "返回设置", "nodes": "打开节点", "unconfigured": "请先在设置中配置沙箱托管,再添加节点。", - "cloud": "E2B 在云端运行沙箱,无需管理节点。", + "cloud": "提供商在云端运行沙箱,无需管理节点。", "reset": "重置期间暂停添加节点,请在设置中查看进度。", "details": "配置详情" } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index b7b60098c..4090cdf44 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -15,18 +15,11 @@ export const sandbox = { "Root disk {{root}} · external workspace": "根磁盘 {{root}} · 外部工作区", "Workspace storage is configured by the operator; no capacity quota is enforced.": "工作区存储由运维人员配置;未实施容量配额。", - "E2B provider": "E2B 提供商", - "Other E2B-compatible provider": "其他兼容 E2B 的提供商", - "Loading templates…": "正在读取模板…", - "Could not load templates. Check the key and provider connection.": "无法读取模板,请检查密钥和提供商连接。", - "No templates are visible to this key.": "这个密钥下没有可见模板。", - "Template": "模板", - "Select a template": "选择模板", - "Loading ready builds…": "正在读取可用构建…", - "Could not load builds.": "无法读取构建。", - "This template has no ready builds.": "这个模板没有 ready 构建。", - "Select a ready build": "选择 ready 构建", - "Core validates the exact ready build again when you save.": "保存时 Core 会再次校验所选的 ready 构建。", + "Service": "服务", + "Other compatible service": "其他兼容服务", + "Loading options…": "正在读取选项…", + "Could not load options. Check the credential and provider connection.": "无法读取选项,请检查凭据和提供商连接。", + "No options are available for this credential.": "这个凭据下没有可选项。", "The selected provider supplies a default. You can edit it for a compatible endpoint.": "提供商已带出默认地址,也可以修改为兼容接口。", ...firstRun, "Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.": "托管 Session 将归档且无法恢复,部署配置与节点注册将清除。未保存的工作区内容可能丢失。", @@ -89,7 +82,7 @@ export const sandbox = { "Cancel reset?": "取消重置?", "Stop the reset and allow new hosted Sessions again. Sessions already archived stay archived and cannot be resumed.": "停止重置并重新允许创建托管 Session。已归档的 Session 会保持归档,无法恢复运行。", "Reset archives this deployment's hosted Sessions. Archived Sessions cannot be resumed. History, Files and Artifacts are kept; self-hosted Sessions are unchanged.": "重置会归档此部署的托管 Session。已归档的 Session 无法恢复运行。历史记录、File 和 Artifact 会保留;自托管 Session 不受影响。", - "After cleanup, Core clears the provider and saved E2B credential, retires nodes and enrollment commands, and returns to setup.": "清理完成后,Core 会清除沙箱提供商和保存的 E2B 凭据,作废节点及注册命令,然后返回配置流程。", + "After cleanup, Core clears the provider and saved credential, retires nodes and enrollment commands, and returns to setup.": "清理完成后,Core 会清除沙箱提供商和保存的凭据,作废节点及注册命令,然后返回配置流程。", "Force immediately cancels remaining work. Offline resources can still block cleanup.": "强制重置会立即取消剩余任务。离线资源仍可能阻止清理完成。", "Clear hosted work": "清理托管任务", "Auto — let busy work finish": "自动 — 等待忙碌任务完成", @@ -146,7 +139,7 @@ export const sandbox = { "Copy command with --force": "复制命令(--force)", "Checking this installation's public URL…": "正在检查这个安装的公网地址…", "The installation couldn't be read, so no command can be issued.": "无法读取安装信息,暂时不能生成命令。", - "It keeps Docker images and volumes; a microsandbox store goes once Core confirmed the removal.": "它保留 Docker 镜像和卷;Core 确认移除后,microsandbox 存储会一并删除。", + "It keeps images and volumes; the sandbox store goes once Core confirmed the removal.": "它保留镜像和卷;Core 确认移除后,沙箱存储会一并删除。", "Old Core address gone?": "旧 Core 地址已失效?", "{{name}} still points at the old Core address {{address}}.": "{{name}} 仍指向旧的 Core 地址 {{address}}。", "If this node's old Core address no longer responds, first remove it on the Nodes page, then add --force to the uninstall command.": "如果这个节点的旧 Core 地址已无法访问,请先在“节点”页移除它,再在卸载命令后加上 --force。", @@ -276,19 +269,16 @@ export const sandbox = { "Set up hosted sandboxes": "配置托管沙箱", "Copied": "已复制", "Select the command above and copy it manually.": "请选择上方命令并手动复制。", - "E2B cloud": "E2B 云端", "Own machines": "自有机器", - "Core creates E2B sandboxes directly. No node enrollment is needed.": "Core 直接创建 E2B 沙箱,无需注册节点。", - "E2B API key": "E2B API 密钥", + "Core creates {{provider}} sandboxes directly. No node enrollment is needed.": "Core 直接创建 {{provider}} 沙箱,无需注册节点。", "Console → API keys": "控制台 → API 密钥", "Sandbox API URL": "沙箱 API 地址", "Sandbox data-plane domain": "沙箱数据面域名", "Enter both a public HTTPS API origin and a domain.": "请同时填写公网 HTTPS API 地址和域名。", - "Enter a template ID and build UUID separated by a colon.": "请输入模板 ID 和构建 UUID,并用冒号分隔。", "Deployment provider": "部署的沙箱提供商", "Allocated resources": "已分配资源", "Pending environments": "待处理 Environment", - "E2B credential": "E2B 凭据", + "Credential": "凭据", "Configured": "已配置", "Not configured": "未配置", "Saved configuration does not confirm execution readiness. Session and Environment state report actual execution.": "配置已保存不代表执行已就绪。实际执行情况以 Session 和 Environment 状态为准。", @@ -307,16 +297,15 @@ export const sandbox = { "{{name}} will be removed from this deployment.": "{{name}} 将从此部署中移除。", "Open {{name}}": "打开 {{name}}", "Remove {{name}}": "移除 {{name}}", - "{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.": "CPU {{cpus}} 核,内存 {{memory}} MiB;microsandbox 的磁盘至少 {{disk}} MiB。", + "{{cpus}} CPUs, {{memory}} MiB of memory. Disks are at least {{disk}} MiB.": "CPU {{cpus}} 核,内存 {{memory}} MiB;磁盘至少 {{disk}} MiB。", "Advanced settings": "高级设置", "CPUs": "CPU(核)", - "Connect E2B": "连接 E2B", + "Connect {{provider}}": "连接 {{provider}}", "Core address": "Core 地址", "Custom size in advanced settings": "在高级设置里自定义规格", "Capacity quota at /environment (MiB)": "/environment 容量配额(MiB)", "This backend runs every sandbox on workspace storage. Select it under Settings, Workspace storage first.": "这个后端的每个沙箱都运行在工作区存储上。请先在“设置”的“工作区存储”中选择。", - "microsandbox, the recommended default, runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM. Docker runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.": "microsandbox(推荐的默认沙箱提供商):每个沙箱是一个轻量虚拟机,隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。Docker:每个沙箱是一个共用主机内核的容器,能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。", - "E2B runs sandboxes in its cloud: no machines to manage, billed by E2B. Own machines run them on hosts you add, with microsandbox (recommended) or Docker.": "E2B 云端:沙箱跑在 E2B 的云上,不用管理机器,由 E2B 计费。自有机器:沙箱跑在你添加的主机上,用 microsandbox(推荐)或 Docker。", + "Own machines run sandboxes on hosts you add.": "自有机器:沙箱跑在你添加的主机上。", "Each sandbox": "每个沙箱", "How big is each sandbox?": "每个沙箱多大?", "Large": "大", @@ -328,11 +317,7 @@ export const sandbox = { "Saving…": "保存中…", "Small": "小", "Standard": "标准", - "Template build": "模板构建", - "Ready": "就绪", - "Not ready": "未就绪", - "{{disk}} disk": "磁盘 {{disk}}", - "From the template build": "按模板构建", + "From the selected configuration": "按所选配置", "Idle suspension": "空闲挂起", "After {{idle}} · kept {{retention}}": "{{idle}}后 · 保留 {{retention}}", "Current": "当前规格", @@ -340,22 +325,17 @@ export const sandbox = { "Where should sandboxes run?": "沙箱在哪里运行?", "Which sandbox backend?": "用哪个沙箱提供商?", "Recommended": "推荐", - "Use Docker instead of microsandbox?": "改用 Docker 而不是 microsandbox?", - "Weaker isolation": "隔离更弱", - "Containers share the host's kernel, so a container escape reaches the host. microsandbox runs each sandbox in its own microVM.": "容器共用主机内核,一旦容器逃逸就能触及主机。microsandbox 则把每个沙箱放在独立的微虚拟机里运行。", - "Root-equivalent access": "等同 root 权限", - "The node's service account joins the docker group, which is equivalent to root on that host.": "节点的服务账号会加入 docker 组,这在那台主机上等同于 root 权限。", - "Limited use": "适用范围有限", - "Docker suits only trusted workloads, or hosts without KVM.": "Docker 只适合可信的工作负载,或没有 KVM 的主机。", - "Use Docker": "使用 Docker", - "Keep microsandbox": "保留 microsandbox", + "Use {{provider}}?": "使用 {{provider}}?", + "Use {{provider}}": "使用 {{provider}}", "{{cpus}} CPU · {{memory}}": "{{cpus}} 核 · {{memory}}", "Save configuration": "保存配置", "Change the sandbox configuration": "修改沙箱配置", "The address nodes and sandboxes use to reach Core.": "节点和沙箱访问 Core 使用的地址。", "Managed in Settings": "在设置中管理", - "Set a public address before connecting remote nodes; E2B sandboxes need an HTTPS one.": "连接远程节点前,请先设置公开地址;E2B 沙箱需要 HTTPS 地址。", - "Enter the E2B key again to save.": "请重新输入 E2B 密钥后再保存。", + "Set a public address before connecting; {{provider}} sandboxes need an HTTPS one.": "连接前请先设置公开地址;{{provider}} 沙箱需要 HTTPS 地址。", + "Enter the key again to save.": "请重新输入密钥后再保存。", + "Set a public address before connecting remote nodes.": "连接远程节点前,请先设置公开地址。", + "Select {{field}}": "选择{{field}}", "Enter the key": "输入密钥", "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", diff --git a/apps/web/src/lib/admin-view.ts b/apps/web/src/lib/admin-view.ts index 258dae7be..b02c900c1 100644 --- a/apps/web/src/lib/admin-view.ts +++ b/apps/web/src/lib/admin-view.ts @@ -24,7 +24,7 @@ export type ProjectSummary = Omit & { usage: AdminSummaryEntry["usage"] | null; }; -/** A Runtime observation with its project; disk is E2B-only and null elsewhere. */ +/** A Runtime observation with its project; disk is null for a Provider that cannot report it. */ export type OwnedRuntimeObservation = AdminRuntimeObservation["observation"] & { project_id: string }; const PAGE = 100; diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index b852464b1..ba1e6197d 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxProvider } from "@oac/agents-client"; +import { AgentCoreError, sandboxConfigurationUnconfirmed, sandboxProviderSetup, type SandboxProvider } from "@oac/agents-client"; import i18n, { type SupportedLanguage } from "../i18n"; import { coreError, knownCoreError } from "./core-error"; import type { ParseKeys } from "i18next"; @@ -31,7 +31,7 @@ export function sandboxRequestError(error: unknown, locale: SupportedLanguage): * read Core again before trusting what it shows. The status alone decides: no * response at all (a network failure or an abort), a 408 timeout or a 5xx (an * unreadable response is a 502). Any other 4xx is Core's clear refusal, and - * nothing changed, even when the client withheld its reason for an E2B key. + * nothing changed, even when the client withheld its reason for a credential. */ export function sandboxWriteUncertain(error: unknown): boolean { if (!(error instanceof AgentCoreError)) return true; @@ -40,7 +40,7 @@ export function sandboxWriteUncertain(error: unknown): boolean { /** * The reason for a refused deployment configuration whose code the console - * explains, such as E2B with a loopback public_url; null for any other failure. + * explains, such as a Provider that needs a public HTTPS address behind a loopback public_url; null for any other failure. * Nothing was saved, so the administrator corrects the cause and saves again. */ export function sandboxConfigurationRejection(error: unknown, locale: SupportedLanguage = "en"): string | null { @@ -48,7 +48,5 @@ export function sandboxConfigurationRejection(error: unknown, locale: SupportedL } export function sandboxProviderLabel(provider: SandboxProvider | "", locale: SupportedLanguage): string { - if (provider === "docker") return "Docker"; - if (provider === "microsandbox") return "microsandbox"; - return i18n.getFixedT(locale, "sandbox")(provider === "e2b" ? "E2B cloud" : "Unknown state"); + return provider === "" ? i18n.getFixedT(locale, "sandbox")("Unknown state") : sandboxProviderSetup(provider).label; } diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index ff92e1b18..0ff190fa2 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -74,8 +74,8 @@ definitions: allOf: - $ref: '#/definitions/api.RuntimeDiskObservation' description: |- - Current Runtime disk usage and capacity. E2B reports them; Docker and - microsandbox observations return null. + Current Runtime disk usage and capacity. Providers that cannot report + them return null. x-nullable: true environment_id: format: uuid @@ -731,8 +731,9 @@ definitions: allOf: - $ref: '#/definitions/sandbox.Resources' description: |- - Per-sandbox limits, required for Docker and microsandbox. E2B may omit - them; Core then uses the validated template build's cpus and memory_mib. + Per-sandbox limits, required for a Provider that declares default + resources. Another Provider may omit them; Core then uses the cpus and + memory_mib of the configuration it validated. required: - expected_generation type: object @@ -750,8 +751,9 @@ definitions: allOf: - $ref: '#/definitions/sandbox.Resources' description: |- - Per-sandbox limits, required for Docker and microsandbox. E2B may omit - them; Core then uses the validated template build's cpus and memory_mib. + Per-sandbox limits, required for a Provider that declares default + resources. Another Provider may omit them; Core then uses the cpus and + memory_mib of the configuration it validated. required: - expected_generation type: object @@ -775,7 +777,7 @@ definitions: description: An omitted limit is derived from the node's host when it enrolls. type: integer max_retained: - description: Docker never suspends, so Core replaces this with max_active; microsandbox uses both limits. + description: Providers without checkpoint support never suspend, so Core replaces this with max_active; the others use both limits. type: integer type: object api.SandboxIdlePolicyInput: @@ -1871,7 +1873,40 @@ definitions: credential: type: object query: - type: object + $ref: '#/definitions/sandbox.ConfigurationDiscoveryQuery' + type: object + sandbox.ConfigurationDiscoveryQuery: + properties: + field: + type: string + path: + items: + type: string + type: array + type: object + sandbox.ConfigurationOption: + properties: + complete: + type: boolean + label: + type: string + resources: + $ref: '#/definitions/sandbox.Resources' + value: + type: string + required: + - complete + - label + - value + type: object + sandbox.ConfigurationOptions: + properties: + options: + items: + $ref: '#/definitions/sandbox.ConfigurationOption' + type: array + required: + - options type: object sandbox.DeploymentMode: enum: @@ -6911,7 +6946,7 @@ paths: - Core Administration /core/v1/sandbox/deployment: get: - description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. Provider metadata is what Core read when the selection was saved; this read does not call the Provider. produces: - application/json responses: @@ -6994,7 +7029,7 @@ paths: post: consumes: - application/json - description: Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. + description: Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. parameters: - description: Deployment selection in: body @@ -7037,7 +7072,7 @@ paths: put: consumes: - application/json - description: 'Requires the observed generation, the same backend type and no active reset. E2B same-team changes apply online: allocations retain immutable generation and current credentials; omitted api_key preserves it, explicit submission including the same key verifies and advances generation. Other teams require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal.' + description: 'Requires the observed generation, the same backend type and no active reset. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal.' parameters: - description: Replacement deployment selection in: body @@ -7442,7 +7477,7 @@ paths: post: consumes: - application/json - description: Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns safe catalog metadata. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. + description: Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns the options of one Discovered setup field. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. parameters: - description: Registered provider kind in: path @@ -7461,8 +7496,7 @@ paths: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/sandbox.ConfigurationOptions' "400": description: Bad Request schema: diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 3d8aa825a..fb79ea726 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -275,7 +275,7 @@ paths: - Native Installation /api/v1/sandbox-node/configuration: get: - description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. + description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose Provider credentials. Node files cannot override this specification. parameters: - description: Retained node UUID in: header diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 2068ea9d6..50f0307af 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -68,7 +68,9 @@ E2B uses `configuration.template` in `template-id:build-uuid` form; the build UU `POST /core/v1/sandbox/providers/{provider}/discovery` takes exactly `configuration`, `credential` and `query` objects, at most 64 KiB, and runs for at most 30 seconds. Unknown members and null objects are rejected. The credential is used only for that request and is never stored or returned; discovery saves nothing, changes no deployment, allocates nothing and never proves that a selection will be admitted. Docker and microsandbox reject it with 400 `sandbox_operation_unsupported`. -For E2B, post `{"configuration": {"api_url": "…", "domain": "…"}, "credential": {"api_key": "…"}, "query": {}}` to list templates, and add `"query": {"template": "template-id"}` to list that template's ready builds; the official endpoint may omit both endpoint fields. The results are `{"templates": [{"id": "…", "names": ["…"]}]}` and `{"builds": [{"id": "build-uuid", "cpus": 2, "memory_mib": 2048}]}`, either possibly empty. The pinned SDK helper reads `GET /v2/templates` and returns at most 200 results; a limit or provider failure returns 503 with a generic message. The deployment write validates the selected build separately. +Post `{"configuration": {…}, "credential": {…}, "query": {"field": "…", "path": […]}}`, where `field` is a field the provider declares as discovered and `path` holds the values selected at the earlier levels. The result is `{"options": [{"value": "…", "label": "…", "resources": {"cpus": 2, "memory_mib": 2048}, "complete": true}]}`, possibly empty. An option with `complete: true` can be saved as the field's value; the others open the next level. Any other field returns 400 `invalid_request_error`. + +E2B declares `template` as discovered. An empty `path` lists the key's templates, and `["template-id"]` lists that template's ready builds as `template-id:build-uuid` values with their CPU count and memory; the official endpoint may omit `api_url` and `domain`. The pinned SDK helper reads `GET /v2/templates` and returns at most 200 results; a limit or provider failure returns 503 with a generic message. The deployment write validates the selected build separately. ## Safe response @@ -249,6 +251,6 @@ Storage and credential failures stay errors: an empty or failed read never prove ## Canonical node specification -`sandbox/deployment_contract.go` owns the resource bounds and canonical field order, and each registered Provider's `sandbox.DeploymentPolicy` declares its requirements; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds and Provider declarations in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or providers. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. +`sandbox/deployment_contract.go` owns the resource bounds and canonical field order, and each registered Provider's `sandbox.DeploymentPolicy` declares its requirements; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds, Provider declarations and each Provider's setup declaration (`sandbox.SetupDeclaration`) in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or providers. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. The specification digest is the SHA-256 of compact UTF-8 JSON with `provider` first, then `resources`, then `workspace` when external storage is selected. Workspace fields follow the filesystem declaration order (`attachment`, `user_xattr`, `capacity_quota`); its Boolean fields remain present. Resource fields follow the contract's declaration order; zero optional disk fields are omitted and required fields stay present. The digest never depends on the incoming field order or whitespace. `services/core/internal/sandbox/testdata/deployment-contract.json` holds shared acceptance cases, exact canonical bytes and digests that both the Go and Python tests consume. diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 4596ede7e..306cefcb9 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 57d2c630e22ee6e6b7333c36bb042af74e820c1205bb9fb6d41cb8f54acd605d +source_hash: 4b77c11fe9a80e4b6d200ca161ab53e006bfc46b185262ee858fc65d75b5112a --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -71,7 +71,9 @@ E2B 使用 `template-id:build-uuid` 形式的 `configuration.template`;构建 `POST /core/v1/sandbox/providers/{provider}/discovery` 恰好接受 `configuration`、`credential` 和 `query` 对象,总大小最多为 64 KiB,运行时间最多为 30 秒。未知成员和 null 对象会被拒绝。凭据仅用于该请求,绝不会被存储或返回;发现操作不会保存任何内容、不会更改部署、不会分配任何资源,也绝不会证明某项选择会被准入。Docker 和 microsandbox 会以 400 `sandbox_operation_unsupported` 拒绝该操作。 -对于 E2B,发布 `{"configuration": {"api_url": "…", "domain": "…"}, "credential": {"api_key": "…"}, "query": {}}` 可列出模板;添加 `"query": {"template": "template-id"}` 可列出该模板的就绪构建;官方端点可能会省略两个端点字段。结果为 `{"templates": [{"id": "…", "names": ["…"]}]}` 和 `{"builds": [{"id": "build-uuid", "cpus": 2, "memory_mib": 2048}]}`,两者都可能为空。固定版本 SDK 辅助工具读取 `GET /v2/templates` 并返回最多 200 条结果;达到限制或提供商失败会返回 503 和通用消息。部署写入操作会单独验证所选构建。 +发布 `{"configuration": {…}, "credential": {…}, "query": {"field": "…", "path": […]}}`,其中 `field` 是提供商声明为 discovered 的字段,`path` 是前几级已选择的值。结果为 `{"options": [{"value": "…", "label": "…", "resources": {"cpus": 2, "memory_mib": 2048}, "complete": true}]}`,可能为空。`complete: true` 的选项可保存为该字段的值,其余选项打开下一级。其他任何字段返回 400 `invalid_request_error`。 + +E2B 把 `template` 声明为 discovered。空 `path` 列出该密钥的模板,`["template-id"]` 以 `template-id:build-uuid` 值列出该模板的就绪构建及其 CPU 数和内存;官方端点可以省略 `api_url` 和 `domain`。固定版本 SDK 辅助工具读取 `GET /v2/templates` 并返回最多 200 条结果;达到限制或提供商失败会返回 503 和通用消息。部署写入操作会单独验证所选构建。 ## 安全响应 {#safe-response} @@ -254,7 +256,7 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。 ## 规范的节点规格 {#canonical-node-specification} -`sandbox/deployment_contract.go`负责资源边界和规范字段顺序,每个已注册提供商的 `sandbox.DeploymentPolicy` 声明其要求;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界和提供商声明,因此不存在第二套限制或提供商列表。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 +`sandbox/deployment_contract.go`负责资源边界和规范字段顺序,每个已注册提供商的 `sandbox.DeploymentPolicy` 声明其要求;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界、提供商声明和各提供商的 setup 声明(`sandbox.SetupDeclaration`),因此不存在第二套限制或提供商列表。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 规范摘要是紧凑 UTF-8 JSON 的 SHA-256,其中 `provider` 位于首位,其次是 `resources`。资源字段遵循契约的声明顺序;值为零的可选磁盘字段会被省略,必填字段则保持存在。摘要绝不会受传入字段顺序或空白字符影响。`services/core/internal/sandbox/testdata/deployment-contract.json`保存共享验收用例、精确的规范字节和摘要,Go 与 Python 测试都会使用这些内容。 diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 0d81fb3da..d2cb06747 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -130,11 +130,11 @@ A new provider takes these steps: 1. Implement the operation contracts in the adapter package, with native contract tests. 2. Add its specification and resource validators. -3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. +3. Implement `sandbox.ConfigurationAdapter` over a typed native configuration. `DecodeInput` strictly parses the separate public `configuration` and write-only `credential` objects of a request. `Encode` produces whitelisted public selectors, read-only observations and separate secret bytes, and never passes request JSON through. `Decode` restores stored selectors, and keeps access to owned resources, without remote admission or new template validation. `Normalize` copies its input before changing it. `ResolveChange`, `Equal` and `WithCredential` own inheritance, identity and credential composition. `Requirements` declares whether a credential and a public Core origin are required, and which setup operations are supported: `Discovery` for `DiscoverConfiguration`, `SelectionDiscovery` for `DiscoverSelection` and `CredentialVerification` for `VerifyCredential`. `DiscoverConfiguration` takes a `ConfigurationDiscoveryQuery{Field, Path}`, where `Field` names a declared `Discovered` field and `Path` holds the values chosen at the earlier levels, and returns `ConfigurationOptions`: a list of `{Value, Label, Resources, Complete}`, where `Complete` marks an option that can be saved as the field's value and the others lead to a deeper level. It validates the query and returns a safe catalog, never a mutation or an admission decision, while Core keeps authorization, input limits and deadlines. `DiscoverSelection` resolves a candidate's omitted native values before commit, and `VerifyCredential` verifies a credential's access to owned resources without mutation. Both receive the candidate's `sandbox.DirectConfig` and build any native client for that call only. A node provider accepts only an empty public object, rejects credentials and returns Unsupported for every setup operation and for credential replacement. 4. For a node adapter, export from its package the `BuildLocal` constructor, the typed `native` object it decodes and the native files it adds to the shared node artifacts. Node-local settings, such as host paths, live only in that object; resources are read from the node configuration's `specification`, and the Runtime image comes from the same object. -5. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode and deployment policy with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads them from `deploy/node/node_spec.py`, and the TypeScript client and Web from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. +5. Register its constructor, policies, configuration adapter and operation declaration in `providers/registry.go`. Its key is the provider kind, which also labels the Provider's observations, and checkpoint support reads this entry. The generated projections combine each registered mode, deployment policy and setup declaration with the shared field bounds in `sandbox/deployment_contract.go`: the installer reads the policies from `deploy/node/node_spec.py`, and the TypeScript client and Web read policies and setup declarations from `packages/agents-client/src/deployment-contract.ts`, so Web reads these declarations instead of comparing provider kinds. Regenerate both with `go run ./services/core/cmd/specification-contract -write`. 6. Supply the distribution artifacts for the adapter and its helper, and offer the provider to operators through the registered configuration contract. -7. Web's setup step, deployment summary and client projection name each provider. Add the new provider to `apps/web` until the protocol declares configuration fields. +7. Declare the setup wizard's content in a `sandbox.SetupDeclaration` beside `Policy()` and register it as the entry's `Setup`. It carries the label, a summary and cautions in English and Simplified Chinese, whether the provider is the Recommended choice of its mode, the public configuration and credential fields (`Name`, localized `Label`, `Secret`, `Discovered`, `Optional`, `Help`), and presets that fill field values and link the credential console. Registration rejects a secret field unless the credential is required, a discovered field unless `Discovery` is supported, an empty locale, a preset value for an unknown field, and more than one Recommended provider in a mode. Web follows the declared mode and fields and names no provider: the declaration reaches it only through the generated `packages/agents-client/src/deployment-contract.ts`, not `deploy/node/node_spec.py`. A node configuration, `sandbox.NodeConfig`, holds `provider`, `generation`, `installation_id`, `core_url`, `specification` and the adapter's opaque `native` object. `providers.Build` validates `provider`, `generation`, `installation_id` and `specification`, and passes the configuration with ephemeral `sandbox.LocalOptions` to `BuildLocal`, which decodes `native` strictly. The caller explicitly selects standalone registration or single-provider execution with `Standalone`, or generation-owned execution with a canonical absolute node state directory in `GenerationStateDirectory`. Missing or mixed contexts are rejected. The adapter owns generation-specific native preparation and readiness checks. Microsandbox binds helper leases to the installation, generation and specification digest, then checks the pinned image after platform, capacity and artifact readiness. diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 49103e848..276eb8de0 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -104,7 +104,7 @@ The list carries each Harness's configuration, so the console does not read `GET | Operation | Route | Console use | | --- | --- | --- | | Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in Settings leading to Settings → Service and deployment, and leaves nothing to confirm; initialize the deployment with Docker or microsandbox `resources`, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows in Settings → Sandbox and scheduling and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows in Settings → Sandbox and scheduling | -| E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the templates the entered E2B key can see, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | +| E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the options of a discovered field with the entered credential, one level at a time. For E2B these are the key's templates, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | | Reset | `POST`, `DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources, or cancel the remaining clear at the observed generation; show Core's remaining and offline projection | | Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; each hosted Runtime's node on Sandbox metrics. An online node whose `provider_ready` is false is degraded; the help tip beside its status on the Nodes list and on the node's page says *Sandbox provider unavailable*, and the target preparation reason (`rollout.diagnostic`, a [readiness code](../getting-started/nodes.md#readiness-codes)) shows in its target status. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Edit node** reads the node's `recommended_capacity` from this list. **Add node** follows only the node whose `enrollment_id` equals its command's | | Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | A node's page on Nodes: the host's CPU busy share, memory and free disk from its last heartbeat, and CPU and memory history over 1 hour, 6 hours or 24 hours, refreshed every 30 s while visible. | diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 9a5b772dd..673b5e66c 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: 93958e2d8c2c983cd63b9a3b75ab2e29322100ba3f175622037dd5b2f6ff4c1b +source_hash: 4fb55cb4416f6b909fce7491a9d53f586d69f4a1abc9deec29c8e1ebc7b52679 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。Docker、microsandbox 和 E2B 实现了该协议。 @@ -132,11 +132,11 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` 1. 在 adapter 包中实现 operation 契约,并编写原生契约测试。 2. 添加 specification 和 resource validator。 -3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node 提供商仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 +3. 基于类型化原生配置实现 `sandbox.ConfigurationAdapter`。`DecodeInput` 严格解析请求中独立的公开 `configuration` 与只写 `credential` 对象。`Encode` 生成白名单公开 selector、只读观测和独立 secret bytes,不透传请求 JSON。`Decode` 恢复已存储 selector 并保留对所属资源的访问,不做远程 admission 或新模板验证。`Normalize` 修改前复制输入。`ResolveChange`、`Equal` 和 `WithCredential` 负责继承、身份与凭据组合。`Requirements` 声明是否需要凭据和公开 Core origin,以及支持哪些 setup 操作:`Discovery` 对应 `DiscoverConfiguration`,`SelectionDiscovery` 对应 `DiscoverSelection`,`CredentialVerification` 对应 `VerifyCredential`。`DiscoverConfiguration` 接收 `ConfigurationDiscoveryQuery{Field, Path}`,其中 `Field` 是已声明的 `Discovered` 字段,`Path` 是前几级已选择的值,并返回 `ConfigurationOptions`:由 `{Value, Label, Resources, Complete}` 组成的列表,`Complete` 表示该选项可保存为字段值,其余选项通向下一级。它验证 query 并返回安全 catalog,不做 mutation 或 admission decision;Core 保留授权、输入限制与 deadline。`DiscoverSelection` 在提交前解析候选项省略的原生值,`VerifyCredential` 验证凭据对所属资源的访问,不修改资源。两者都接收候选项的 `sandbox.DirectConfig`,原生 client 只为该次调用构造。node 提供商仅接受空公开对象,拒绝凭据,对每项 setup 操作和 credential replacement 返回 Unsupported。 4. node adapter 从自己的包中导出 `BuildLocal` constructor、它解码的类型化 `native` 对象,以及它在共享 node artifact 之外添加的原生文件。node-local 设置(如主机路径)只存放在该对象中;resources 从 node 配置的 `specification` 读取,Runtime 镜像来自同一对象。 -5. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即提供商 kind,也用于标记该提供商的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式和 deployment policy 与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取,因此 Web 读取这些声明,而不比较提供商 kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 +5. 在 `providers/registry.go` 中注册 constructor、policy、configuration adapter 和 operation 声明。其键即提供商 kind,也用于标记该提供商的观测;checkpoint 支持读取此项。生成的投影组合每个已注册的部署模式、deployment policy 和 setup 声明与 `sandbox/deployment_contract.go` 中的共享 field bound:installer 从 `deploy/node/node_spec.py` 读取 policy,TypeScript 客户端和 Web 从 `packages/agents-client/src/deployment-contract.ts` 读取 policy 和 setup 声明,因此 Web 读取这些声明,而不比较提供商 kind。通过 `go run ./services/core/cmd/specification-contract -write` 重新生成两者。 6. 提供 adapter 和 helper 的发行产物,通过已注册 configuration 契约把该提供商交付给运维人员。 -7. Web 的 setup 步骤、部署摘要和客户端投影会指名每个提供商。在协议声明配置字段之前,把新提供商加入 `apps/web`。 +7. 在 `Policy()` 旁声明 setup 向导的内容:编写 `sandbox.SetupDeclaration`,并注册为条目的 `Setup`。它包含中英文的标签、摘要和注意事项,该提供商是否为其模式的 Recommended 选择,公开配置字段和凭据字段(`Name`、本地化的 `Label`、`Secret`、`Discovered`、`Optional`、`Help`),以及填充字段值并链接凭据控制台的 preset。注册会拒绝:凭据非必需却声明了 secret 字段、不支持 `Discovery` 却声明了 discovered 字段、空的语言文本、指向未知字段的 preset 值,以及同一模式下多于一个 Recommended 提供商。Web 遵循声明的模式和字段,不出现任何提供商名称:声明只通过生成的 `packages/agents-client/src/deployment-contract.ts` 到达 Web,不进入 `deploy/node/node_spec.py`。 node 配置 `sandbox.NodeConfig` 包含 `provider`、`generation`、`installation_id`、`core_url`、`specification`,以及 adapter 的不透明 `native` 对象。`providers.Build` 验证 `provider`、`generation`、`installation_id` 和 `specification`,并将配置与临时 `sandbox.LocalOptions` 传给 `BuildLocal`,由它严格解码 `native`。调用方通过 `Standalone` 明确选择独立注册或单提供商执行,或通过 `GenerationStateDirectory` 中的规范绝对 node state directory 选择 generation 拥有的执行。context 缺失或混合时拒绝。adapter 负责 generation 特有的原生 preparation 和 readiness 检查。Microsandbox 将 helper lease 绑定到安装实例、generation 和 specification digest,然后在平台、容量和 artifact readiness 后检查固定 image。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 54fafc2c6..4cf32fc4b 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: fb035e336b49105be6cc6fb4ec72e2f62e98b7ca6ea696a907cdce2fd8824e9e +source_hash: c20ea9f9af5f9f642b0df4fee8046aa4e97aabdaa53ffb07cf8a1022bbadc09a --- 本页列出各控制台页面调用的 Core 路由,以及控制台如何限定读取范围。本页面向控制台开发者;运维人员请阅读 [Web 控制台](index.md)。[Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API 命名空间和凭据](../api/index.md) 定义了本文使用的术语。 @@ -106,7 +106,7 @@ source_hash: fb035e336b49105be6cc6fb4ec72e2f62e98b7ca6ea696a907cdce2fd8824e9e | 操作 | 路由 | 控制台用途 | | --- | --- | --- | | 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 **在设置中管理** 前往 **设置 → 服务与部署**,且无需确认;使用 Docker 或 microsandbox 的 `resources` 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。E2B 的 `metadata.template_build`(状态、CPU、内存、磁盘)会显示在 **设置 → 沙箱与调度** 和 **沙箱监控** 中;当缺少 `specification.resources` 时,它还会确定每个 Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 **设置 → 沙箱与调度** 中 | -| E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导先列出输入的 E2B 密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | +| E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导用输入的凭据逐级列出 discovered 字段的选项;E2B 先列出该密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | | 重置 | `POST`、`DELETE /core/v1/sandbox/deployment/reset` | 显式清除托管资源,或在观测到的 generation 处取消剩余清除;显示 Core 的剩余资源和离线预测 | | 节点 | `GET /core/v1/sandbox/nodes` | **节点** 页面;**概览** 上的机群;**沙箱监控** 中每个托管 Runtime 所在的节点。`provider_ready` 为 false 的在线节点为降级;**节点** 列表及节点页面中紧邻其状态的帮助提示显示 *沙箱提供商不可用*,目标准备原因(`rollout.diagnostic`,一个[就绪状态码](../getting-started/nodes.md#readiness-codes))显示在其目标状态中。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,**节点** 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 **地址已过期**,而不是健康状态。**添加节点** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | | 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | **节点** 中的节点页面:主机最近一次心跳报告的 CPU 忙碌占比、内存使用量和可用磁盘,以及 1 小时、6 小时或 24 小时内 CPU 与内存的历史记录;页面可见时每 30 秒刷新一次。**编辑节点** 读取节点的 `recommended_capacity` 作为推荐值 | diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 4b04cab05..d7376ad97 100644 --- a/packages/agents-client/src/deployment-contract.ts +++ b/packages/agents-client/src/deployment-contract.ts @@ -1,3 +1,3 @@ // Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT. -export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"providers":{"docker":{"mode":"nodes","disk":false,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"default_resources":null},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192}}},"minimum_disk":1024} as const; +export const deploymentContract = {"workspace_fields":["attachment","user_xattr","capacity_quota"],"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"providers":{"docker":{"mode":"nodes","disk":false,"default_resources":{"cpus":2,"memory_mib":2048},"setup":{"label":"Docker","summary":{"en":"Runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.","zh-CN":"每个沙箱是一个共用主机内核的容器:能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。"},"cautions":[{"en":"Containers share the host's kernel, so a container escape reaches the host.","zh-CN":"容器共用主机内核,一旦容器逃逸就能触及主机。"},{"en":"The node's service account joins the docker group, which is equivalent to root on that host.","zh-CN":"节点的服务账号会加入 docker 组,这在那台主机上等同于 root 权限。"}],"recommended":false,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}},"e2b":{"mode":"direct","disk":false,"default_resources":null,"setup":{"label":"E2B","summary":{"en":"Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.","zh-CN":"沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。"},"cautions":[],"recommended":false,"fields":[{"name":"api_key","secret":true,"optional":false,"discovered":false,"label":{"en":"API key","zh-CN":"API 密钥"},"help":{"en":"","zh-CN":""}},{"name":"template","secret":false,"optional":false,"discovered":true,"label":{"en":"Template build","zh-CN":"模板构建"},"help":{"en":"Core validates the exact ready build again when you save.","zh-CN":"保存时 Core 会再次校验这个已就绪的构建。"}},{"name":"api_url","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox API URL","zh-CN":"沙箱 API 地址"},"help":{"en":"The selected service supplies a default. You can edit it for a compatible endpoint.","zh-CN":"所选服务会提供默认值,兼容的端点可以自行修改。"}},{"name":"domain","secret":false,"optional":true,"discovered":false,"label":{"en":"Sandbox data-plane domain","zh-CN":"沙箱数据面域名"},"help":{"en":"","zh-CN":""}}],"presets":[{"label":"SandBase Sandbox","values":{"api_url":"https://sandbox.sandbase.ai","domain":"sandbox.sandbase.ai"},"credential_url":"https://www.sandbase.ai/console/keys"},{"label":"E2B","values":{"api_url":"https://api.e2b.app","domain":"e2b.app"},"credential_url":"https://e2b.dev/dashboard?tab=api-keys"}],"credential":"required","public_origin":"required","discovery":true}},"microsandbox":{"mode":"nodes","workspace":{"attachment":"host_directory","user_xattr":true},"disk":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192},"setup":{"label":"microsandbox","summary":{"en":"Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.","zh-CN":"每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。"},"cautions":[],"recommended":true,"fields":[],"presets":[],"credential":"not_required","public_origin":"not_required","discovery":false}}},"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 9af63ee48..b8eeb6163 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -124,10 +124,28 @@ export const capacityFields = ["max_active", "max_retained"] as const; export interface ConfigurationDiscoveryInput { configuration?: Record; credential?: Record; - query?: Record; + query?: ConfigurationDiscoveryQuery; } export const configurationDiscoveryInputFields = ["configuration", "credential", "query"] as const; export const configurationDiscoveryInputRequired = [] as const; +export interface ConfigurationDiscoveryQuery { + field?: string; + path?: string[]; +} +export const configurationDiscoveryQueryFields = ["field", "path"] as const; +export const configurationDiscoveryQueryRequired = [] as const; +export interface ConfigurationOption { + complete: boolean; + label: string; + resources?: SandboxResources; + value: string; +} +export const configurationOptionFields = ["complete", "label", "resources", "value"] as const; +export const configurationOptionRequired = ["complete", "label", "value"] as const; +export interface ConfigurationOptions { + options: ConfigurationOption[]; +} +export const configurationOptionsFields = ["options"] as const; export interface CoreAPIError { code: string | null; details?: Record; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 46e4f9b18..126533cf9 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -122,24 +122,25 @@ describe("strict sandbox administration projections", () => { }); describe("Core sandbox credential boundaries", () => { - it("uses Core-key routes for transient template discovery and projects safe fields", async () => { + it("uses a Core-key route for transient discovery and projects safe fields", async () => { + const build = { value: "tpl_123:00000000-0000-0000-0000-000000000001", label: "00000000-0000-0000-0000-000000000001", resources: { cpus: 2, memory_mib: 2048 }, complete: true }; const fetch = vi.fn() - .mockResolvedValueOnce(response({ templates: [{ id: "tpl_123", names: ["runtime"] }] })) - .mockResolvedValueOnce(response({ builds: [{ id: "00000000-0000-0000-0000-000000000001", cpus: 2, memory_mib: 2048 }] })); + .mockResolvedValueOnce(response({ options: [{ value: "tpl_123", label: "runtime", complete: false }] })) + .mockResolvedValueOnce(response({ options: [build] })); const client = new SandboxAdminClient({ fetch }); - const input = { api_key: "private-test-key", api_url: "https://sandbox.sandbase.ai", domain: "sandbox.sandbase.ai" }; - expect(await client.listE2BTemplates(input)).toHaveLength(1); - expect(await client.listE2BReadyBuilds("tpl_123", input)).toHaveLength(1); + const input = { configuration: { api_url: "https://sandbox.sandbase.ai" }, credential: { api_key: "private-test-key" }, query: { field: "template", path: [] as string[] } }; + expect(await client.discoverConfiguration("e2b", input)).toHaveLength(1); + expect(await client.discoverConfiguration("e2b", { ...input, query: { field: "template", path: ["tpl_123"] } })).toEqual([build]); expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/core/v1/sandbox/providers/e2b/discovery", "/core/v1/sandbox/providers/e2b/discovery"]); expect(fetch.mock.calls.every(([, init]) => init?.method === "POST" && !new Headers(init?.headers).has("Authorization") && String(init?.body).includes("private-test-key"))).toBe(true); }); it("rejects extra fields in credentialed discovery responses", async () => { - const input = { api_key: "private-test-key" }; - const templates = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response({ templates: [], builds: [] })) }); - const builds = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response({ builds: [], templates: [] })) }); - await expect(templates.listE2BTemplates(input)).rejects.toMatchObject({ code: "invalid_admin_response" }); - await expect(builds.listE2BReadyBuilds("tpl_123", input)).rejects.toMatchObject({ code: "invalid_admin_response" }); + const input = { configuration: {}, credential: { api_key: "private-test-key" }, query: { field: "template", path: [] as string[] } }; + for (const body of [{ options: [], templates: [] }, { options: [{ value: "a", label: "a", complete: false, id: "a" }] }, { options: [{ value: "a", label: "a" }] }]) { + const client = new SandboxAdminClient({ fetch: vi.fn().mockResolvedValue(response(body)) }); + await expect(client.discoverConfiguration("e2b", input)).rejects.toMatchObject({ code: "invalid_admin_response" }); + } }); it("defaults to /core/v1/sandbox and is not a /v1 client", async () => { @@ -285,7 +286,7 @@ describe("hosted provider configuration", () => { expect(fetch).toHaveBeenCalledTimes(2); }); it("projects public-URL rejection to fixed copy even when the upstream message reflects a key", async () => { - const rejection = { message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", code: "sandbox_configuration_error", param: null, type: "reflected" }; + const rejection = { message: "This sandbox provider reaches Core over the internet. Set an HTTPS public URL that is not loopback.", code: "sandbox_configuration_error", param: null, type: "reflected" }; const fetch = vi.fn() .mockResolvedValueOnce(response({ error: rejection }, 409)) .mockResolvedValueOnce(response({ error: { ...rejection, message: rejection.message + e2b.api_key } }, 409)); @@ -397,7 +398,7 @@ it("keeps omitted-key public-URL errors actionable without reflecting a stored k const fetch = vi.fn().mockResolvedValue(response({ error: { code: "sandbox_configuration_error", message: `arbitrary upstream ${secret}`, param: secret, details: { credential: secret } } }, 409)); const client = new SandboxAdminClient({ fetch }); const error = await client.updateDeployment({ provider: "e2b", expected_generation: 1, configuration: { template: e2bDeployment.configuration.template } }).catch(error => error); - expect(error).toMatchObject({ status: 409, code: "sandbox_configuration_error", param: null, message: "E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback." }); + expect(error).toMatchObject({ status: 409, code: "sandbox_configuration_error", param: null, message: "This sandbox provider reaches Core over the internet. Set an HTTPS public URL that is not loopback." }); expect(JSON.stringify(error)).not.toContain(secret); expect(error.message).not.toContain(secret); expect(error.details).toBeUndefined(); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index c4fd8ba8b..19ec1f72f 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -1,15 +1,15 @@ import { AgentCoreError } from "./client"; import { CoreRequester, type CoreClientOptions } from "./core-request"; import { deploymentContract } from "./deployment-contract"; -import { hasOwn, isNonnegativeInteger, isOneOf, isRecord, onlyFields, sameResourceId, schemaFields } from "./response-projection"; +import { hasOwn, isNonnegativeInteger, isOneOf, isRecord, sameResourceId, schemaFields } from "./response-projection"; import { attachmentKindValues, deploymentResourcesFields, deploymentSpecFields, deploymentSpecRequired, deploymentViewFields, deploymentModeValues, deploymentViewRequired, hostHistoryFields, hostHistoryPointFields, nodeAllocationDiagnosticValues, nodeAllocationFields, nodeDetailFields, - capacityFields, nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, + capacityFields, configurationOptionFields, configurationOptionRequired, configurationOptionsFields, nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, resetFields, resetOfflineNodeFields, resetRemainingFields, rolloutFields, rolloutNodesFields, rolloutStateValues, sandboxAllocationListFields, sandboxNodeListFields, sandboxResourcesFields, sandboxResourcesRequired, suspensionFields, - type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnosticCode, + type ConfigurationOption, type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnosticCode, type NodeHost, type NodeRollout, type NodeUpdate, type Reset, type ResetOfflineNode, type ResetRequest, type Rollout, type SandboxIdlePolicyInput, type SandboxDeploymentInput, type SandboxEnrollmentToken, type SandboxResources as SandboxResourcesResource, } from "./generated/core-api"; @@ -25,6 +25,27 @@ export function normalizeSandboxNodeDiagnostic(value: string): SandboxNodeDiagno /** A registered Provider kind; deploymentContract.providers holds each one's declaration. */ export type SandboxProvider = keyof typeof deploymentContract.providers; +/** Operator-facing copy in every supported locale. */ +export interface SandboxSetupText { readonly en: string; readonly "zh-CN": string } +/** One value an operator enters: a secret one is the credential, any other configuration. */ +export interface SandboxSetupField { readonly name: string; readonly secret: boolean; readonly optional: boolean; readonly discovered: boolean; readonly label: SandboxSetupText; readonly help: SandboxSetupText } +/** A named set of field values, such as one hosted service. */ +export interface SandboxSetupPreset { readonly label: string; readonly values: Readonly>; readonly credential_url?: string } +/** What the console needs to set up one Provider, generated from the Provider's declaration. */ +export interface SandboxProviderSetup { + readonly label: string; + readonly summary: SandboxSetupText; + readonly cautions: readonly SandboxSetupText[]; + readonly recommended: boolean; + readonly fields: readonly SandboxSetupField[]; + readonly presets: readonly SandboxSetupPreset[]; + readonly credential: "required" | "not_required"; + readonly public_origin: "required" | "not_required"; + readonly discovery: boolean; +} +export function sandboxProviderSetup(provider: SandboxProvider): SandboxProviderSetup { + return deploymentContract.providers[provider].setup as SandboxProviderSetup; +} /** Client-generated, never a Core code: a deployment write whose rejection could echo the key and is withheld. */ export const sandboxConfigurationUnconfirmed = "sandbox_configuration_unconfirmed"; const sandboxConfigurationParams = new Set([...deploymentContract.resources.map(({ name }) => `resources.${name}`)]); @@ -44,37 +65,35 @@ export type SandboxNode = Node; export type SandboxEnrollment = SandboxEnrollmentToken; /** One node with its host observation and complete UTC buckets of host history. */ export type SandboxNodeDetail = NodeDetail; -/** A node's name and sandbox limits, with a retained limit of at least the active one. Under Docker, Core sets the retained limit to the active one. */ +/** A node's name and sandbox limits, with a retained limit of at least the active one. Core sets the retained limit to the active one on a Provider without checkpoint support. */ export type SandboxNodeUpdate = NodeUpdate; export type SandboxAllocation = NodeAllocation; -// The schema types each Provider's configuration, credential and metadata as free-form objects; the client names E2B's. +// The schema types each Provider's configuration and credential as free-form objects; their members are the Provider's declared setup fields. /** * Core derives the deployment's address from the installation public URL; a `core_url` member is rejected. - * `expected_generation` is required, zero for first setup. Docker and microsandbox require `resources`; - * E2B may omit `resources` to adopt its template build's CPU and memory, and always runs that build. + * `expected_generation` is required, zero for first setup. A Provider that declares default resources requires `resources`; + * another may omit them to adopt the size of the configuration it validated. */ export type InitializeSandboxDeployment = Omit & { provider: SandboxProvider; - configuration?: SandboxE2BConfiguration; - /** Write-only. Omission on update preserves the current credential. */ - credential?: { api_key: string }; + /** The declared non-secret field values. */ + configuration?: Record; + /** Write-only, the declared secret field values. Omission on update preserves the current credential. */ + credential?: Record; }; export type UpdateSandboxDeployment = InitializeSandboxDeployment; -export type SandboxDeployment = Omit & { +export type SandboxDeployment = Omit & { provider: SandboxProvider | ""; - configuration?: SandboxE2BConfiguration; - metadata?: { template_build?: SandboxE2BTemplateBuild }; + configuration?: Record; }; -export interface SandboxE2BConfiguration { template?: string; api_url?: string; domain?: string } -/** The fixed E2B build as Core read it when the selection was saved; unknown values are null. */ -export interface SandboxE2BTemplateBuild { - status: string | null; - resources: { cpus: number | null; memory_mib: number | null; root_disk_mib: number | null }; +/** One choice for a discovered field: a complete option's value is the field's value, another's is the next path element. */ +export type SandboxConfigurationOption = ConfigurationOption; +export interface SandboxDiscoveryInput { + configuration: Record; + credential: Record; + query: { field: string; path: string[] }; } -export interface SandboxE2BDiscoveryInput { api_key: string; api_url?: string; domain?: string } -export interface SandboxE2BTemplate { id: string; names: string[] } -export interface SandboxE2BReadyBuild { id: string; cpus: number; memory_mib: number } export type SandboxNodeHistoryRange = "1h" | "6h" | "24h"; function invalidSandboxResponse(): never { @@ -106,17 +125,6 @@ function projectSpecification(value: unknown): SandboxSpecification { } return result; } -/** The adapter's public projection has no credential member. */ -function projectE2B(configuration: unknown, metadata: unknown): Pick { - const config = members(configuration, ["template", "api_url", "domain"]); - valid(strings(config, ["template", "api_url", "domain"])); - const facts = members(metadata, ["template_build"], []); - if (!hasOwn(facts, "template_build")) return { configuration: { ...config }, metadata: {} }; - const build = members(facts.template_build, ["status", "resources"]); - const resources = members(build.resources, ["cpus", "memory_mib", "root_disk_mib"]); - valid((build.status === null || typeof build.status === "string") && Object.values(resources).every(nullable(isNonnegativeInteger))); - return { configuration: { ...config }, metadata: { template_build: { status: build.status as string | null, resources: { ...resources } as SandboxE2BTemplateBuild["resources"] } } }; -} /** Counts and blocker identities are one Core snapshot, never reconstructed from node lists. */ function projectReset(value: unknown, held: number): SandboxReset | null { if (value === null) return null; @@ -152,13 +160,17 @@ function projectNodeRollout(value: unknown, online: unknown): SandboxNodeRollout } /** Configured deployments carry a validated public configuration and observation object. */ function projectDeployment(value: unknown): SandboxDeployment { - const e2b = isRecord(value) && value.provider === "e2b"; // A selected Provider always has its configuration and metadata; no Provider has neither. const selected = isRecord(value) && value.provider !== ""; const deployment = members(value, deploymentViewFields, selected ? [...deploymentViewRequired, "configuration", "metadata"] : deploymentViewRequired); valid(selected || (!hasOwn(deployment, "configuration") && !hasOwn(deployment, "metadata"))); valid(typeof deployment.credential_configured === "boolean"); - if (!e2b && deployment.provider !== "") { members(deployment.configuration, []); members(deployment.metadata, []); valid(deployment.credential_configured === false); } + if (selected) { + valid(typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider) && isRecord(deployment.metadata)); + const setup = sandboxProviderSetup(deployment.provider as SandboxProvider); + const configuration = members(deployment.configuration, setup.fields.filter((field) => !field.secret).map((field) => field.name)); + valid(strings(configuration, Object.keys(configuration)) && (setup.credential === "required" || deployment.credential_configured === false)); + } const resources = members(deployment.resources, deploymentResourcesFields); const suspension = deployment.suspension === null ? null : members(deployment.suspension, suspensionFields); const configured = hasOwn(deployment, "specification"); @@ -169,7 +181,6 @@ function projectDeployment(value: unknown): SandboxDeployment { return { ...deployment, rollout: projectRollout(deployment.rollout, deployment.mode, Number(resources.allocations) + Number(resources.pending)), reset: projectReset(deployment.reset, Number(resources.allocations) + Number(resources.pending)), resources: { ...resources } as unknown as SandboxDeployment["resources"], suspension: suspension && { ...suspension } as unknown as SandboxDeployment["suspension"], ...(configured ? { specification: projectSpecification(deployment.specification) } : {}), - ...(e2b ? projectE2B(deployment.configuration, deployment.metadata) : {}), } as unknown as SandboxDeployment; } @@ -225,20 +236,15 @@ export class SandboxAdminClient { this.#core = new CoreRequester(options.baseUrl ?? "/core/v1/sandbox", options.token, options.fetch, invalidSandboxResponse); } - async listE2BTemplates(input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise { - const value = await this.#core.json("/providers/e2b/discovery", options, "POST", { configuration: { api_url: input.api_url, domain: input.domain }, credential: { api_key: input.api_key }, query: {} }); - if (!isRecord(value) || !onlyFields(value, new Set(["templates"])) || !Array.isArray(value.templates) || value.templates.length > 200) invalidSandboxResponse(); - return value.templates.map((item) => { - if (!isRecord(item) || !onlyFields(item, new Set(["id", "names"])) || typeof item.id !== "string" || !Array.isArray(item.names) || !item.names.every((name) => typeof name === "string")) invalidSandboxResponse(); - return { id: item.id, names: item.names }; - }); - } - async listE2BReadyBuilds(templateId: string, input: SandboxE2BDiscoveryInput, options?: ReadOptions): Promise { - const value = await this.#core.json("/providers/e2b/discovery", options, "POST", { configuration: { api_url: input.api_url, domain: input.domain }, credential: { api_key: input.api_key }, query: { template: templateId } }); - if (!isRecord(value) || !onlyFields(value, new Set(["builds"])) || !Array.isArray(value.builds) || value.builds.length > 200) invalidSandboxResponse(); - return value.builds.map((item) => { - if (!isRecord(item) || !onlyFields(item, new Set(["id", "cpus", "memory_mib"])) || typeof item.id !== "string" || !isNonnegativeInteger(item.cpus) || !isNonnegativeInteger(item.memory_mib)) invalidSandboxResponse(); - return { id: item.id, cpus: item.cpus, memory_mib: item.memory_mib }; + /** The options of one discovered setup field after the values chosen at earlier levels. */ + async discoverConfiguration(provider: SandboxProvider, input: SandboxDiscoveryInput, options?: ReadOptions): Promise { + const value = members(await this.#core.json(`/providers/${encodeURIComponent(provider)}/discovery`, options, "POST", input), configurationOptionsFields); + valid(Array.isArray(value.options) && value.options.length <= 200); + return (value.options as unknown[]).map((entry) => { + const option = members(entry, configurationOptionFields, configurationOptionRequired); + valid(typeof option.value === "string" && typeof option.label === "string" && typeof option.complete === "boolean"); + if (hasOwn(option, "resources")) valid(Object.values(members(option.resources, sandboxResourcesFields, sandboxResourcesRequired)).every(isNonnegativeInteger)); + return { ...option } as unknown as SandboxConfigurationOption; }); } @@ -278,10 +284,10 @@ export class SandboxAdminClient { sandbox_reset_in_progress: "A sandbox reset is in progress.", sandbox_not_configured: "The sandbox deployment is not configured.", sandbox_in_use: "Hosted sandbox resources still belong to this deployment.", - sandbox_credential_ownership: "This E2B key cannot manage the retained deployment. Reset before changing teams.", - sandbox_credential_invalid: "The E2B API key was rejected.", - sandbox_configuration_invalid: "Select a ready immutable E2B template build with matching resources.", - sandbox_verification_unconfirmed: "E2B verification could not be confirmed. Refresh before submitting again.", + sandbox_credential_ownership: "This credential cannot manage the retained deployment. Reset before changing accounts.", + sandbox_credential_invalid: "The sandbox provider credential was rejected.", + sandbox_configuration_invalid: "Select a ready immutable provider configuration with matching resources.", + sandbox_verification_unconfirmed: "Sandbox provider verification could not be confirmed. Refresh before submitting again.", }; if (error.code && Object.hasOwn(messages, error.code)) { // Credential-bearing errors expose fixed local copy and allowlisted @@ -298,7 +304,7 @@ export class SandboxAdminClient { // This code has one fixed Core meaning. Never forward its raw message or // param: an omitted key cannot be used to detect a reflected stored key. if (error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error") { - throw new AgentCoreError("E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", 409, "sandbox_configuration_error", null); + throw new AgentCoreError("This sandbox provider reaches Core over the internet. Set an HTTPS public URL that is not loopback.", 409, "sandbox_configuration_error", null); } // Any other credential-bearing rejection may reflect the key in any error field. throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, sandboxConfigurationUnconfirmed); diff --git a/packages/agents-client/src/sandbox-e2b-error-params.test.ts b/packages/agents-client/src/sandbox-e2b-error-params.test.ts index 608a1d372..42b831784 100644 --- a/packages/agents-client/src/sandbox-e2b-error-params.test.ts +++ b/packages/agents-client/src/sandbox-e2b-error-params.test.ts @@ -3,9 +3,9 @@ import { AgentCoreError } from "./client"; import { SandboxAdminClient } from "./sandbox-client"; const cases = [ - { status: 400, code: "sandbox_credential_invalid", param: "credential", message: "The E2B API key was rejected." }, - { status: 400, code: "sandbox_configuration_invalid", param: "configuration", message: "Select a ready immutable E2B template build with matching resources." }, - { status: 409, code: "sandbox_credential_ownership", param: "credential", message: "This E2B key cannot manage the retained deployment. Reset before changing teams." }, + { status: 400, code: "sandbox_credential_invalid", param: "credential", message: "The sandbox provider credential was rejected." }, + { status: 400, code: "sandbox_configuration_invalid", param: "configuration", message: "Select a ready immutable provider configuration with matching resources." }, + { status: 409, code: "sandbox_credential_ownership", param: "credential", message: "This credential cannot manage the retained deployment. Reset before changing accounts." }, ] as const; const currentKey = "current-secret-canary"; const storedKey = "stored-secret-canary"; diff --git a/services/core/cmd/server/managed_setup.go b/services/core/cmd/server/managed_setup.go index a0aee5c87..26d83a1f5 100644 --- a/services/core/cmd/server/managed_setup.go +++ b/services/core/cmd/server/managed_setup.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "sync/atomic" @@ -56,7 +55,7 @@ type generationAllocations interface { // DiscoverConfiguration asks a Provider which configuration values its // credential can use, with this installation's process paths. -func (s *managedSetup) DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (s *managedSetup) DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { return s.registry.DiscoverConfiguration(ctx, provider, input, s.processPaths) } diff --git a/services/core/internal/api/admin_runtime.go b/services/core/internal/api/admin_runtime.go index 77f96d043..b03d361df 100644 --- a/services/core/internal/api/admin_runtime.go +++ b/services/core/internal/api/admin_runtime.go @@ -18,8 +18,8 @@ type AdminRuntimeObservation struct { // project observation shape, which stays unchanged. type AdminRuntimeObservationDetail struct { v1.RuntimeObservation - // Current Runtime disk usage and capacity. E2B reports them; Docker and - // microsandbox observations return null. + // Current Runtime disk usage and capacity. Providers that cannot report + // them return null. Disk *RuntimeDiskObservation `json:"disk" extensions:"x-nullable" binding:"required"` } diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index c5f7f7e65..f70accccc 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -172,10 +172,10 @@ func (f *fakeArtifactsReader) ReadSessionArtifact(a0 context.Context, a1 string, type fakeConfigurationDiscovery struct { t testing.TB - discoverConfiguration func(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) + discoverConfiguration func(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) } -func (f *fakeConfigurationDiscovery) DiscoverConfiguration(a0 context.Context, a1 string, a2 sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (f *fakeConfigurationDiscovery) DiscoverConfiguration(a0 context.Context, a1 string, a2 sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { if f.discoverConfiguration == nil { unexpectedCall(f.t, "DiscoverConfiguration") } diff --git a/services/core/internal/api/sandbox_configuration_discovery.go b/services/core/internal/api/sandbox_configuration_discovery.go index 95a4f3e59..c0820d641 100644 --- a/services/core/internal/api/sandbox_configuration_discovery.go +++ b/services/core/internal/api/sandbox_configuration_discovery.go @@ -2,7 +2,6 @@ package api import ( "context" - "encoding/json" "net/http" "time" @@ -14,18 +13,18 @@ import ( // ConfigurationDiscovery asks a Sandbox Provider which configuration values // its credential can use. type ConfigurationDiscovery interface { - DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) + DiscoverConfiguration(ctx context.Context, provider string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) } // @Summary Discover sandbox provider configuration -// @Description Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns safe catalog metadata. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. +// @Description Core key only. Uses transient write-only credentials. The provider validates configuration and query fields and returns the options of one Discovered setup field. Does not save credentials, change a deployment or allocate compute. Discovery is not deployment admission. // @Tags Sandbox Manager // @Accept json // @Produce json // @Security DeploymentAdminAuth // @Param provider path string true "Registered provider kind" // @Param body body sandbox.ConfigurationDiscoveryInput true "Transient provider connection and query" -// @Success 200 {object} map[string]interface{} +// @Success 200 {object} sandbox.ConfigurationOptions // @Failure 400,401,500,503 {object} CoreErrorResponse // @Router /core/v1/sandbox/providers/{provider}/discovery [post] func (h *Handler) discoverSandboxConfiguration(w http.ResponseWriter, r *http.Request) { diff --git a/services/core/internal/api/sandbox_configuration_discovery_test.go b/services/core/internal/api/sandbox_configuration_discovery_test.go index 4111a5bf7..10ea9b264 100644 --- a/services/core/internal/api/sandbox_configuration_discovery_test.go +++ b/services/core/internal/api/sandbox_configuration_discovery_test.go @@ -2,7 +2,6 @@ package api import ( "context" - "encoding/json" "net/http" "net/http/httptest" "strings" @@ -14,7 +13,7 @@ import ( func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { deps, fakes := sandboxFakes(t) calls := 0 - discover := func(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { + discover := func(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { calls++ if kind != "e2b" || !strings.Contains(string(input.Credential), "private-test-key") { t.Fatal("wrong request") @@ -22,13 +21,10 @@ func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { if _, ok := ctx.Deadline(); !ok { t.Fatal("unbounded discovery") } - if strings.Contains(string(input.Query), "bad") { - return nil, sandbox.ErrConfigurationUnconfirmed + if len(input.Query.Path) == 1 && input.Query.Path[0] == "bad" { + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationUnconfirmed } - if strings.Contains(string(input.Query), "tpl_123") { - return json.RawMessage(`{"builds":[]}`), nil - } - return json.RawMessage(`{"templates":[]}`), nil + return sandbox.ConfigurationOptions{Options: []sandbox.ConfigurationOption{}}, nil } fakes.configurationDiscovery.discoverConfiguration = discover h := newTestHandler(t, deps) @@ -36,11 +32,11 @@ func TestSandboxE2BDiscoveryAuthenticationAndCredentialPrivacy(t *testing.T) { token, path, body string status int }{ - {"caller", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{}}`, 401}, + {"caller", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":[]}}`, 401}, {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"unexpected":true}`, 400}, - {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{}}`, 200}, - {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"template":"tpl_123"}}`, 200}, - {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"template":"bad"}}`, 503}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":[]}}`, 200}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":["tpl_123"]}}`, 200}, + {"administrator", "/core/v1/sandbox/providers/e2b/discovery", `{"credential":{"api_key":"private-test-key"},"query":{"field":"template","path":["bad"]}}`, 503}, } { req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) req.Header.Set("Authorization", "Bearer "+tc.token) diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index 6e0ba893e..4aa58c496 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -14,8 +14,9 @@ import ( type SandboxDeploymentInput struct { ExpectedGeneration *uint64 `json:"expected_generation" binding:"required"` - // Per-sandbox limits, required for Docker and microsandbox. E2B may omit - // them; Core then uses the validated template build's cpus and memory_mib. + // Per-sandbox limits, required for a Provider that declares default + // resources. Another Provider may omit them; Core then uses the cpus and + // memory_mib of the configuration it validated. Resources sandbox.Resources `json:"resources"` Provider string `json:"provider"` Configuration json.RawMessage `json:"configuration" swaggertype:"object"` @@ -50,7 +51,7 @@ type DeploymentReset interface { } // @Summary Initialize the deployment sandbox provider -// @Description Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. E2B returns 409 sandbox_configuration_error while the public URL is loopback. E2B credentials are write-only. E2B may omit resources to adopt the validated template build's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. +// @Description Selects a provider and its enforced resource limits. Core derives the deployment's core_url from the installation public URL and rejects a core_url member with 400. A Provider that requires a public origin returns 409 sandbox_configuration_error while the public URL is loopback. Credentials are write-only. A Provider without default resources may omit resources to adopt the validated configuration's CPU and memory, returned in specification.resources. Requires explicit expected_generation, including zero at first setup. Stale retries reject before provider validation. An identical selection at the current generation is a no-op; a differing selection rejects. This does not create compute or execute work. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -83,7 +84,7 @@ func (h *Handler) initializeSandboxDeployment(w http.ResponseWriter, r *http.Req } // @Summary Change the sandbox deployment configuration -// @Description Requires the observed generation, the same backend type and no active reset. E2B same-team changes apply online: allocations retain immutable generation and current credentials; omitted api_key preserves it, explicit submission including the same key verifies and advances generation. Other teams require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. +// @Description Requires the observed generation, the same backend type and no active reset. Credential-holding Providers apply same-account changes online: allocations retain immutable generation and current credentials; an omitted credential preserves it, explicit submission including the same credential verifies and advances generation. Other accounts require explicit reset. Node providers retain the zero-resource guard and retire old nodes/tokens on change. Core rejects core_url input. Never automatically replay an uncertain write; rollout.state is the authoritative preparation polling signal. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth diff --git a/services/core/internal/api/sandbox_manager.go b/services/core/internal/api/sandbox_manager.go index b9934fd99..29a754533 100644 --- a/services/core/internal/api/sandbox_manager.go +++ b/services/core/internal/api/sandbox_manager.go @@ -33,7 +33,7 @@ type SandboxMutationResponse struct { type SandboxEnrollmentTokenRequest struct { // An omitted limit is derived from the node's host when it enrolls. MaxActive *int `json:"max_active,omitempty"` - // Docker never suspends, so Core replaces this with max_active; microsandbox uses both limits. + // Providers without checkpoint support never suspend, so Core replaces this with max_active; the others use both limits. MaxRetained *int `json:"max_retained,omitempty"` } @@ -87,7 +87,7 @@ func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { } // @Summary Retrieve sandbox deployment -// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. Provider metadata is what Core read when the selection was saved; this read does not call the Provider. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth diff --git a/services/core/internal/api/sandbox_node_configuration.go b/services/core/internal/api/sandbox_node_configuration.go index afab10889..43c67cd0c 100644 --- a/services/core/internal/api/sandbox_node_configuration.go +++ b/services/core/internal/api/sandbox_node_configuration.go @@ -9,7 +9,7 @@ import ( ) // @Summary Read the active configuration for node installation -// @Description Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. +// @Description Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose Provider credentials. Node files cannot override this specification. // @Tags Sandbox Node // @Produce json // @Security NodeEnrollmentAuth diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index 4a0a94418..eeb2b27e1 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -29,9 +29,20 @@ var resourceContract = []resourceRule{ } // ProviderProjection is one registered Provider in the generated projections. +// Setup reaches the TypeScript client only: the node installer needs no setup copy. type ProviderProjection struct { Mode DeploymentMode `json:"mode"` DeploymentPolicy + Setup *ProviderSetup `json:"setup,omitempty"` +} + +// ProviderSetup is a Provider's setup declaration with the configuration +// requirements the console needs beside it. +type ProviderSetup struct { + SetupDeclaration + Credential Requirement `json:"credential"` + PublicOrigin Requirement `json:"public_origin"` + Discovery bool `json:"discovery"` } // DeploymentContract generates the installer and TypeScript client projections @@ -59,14 +70,22 @@ func DeploymentContract(providers map[string]ProviderProjection) (python, typesc for field := range workspaceType.NumField() { workspaceFields = append(workspaceFields, strings.Split(workspaceType.Field(field).Tag.Get("json"), ",")[0]) } - raw, _ := json.Marshal(struct { - WorkspaceFields []string `json:"workspace_fields"` - Resources []resourceRule `json:"resources"` - Providers map[string]ProviderProjection `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{workspaceFields, resourceContract, providers, minimumDiskMiB}) - return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT", - "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + string(raw) + " as const;\n" + render := func(providers map[string]ProviderProjection) string { + raw, _ := json.Marshal(struct { + WorkspaceFields []string `json:"workspace_fields"` + Resources []resourceRule `json:"resources"` + Providers map[string]ProviderProjection `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{workspaceFields, resourceContract, providers, minimumDiskMiB}) + return string(raw) + } + installer := make(map[string]ProviderProjection, len(providers)) + for kind, p := range providers { + p.Setup = nil + installer[kind] = p + } + return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", render(installer)) + ")\n# END GENERATED DEPLOYMENT CONTRACT", + "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + render(providers) + " as const;\n" } func resourceNames() []string { var names []string diff --git a/services/core/internal/sandbox/docker/provider.go b/services/core/internal/sandbox/docker/provider.go index b56906eb5..f97c8070e 100644 --- a/services/core/internal/sandbox/docker/provider.go +++ b/services/core/internal/sandbox/docker/provider.go @@ -39,7 +39,7 @@ func New(c *client.Client, config Config) (*Provider, error) { return nil, sandbox.ErrInvalid } if config.Resources != nil { - if ValidateResources(*config.Resources) != nil { + if config.Resources.ValidatePolicy("docker", Policy()) != nil { return nil, sandbox.ErrInvalid } resources := *config.Resources diff --git a/services/core/internal/sandbox/docker/selection.go b/services/core/internal/sandbox/docker/selection.go index 80c83f758..12a29c724 100644 --- a/services/core/internal/sandbox/docker/selection.go +++ b/services/core/internal/sandbox/docker/selection.go @@ -8,7 +8,17 @@ func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} } -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("docker", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { - return s.ValidatePolicy("docker", Policy()) +// Setup declares how the console connects Docker nodes. +func Setup() sandbox.SetupDeclaration { + return sandbox.SetupDeclaration{ + Label: "Docker", + Summary: sandbox.Text{ + EN: "Runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.", + ZHCN: "每个沙箱是一个共用主机内核的容器:能限制 CPU 和内存,但没有磁盘配额,只适合可信的工作负载或没有 KVM 的主机。", + }, + Cautions: []sandbox.Text{ + {EN: "Containers share the host's kernel, so a container escape reaches the host.", ZHCN: "容器共用主机内核,一旦容器逃逸就能触及主机。"}, + {EN: "The node's service account joins the docker group, which is equivalent to root on that host.", ZHCN: "节点的服务账号会加入 docker 组,这在那台主机上等同于 root 权限。"}, + }, + } } diff --git a/services/core/internal/sandbox/e2b/configuration.go b/services/core/internal/sandbox/e2b/configuration.go index 5a00ed53e..5e7865a5f 100644 --- a/services/core/internal/sandbox/e2b/configuration.go +++ b/services/core/internal/sandbox/e2b/configuration.go @@ -37,7 +37,7 @@ func (ConfigurationAdapter) Requirements() sandbox.ConfigurationRequirements { } func (ConfigurationAdapter) DecodeInput(public, secret json.RawMessage) (sandbox.Configuration, error) { var p publicConfiguration - if err := sandbox.DecodeConfigurationObject(public, &p, "template", "api_url", "domain"); err != nil { + if err := sandbox.DecodeConfigurationObject(public, &p, fieldNames(false)...); err != nil { return nil, err } c := &DeploymentConfiguration{Template: p.Template, APIURL: p.APIURL, Domain: p.Domain} @@ -45,7 +45,7 @@ func (ConfigurationAdapter) DecodeInput(public, secret json.RawMessage) (sandbox var credential struct { APIKey string `json:"api_key"` } - if err := sandbox.DecodeConfigurationObject(secret, &credential, "api_key"); err != nil { + if err := sandbox.DecodeConfigurationObject(secret, &credential, fieldNames(true)...); err != nil { return nil, err } if credential.APIKey == "" { @@ -81,7 +81,7 @@ func (ConfigurationAdapter) Encode(value sandbox.Configuration) (sandbox.Configu func (ConfigurationAdapter) Decode(record sandbox.ConfigurationRecord) (sandbox.Configuration, error) { var p publicConfiguration var m buildMetadata - if sandbox.DecodeConfigurationObject(record.Public, &p, "template", "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(record.Metadata, &m, "template_build") != nil { + if sandbox.DecodeConfigurationObject(record.Public, &p, fieldNames(false)...) != nil || sandbox.DecodeConfigurationObject(record.Metadata, &m, "template_build") != nil { return nil, sandbox.ErrInvalid } c := &DeploymentConfiguration{Template: p.Template, APIURL: p.APIURL, Domain: p.Domain, APIKey: string(record.Secret)} diff --git a/services/core/internal/sandbox/e2b/configuration_discovery.go b/services/core/internal/sandbox/e2b/configuration_discovery.go index e3902e66c..7c24bd9ba 100644 --- a/services/core/internal/sandbox/e2b/configuration_discovery.go +++ b/services/core/internal/sandbox/e2b/configuration_discovery.go @@ -2,14 +2,15 @@ package e2b import ( "context" - "encoding/json" "errors" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (json.RawMessage, error) { +// DiscoverConfiguration lists templates for the declared template field, then +// the ready builds of the template chosen at the first level. +func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { var connection struct { APIURL string `json:"api_url"` Domain string `json:"domain"` @@ -17,31 +18,37 @@ func (ConfigurationAdapter) DiscoverConfiguration(ctx context.Context, input san var credential struct { APIKey string `json:"api_key"` } - var query struct { - Template string `json:"template,omitempty"` - } - if sandbox.DecodeConfigurationObject(input.Configuration, &connection, "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(input.Credential, &credential, "api_key") != nil || sandbox.DecodeConfigurationObject(input.Query, &query, "template") != nil { - return nil, sandbox.ErrInvalid + path := input.Query.Path + if sandbox.DecodeConfigurationObject(input.Configuration, &connection, "api_url", "domain") != nil || sandbox.DecodeConfigurationObject(input.Credential, &credential, "api_key") != nil || len(path) > 1 { + return sandbox.ConfigurationOptions{}, sandbox.ErrInvalid } binary, _, err := InstalledPaths(paths) if err != nil { - return nil, sandbox.ErrConfigurationUnconfirmed + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationUnconfirmed } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - out, err := Discover(ctx, &ProcessCaller{}, binary, credential.APIKey, connection.APIURL, connection.Domain, query.Template) + template := "" + if len(path) == 1 { + template = path[0] + } + out, err := Discover(ctx, &ProcessCaller{}, binary, credential.APIKey, connection.APIURL, connection.Domain, template) if err != nil { if errors.Is(err, sandbox.ErrInvalid) { - return nil, sandbox.ErrConfigurationSelection + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationSelection + } + return sandbox.ConfigurationOptions{}, sandbox.ErrConfigurationUnconfirmed + } + options := []sandbox.ConfigurationOption{} + for _, t := range out.Templates { + label := t.ID + if len(t.Names) > 0 { + label = t.Names[0] + " · " + t.ID } - return nil, sandbox.ErrConfigurationUnconfirmed + options = append(options, sandbox.ConfigurationOption{Value: t.ID, Label: label}) } - if query.Template == "" { - return json.Marshal(struct { - Templates []TemplateSummary `json:"templates"` - }{out.Templates}) + for _, b := range out.Builds { + options = append(options, sandbox.ConfigurationOption{Value: template + ":" + b.ID, Label: b.ID, Resources: &sandbox.Resources{CPUs: b.CPUs, MemoryMiB: b.MemoryMiB}, Complete: true}) } - return json.Marshal(struct { - Builds []ReadyBuild `json:"builds"` - }{out.Builds}) + return sandbox.ConfigurationOptions{Options: options}, nil } diff --git a/services/core/internal/sandbox/e2b/deployment.go b/services/core/internal/sandbox/e2b/deployment.go index 8fcd9c285..40d2ba04e 100644 --- a/services/core/internal/sandbox/e2b/deployment.go +++ b/services/core/internal/sandbox/e2b/deployment.go @@ -15,8 +15,40 @@ func Policy() sandbox.DeploymentPolicy { return sandbox.DeploymentPolicy{} } -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { return s.ValidatePolicy("e2b", Policy()) } +// Setup declares how the console connects E2B and compatible services. The +// adapter reads its configuration and credential field names from here. +func Setup() sandbox.SetupDeclaration { + return sandbox.SetupDeclaration{ + Label: "E2B", + Summary: sandbox.Text{ + EN: "Runs sandboxes in a hosted E2B-compatible cloud: no machines to manage, billed by the service.", + ZHCN: "沙箱运行在托管的 E2B 兼容云端:无需管理机器,由服务方计费。", + }, + Fields: []sandbox.SetupField{ + {Name: "api_key", Secret: true, Label: sandbox.Text{EN: "API key", ZHCN: "API 密钥"}}, + {Name: "template", Discovered: true, Label: sandbox.Text{EN: "Template build", ZHCN: "模板构建"}, + Help: sandbox.Text{EN: "Core validates the exact ready build again when you save.", ZHCN: "保存时 Core 会再次校验这个已就绪的构建。"}}, + {Name: "api_url", Optional: true, Label: sandbox.Text{EN: "Sandbox API URL", ZHCN: "沙箱 API 地址"}, + Help: sandbox.Text{EN: "The selected service supplies a default. You can edit it for a compatible endpoint.", ZHCN: "所选服务会提供默认值,兼容的端点可以自行修改。"}}, + {Name: "domain", Optional: true, Label: sandbox.Text{EN: "Sandbox data-plane domain", ZHCN: "沙箱数据面域名"}}, + }, + Presets: []sandbox.SetupPreset{ + {Label: "SandBase Sandbox", Values: map[string]string{"api_url": "https://sandbox.sandbase.ai", "domain": "sandbox.sandbase.ai"}, CredentialURL: "https://www.sandbase.ai/console/keys"}, + {Label: "E2B", Values: map[string]string{"api_url": "https://api.e2b.app", "domain": "e2b.app"}, CredentialURL: "https://e2b.dev/dashboard?tab=api-keys"}, + }, + } +} + +// fieldNames lists the declared secret or non-secret field names. +func fieldNames(secret bool) []string { + var names []string + for _, f := range Setup().Fields { + if f.Secret == secret { + names = append(names, f.Name) + } + } + return names +} func ValidateConfiguration(c *DeploymentConfiguration) error { if c == nil || c.APIKey == "" || len(c.APIKey) > 4096 || strings.IndexFunc(c.APIKey, func(r rune) bool { return unicode.IsSpace(r) || r == 0 }) >= 0 { @@ -39,13 +71,13 @@ func ValidateConfiguration(c *DeploymentConfiguration) error { } // NormalizeSelection accepts omitted candidate resources only until live build -// discovery. Persistence requires ValidateSpecification after preparation. +// discovery. Persistence requires a valid specification after preparation. func NormalizeSelection(s sandbox.Selection) (sandbox.Selection, error) { if s.Workspace != nil { return s, sandbox.ErrInvalid } if s.Resources != (sandbox.Resources{}) { - if err := ValidateSpecification(s.DeploymentSpec); err != nil { + if err := s.DeploymentSpec.ValidatePolicy("e2b", Policy()); err != nil { return s, err } } @@ -92,7 +124,7 @@ func (ConfigurationAdapter) DiscoverSelection(ctx context.Context, c sandbox.Dir recorded.RootDiskMiB = &disk } s := WithTemplateBuild(c.Selection, recorded) - if err := ValidateSpecification(s.DeploymentSpec); err != nil { + if err := s.DeploymentSpec.ValidatePolicy("e2b", Policy()); err != nil { return sandbox.Selection{}, &sandbox.ValidationError{Param: "resources", Message: "E2B template build resources are outside the supported sandbox limits; select another build"} } return s, nil diff --git a/services/core/internal/sandbox/e2b/installed_paths_test.go b/services/core/internal/sandbox/e2b/installed_paths_test.go index 5012e0d97..41635e915 100644 --- a/services/core/internal/sandbox/e2b/installed_paths_test.go +++ b/services/core/internal/sandbox/e2b/installed_paths_test.go @@ -36,8 +36,8 @@ func TestConfigurationDiscoveryUsesSuppliedProcessPaths(t *testing.T) { } input := sandbox.ConfigurationDiscoveryInput{Credential: json.RawMessage(`{"api_key":"synthetic-key"}`)} result, err := (ConfigurationAdapter{}).DiscoverConfiguration(t.Context(), input, paths) - if err != nil || string(result) != `{"templates":[]}` { - t.Fatalf("configured discovery: %s %v", result, err) + if err != nil || len(result.Options) != 0 { + t.Fatalf("configured discovery: %v %v", result, err) } if _, err := (ConfigurationAdapter{}).DiscoverConfiguration(t.Context(), input, sandbox.ProcessPaths{}); !errors.Is(err, sandbox.ErrConfigurationUnconfirmed) { t.Fatalf("missing roots: %v", err) diff --git a/services/core/internal/sandbox/e2b/provider.go b/services/core/internal/sandbox/e2b/provider.go index 1b77b990c..e420a406c 100644 --- a/services/core/internal/sandbox/e2b/provider.go +++ b/services/core/internal/sandbox/e2b/provider.go @@ -117,7 +117,7 @@ func validReference(r sandbox.Reference) bool { return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) } func (c Config) Validate() error { - if c.Resources != nil && ValidateResources(*c.Resources) != nil { + if c.Resources != nil && c.Resources.ValidatePolicy("e2b", Policy()) != nil { return sandbox.ErrInvalid } if !validID(c.InstallationID) || c.TimeoutSeconds < 1 || c.TimeoutSeconds > 86400 { diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go index bd471f9d3..fbdea40b6 100644 --- a/services/core/internal/sandbox/microsandbox/deployment.go +++ b/services/core/internal/sandbox/microsandbox/deployment.go @@ -10,7 +10,14 @@ func Policy() sandbox.DeploymentPolicy { DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192}} } -func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("microsandbox", Policy()) } -func ValidateSpecification(s sandbox.DeploymentSpec) error { - return s.ValidatePolicy("microsandbox", Policy()) +// Setup declares how the console connects microsandbox nodes. +func Setup() sandbox.SetupDeclaration { + return sandbox.SetupDeclaration{ + Label: "microsandbox", + Recommended: true, + Summary: sandbox.Text{ + EN: "Runs each sandbox as a lightweight virtual machine: stronger isolation and a root disk size limit, with each sandbox's /environment on workspace storage, but the host needs KVM.", + ZHCN: "每个沙箱是一个轻量虚拟机:隔离更强,能限制根盘大小,每个沙箱的 /environment 位于工作区存储上,但主机需要支持 KVM。", + }, + } } diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go index 20f4a9edf..4357b136a 100644 --- a/services/core/internal/sandbox/providers/config.go +++ b/services/core/internal/sandbox/providers/config.go @@ -48,7 +48,7 @@ func (r *Registry) Build(config sandbox.NodeConfig, options sandbox.LocalOptions if config.Specification.Workspace != nil && options.Workspace == nil { return nil, closeProvider, sandbox.ErrInvalid } - if err := adapter.ValidateSpecification(config.Specification); err != nil { + if err := config.Specification.ValidatePolicy(config.Provider, adapter.Policy); err != nil { return nil, closeProvider, err } id, err := uuid.Parse(config.InstallationID) diff --git a/services/core/internal/sandbox/providers/configuration.go b/services/core/internal/sandbox/providers/configuration.go index 7658c0668..fd8b08c99 100644 --- a/services/core/internal/sandbox/providers/configuration.go +++ b/services/core/internal/sandbox/providers/configuration.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -100,17 +101,20 @@ func (r *Registry) WithCredential(owner, candidate sandbox.Selection) (sandbox.S owner.Configuration, e = a.Configuration.WithCredential(owner.Configuration, candidate.Configuration) return owner, e } -func (r *Registry) DiscoverConfiguration(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (json.RawMessage, error) { +func (r *Registry) DiscoverConfiguration(ctx context.Context, kind string, input sandbox.ConfigurationDiscoveryInput, paths sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { a, err := r.Lookup(kind) if err != nil { - return nil, err + return sandbox.ConfigurationOptions{}, err } if err := a.Configuration.Requirements().Discovery.Check("DiscoverConfiguration"); err != nil { - return nil, err + return sandbox.ConfigurationOptions{}, err + } + if !slices.ContainsFunc(a.Setup.Fields, func(f sandbox.SetupField) bool { return f.Discovered && f.Name == input.Query.Field }) { + return sandbox.ConfigurationOptions{}, sandbox.ErrInvalid } result, err := a.Configuration.DiscoverConfiguration(ctx, input, paths) if err != nil { - return nil, declaredResult("DiscoverConfiguration", err) + return sandbox.ConfigurationOptions{}, declaredResult("DiscoverConfiguration", err) } return result, nil } @@ -158,7 +162,8 @@ func (nodeConfiguration) HasCredential() bool { return false } func (nodeConfiguration) ReplacesCredential() bool { return false } type nodeConfigurationAdapter struct { - validate func(sandbox.DeploymentSpec) error + kind string + policy sandbox.DeploymentPolicy } func (nodeConfigurationAdapter) Requirements() sandbox.ConfigurationRequirements { @@ -192,7 +197,7 @@ func (a nodeConfigurationAdapter) Normalize(s sandbox.Selection) (sandbox.Select return s, err } s.Configuration = nodeConfiguration{} - return s, a.validate(s.DeploymentSpec) + return s, s.ValidatePolicy(a.kind, a.policy) } func (a nodeConfigurationAdapter) ResolveChange(next, previous sandbox.Selection) (sandbox.Selection, error) { return a.Normalize(next) @@ -209,8 +214,8 @@ func (a nodeConfigurationAdapter) Equal(x, y sandbox.Configuration) (bool, error } return true, nil } -func (nodeConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { - return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} +func (nodeConfigurationAdapter) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { + return sandbox.ConfigurationOptions{}, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} } func (nodeConfigurationAdapter) DiscoverSelection(context.Context, sandbox.DirectConfig) (sandbox.Selection, error) { return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "node_configuration_has_no_catalog"} diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index 1f1f095f6..037daac32 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -74,8 +74,8 @@ func (a regionalCodec) Equal(x, y sandbox.Configuration) (bool, error) { _, err = a.Encode(y) return x == y, err } -func (regionalCodec) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { - return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} +func (regionalCodec) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { + return sandbox.ConfigurationOptions{}, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "node_configuration_has_no_catalog"} } func (regionalCodec) DiscoverSelection(context.Context, sandbox.DirectConfig) (sandbox.Selection, error) { return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "node_configuration_has_no_catalog"} diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 86722aa1c..017965442 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -9,8 +9,9 @@ import ( ) // ValidateRegistration checks wiring before configuration parsing or construction. -// Only configuration requirements and operation declarations are read, and the -// resource validator only for a declared default size, after every other check. +// Only configuration requirements, the setup declaration and operation +// declarations are read, and the policy only for a declared default size, after +// every other check. func ValidateRegistration(a Adapter) error { invalid := func(field string) error { return fmt.Errorf("%w: invalid registration %s", providercontract.ErrContract, field) @@ -33,15 +34,12 @@ func ValidateRegistration(a Adapter) error { default: return invalid("mode") } - if a.ValidateSpecification == nil { - return invalid("specification validator") - } - if a.ValidateResources == nil { - return invalid("resource validator") - } if err := validateConfigurationAdapter(a.Configuration); err != nil { return err } + if a.Setup.Validate(a.Configuration.Requirements()) != nil { + return invalid("setup declaration") + } if a.Policy.Workspace != nil && a.Policy.Workspace.Attachment != workspacefs.AttachmentHostDirectory { return invalid("workspace attachment requirements") } @@ -57,7 +55,7 @@ func ValidateRegistration(a Adapter) error { if operations["Initial"].State == providercontract.Supported && a.Mode != sandbox.DeploymentNodes { return invalid("checkpoint support outside nodes mode") } - if a.Policy.DefaultResources != nil && a.ValidateResources(*a.Policy.DefaultResources) != nil { + if a.Policy.DefaultResources != nil && a.Policy.DefaultResources.ValidatePolicy("", a.Policy) != nil { return invalid("default resources") } return nil diff --git a/services/core/internal/sandbox/providers/registration_configuration_test.go b/services/core/internal/sandbox/providers/registration_configuration_test.go index 4a456d31e..61677895a 100644 --- a/services/core/internal/sandbox/providers/registration_configuration_test.go +++ b/services/core/internal/sandbox/providers/registration_configuration_test.go @@ -2,7 +2,6 @@ package providers import ( "context" - "encoding/json" "errors" "reflect" "testing" @@ -82,6 +81,9 @@ func TestConfigurationRequirementsDoNotInventDependencies(t *testing.T) { requirements := a.Configuration.Requirements() requirements.Credential, requirements.PublicOrigin = credential, public a.Configuration = registrationConfiguration{requirements: requirements} + if credential == sandbox.Required { + a.Setup.Fields = []sandbox.SetupField{{Name: "key", Secret: true, Label: sandbox.Text{EN: "Key", ZHCN: "密钥"}}} + } if err := ValidateRegistration(a); err != nil { t.Fatal(err) } @@ -122,9 +124,9 @@ func TestUnsupportedSetupOperationsMatchAuthoredReasons(t *testing.T) { for kind, a := range registry.adapters { requirements := a.Configuration.Requirements() direct := sandbox.DirectConfig{Selection: sandbox.Selection{Provider: kind}} - discover := func(read func() (json.RawMessage, error)) func() error { + discover := func(read func() (sandbox.ConfigurationOptions, error)) func() error { return func() error { - if result, err := read(); result != nil { + if result, err := read(); result.Options != nil { return errors.New("fabricated catalog") } else { return err @@ -137,10 +139,10 @@ func TestUnsupportedSetupOperationsMatchAuthoredReasons(t *testing.T) { calls []func() error }{ {"DiscoverConfiguration", requirements.Discovery, []func() error{ - discover(func() (json.RawMessage, error) { + discover(func() (sandbox.ConfigurationOptions, error) { return a.Configuration.DiscoverConfiguration(t.Context(), sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}) }), - discover(func() (json.RawMessage, error) { + discover(func() (sandbox.ConfigurationOptions, error) { return registry.DiscoverConfiguration(t.Context(), kind, sandbox.ConfigurationDiscoveryInput{}, sandbox.ProcessPaths{}) }), }}, @@ -170,8 +172,8 @@ func TestUnsupportedSetupOperationsMatchAuthoredReasons(t *testing.T) { // reports each as Unsupported. type contradictingConfiguration struct{ registrationConfiguration } -func (contradictingConfiguration) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (json.RawMessage, error) { - return nil, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "not_ready"} +func (contradictingConfiguration) DiscoverConfiguration(context.Context, sandbox.ConfigurationDiscoveryInput, sandbox.ProcessPaths) (sandbox.ConfigurationOptions, error) { + return sandbox.ConfigurationOptions{}, &providercontract.UnsupportedError{Operation: "DiscoverConfiguration", Reason: "not_ready"} } func (contradictingConfiguration) DiscoverSelection(context.Context, sandbox.DirectConfig) (sandbox.Selection, error) { return sandbox.Selection{}, &providercontract.UnsupportedError{Operation: "DiscoverSelection", Reason: "not_ready"} diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 455670bb4..9a9a99659 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -35,8 +35,7 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { return nil, nil } }}, - {"missing specification validator", func(a *Adapter) { a.ValidateSpecification = nil }}, - {"missing resource validator", func(a *Adapter) { a.ValidateResources = nil }}, + {"invalid setup declaration", func(a *Adapter) { a.Setup.Summary.ZHCN = "" }}, {"missing configuration", func(a *Adapter) { a.Configuration = nil }}, {"typed nil configuration", func(a *Adapter) { var c *registrationConfiguration; a.Configuration = c }}, {"missing configuration requirement", func(a *Adapter) { a.Configuration = registrationConfiguration{} }}, @@ -60,8 +59,6 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { t.Fatal("called local constructor") return nil, nil } - a.ValidateSpecification = func(sandbox.DeploymentSpec) error { t.Fatal("called specification validator"); return nil } - a.ValidateResources = func(sandbox.Resources) error { t.Fatal("called resource validator"); return nil } a.Configuration = registrationConfiguration{requirements: a.Configuration.Requirements()} tc.mutate(&a) registry.adapters[kind] = a @@ -174,3 +171,26 @@ func TestRegistrationCheckpointRequiresNodes(t *testing.T) { t.Fatal("direct checkpoint registration accepted", err) } } + +func TestSetupDeclarationMustMatchRequirements(t *testing.T) { + registry := Builtin() + for name, change := range map[string]func(*Adapter){ + "secret field without credential": func(a *Adapter) { + a.Setup.Fields = []sandbox.SetupField{{Name: "key", Secret: true, Label: sandbox.Text{EN: "Key", ZHCN: "密钥"}}} + }, + "missing locale": func(a *Adapter) { a.Setup.Summary.ZHCN = "" }, + "preset value for unknown field": func(a *Adapter) { + a.Setup.Presets = []sandbox.SetupPreset{{Label: "x", Values: map[string]string{"unknown": "x"}}} + }, + } { + a := registry.adapters["docker"] + change(&a) + if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) { + t.Fatalf("%s: %v", name, err) + } + } + registry.adapters["second"] = registry.adapters["microsandbox"] + if _, _, err := registry.DeploymentContract(); !errors.Is(err, providercontract.ErrContract) { + t.Fatalf("two recommended providers in one mode: %v", err) + } +} diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index a39c0429f..a9c0598ba 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -16,15 +16,14 @@ import ( // Adapter describes configuration and transport independently of compute operations. // Native operation support comes from the adapter-owned complete declaration. type Adapter struct { - NodeArtifacts []providerassets.Artifact - Policy sandbox.DeploymentPolicy - Configuration sandbox.ConfigurationAdapter - BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) - BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) - Mode sandbox.DeploymentMode - Operations func() providercontract.Operations - ValidateSpecification func(sandbox.DeploymentSpec) error - ValidateResources func(sandbox.Resources) error + NodeArtifacts []providerassets.Artifact + Policy sandbox.DeploymentPolicy + Setup sandbox.SetupDeclaration + Configuration sandbox.ConfigurationAdapter + BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) + BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) + Mode sandbox.DeploymentMode + Operations func() providercontract.Operations } // Registry holds the registered adapters. Core and the node program each build @@ -39,20 +38,17 @@ func Builtin() *Registry { return &Registry{adapters: map[string]Adapter{ "docker": { NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, - Policy: docker.Policy(), Operations: docker.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: docker.BuildNode, - ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, - Configuration: nodeConfigurationAdapter{docker.ValidateSpecification}, + Policy: docker.Policy(), Setup: docker.Setup(), Operations: docker.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: docker.BuildNode, + Configuration: nodeConfigurationAdapter{"docker", docker.Policy()}, }, "microsandbox": { NodeArtifacts: append([]providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, microsandbox.NodeArtifacts...), - Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: microsandbox.BuildNode, - ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, - Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, + Policy: microsandbox.Policy(), Setup: microsandbox.Setup(), Operations: microsandbox.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: microsandbox.BuildNode, + Configuration: nodeConfigurationAdapter{"microsandbox", microsandbox.Policy()}, }, "e2b": { - Policy: e2b.Policy(), Operations: e2b.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: e2b.BuildDirect, - Configuration: e2b.ConfigurationAdapter{}, - ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, + Policy: e2b.Policy(), Setup: e2b.Setup(), Operations: e2b.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: e2b.BuildDirect, + Configuration: e2b.ConfigurationAdapter{}, }, }} } @@ -114,14 +110,14 @@ func (r *Registry) ValidateSpecification(kind string, s sandbox.DeploymentSpec) if e != nil { return e } - return a.ValidateSpecification(s) + return s.ValidatePolicy(kind, a.Policy) } func (r *Registry) ValidateResources(kind string, s sandbox.Resources) error { a, e := r.Lookup(kind) if e != nil { return e } - return a.ValidateResources(s) + return s.ValidatePolicy(kind, a.Policy) } // Describe derives the description once for both preview and persistence. @@ -138,18 +134,37 @@ func (r *Registry) Describe(kind, installation string) (sandbox.Description, err return sandbox.Description{Mode: a.Mode, BackendFingerprint: sandbox.BackendFingerprint(kind, namespace+":"+installation)}, nil } -// DeploymentContract projects the registered modes and policies into the node -// installer and the TypeScript client; no second provider list exists in -// another language. +// DeploymentContract projects the registered modes, policies and setup +// declarations into the node installer and the TypeScript client; no second +// provider list exists in another language. At most one Provider per mode is +// recommended. func (r *Registry) DeploymentContract() (python, typescript string, err error) { providers := make(map[string]sandbox.ProviderProjection, len(r.adapters)) + recommended := map[sandbox.DeploymentMode]bool{} for kind := range r.adapters { a, err := r.Lookup(kind) if err != nil { return "", "", err } - providers[kind] = sandbox.ProviderProjection{Mode: a.Mode, DeploymentPolicy: a.Policy} + if a.Setup.Recommended { + if recommended[a.Mode] { + return "", "", fmt.Errorf("%w: several recommended %s providers", providercontract.ErrContract, a.Mode) + } + recommended[a.Mode] = true + } + requirements := a.Configuration.Requirements() + setup := a.Setup + setup.Cautions, setup.Fields, setup.Presets = nonNil(setup.Cautions), nonNil(setup.Fields), nonNil(setup.Presets) + providers[kind] = sandbox.ProviderProjection{Mode: a.Mode, DeploymentPolicy: a.Policy, Setup: &sandbox.ProviderSetup{SetupDeclaration: setup, + Credential: requirements.Credential, PublicOrigin: requirements.PublicOrigin, Discovery: requirements.Discovery.State == providercontract.Supported}} } python, typescript = sandbox.DeploymentContract(providers) return python, typescript, nil } + +func nonNil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 571ce6bb6..2e0a563a4 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -309,6 +309,77 @@ type ConfigurationRequirements struct { CredentialVerification providercontract.Support } +// Text is operator-facing copy in every supported locale. +type Text struct { + EN string `json:"en"` + ZHCN string `json:"zh-CN"` +} + +func (t Text) valid() bool { return t.EN != "" && t.ZHCN != "" } + +// SetupField is one value an operator enters to connect the Provider. A Secret +// field is submitted as the credential, any other as configuration. An Optional +// field may stay empty and the Provider supplies its default. A Discovered field +// takes its value from DiscoverConfiguration. Help may be empty in both locales. +type SetupField struct { + Name string `json:"name"` + Secret bool `json:"secret"` + Optional bool `json:"optional"` + Discovered bool `json:"discovered"` + Label Text `json:"label"` + Help Text `json:"help"` +} + +// SetupPreset is a named set of field values, such as one hosted service. +// CredentialURL, when set, is where the operator creates the credential. +type SetupPreset struct { + Label string `json:"label"` + Values map[string]string `json:"values"` + CredentialURL string `json:"credential_url,omitempty"` +} + +// SetupDeclaration is everything the console needs to set up one Provider. +// Label is the Provider's proper noun. Cautions are risks an operator confirms +// before choosing it. Recommended marks the default among Providers of one +// deployment mode, and at most one does. +type SetupDeclaration struct { + Label string `json:"label"` + Summary Text `json:"summary"` + Cautions []Text `json:"cautions"` + Recommended bool `json:"recommended"` + Fields []SetupField `json:"fields"` + Presets []SetupPreset `json:"presets"` +} + +// Validate checks the declaration against the Provider's configuration +// requirements: secret fields exist exactly when a credential is required, +// discovered fields exactly when discovery is supported, and every text has +// both locales. +func (d SetupDeclaration) Validate(r ConfigurationRequirements) error { + secret, discovered := false, false + ok := d.Label != "" && d.Summary.valid() + names := map[string]bool{} + for _, f := range d.Fields { + ok = ok && f.Name != "" && !names[f.Name] && f.Label.valid() && (f.Help == Text{} || f.Help.valid()) + names[f.Name] = true + secret, discovered = secret || f.Secret, discovered || f.Discovered + ok = ok && !(f.Secret && (f.Optional || f.Discovered)) + } + for _, c := range d.Cautions { + ok = ok && c.valid() + } + for _, p := range d.Presets { + ok = ok && p.Label != "" + for name := range p.Values { + ok = ok && names[name] + } + } + if !ok || secret != (r.Credential == Required) || discovered != (r.Discovery.State == providercontract.Supported) { + return ErrInvalid + } + return nil +} + // DeploymentPolicy is the deployment declaration. Workspace, when present, means // the Provider runs every Environment on external storage; absent means its own // disk. Disk declares independent disk limits. DefaultResources is the size setup @@ -395,12 +466,33 @@ type Built struct { Quiescent func() bool } -// ConfigurationDiscoveryInput is a transient read-only request. Query is typed -// and validated by the adapter; it cannot select a compute mutation. +// ConfigurationDiscoveryQuery selects the options of one Discovered field. Path +// holds the values chosen at earlier levels, empty for the first level. +type ConfigurationDiscoveryQuery struct { + Field string `json:"field"` + Path []string `json:"path"` +} + +// ConfigurationDiscoveryInput is a transient read-only request. The adapter +// validates Configuration and Credential; Query cannot select a compute mutation. type ConfigurationDiscoveryInput struct { - Configuration json.RawMessage `json:"configuration" swaggertype:"object"` - Credential json.RawMessage `json:"credential" swaggertype:"object"` - Query json.RawMessage `json:"query" swaggertype:"object"` + Configuration json.RawMessage `json:"configuration" swaggertype:"object"` + Credential json.RawMessage `json:"credential" swaggertype:"object"` + Query ConfigurationDiscoveryQuery `json:"query"` +} + +// ConfigurationOption is one choice for a Discovered field. A Complete option's +// Value is the field's value; any other is a Path element for the next level. +type ConfigurationOption struct { + Value string `json:"value" binding:"required"` + Label string `json:"label" binding:"required"` + Resources *Resources `json:"resources,omitempty"` + Complete bool `json:"complete" binding:"required"` +} + +// ConfigurationOptions is the result of one discovery level. +type ConfigurationOptions struct { + Options []ConfigurationOption `json:"options" binding:"required"` } // ConfigurationAdapter owns all interpretation of provider configuration. @@ -422,7 +514,7 @@ type ConfigurationAdapter interface { Equal(a, b Configuration) (bool, error) // DiscoverConfiguration lists the native catalog a credential can use, // without a saved deployment. - DiscoverConfiguration(context.Context, ConfigurationDiscoveryInput, ProcessPaths) (json.RawMessage, error) + DiscoverConfiguration(context.Context, ConfigurationDiscoveryInput, ProcessPaths) (ConfigurationOptions, error) // DiscoverSelection resolves a candidate's omitted native values before // commit. Loading retained ownership never calls it. DiscoverSelection(context.Context, DirectConfig) (Selection, error) diff --git a/services/core/tests/integration/public_handler_fixture_test.go b/services/core/tests/integration/public_handler_fixture_test.go index e815d9ff6..8d26a015a 100644 --- a/services/core/tests/integration/public_handler_fixture_test.go +++ b/services/core/tests/integration/public_handler_fixture_test.go @@ -2,7 +2,6 @@ package integration import ( "context" - "encoding/json" "net/http" "testing" @@ -252,9 +251,9 @@ func (s strictStandIn) WriteEnvironmentFile(context.Context, sessions.Environmen return 0, nil } -func (s strictStandIn) DiscoverConfiguration(context.Context, string, sandbox.ConfigurationDiscoveryInput) (json.RawMessage, error) { +func (s strictStandIn) DiscoverConfiguration(context.Context, string, sandbox.ConfigurationDiscoveryInput) (sandbox.ConfigurationOptions, error) { s.unexpected("DiscoverConfiguration") - return nil, nil + return sandbox.ConfigurationOptions{}, nil } func (s strictStandIn) InitializeSandboxDeployment(context.Context, sandbox.Selection) (deployment.View, error) { From fc7f7df449f267e0528289048257200631e3305b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 05:17:32 +0800 Subject: [PATCH 21/39] Report official Turn error codes on the stream (#847) * Report official Turn error codes on the stream Journaled Turn snapshots dropped the whole outcome, so every agent.session.turn.failed event reported internal_error while reads reported the classified code. Snapshots now keep Core's error code and the normalized model category, and nothing native. runtime_unavailable also covers Core failing to dispatch a Turn, so it reports internal_error; only a Runtime that disconnected or failed its preparation is sandbox_error. Docs that still described the generic Turn error and the old message rule are corrected. * List command_output and not_directory in the Runtime protocol --- contracts/agents-api/core-errors.md | 2 +- contracts/agents-api/index.md | 2 +- contracts/agents-api/sessions-events.md | 4 +-- contracts/agents-api/zh/core-errors.md | 4 +-- contracts/agents-api/zh/index.md | 4 +-- contracts/agents-api/zh/sessions-events.md | 6 ++-- docs/api/public-agent-api.md | 2 +- docs/runtime-protocol.md | 8 ++--- docs/zh/api/public-agent-api.md | 4 +-- docs/zh/runtime-protocol.md | 10 +++---- .../native_classification_integration_test.go | 17 +++++++++-- .../internal/api/turn_diagnostic_failure.go | 8 +++-- .../api/turn_diagnostic_failure_test.go | 1 + .../core/internal/sessions/turn_changes.go | 30 +++++++++++++++++-- .../internal/sessions/turn_changes_test.go | 9 ++++-- .../input_conflicts_public_test.go | 3 +- 16 files changed, 80 insertions(+), 34 deletions(-) diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index 3fe655a6d..430cefc5a 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -106,7 +106,7 @@ These codes have null `param` and no `details`. [Sandbox deployment](./sandbox-d ## Diagnostic failure categories -The [Session diagnostics](./session-diagnostics.md) routes return these categories inside a successful 200 snapshot, not as an error envelope. Public `/v1` Turn errors do not change. `params` is `{}` unless the table says otherwise. +The [Session diagnostics](./session-diagnostics.md) routes return these categories inside a successful 200 snapshot, not as an error envelope. A public `/v1` Turn error reports some of them under official codes, as [Differences from OpenAI](./index.md#differences-from-openai) describes. `params` is `{}` unless the table says otherwise. | Code | Stored cause or safe meaning | | --- | --- | diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index bc93f43bc..c0a4ee7cf 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -90,7 +90,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Attaching to the stream mid-Turn sends no catch-up Item snapshots. - The Items list includes in-progress and incomplete output Items, and keeps a failed function result's submitted `output`. - Error messages omit the call and executor IDs that official messages include, except that the unknown tool call message names its `call_id`. -- A failed Turn reports the model failure categories under their official names and `sandbox_error` for Runtime or sandbox failures in the execution environment. Every other Core failure, such as a missing model provider, a rejected input or a storage failure, reports `internal_error`. The message is a fixed sentence per code and never carries native text. Subagent Turns always report `internal_error`. +- A failed Turn reports the model failure categories under their official names and `sandbox_error` for a Runtime that disconnected or failed its preparation. Every other Core failure, such as an unavailable Runtime, a missing model provider, a rejected input or a storage failure, reports `internal_error`. The message is a fixed sentence per code and never carries native text. Subagent Turns always report `internal_error`. - An empty text part beside other text is accepted and stored. - Empty input returns 400 `invalid_request` with a generic message and a null param; the official response is `invalid_request_error` with param `input`. - Session usage is available as soon as every root Turn has settled; official reads lag by seconds. diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 4b335cc2c..b56910b2a 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -78,7 +78,7 @@ Checks run in this order: request validation, Session lookup, retry lookup, the | New input after a `self_hosted` Environment failed, input already waiting when the Environment failed, or an expired Environment | 409 `environment_unavailable` | "The environment is no longer available for new input." | | A message the Session's Harness cannot take, such as whitespace-only text on MiniMax Code | 400 `unsupported_or_invalid_configuration` | See [whitespace-only text](./message-content.md#whitespace-only-text) | -An empty `turn_id` or blank `call_id` is the generic 400 `invalid_request`. Error messages never repeat caller input or internal identifiers. +An empty `turn_id` or blank `call_id` is the generic 400 `invalid_request`. Apart from the unknown tool call message, which names its `call_id` as the official SDK expects, error messages never repeat caller input or internal identifiers. ## Initial input at Session creation @@ -127,7 +127,7 @@ A retry with the same `Idempotency-Key` and `stream: true` returns 201 with only Turn and Item lists take `after`, `limit` and `order` ([list rules](./wire-semantics.md)). Cursors are IDs within the same Session. -**Turns.** Session Turn routes hold root Turns only, ordered by creation time then ID; a child Turn ID returns 404 there. A failed Turn has `error: {code: "internal_error", message: "The execution could not complete."}` and never raw engine diagnostics. Administrators read the failure category through [Session diagnostics](./session-diagnostics.md). +**Turns.** Session Turn routes hold root Turns only, ordered by creation time then ID; a child Turn ID returns 404 there. A failed Turn's `error` has an official `code` and a fixed message for that code, never raw engine diagnostics; [Differences from OpenAI](./index.md#differences-from-openai) lists the codes Core reports. Stream snapshots report the same `error` as reads. Administrators read the failure category through [Session diagnostics](./session-diagnostics.md). **Items.** Items are ordered by the time they were first observed, then by their position in the Session, then by ID. Updates and retries never move an Item or change its `output_index`, a zero-based position among the Turn's output Items; input Items have none. Reads use the stored history index and never rebuild it from native journals. The Items list includes Items that are still in progress or incomplete. diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index 110a55188..a09140a3e 100644 --- a/contracts/agents-api/zh/core-errors.md +++ b/contracts/agents-api/zh/core-errors.md @@ -1,7 +1,7 @@ --- title: "Core 管理错误" source: contracts/agents-api/core-errors.md -source_hash: fbdf667a44da9eb95dae54ca7fc54129d7f8d1b50e1d7e864f00a8ce1794860b +source_hash: b2a6cd40a67bbe58fc78efa068a8ad0cf23cf62ad02e3c1456323fa66e9bb6ef --- `/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。 @@ -108,7 +108,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 ## 诊断失败类别 {#diagnostic-failure-categories} -[Session 诊断](session-diagnostics.md)接口会在成功的 200 快照内返回以下类别,而不是以错误封装的形式返回。公开 `/v1` 的 Turn 错误保持不变。除非表格另有说明,否则 `params` 为 `{}`。 +[Session 诊断](session-diagnostics.md)接口会在成功的 200 快照内返回以下类别,而不是以错误封装的形式返回。公开 `/v1` 的 Turn 错误会以官方 code 报告其中一部分类别,见[与 OpenAI 的差异](index.md#differences-from-openai)。除非表格另有说明,否则 `params` 为 `{}`。 | 代码 | 存储原因或安全含义 | | --- | --- | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 27fa0234e..053bd7ea6 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: fd489e7a0eb5b5847ba9b1a0ac8ad4bbc73b315e012adb9c9d7a8facbfa79c7f +source_hash: 025c67e723ae2e055c4674f5c721cb51bd2b59899f907ccc05b27ce70360f142 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -93,7 +93,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 在 Turn 中途接入流时,不会发送补发 Item 快照。 - Items 列表会包含进行中和未完成的输出 Items,并保留失败函数结果中已提交的 `output`。 - 错误消息会省略官方消息包含的 call 和 executor ID,但未知工具调用的消息会带上其 `call_id`。 -- 失败的 Turn 以官方名称报告模型失败类别,Runtime 或沙箱在执行环境中的失败报告为 `sandbox_error`。Core 的其他失败,例如缺少模型提供方、输入被拒绝或存储失败,一律报告 `internal_error`。消息是每个 code 对应的固定句子,不含原生文本。Subagent Turn 始终报告 `internal_error`。 +- 失败的 Turn 以官方名称报告模型失败类别,Runtime 断开连接或准备失败报告为 `sandbox_error`。Core 的其他失败,例如 Runtime 不可用、缺少模型提供方、输入被拒绝或存储失败,一律报告 `internal_error`。消息是每个 code 对应的固定句子,不含原生文本。Subagent Turn 始终报告 `internal_error`。 - 与其他文本并存的空文本部分可被接受并存储。 - 空输入会返回 400 `invalid_request`,附带通用消息和值为 null 的 param;官方响应为 `invalid_request_error`,param 为 `input`。 - 一旦所有根级 Turn 均已进入终态,Session 用量即可用;官方读取会滞后数秒。 diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 78c27edba..2ccb30548 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: ef11e7900a0dec4acceb06c60a0f1275c3e0231081a929c3f1a074e454fb785b +source_hash: 45b743284c8a03aa016c1b874f6dbeb578e7ec7afcbc565bdcafba333481c950 --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -80,7 +80,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 | `self_hosted` Environment 失败后提交新输入、Environment 失败时输入已在等待,或 Environment 已过期 | 409 `environment_unavailable` | "The environment is no longer available for new input." | | Session Harness 无法处理的消息,例如 MiniMax Code 上仅含空白的文本 | 400 `unsupported_or_invalid_configuration` | 见[仅含空白的文本](message-content.md#whitespace-only-text) | -`turn_id` 为空或 `call_id` 为空白时,会返回通用的 400 `invalid_request`。错误消息绝不会重复调用方输入或内部标识符。 +`turn_id` 为空或 `call_id` 为空白时,会返回通用的 400 `invalid_request`。除未知工具调用的消息按官方 SDK 的预期给出其 `call_id` 外,错误消息绝不会重复调用方输入或内部标识符。 ## 创建 Session 时的初始输入 {#initial-input-at-session-creation} @@ -129,7 +129,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 Turn 和 Item 列表接受 `after`、`limit` 和 `order`([列表规则](wire-semantics.md))。游标是同一 Session 内的 ID。 -**Turn。** Session Turn 路由只包含根 Turn,按创建时间、再按 ID 排序;在该路由中使用子 Turn ID 会返回 404。失败的 Turn 包含 `error: {code: "internal_error", message: "The execution could not complete."}`,绝不包含引擎原始诊断信息。管理员可通过[Session 诊断](session-diagnostics.md)读取失败类别。 +**Turn。** Session Turn 路由只包含根 Turn,按创建时间、再按 ID 排序;在该路由中使用子 Turn ID 会返回 404。失败 Turn 的 `error` 包含官方 `code` 和该 code 对应的固定消息,绝不包含引擎原始诊断信息;[与 OpenAI 的差异](index.md#differences-from-openai)列出 Core 报告的 code。流中的快照与读取返回相同的 `error`。管理员可通过[Session 诊断](session-diagnostics.md)读取失败类别。 **Item。** Item 按首次观察时间、再按其在 Session 中的位置、最后按 ID 排序。更新和重试绝不会移动 Item,也不会更改其 `output_index`;`output_index` 是 Item 在该 Turn 输出 Item 中从零开始的位置,输入 Item 没有此值。读取操作使用已存储的历史索引,绝不会根据原生日志重新构建该索引。Item 列表包含仍在进行中或尚未完成的 Item。 diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 607dbb04d..eea9b7267 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -575,7 +575,7 @@ The HTTP path is `/vaults`, with the Beta header. A Session selects credentials ## Diagnose a failure -1. Read the Session's `status` and `error`, and the latest Turn's `error`. A failed Turn reports only a generic `internal_error`. +1. Read the Session's `status` and `error`, and the latest Turn's `error`. A failed Turn's `error.code` names the model failure category, `sandbox_error` or `internal_error`. 2. Check that the Environment is connected and its Harness is available. 3. Check the Harness, model and tool combination in [Harness capabilities](../../contracts/agents-api/harness-capabilities.md). 4. Ask the administrator for the [Session diagnostics](../../contracts/agents-api/session-diagnostics.md), which name the failure category, and to check [Troubleshooting](../getting-started/troubleshooting.md) for service logs, credentials and node readiness. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index c8c1adb64..387dcd763 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -108,7 +108,7 @@ The linked source files define the required fields, validators, limits and finit | --- | --- | --- | | `runtime_prepare` | `runtime_prepare_result` | [Initialization and capability transfer](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/runtime_prepare.go) | | `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [Execution admission](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/preparation.go) | -| `prompt_cancel` | `delta`, `reasoning`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [Requests](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go), [events and capabilities](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | +| `prompt_cancel` | `delta`, `reasoning`, `output_message`, `tool_call`, `command_output`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [Requests](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go), [events and capabilities](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | | `prompt_steer` | `prompt_steer_ack` | [Active input receipts](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/steering.go) | | `function_result` | `function_call`, `function_call_withdrawn`, `interaction_decision_ack` | [Function calls](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/functions.go) | | `workspace_read`, `workspace_write`, `workspace_export` | Matching `*_result` | [Read](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_read.go), [write](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_write.go), [export](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_export.go) | @@ -293,17 +293,17 @@ Retry only an operation whose own contract makes retry safe. A retired preparati ## Native failure classification -An adapter may add `code` and `http_status` to a Run's `error` frame. They are optional neutral metadata, not a terminal event or a public error contract; the frame's text, Usage, `done`, native Session identity and cancellation receipts keep their order and meaning. +An adapter may add `code` and `http_status` to a Run's `error` frame. They are optional neutral metadata, not a terminal event; the frame's text, Usage, `done`, native Session identity and cancellation receipts keep their order and meaning. The accepted codes are `authentication_error`, `rate_limit_exceeded`, `usage_limit_exceeded`, `server_overloaded`, `server_error`, `invalid_request`, `resource_not_found`, `request_timeout`, `context_length_exceeded`, `cyber_policy` and `connection_failed` ([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go)). Only `connection_failed` keeps `http_status`, and only an integer from 100 to 599; every other status is discarded. A missing, malformed or unknown value leaves the error unclassified without discarding Usage or `done`. -Core stores accepted values in the Turn outcome as `engine_error_code` and `engine_http_status`. A Run may carry more than one `error` frame, such as an adapter's classified failure followed by the Runtime's own settlement failure; a frame without an accepted code leaves the stored classification in place, and a later accepted code replaces it. The classification is subordinate to the terminal status and Core's `error_code`: it cannot turn a completed or cancelled Turn into a failure, hide an incomplete event stream, or override a persistence or cancellation-receipt failure. Normal delivery and terminal journal draining use the same extraction. [Session diagnostics](../contracts/agents-api/session-diagnostics.md) expose the category only for a failed Turn whose Core error is `engine_failed`. The [Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) and [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter guides give each Harness's mapping. An adapter classifies only from structured error data: the Harness's own error variant or status, or a model provider's JSON error body that the Harness passes through, whose own `error.code` or `error.type` may name an accepted code. It never classifies from error prose. +Core stores accepted values in the Turn outcome as `engine_error_code` and `engine_http_status`. A Run may carry more than one `error` frame, such as an adapter's classified failure followed by the Runtime's own settlement failure; a frame without an accepted code leaves the stored classification in place, and a later accepted code replaces it. The classification is subordinate to the terminal status and Core's `error_code`: it cannot turn a completed or cancelled Turn into a failure, hide an incomplete event stream, or override a persistence or cancellation-receipt failure. Normal delivery and terminal journal draining use the same extraction. Only a failed Turn whose Core error is `engine_failed` exposes the category: [Session diagnostics](../contracts/agents-api/session-diagnostics.md) name it, and the public Turn `error.code` reports it under its official name. The [Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) and [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter guides give each Harness's mapping. An adapter classifies only from structured error data: the Harness's own error variant or status, or a model provider's JSON error body that the Harness passes through, whose own `error.code` or `error.type` may name an accepted code. It never classifies from error prose. ## Workspace operations A workspace read that needs no running Turn uses the read-only preparation profile: `execution_prepare` with `workspace_read_only`, which requires the `workspace_read_preparation` capability. It accepts only the bound Environment and resource identity; execution options, model and MCP credentials, native Session continuation and model or tool input are excluded, and the owner rejects `execution_start`. The Runtime serves it from its bound local workspace without starting a Harness process. The profile publishes `released` after the Runtime drops the preparation's ownership; a stale status snapshot never publishes success. A release request, HTTP disconnect or remote socket closure alone does not confirm the release. -`workspace_read` targets an existing preparation handle, or the Run it was transferred to, on the same authenticated device connection, with the exact frozen Environment identity; callers cannot supply sockets, credentials or workspace roots. `operation: directory` lists one workspace-relative directory (an empty path selects the root) with mutually exclusive byte and entry limits. A result carries a bounded number of single-component UTF-8 names, the entry kind, regular-file sizes and explicit truncation, and is returned only after directory access and handle cleanup settle. There is no snapshot, recursion or pagination at this layer. Byte and directory reads share target checks, correlation, capacity and retained operation waits. A rejected result carries one of `invalid_request`, `resource_unavailable`, `read_capacity`, `not_found` and `permission_denied`. +`workspace_read` targets an existing preparation handle, or the Run it was transferred to, on the same authenticated device connection, with the exact frozen Environment identity; callers cannot supply sockets, credentials or workspace roots. `operation: directory` lists one workspace-relative directory (an empty path selects the root) with mutually exclusive byte and entry limits. A result carries a bounded number of single-component UTF-8 names, the entry kind, regular-file sizes and explicit truncation, and is returned only after directory access and handle cleanup settle. There is no snapshot, recursion or pagination at this layer. Byte and directory reads share target checks, correlation, capacity and retained operation waits. A rejected result carries one of `invalid_request`, `resource_unavailable`, `read_capacity`, `not_found` and `permission_denied`, and a directory read whose path is missing, a regular file or a symbolic link carries `not_directory`. Request payloads and correlation IDs are bounded before admission; oversized IDs are not echoed, and oversized trace metadata is omitted from replies. Raw read results are bounded within the transport frame. These are private transport limits, not public Files parameters. A successful read requires complete bytes or explicit truncation and an acknowledged native close. A safe native rejection carries no bytes; an interrupted or ambiguous read stays unknown and stops further reads on that owner. A dispatched read keeps its bounded waiter across observer cancellation and resource transfer or release, and resource closure stops new admission. The gateway bounds subscriptions and never retries or replays a read on reconnect; a duplicate pending operation ID cannot start another read. diff --git a/docs/zh/api/public-agent-api.md b/docs/zh/api/public-agent-api.md index 53dca8f76..8d459bd18 100644 --- a/docs/zh/api/public-agent-api.md +++ b/docs/zh/api/public-agent-api.md @@ -1,7 +1,7 @@ --- title: "Agents API 指南" source: docs/api/public-agent-api.md -source_hash: 83f4c5d7c0e5c748087e6baa51c792a7cd831e217b47980c65db7d04e9222140 +source_hash: bc22d7ef93e4284b122c25f39b15acb53eec314005b5273f9c9456638e727c67 --- Core 在 `/v1` 提供 OpenAI Agents API。本指南用官方 OpenAI SDK 和普通 HTTP 两种方式演示每项常见任务,并说明 Core 与 OpenAI 的差异。如果尚未启动过 Session,请先阅读[运行第一个 Session](../getting-started/quickstart.md)。 @@ -577,7 +577,7 @@ HTTP 路径为 `/vaults`,需要 Beta 请求头。Session 从其 `vault_ids` ## 诊断故障 {#diagnose-a-failure} -1. 读取 Session 的 `status` 和 `error`,以及最新 Turn 的 `error`。失败的 Turn 只会报告通用的 `internal_error`。 +1. 读取 Session 的 `status` 和 `error`,以及最新 Turn 的 `error`。失败 Turn 的 `error.code` 给出模型失败类别、`sandbox_error` 或 `internal_error`。 2. 检查 Environment 是否已连接,以及其 Harness 是否可用。 3. 在 [Harness 能力](../../../contracts/agents-api/zh/harness-capabilities.md) 中检查 Harness、模型和工具的组合。 4. 向管理员索取[Session 诊断](../../../contracts/agents-api/zh/session-diagnostics.md),其中会指出故障类别;同时请查看[故障排查](../getting-started/troubleshooting.md),了解服务日志、凭据和节点就绪状态。 diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 63c06f23e..5cbe4e530 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: bf12f97d6420804e057223a02609bc4295d9169a834e2758bc0210ef70923efa +source_hash: 986ab6e6ef3979a672f2e49f7c75346e7bfff327d9de1a6138e7f5c75b55d720 --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -110,7 +110,7 @@ Core 对 `usage` 和 `resume` 没有准入规则。 | --- | --- | --- | | `runtime_prepare` | `runtime_prepare_result` | [初始化与能力传输](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/runtime_prepare.go) | | `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [执行准入](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/preparation.go) | -| `prompt_cancel` | `delta`, `reasoning`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [请求](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go)、[事件与能力](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | +| `prompt_cancel` | `delta`, `reasoning`, `output_message`, `tool_call`, `command_output`, `usage`, `error`, `done`, `heartbeat`, `interaction_decision_ack` | [请求](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/outbound.go)、[事件与能力](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/inbound.go) | | `prompt_steer` | `prompt_steer_ack` | [活动输入回执](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/steering.go) | | `function_result` | `function_call`, `function_call_withdrawn`, `interaction_decision_ack` | [Function 调用](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/functions.go) | | `workspace_read`, `workspace_write`, `workspace_export` | 对应的 `*_result` | [读取](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_read.go)、[写入](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_write.go)、[导出](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/workspace_export.go) | @@ -295,17 +295,17 @@ transport 与 execution 结果分开。Core 唯一 Run 订阅入口是 `Subscrib ## 原生故障分类 {#native-failure-classification} -adapter 可以给 Run 的 `error` frame 添加 `code` 和 `http_status`。它们是可选的中性元数据,不是终结事件或公开错误契约;frame 文本、Usage、`done`、原生 Session 身份和取消回执保持原顺序与含义。 +adapter 可以给 Run 的 `error` frame 添加 `code` 和 `http_status`。它们是可选的中性元数据,不是终结事件;frame 文本、Usage、`done`、原生 Session 身份和取消回执保持原顺序与含义。 接受的 code 为 `authentication_error`、`rate_limit_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`server_error`、`invalid_request`、`resource_not_found`、`request_timeout`、`context_length_exceeded`、`cyber_policy` 和 `connection_failed`([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go))。仅 `connection_failed` 保留 `http_status`,且只能是 100 到 599 的整数;其他 status 都丢弃。值缺失、格式错误或未知时保持未分类错误,不丢弃 Usage 或 `done`。 -Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engine_http_status`。一个 Run 可以有多个 `error` frame,例如 adapter 的分类失败之后跟着 Runtime 自己的结算失败;没有接受 code 的 frame 不改变已保存的分类,后来接受的 code 会替换它。分类从属于终结状态和 Core 的 `error_code`:不能将已完成或已取消 Turn 变成失败、隐藏不完整事件流,或覆盖持久化或取消回执失败。正常 delivery 和 terminal journal draining 使用同一提取逻辑。[Session 诊断](../../contracts/agents-api/zh/session-diagnostics.md)仅为 Core error 为 `engine_failed` 的失败 Turn 暴露类别。[Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) 和 [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter 指南给出各 Harness 的映射。adapter 只依据结构化错误数据分类:Harness 自身的错误类型或状态,或 Harness 原样转交的模型提供商 JSON 错误 body,其 `error.code` 或 `error.type` 可以给出接受的 code。adapter 不依据错误文字分类。 +Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engine_http_status`。一个 Run 可以有多个 `error` frame,例如 adapter 的分类失败之后跟着 Runtime 自己的结算失败;没有接受 code 的 frame 不改变已保存的分类,后来接受的 code 会替换它。分类从属于终结状态和 Core 的 `error_code`:不能将已完成或已取消 Turn 变成失败、隐藏不完整事件流,或覆盖持久化或取消回执失败。正常 delivery 和 terminal journal draining 使用同一提取逻辑。只有 Core error 为 `engine_failed` 的失败 Turn 会暴露类别:[Session 诊断](../../contracts/agents-api/zh/session-diagnostics.md)给出该类别,公开 Turn 的 `error.code` 以官方名称报告它。[Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) 和 [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter 指南给出各 Harness 的映射。adapter 只依据结构化错误数据分类:Harness 自身的错误类型或状态,或 Harness 原样转交的模型提供商 JSON 错误 body,其 `error.code` 或 `error.type` 可以给出接受的 code。adapter 不依据错误文字分类。 ## 工作区操作 {#workspace-operations} 无需运行 Turn 的工作区读取使用只读 preparation profile:带 `workspace_read_only` 的 `execution_prepare`,要求 `workspace_read_preparation` 能力。仅接受绑定的 Environment 和 resource 身份;不包含 execution option、model 与 MCP 凭据、原生 Session continuation、model 或 tool 输入,owner 拒绝 `execution_start`。Runtime 从绑定的本地工作区提供读取,不启动 Harness 进程。profile 在 Runtime 放弃该 preparation 的所有权后发布 `released`;旧 status snapshot 不发布成功。release 请求、HTTP 断连或远端 socket 关闭本身都不确认释放。 -`workspace_read` 在同一已认证设备连接上,针对现有 preparation handle 或它已转移给的 Run,使用精确冻结的 Environment 身份;调用方不能提供 socket、凭据或 workspace root。`operation: directory` 列出一个 workspace 相对目录(空路径选择根目录),字节与条目限制互斥。结果携带有限数量的单路径组件 UTF-8 名称,并包含 entry kind、普通文件大小和明确截断信息;仅在目录访问与 handle 清理结算后返回。此层没有快照、递归或分页。字节读取与目录读取共享目标检查、关联、容量和保留的操作等待。被拒绝的结果携带 `invalid_request`、`resource_unavailable`、`read_capacity`、`not_found` 和 `permission_denied` 之一。 +`workspace_read` 在同一已认证设备连接上,针对现有 preparation handle 或它已转移给的 Run,使用精确冻结的 Environment 身份;调用方不能提供 socket、凭据或 workspace root。`operation: directory` 列出一个 workspace 相对目录(空路径选择根目录),字节与条目限制互斥。结果携带有限数量的单路径组件 UTF-8 名称,并包含 entry kind、普通文件大小和明确截断信息;仅在目录访问与 handle 清理结算后返回。此层没有快照、递归或分页。字节读取与目录读取共享目标检查、关联、容量和保留的操作等待。被拒绝的结果携带 `invalid_request`、`resource_unavailable`、`read_capacity`、`not_found` 和 `permission_denied` 之一;路径不存在、是普通文件或符号链接的目录读取携带 `not_directory`。 准入前,请求 payload 与 correlation ID 受限;过长 ID 不回显,过大的 trace metadata 不进入回复。原始 read result 受限于 transport frame 之内。这些是私有 transport 限制,不是公开 Files 参数。成功读取要求完整字节或明确截断,并确认原生 close。安全的原生拒绝不携带字节;中断或有歧义的读取保持未知,并停止该 owner 的后续读取。已 dispatch 的读取在 observer 取消以及资源转移或释放后仍保留有限时等待方,资源关闭阻止新准入。gateway 限制订阅,不在重连后重试或重放读取;重复的 pending operation ID 不能启动另一次读取。 diff --git a/services/core/internal/api/native_classification_integration_test.go b/services/core/internal/api/native_classification_integration_test.go index 6c7baae4c..be2bae85d 100644 --- a/services/core/internal/api/native_classification_integration_test.go +++ b/services/core/internal/api/native_classification_integration_test.go @@ -72,10 +72,10 @@ func TestNativeClassificationPostgresRoundTripAndPublicPrivacy(t *testing.T) { t.Fatal(err) } eventBytes, _ := json.Marshal(events) - if strings.Contains(string(eventBytes), "canary") || strings.Contains(string(eventBytes), "engine_error") || strings.Contains(string(eventBytes), "http_status") { + if strings.Contains(string(eventBytes), "canary") || strings.Contains(string(eventBytes), "private.example") || strings.Contains(string(eventBytes), "http_status") { t.Fatal("private metadata entered SSE") } - // The Turn read alone publishes the category: official names stay, anything else is internal_error. + // The Turn read and its stream event publish the same category: official names stay, anything else is internal_error. public := code if code == "secret-canary" { public = "internal_error" @@ -83,6 +83,19 @@ func TestNativeClassificationPostgresRoundTripAndPublicPrivacy(t *testing.T) { if !strings.Contains(string(before[1]), `"error":{"code":"`+public+`"`) { t.Fatal("Turn error code", before[1]) } + streamed := false + for _, change := range events { + if change.Event.Type == "agent.session.turn.failed" { + event, err := streamResponse(session, change, "") + if err != nil || event.Turn == nil || event.Turn.Error == nil || event.Turn.Error.Code != public { + t.Fatal("streamed Turn error", err, event.Turn) + } + streamed = true + } + } + if !streamed { + t.Fatal("no agent.session.turn.failed event") + } // Remove only the private fields; every other public response must keep its bytes. if _, err = pool.Exec(t.Context(), `UPDATE turns SET outcome=outcome-'engine_error_code'-'engine_http_status' WHERE id=$1`, receipt.TurnID); err != nil { t.Fatal(err) diff --git a/services/core/internal/api/turn_diagnostic_failure.go b/services/core/internal/api/turn_diagnostic_failure.go index 53805d0f7..e508c4e98 100644 --- a/services/core/internal/api/turn_diagnostic_failure.go +++ b/services/core/internal/api/turn_diagnostic_failure.go @@ -60,8 +60,10 @@ func turnDiagnosticFailure(turn sessions.Turn) *DiagnosticFailure { // publicTurnErrors maps the diagnostics code to the official // SessionTurnErrorCodeResource with a fixed customer-safe sentence: native -// model categories keep their names and Runtime or sandbox failures in the -// execution environment are sandbox_error. Native text never reaches it. +// model categories keep their names and a Runtime that disconnected or failed +// its preparation is sandbox_error. runtime_unavailable stays internal_error +// because it also covers Core failing to dispatch the Turn. Native text never +// reaches it. var publicTurnErrors = map[string]string{ "context_length_exceeded": "The request exceeds the model's context window.", "usage_limit_exceeded": "The organization has reached a usage, plan, or billing limit.", @@ -80,7 +82,7 @@ var publicTurnErrors = map[string]string{ func publicTurnError(diagnosticCode string) *v1.TurnError { code := diagnosticCode switch diagnosticCode { - case "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed": + case "runtime_disconnected", "runtime_preparation_failed": code = "sandbox_error" } if message, ok := publicTurnErrors[code]; ok { diff --git a/services/core/internal/api/turn_diagnostic_failure_test.go b/services/core/internal/api/turn_diagnostic_failure_test.go index 9a46e8c1f..e0cdd78cb 100644 --- a/services/core/internal/api/turn_diagnostic_failure_test.go +++ b/services/core/internal/api/turn_diagnostic_failure_test.go @@ -155,6 +155,7 @@ func TestPublicTurnErrorFollowsDiagnosticClassification(t *testing.T) { `{"error_code":"device_disconnected"}`: "sandbox_error", `{"error_code":"preparation_start_failed"}`: "sandbox_error", `{"error_code":"model_provider_required"}`: "internal_error", + `{"error_code":"execution_unavailable"}`: "internal_error", `{"error_code":"event_persistence_failed"}`: "internal_error", ``: "internal_error", } { diff --git a/services/core/internal/sessions/turn_changes.go b/services/core/internal/sessions/turn_changes.go index 789233f9f..b8876be72 100644 --- a/services/core/internal/sessions/turn_changes.go +++ b/services/core/internal/sessions/turn_changes.go @@ -7,6 +7,7 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" ) @@ -19,7 +20,7 @@ func TurnChanges(turn Turn, created bool) []SessionChange { if turn.Status == TurnWaiting { return nil } - turn.Outcome = nil + turn.Outcome = failureClassification(turn.Outcome) kind := turn.Status if created { kind = "created" @@ -33,7 +34,7 @@ func TurnChanges(turn Turn, created bool) []SessionChange { // usage. The snapshot of a Turn that ended is settled: it marks that Turn's // end, while an input reserved during its Artifact capture is newer work. func ActivityChange(turn Turn, usage json.RawMessage, actions []v1.FunctionCallAction) SessionChange { - turn.Outcome = nil + turn.Outcome = failureClassification(turn.Outcome) status := "in_progress" if TerminalStatus(turn.Status) { status = "idle" @@ -49,6 +50,31 @@ func ActivityChange(turn Turn, usage json.RawMessage, actions []v1.FunctionCallA } } +// failureClassification keeps only the categories a failed Turn's public error +// is classified from: Core's error code and the normalized model category. A +// journaled snapshot then reports the same error as a read, while native codes, +// text, HTTP status and the Runtime's result stay out of the journal. +func failureClassification(outcome json.RawMessage) json.RawMessage { + var fields map[string]json.RawMessage + if json.Unmarshal(outcome, &fields) != nil { + return nil + } + var failure struct { + ErrorCode string `json:"error_code"` + EngineErrorCode string `json:"engine_error_code,omitempty"` + } + var native string + // Malformed optional metadata cannot hide Core's error code. + _ = json.Unmarshal(fields["error_code"], &failure.ErrorCode) + _ = json.Unmarshal(fields["engine_error_code"], &native) + failure.EngineErrorCode, _ = proto.NormalizeEngineFailure(native, nil) + if failure.ErrorCode == "" { + return nil + } + value, _ := json.Marshal(failure) + return value +} + // ArtifactSettlement is what a Turn that ended does with the Artifacts it // staged. type ArtifactSettlement string diff --git a/services/core/internal/sessions/turn_changes_test.go b/services/core/internal/sessions/turn_changes_test.go index 3536e39e2..30eb4470f 100644 --- a/services/core/internal/sessions/turn_changes_test.go +++ b/services/core/internal/sessions/turn_changes_test.go @@ -15,9 +15,12 @@ const ( ) func turnWith(status string) Turn { - return Turn{ID: testTurn, SessionID: testSession, Status: status, CompletedAt: time.Unix(1700000000, 0), Outcome: json.RawMessage(`{"private":true}`)} + return Turn{ID: testTurn, SessionID: testSession, Status: status, CompletedAt: time.Unix(1700000000, 0), Outcome: json.RawMessage(`{"error":"native text","error_code":"engine_failed","engine_error_code":"rate_limit_exceeded","engine_http_status":429}`)} } +// classified is turnWith's outcome without its native text and HTTP status. +const classified = `{"error_code":"engine_failed","engine_error_code":"rate_limit_exceeded"}` + func types(changes []SessionChange) []string { var kinds []string for _, change := range changes { @@ -80,7 +83,7 @@ func TestTurnChangesReportTheNewStatus(t *testing.T) { t.Fatalf("%s: %q", test.status, types(changes)) } for _, change := range changes { - if change.Turn.Outcome != nil || change.Event.TurnID != testTurn || change.Turn.Status != test.status { + if string(change.Turn.Outcome) != classified || change.Event.TurnID != testTurn || change.Turn.Status != test.status { t.Fatalf("%s: snapshot %+v", test.status, change) } } @@ -108,7 +111,7 @@ func TestActivityChangeDerivesSessionActivity(t *testing.T) { {TurnFailed, nil, "agent.session.failed", true}, } { change := ActivityChange(turnWith(test.status), usage, test.actions) - if change.Event.Type != test.want || change.Settled != test.settled || change.Turn.Outcome != nil || + if change.Event.Type != test.want || change.Settled != test.settled || string(change.Turn.Outcome) != classified || string(change.SessionUsage) != string(usage) || !reflect.DeepEqual(change.RequiredActions, test.actions) { t.Fatalf("%s: %+v", test.status, change) } diff --git a/services/core/tests/integration/input_conflicts_public_test.go b/services/core/tests/integration/input_conflicts_public_test.go index 8313628ee..3506c6e34 100644 --- a/services/core/tests/integration/input_conflicts_public_test.go +++ b/services/core/tests/integration/input_conflicts_public_test.go @@ -18,7 +18,8 @@ import ( const conflictAgent = `"agent":{"id":"agent_conflicts","model":"fixture","tools":[],"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"}}` // Official error bodies for Session input rejections (EVT-11, EVT-12, ERR-22, -// ERR-27). Every param is null and no message repeats caller input. +// ERR-27). Every param is null and only the unknown tool call message names +// the caller's call_id. const ( missingSessionBody = `{"error":{"message":"Resource not found.","type":"not_found_error","code":"not_found_error","param":null}}` + "\n" otherTurnBody = `{"error":{"message":"The tool call belongs to a different Turn.","type":"invalid_request_error","code":"invalid_request_error","param":null}}` + "\n" From acbe11710111ed52cd0b40cbf855d731e7f9d91a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 05:21:46 +0800 Subject: [PATCH 22/39] Fix the Sandbox Provider setup review findings (#848) * Fix the Sandbox Provider setup review findings Hide Advanced settings when it has nothing to show, keep the saved E2B template editable as text, show the Provider default on the review, derive the client's setup type and the E2B discovery fields from their declarations, delete the duplicate resource validator, and bring the docs up to date. * Point machine API readers to each route's errors --- apps/web/DESIGN.md | 4 ++-- apps/web/PRODUCT.md | 4 ++-- .../features/sandbox/SandboxSetupWizard.tsx | 15 +++++++------ apps/web/src/i18n/locales/zh-CN/sandbox.ts | 1 + contracts/agents-api/machine-api.md | 2 +- contracts/agents-api/model-execution.md | 2 +- contracts/agents-api/zh/machine-api.md | 4 ++-- contracts/agents-api/zh/model-execution.md | 4 ++-- deploy/node/README.md | 2 +- deploy/node/node_install.py | 2 ++ deploy/node/test_node_install.py | 4 ++++ docs/getting-started/sandbox-backends.md | 8 ++++--- docs/sandbox-provider.md | 8 +++---- docs/web/console-api-usage.md | 2 +- docs/zh/getting-started/sandbox-backends.md | 10 +++++---- docs/zh/sandbox-provider.md | 10 ++++----- docs/zh/web/console-api-usage.md | 4 ++-- packages/agents-client/src/client.test.ts | 1 - packages/agents-client/src/sandbox-client.ts | 21 +++---------------- services/core/IMPLEMENTATION.md | 2 +- .../internal/sandbox/e2b/configuration.go | 6 +++--- .../sandbox/e2b/configuration_discovery.go | 10 +++++++-- .../core/internal/sandbox/e2b/deployment.go | 6 +++--- .../internal/sandbox/e2b/discovery_test.go | 12 +++++++++++ .../sandbox/providers/registration_test.go | 2 +- .../internal/sandbox/providers/registry.go | 7 ------- .../sandbox/providers/validation_test.go | 12 ++++++++--- 27 files changed, 90 insertions(+), 75 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index f288d1905..d35739d74 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -368,7 +368,7 @@ Dialogs are 448px Paper cards (960px when wide) with 8px corners, a 52px header - **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: its absolute path is entered during interactive installation or passed with `--credential-file`; tokens never enter command arguments. - **Add node**: the Limits choice first (Recommended, which leaves the limits for Core to derive from the host once it enrolls, or Custom with the two numbers), 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 and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. The docker group's root-equivalent access is stated once, in **Use Docker instead of microsandbox?**. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to Settings → Models and credentials or the Overview. - **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images; the uninstaller prints what it keeps. The command requires root or sudo; there is no user-service alternative. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public URL, which the dialog reads again if it is not at hand: until then one line says it is being checked, a failed read says so with Try again, and a public URL other machines can't use (loopback, or none) gets a line saying the service stays on the host and no command can be given. Done dismisses it and focus returns to the page heading. -- **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists what it gives up, each point a 600 Ink lead over a Graphite line: weaker isolation (containers share the host kernel; microsandbox gives each sandbox its own microVM), root-equivalent access (the node's account joins the docker group) and limited use (trusted workloads, or hosts without KVM). The footer holds Use Docker (outline) and Keep microsandbox (primary), which takes focus; closing or Escape keeps microsandbox too. +- **Use Docker?**: choosing a backend that declares cautions in sandbox setup, Docker today, titles the dialog "Use {backend}?" and lists each caution as a plain list item: for Docker, that containers share the host kernel and that the node's account joins the docker group, which is root-equivalent on the host. The footer holds Use Docker (outline) and Back (primary), which takes focus; Back, closing or Escape leaves the choice unmade. - **Edit node**: the name, then the Limits choice, Recommended showing the node's `recommended_capacity` while Core has one, or Custom with the numbers. The Nodes list and a node's Capacity show "Active / limit" for Docker and microsandbox alike, so a saved limit shows where it was set. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key) together, then curl and Python (with the pinned SDK), each listing the project's Agents and creating a Session with a first message (`environment`, an inline `agent` with `model: ""`, and `input`). A copy the clipboard refuses selects the sample and says so in red underneath. One Graphite line says to put a model the model provider serves in place of ``, and that running an Agent needs a model provider: in each request, saved on the Agent, or the deployment default. An active project's page shows the same samples as a section without any key: the Shell block exports a quoted placeholder, and a Graphite line above the samples says to use a key issued for this project, shown only once at issuance. When the public address is loopback, a note above the samples says the API is reachable only on the Core machine; without a public address only a note to set one shows. Before the installation is read, a skeleton holds the first sample's place. @@ -407,7 +407,7 @@ Signing in and the console tour share one frame: a dark stage on the left (alway The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open Settings; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open Settings brings the Models and credentials tab to the top of the page body and focuses the default Harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review states the reason and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. +Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend, or for E2B the Connect E2B page (service, API key, template build and endpoint), then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, each declared connection value (an empty optional one reads Provider default) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review states the reason and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away on the review page of a backend that declares a default size, hold the exact CPU, memory and disk values; E2B has none, so the review offers no link. A change keeps the saved size while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or the E2B connection with the provider fixed. With the saved key kept, the template build stays an editable text field of the saved value; entering a key loads the build list again. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Sandbox lifecycle diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 98a0c9709..7c79d41b1 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -37,9 +37,9 @@ The console runs beside the administrator's own Core, with execution, files and - **Sandbox reset** is an explicit administrator operation in Settings → Sandbox and scheduling. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Its semantics are defined in [Reset](../../contracts/agents-api/sandbox-deployment.md#reset); Web shows only Core's progress and completion, and a new configuration is a separate deliberate save. - **Online sandbox configuration** changes the same backend's resources or E2B template without retiring existing nodes or changing existing Sessions' resource ownership. New placement follows Core's qualified capacity; saving a target does not promise immediate placement on it. Configuration rollout shows Core's target preparation and retained previous-generation sandbox count. A settled rollout can still have failed or unknown nodes and old resources. An offline node stays offline even when it has a recorded serving generation. Node and allocation detail distinguish the serving pin, target preparation and each resource's configuration generation. - **E2B credential replacement** uses the same configuration form. Setup requires a key; leaving it blank during an update keeps the saved key. An explicit key, even the same value, is verified as a replacement and advances the target generation after successful verification. Another backend or E2B team requires a deliberate reset. A rejected or uncertain replacement never clears the committed configuration or replays the write. -- **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting, size, template build) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use. +- **E2B deployments** have no machines: Nodes offers a link to Settings → Sandbox and scheduling. Overview and Sandbox metrics show the sandboxes Core holds in E2B's cloud (running, starting or changing, and the size of each) instead of node capacity, with no Add node action; a sandbox's dialog adds its disk use. - **Automatic suspension** appears when the deployment declares a suspension policy. Nodes shows each node's suspended count and retained limit, the most sandboxes it keeps running and suspended together, with Edit node for its limits; Overview adds the suspended total of all registered nodes under its sandbox slots. Settings → Sandbox and scheduling owns **Automatic suspension**: deployment-wide idle seconds are editable there; snapshot retention remains read-only. These are Core records, not measured CPU or memory savings. Node allocations show the recorded compute phase and elapsed phase time, never an inferred snapshot-expiry countdown. Sandbox metrics' Hosted Runtimes has one Status column: Core's lifecycle record of each sandbox (Active, Suspended, Changing state, Waiting for allocation or Stopped), which Core derives from the allocation's recorded phase. The Session page's Runtime state reads the same way. When an active sandbox has no sample, its CPU and Memory cells name why. Independent workspace persistence is explained only when the deployment declares it. Nodes refreshes its inventory every 30 seconds while visible; reset and active preparation retain their faster polling. -- **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment whose template build is not reported as not ready), a default model provider on the default Harness (on any enabled Harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. +- **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment), a default model provider on the default Harness (on any enabled Harness when none is default), a project with an active key, and a first Session, whose action opens the call samples of the newest active project, preferring one with an active key. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". While it is open, Add node ends with the next step once its node is ready: the default model provider while that is to do, otherwise back to the checklist. The optional three-chapter tour of the console (Monitor, Resources, Platform) opens from it, on the sign-in stage. - Terminology: API terms stay in English in the Chinese UI and docs (Agent, Session, Turn, Item, Runtime, Environment, Skill, Vault, Credential, Harness); providers are 提供商 (沙箱提供商, 模型提供商). The sign-in credential is the Core key ("Core 密钥"); keys issued in a project for applications are project API keys ("项目 API 密钥"). Empty lists read "No … yet" ("还没有…"). Sandbox provider readiness is "Provider not ready / 沙箱提供商未就绪"; revoked credentials and keys use "Revoked / 已撤销". A default model provider is a service address and write-only key; the application's Agent `model` chooses the provider-supported model name. A sandbox is Core-managed compute; Runtime names Core's execution observations, and Environment is the Session's API execution environment. These are distinct counts and resources, not synonyms. ## Capabilities and Constraints diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 95e0138d6..ec83e573d 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -37,7 +37,8 @@ function serviceOf(provider: SandboxProvider, configuration: Values): number { /** The values of the connection fields a service sets, empty for a custom one. */ function serviceValues(provider: SandboxProvider, index: number): Values { const setup = sandboxProviderSetup(provider); - return Object.fromEntries(setup.fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, setup.presets[index]?.values[field.name] ?? ""])); + const values: Readonly> | undefined = setup.presets[index]?.values; + return Object.fromEntries(setup.fields.filter((field) => !field.secret && !field.discovered).map((field) => [field.name, values?.[field.name] ?? ""])); } const MIB = 2 ** 20; @@ -313,7 +314,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{t("Sandboxes run on")}
{where === "direct" ? setup?.label : `${t("Own machines")} · ${sandboxProviderLabel(provider ?? "", locale)}`}
{t("Each sandbox")}
{sized ? sizeLabel(resources) : t("From the selected configuration")}{policy?.disk ? {diskLabel(resources)} : null}
- {fields.filter((field) => !field.secret).map((field) =>
{text(field.label)}
{valueOf(field) || "—"}
)} + {fields.filter((field) => !field.secret).map((field) =>
{text(field.label)}
{valueOf(field) ? {valueOf(field)} : t("Provider default")}
)} {secretFields.length > 0 && editing ?
{t("Credential")}
{t(replacementRequested ? "Replace saved key" : "Keep saved key")}
: null}
{t("Core address")}{t("The address nodes and sandboxes use to reach Core.")}
@@ -339,9 +340,11 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab {editing ? : null}

) : null} - + {sized ? ( + + ) : null}
diff --git a/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts b/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts index be04e2036..d1066ddd3 100644 --- a/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts +++ b/apps/web/src/features/sandbox/sandbox-deployment-write.test.ts @@ -5,7 +5,7 @@ import { sandboxAdmin, sandboxDeploymentQuery } from "./sandbox-queries"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; -const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", owner_epoch: 1, generation: 1, provider: "docker", core_url: "http://core", mode: "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null }; +const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", owner_epoch: 1, generation: 1, provider: "docker", core_url: "http://core", mode: "nodes", reset: null, resources: { allocations: 0, running: 0, pending: 0 }, suspension: null }; function deferred() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; 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 d9842bc34..dddbf62b0 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -18,7 +18,7 @@ function cache(provider: SandboxDeployment["provider"]) { const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false, gcTime: Infinity } } }); const deployment: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, installation_id: "install", owner_epoch: 1, generation: 2, provider, core_url: "https://core.example", - mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, + mode: provider === "" ? "" : provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, running: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; const config: SandboxConsoleConfig = { node_installer: false, node_installer_sha256: "", node_artifacts: [] }; diff --git a/apps/web/src/features/sandbox/sandbox-queries.test.ts b/apps/web/src/features/sandbox/sandbox-queries.test.ts index 62bef4afb..08854805d 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.test.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.test.ts @@ -10,7 +10,7 @@ const reset: SandboxReset = { }; const deployment = (overrides: Partial = {}): SandboxDeployment => ({ credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "installation-a", provider: "docker", core_url: "https://core.example", reset, - owner_epoch: 1, generation: 2, mode: "nodes", resources: { allocations: 2, pending: 0 }, suspension: null, ...overrides, + owner_epoch: 1, generation: 2, mode: "nodes", resources: { allocations: 2, running: 2, pending: 0 }, suspension: null, ...overrides, }); const clients: QueryClient[] = []; const cache = () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); clients.push(client); return client; }; diff --git a/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx index 63c8667c2..ae16d744a 100644 --- a/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx +++ b/apps/web/src/features/sandbox/use-sandbox-page-state.test.tsx @@ -6,7 +6,7 @@ import { node } from "../overview/test-fixtures"; import { sandboxAdmin, sandboxDeploymentQuery, sandboxSnapshotQuery } from "./sandbox-queries"; import { useSandboxPageState } from "./use-sandbox-page-state"; -const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, pending: 0 }, suspension: null }; +const configured: SandboxDeployment = { credential_configured: false, configuration: {}, metadata: {}, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, installation_id: "i", provider: "docker", core_url: "http://core", reset: null, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 1, running: 1, pending: 0 }, suspension: null }; const resetting: SandboxDeployment = { ...configured, reset: { clear: "auto", requested_at: "2026-09-27T10:00:00Z", deadline_at: "2026-09-27T11:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } }; const clients: QueryClient[] = []; function cache() { diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index e36b7d79a..4ada3db8e 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -189,11 +189,11 @@ export const metrics = { }, cloud: { title: "Provider cloud", - counts: "The running count comes from Core’s allocations in the provider’s cloud. The hosted Runtime total below counts Core’s hosted observation records across projects, including every reported lifecycle state. These sources have different coverage and refresh separately.", + counts: "The running count comes from Core’s running allocations in the provider’s cloud; suspended sandboxes are not counted. The hosted Runtime total below counts Core’s hosted observation records across projects, including every reported lifecycle state. These sources have different coverage and refresh separately.", help: "This deployment runs its sandboxes in the provider's cloud: there are no machines to manage. Counts are Core's own records of what it placed there.", manage: "Sandbox backend", running: "Running sandboxes", - runningHelp: "Sandboxes Core currently holds in the provider’s cloud.", + runningHelp: "Sandboxes running in the provider’s cloud for Core.", pending: "Starting or changing", size: "Each sandbox", }, diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index b6bf73549..4d9405e28 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -189,11 +189,11 @@ export const metrics = { }, cloud: { title: "提供商云端", - counts: "运行数量来自 Core 在提供商云端的分配记录;下方托管 Runtime 总数统计各项目的托管观测记录,包含所有已报告的生命周期状态。两者覆盖范围不同,并分别刷新。", + counts: "运行数量来自 Core 在提供商云端正在运行的分配记录,不含已挂起的沙箱;下方托管 Runtime 总数统计各项目的托管观测记录,包含所有已报告的生命周期状态。两者覆盖范围不同,并分别刷新。", help: "这个部署的沙箱在提供商的云端运行,没有需要管理的机器。数量是 Core 自己记录的、放在云端的沙箱。", manage: "沙箱提供商", running: "运行中的沙箱", - runningHelp: "Core 当前在提供商云端占用的沙箱。", + runningHelp: "Core 在提供商云端正在运行的沙箱。", pending: "启动或切换中", size: "每个沙箱", }, diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index 7f2768737..116629d96 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -5,9 +5,12 @@ export const sandbox = { "Reset": "重置", "Clear this configuration to choose a different backend. Hosted Sessions are archived permanently.": "清除当前配置后才能选择其他沙箱提供商。托管 Session 将被永久归档。", "Idle before suspension (seconds)": "空闲多久后挂起(秒)", - "Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged. Capacity pressure may suspend an idle sandbox after 15 seconds.": "整个部署统一生效:30–86,400 秒。适用于现有和新建的运行中沙箱,无需重启。已经开始的挂起和快照保留期不变。容量紧张时,沙箱空闲 15 秒后可能提前挂起。", + "Deployment-wide: 30–86,400 seconds. Applies to existing and new running sandboxes without restarting them. Suspension already in progress and snapshot retention are unchanged.": "整个部署统一生效:30–86,400 秒。适用于现有和新建的运行中沙箱,无需重启。已经开始的挂起和快照保留期不变。", + "Capacity pressure may suspend an idle sandbox after 15 seconds.": "容量紧张时,沙箱空闲 15 秒后可能提前挂起。", "Enter a whole number from 30 to 86,400.": "请输入 30 到 86,400 之间的整数。", "Automatic suspension": "自动挂起", + "Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume.": "这是新挂起任务采用的当前策略,不是现有快照的到期倒计时。下一轮 Turn 会请求恢复。", + "The idle policy applies to the whole deployment.": "空闲策略对整个部署生效。", "Snapshot retention": "快照保留期", "Current policy for new suspensions, not a countdown for existing snapshots. The next Turn requests a resume; capacity and a compatible node must be available.": "这是新挂起任务采用的当前策略,不是现有快照的到期倒计时。下一轮 Turn 会请求恢复,需要可用容量及兼容节点。", "Snapshot expiry releases memory state. With independent workspace storage, files and conversation remain available for a cold continuation; processes restart.": "快照到期后释放内存状态。使用独立工作区存储时,文件和对话仍可用于冷启动续接,进程会重新启动。", diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index e0d305ff2..967570464 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1637,9 +1637,12 @@ definitions: type: integer pending: type: integer + running: + type: integer required: - allocations - pending + - running type: object deployment.Rollout: properties: diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index c6b2972b1..a7a7e997a 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -79,6 +79,7 @@ Each operation carries its own arguments and returns the following result on suc | `initial` | `Initial` | None | `compute` | | `new_compute` | `NewCompute` | Positive compute `generation` and optional `snapshot` | `compute` | | `compute` | `GetCompute` | `compute` | `state` | +| `renew_compute` | `RenewCompute` | `compute` | `state` | | `kill_compute` | `KillCompute` | `compute` | None | | `resume_compute` | `ResumeCompute` | `compute` | `state` | | `command_compute` | `RunCommandCompute` | `compute` and `command` | `command` | diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 50f0307af..22f6c0740 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -78,7 +78,7 @@ GET and successful writes return `installation_id`, `provider`, `core_url` (read - `metadata.template_build` is `{status, resources: {cpus, memory_mib, root_disk_mib}}`: the build as Core read it through the pinned SDK when the selection was saved. GET never calls E2B, so it stays cheap during an E2B outage. Validation admits only a `ready` build whose CPU count and memory equal the selected values; `root_disk_mib` is the build's native disk size, which Core does not enforce. Unknown values are null, `metadata: {}` means no observation was recorded, and an identical PUT without a credential does not refresh it. - `suspension` is `{idle_seconds, retention_seconds}`, Core's [suspension policy](../../docs/sandbox-provider.md#suspension), when the selected Provider declares checkpoint support; any other deployment, including an unconfigured one, returns null. -- Request `resources` and response `specification.resources` are per-sandbox limits. Response `resources.allocations` and `resources.pending` count unreleased allocations and pending hosted Environments without an allocation. +- Request `resources` and response `specification.resources` are per-sandbox limits. Response `resources.allocations` and `resources.pending` count unreleased allocations and pending hosted Environments without an allocation; `resources.running` counts the unreleased allocations whose compute is running, so it excludes suspended ones. - An unconfigured deployment has an empty provider and no specification. Docker and microsandbox use `mode: nodes`; E2B uses `mode: direct`, without a synthetic node. - `generation` identifies the saved selection. `owner_epoch` fences the execution owner and node connections; it does not replace `expected_generation`. - `specification_digest` is the server's identity of the provider, limits and derived workspace capability receipt; enrollment echoes it unchanged. @@ -220,7 +220,7 @@ Some fields keep one name across providers but differ in meaning, or do not appl | Independent workspace filesystem | Required: [Kernel NFS adapter](../../docs/workspace-provider.md#kernel-nfs-adapter), configured separately from compute | Unsupported | Unsupported | | Deployment `specification.resources` | `cpus`, `memory_mib`, `root_disk_mib` and, as a quota request, `environment_disk_mib` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`; no disk quota | | Deployment `metadata.template_build` | Absent: `metadata` is empty | The build as Core read it when the selection was saved | Absent: `metadata` is empty | -| Deployment `suspension` | `{idle_seconds, retention_seconds}` | `null`; Core does not suspend E2B sandboxes | `null` | +| Deployment `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`; Core pauses an idle E2B sandbox with the SDK's native pause | `null` | | Deployment `resources.allocations`, `resources.pending` | Totals across all nodes | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Totals across all nodes | | Enrollment-token `max_active`, `max_retained` | Both limits apply; an omitted limit is derived at enrollment | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | `max_retained` always equals `max_active` | | Node list and detail | Enrolled nodes | Empty list; detail returns 404 | Enrolled nodes | diff --git a/contracts/agents-api/zh/node-generation-protocol.md b/contracts/agents-api/zh/node-generation-protocol.md index ac649c21d..64dd5a810 100644 --- a/contracts/agents-api/zh/node-generation-protocol.md +++ b/contracts/agents-api/zh/node-generation-protocol.md @@ -1,7 +1,7 @@ --- title: "沙箱节点协议" source: contracts/agents-api/node-generation-protocol.md -source_hash: 090803f51d2e29d8900cfe8612b0eb21363a0fd2687daf3d368cc746dd65809f +source_hash: 3648a80013f1ae90031430c5bdf28d6ee5b59f55dda4b168dfeb99ab5bcec3e2 --- 沙箱节点协议连接 Core 与 Docker 或 microsandbox 主机:主机针对其本地提供商执行提供商操作,并报告就绪状态、主机测量值及其持有的部署代次。 @@ -81,6 +81,7 @@ Core 发送包含以下内容的 `request` 帧: | `initial` | `Initial` | 无 | `compute` | | `new_compute` | `NewCompute` | 大于零的计算 `generation` 和可选的 `snapshot` | `compute` | | `compute` | `GetCompute` | `compute` | `state` | +| `renew_compute` | `RenewCompute` | `compute` | `state` | | `kill_compute` | `KillCompute` | `compute` | 无 | | `resume_compute` | `ResumeCompute` | `compute` | `state` | | `command_compute` | `RunCommandCompute` | `compute` 和 `command` | `command` | diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 306cefcb9..8ddf657b2 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 4b77c11fe9a80e4b6d200ca161ab53e006bfc46b185262ee858fc65d75b5112a +source_hash: 673ae35810faa99f2a3fa480f6b487b4b8a3e7a21a47a4b1fa9c22df6bc53845 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -81,7 +81,7 @@ GET 和成功的写入操作会返回 `installation_id`、`provider`、`core_url - `metadata.template_build` 为 `{status, resources: {cpus, memory_mib, root_disk_mib}}`:这是保存选择时 Core 通过固定版本 SDK 读取的构建。GET 绝不会调用 E2B,因此 E2B 中断期间该操作仍保持低成本。验证仅接受 CPU 数量和内存与所选值一致的 `ready` 构建;`root_disk_mib` 是构建的原生磁盘大小,Core 不会强制执行该值。未知值为 null,`metadata: {}` 表示未记录任何观测,在不提供凭据的情况下提交完全相同的 PUT 也不会刷新它。 - 所选提供商声明 checkpoint 支持时,`suspension` 为 `{idle_seconds, retention_seconds}`,即 Core 的 [suspension policy](../../../docs/zh/sandbox-provider.md#suspension);其他部署(包括未配置的部署)返回 null。 -- 请求中的 `resources` 和响应中的 `specification.resources` 是每个沙箱的限制。响应中的 `resources.allocations` 和 `resources.pending` 分别计算尚未释放的分配,以及尚未分配沙箱的待处理托管 Environment。 +- 请求中的 `resources` 和响应中的 `specification.resources` 是每个沙箱的限制。响应中的 `resources.allocations` 和 `resources.pending` 分别计算尚未释放的分配,以及尚未分配沙箱的待处理托管 Environment;`resources.running` 计算计算资源正在运行的未释放分配,因此不含已挂起的分配。 - 未配置的部署具有空的提供商且没有 specification。Docker 和 microsandbox 使用 `mode: nodes`;E2B 使用 `mode: direct`,且没有合成节点。 - `generation` 标识已保存的选择。`owner_epoch` 用于对执行所有者和节点连接进行栅栏隔离;它不能替代 `expected_generation`。 - `specification_digest` 是服务器对提供商、限制和派生的工作区能力回执的标识;注册过程会原样回显该值。 @@ -225,7 +225,7 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。 | 独立工作区文件系统 | 必需:[Kernel NFS adapter](../../../docs/zh/workspace-provider.md#kernel-nfs-adapter),与计算资源分开配置 | 不支持 | 不支持 | | 部署 `specification.resources` | `cpus`、`memory_mib`、`root_disk_mib`,以及作为配额请求的 `environment_disk_mib` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`;无磁盘配额 | | 部署 `metadata.template_build` | 不存在:`metadata` 为空 | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 | -| 部署 `suspension` | `{idle_seconds, retention_seconds}` | `null`;Core 不暂停 E2B 沙箱 | `null` | +| 部署 `suspension` | `{idle_seconds, retention_seconds}` | `{idle_seconds, retention_seconds}`;Core 用 SDK 的原生 pause 暂停空闲的 E2B 沙箱 | `null` | | 部署 `resources.allocations`、`resources.pending` | 所有节点的总数 | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | 所有节点的总数 | | 注册令牌 `max_active`、`max_retained` | 两个限制均适用;省略的限制在注册时推导 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | `max_retained` 始终等于 `max_active` | | 节点列表和详情 | 已注册节点 | 空列表;详情返回 404 | 已注册节点 | diff --git a/docs/runtime-bootstrap.md b/docs/runtime-bootstrap.md index ac302218b..5fd98fa34 100644 --- a/docs/runtime-bootstrap.md +++ b/docs/runtime-bootstrap.md @@ -53,11 +53,16 @@ oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json | `environment_id` | Canonical nonzero UUID of the Environment the Runtime serves | | `session_id` | Canonical nonzero UUID of the Session whose native history the Runtime keeps | | `workspace` | Clean absolute directory of the workspace, `runtimebootstrap.RuntimeWorkspace` in the packaged Runtime layout. The Runtime serves it as `runtimebootstrap.PublicWorkspace` (`/workspace`), the path applications and Core use | +| `suspend_control` | Required boolean. Core sets it exactly when the Provider declares [checkpoint support](./sandbox-provider.md), and the Provider forwards it unchanged | The decoder rejects unknown, duplicate, missing and case-aliased fields, other versions and documents larger than `runtimebootstrap.MaxBytes`. Errors never include submitted values. A missing or malformed file fails before the daemon connects. The file is the only authentication input for this launch: the daemon refuses to combine it with self-hosted enrollment options, and reads the credential into memory without saving it to a stored profile. Credentials never go in command arguments, environment variables or receipts. The provider keeps the file for process restarts and removes it only during explicit cleanup of the resources it owns. +## Suspend control + +When `suspend_control` is true, the daemon publishes its suspend control at `runtimebootstrap.SuspendControlFile` and parks for suspension. Core's wake command, `oac-daemon resume --control-file`, reads that file. A Provider that declares checkpoint support prepares the file's directory before it starts the daemon: private to the Runtime account (mode 0700, owned by UID 1000). `oac-daemon connect` fails when `suspend_control` is true and it cannot publish the control, and the control is never enabled by any other setting. + ## Timeouts and limits | Value | Applies to | On breach | diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index ca101398a..f47f4f749 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -52,7 +52,7 @@ Isolation belongs to the provider's infrastructure, not the daemon; see [Runtime A backend without a native renewable lease, such as Docker, still keeps Core's hosted expiry and cleanup requirements. Every adapter implements `RunCommand` and its tests exercise it, but Core's orchestration does not call it; only the node transport forwards it. Confidential command input travels in `Command.Stdin`, never in arguments or logs, and the result keeps byte order, bounded output and the actual exit status. -`Bootstrap.Workspace` and `ResumeRequest.Workspace` carry an external workspace binding exactly when the Provider's `DeploymentPolicy.Workspace` is declared; the Provider obligations for it are in [Attachment](./workspace-provider.md#attachment). +`Bootstrap.Workspace` and `ResumeRequest.Workspace` carry an external workspace binding exactly when the Provider's `DeploymentPolicy.Workspace` is declared; the Provider obligations for it are in [Attachment](./workspace-provider.md#attachment). Core sets `Bootstrap.SuspendControl` exactly when the Provider declares checkpoint support, and the Provider passes it unchanged in the [Runtime bootstrap](./runtime-bootstrap.md). ### Explicit operation contracts @@ -62,10 +62,10 @@ Every provider returns a complete `ProviderOperations()` declaration with one en | --- | --- | --- | | `Create`, `GetInfo`, `Renew`, `Kill`, `RunCommand` | Supported | Allocation lifecycle and bounded commands | | `Observe` | Explicit decision | Ownership-checked read-only observation of one allocation | -| `Initial`, `NewCompute`, `GetCompute`, `Suspend`, `Resume`, `ResumeCompute`, `KillCompute`, `DeleteSnapshot`, `RunCommandCompute` | The same decision for every checkpoint method; supported only by a `nodes` registration | Exact compute incarnations, capture and restore, retained-source resume and cleanup | +| `Initial`, `NewCompute`, `GetCompute`, `RenewCompute`, `Suspend`, `Resume`, `ResumeCompute`, `KillCompute`, `DeleteSnapshot`, `RunCommandCompute` | The same decision for every checkpoint method | Exact compute incarnations, capture and restore, retained-source resume and cleanup | | `RestorePreparation`, `SavePreparation` | The same decision for both | Reuse of a finished Environment preparation | -`Initial` and `NewCompute` construct compute references without allocating, `ResumeCompute` thaws only the same resident instance after an aborted pause, and `RunCommandCompute` runs a bounded command in one exact compute incarnation. Core uses `RunCommandCompute` to wake a parked daemon after a restore ([`runtime_compute_wake.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute_wake.go)). +`Initial` and `NewCompute` construct compute references without allocating, `ResumeCompute` thaws only the same resident instance after an aborted pause, `RenewCompute` extends the native lease of the exact running incarnation where the Provider has one and otherwise observes exactly like `GetCompute`, and `RunCommandCompute` runs a bounded command in one exact compute incarnation. Core uses `RunCommandCompute` to wake a parked daemon after a restore ([`runtime_compute_wake.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute_wake.go)). `SavePreparation(reference, key)` stores what preparation wrote under `/environment` (workspace, packages and initialization, never Runtime state) in the allocation's initial compute after a complete preparation, and `RestorePreparation(reference, key)` puts a stored one back and returns `false` when it holds no valid entry. The `key` is opaque: Core derives it from the Project and everything that reaches preparation, never from the Environment, Session, Harness or Agent, and the Provider never interprets it. The Provider owns storage, scoping to its Runtime image, expiry and eviction. An entry is valid for 24 hours after its save, and a restore must not use an expired one. Core calls `RestorePreparation` after the Runtime connects and, on a hit, sends only the `finalize` operation before completing initialization. On a miss it prepares in full and calls `SavePreparation`; a save error is logged and never fails the Session. A Provider that declares neither always prepares in full. The microsandbox adapter keeps one archive per key in a private directory under the Runtime home. @@ -109,7 +109,7 @@ Every call receives a bounded context. Expiry or cancellation ends the caller's `ErrInvalid`, `ErrOwnership`, `ErrExists`, `ErrNotFound`, `ErrComputeUnconfirmed`, `ErrCommandUnconfirmed` and, for `Resume` only, `ErrCheckpointUnusable` keep their defined meanings. An unclassified native or transport error is unknown, never permission to retry a mutation. Core never reads provider diagnostics as lifecycle truth or exposes native error text or credentials; the node transport maps errors to fixed codes, and direct SDK details stay private. -Checkpoint support adds `Compute` generation, name and ID and `SnapshotIdentity`; persist operation IDs and the provider's snapshot provenance unchanged. `ObserveOnly` on suspend or resume observes the previous attempt and never starts another capture or restore. `ResumeCompute` only thaws the retained source and never cold-starts a stopped one. Cleanup targets the exact compute incarnation and snapshot, not whatever instance now has the same name. `GetCompute` returns `ErrNotFound` only when the owning node has verified that the exact incarnation no longer exists; an instance with the same name and another native ID is `ErrOwnership`, and an unreachable node or backend is an unknown result. A `ComputeState.Status` of `stopped` or `crashed` means the adapter verified that the exact incarnation’s process no longer exists; its record or disks can remain until `KillCompute` removes them. `paused`, `running` without a Runtime connection, and unknown states do not establish termination. Read [`runtime_compute.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute.go) and its failure tests before declaring checkpoint support. +Checkpoint support adds `Compute` generation, name and ID and `SnapshotIdentity`; persist operation IDs and the provider's snapshot provenance unchanged. `ObserveOnly` on suspend or resume observes the previous attempt and never starts another capture or restore. `ResumeCompute` only thaws the retained source and never cold-starts a stopped one. `RenewCompute` never revives stopped compute, and Core calls it instead of `GetCompute` while a running allocation's Runtime is connected, because `Renew` addresses only the initial incarnation. Cleanup targets the exact compute incarnation and snapshot, not whatever instance now has the same name. `GetCompute` returns `ErrNotFound` only when the owning node has verified that the exact incarnation no longer exists; an instance with the same name and another native ID is `ErrOwnership`, and an unreachable node or backend is an unknown result. A `ComputeState.Status` of `stopped` or `crashed` means the adapter verified that the exact incarnation’s process no longer exists; its record or disks can remain until `KillCompute` removes them. `paused`, `running` without a Runtime connection, and unknown states do not establish termination. Read [`runtime_compute.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute.go) and its failure tests before declaring checkpoint support. ### Four distinct readiness facts @@ -144,7 +144,7 @@ A node configuration, `sandbox.NodeConfig`, holds `provider`, `generation`, `ins - A `nodes` registration has only `BuildLocal`, and a `direct` registration only `BuildDirect`; missing, mixed or unknown modes are rejected. - The configuration adapter and a complete operation declaration are mandatory, so an incomplete registration cannot publish a partial projection. A declared default size must pass `ValidatePolicy("", Policy)`. -- Checkpoint is admitted only for a `nodes` registration, because the common lifecycle suspends only node allocations; registration rejects a `direct` Provider that declares it. A registration carries no suspension values: Core applies its one [suspension policy](#suspension) to every Provider that declares checkpoint support. +- Checkpoint is admitted for both modes: the common lifecycle suspends, retains, restores and cleans up a `direct` allocation exactly as a node allocation, with no node. A registration carries no suspension values: Core applies its one [suspension policy](#suspension) to every Provider that declares checkpoint support. The configuration adapter must be non-nil, including its concrete value. Every `ConfigurationRequirements` field needs an explicit valid decision: `Credential` and `PublicOrigin` are `Required` or `NotRequired`, and `Discovery`, `SelectionDiscovery` and `CredentialVerification` use the shared supported or unsupported declaration with a safe reason. A new requirement field needs an explicit validation update and never inherits an existing decision. Requiring a credential does not promise the `VerifyCredential` operation. These checks establish complete registration, not correct native SDK behavior; constructor and adapter contract tests still apply. @@ -193,7 +193,7 @@ An allocation moves to `cleanup_pending` when its Session is deleted or archived ## Checkpoint lifecycle -A Provider that declares the checkpoint operations (`Initial`, `NewCompute`, `GetCompute`, `Suspend`, `Resume`, `ResumeCompute`, `KillCompute`, `DeleteSnapshot`, `RunCommandCompute`) lets Core suspend an idle allocation to a snapshot and restore it on the same or another compatible node. Core records the allocation's `compute_phase`; it is `disabled` for a Provider that does not declare checkpoint support, and the other phases move as follows. Deletion, archive, reset and retention expiry release the allocation from any phase after the cleanup described in [What Core guarantees to a Provider](#what-core-guarantees-to-a-provider). +A Provider that declares the checkpoint operations (`Initial`, `NewCompute`, `GetCompute`, `RenewCompute`, `Suspend`, `Resume`, `ResumeCompute`, `KillCompute`, `DeleteSnapshot`, `RunCommandCompute`) lets Core suspend an idle allocation to a snapshot and restore it on the same or another compatible node. Core records the allocation's `compute_phase`; it is `disabled` for a Provider that does not declare checkpoint support, and the other phases move as follows. Deletion, archive, reset and retention expiry release the allocation from any phase after the cleanup described in [What Core guarantees to a Provider](#what-core-guarantees-to-a-provider). ```mermaid stateDiagram-v2 @@ -260,6 +260,8 @@ A checkpoint-capable generation reports `CheckpointCompatibility`: opaque `Artif Recovery uses `Resume.ObserveOnly` for the persisted target and operation. `RestoreAttemptClosed` is an exact operation ID, not a generic absence flag: the adapter may return it only after durably closing an attempt that never entered native execution and fencing every late request for that ID. An adapter may also close a durably admitted attempt when it can prove that its current invocation failed before dispatching native restoration. The microsandbox adapter does this for the fresh request that created the admission, on any failure before it dispatches native restoration. An expired wire deadline or an unclassified failure closes the attempt, and Core then persists a new operation ID. A snapshot that is missing, fails identity or integrity verification, or fails the adapter's qualification for this generation, such as a resource, workspace or runtime image mismatch, is also closed and reported as `ErrCheckpointUnusable`. That outcome is durable: `Resume.ObserveOnly` for the same operation reports it again, and late requests for the operation ID stay fenced. A Provider never returns it once native restoration was dispatched. On `ErrCheckpointUnusable` Core never restores that snapshot again and [ends its retention at once](#cold-replacement-after-checkpoint-retention). Once native restoration has been dispatched, an unknown outcome retains the same target, ownership and original retention deadline; a missing native listing, helper death or timeout does not authorize replay. Such a restore can remain unavailable until explicit deletion or ordinary retention expiry, and pending inputs can reach their public deadline while waiting. User deletion still requires exact native settlement before resources and ownership can be released. +The E2B adapter checkpoints with the SDK's native pause and `connect`. One sandbox spans every generation of an allocation: `Suspend` pauses it with a memory snapshot, so the daemon survives, and `Resume` reconnects to the same sandbox. The helper's private receipt records the current generation, so a superseded generation is `ErrNotFound`, and `KillCompute` and `DeleteSnapshot` never touch a sandbox that another generation owns. `CheckpointCompatibility` names the endpoint and installation and the template build, and a snapshot that does not match is `ErrCheckpointUnusable` before any native call. E2B keeps no independent workspace filesystem, so retention expiry follows the unqualified branch below. + The microsandbox adapter requires an explicit private `checkpoint_root`, outside every guest-accessible filesystem. Its directory is mode `0700`; a private namespace marker identifies the actual checkpoint store. The default installation creates a node-local private store. Cross-node restoration requires operators to mount the same private store on each participating node; mount paths may differ. Each retained checkpoint is an uncompressed native snapshot of the guest's memory and root-disk layers, so a shared store carries that volume for every suspension and cross-node restore. Runtime state directories and workspace bindings are never used to infer this root. The adapter verifies the checkpoint, external filesystem identity and native execution class again before restoration. Restored RAM and file descriptors do not promise continuity of external TCP connections or replay safety for application side effects. ### Cold replacement after checkpoint retention diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 48e577463..f5c5e0329 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -114,7 +114,7 @@ The list carries each Harness's configuration, so the console does not read `GET | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion, and a Clean up the host dialog then gives the host's uninstall command (requiring root or sudo; for a node enrolled with another address than the deployment's, also with `--force`, which skips the installer's confirmation with Core) | | Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | -An E2B deployment has no nodes; its API key is write-only. Overview and Sandbox metrics count its running and starting sandboxes from the deployment's `resources.allocations` and `resources.pending`, while the hosted Runtime rows come from Runtime observations. The two sources refresh independently, so the console does not infer retention or cleanup from their difference. +An E2B deployment has no nodes; its API key is write-only. Overview and Sandbox metrics count its running and starting sandboxes from the deployment's `resources.running` and `resources.pending`, while the hosted Runtime rows come from Runtime observations. The two sources refresh independently, so the console does not infer retention or cleanup from their difference. ## Writes diff --git a/docs/zh/runtime-bootstrap.md b/docs/zh/runtime-bootstrap.md index dcd137d1b..a5cc5f88e 100644 --- a/docs/zh/runtime-bootstrap.md +++ b/docs/zh/runtime-bootstrap.md @@ -1,7 +1,7 @@ --- title: "Runtime 引导" source: docs/runtime-bootstrap.md -source_hash: 5a6b2fff6d4f54505022e783ebb4a4c20cc8602935842053416870786ee7bc74 +source_hash: 68bb957984c615d36b941efae570b366b6245f08fe4c76561f8aaa92966e1a4b --- Runtime 引导协议连接沙箱提供商与其启动的托管 Runtime。提供商向 Runtime 交付一个引导文件,该文件是此次启动中 Runtime 唯一的认证输入,也是其 Environment 绑定的唯一来源。 @@ -55,11 +55,16 @@ oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json | `environment_id` | Runtime 所服务 Environment 的规范非零 UUID | | `session_id` | Runtime 保存其原生历史的 Session 的规范非零 UUID | | `workspace` | 工作区的干净绝对目录;打包的 Runtime 布局中为 `runtimebootstrap.RuntimeWorkspace`。Runtime 将其作为 `runtimebootstrap.PublicWorkspace`(`/workspace`)提供,这是应用与 Core 使用的路径 | +| `suspend_control` | 必填布尔值。当且仅当提供商声明[检查点支持](./sandbox-provider.md)时,Core 将其置为 true,提供商原样转交 | 解码器拒绝未知、重复、缺失和大小写别名字段,拒绝其他版本及超过 `runtimebootstrap.MaxBytes`的文档。错误不包含提交的值。文件缺失或格式错误时,daemon 在连接前失败。 该文件是此次启动唯一的认证输入:daemon 拒绝将其与自托管注册选项组合使用,并将凭据读入内存而不保存到存储的 profile。凭据不进入命令参数、环境变量或回执。提供商为进程重启保留该文件,仅在明确清理自己拥有的资源时删除。 +## 挂起控制 {#suspend-control} + +`suspend_control` 为 true 时,daemon 在 `runtimebootstrap.SuspendControlFile` 发布其挂起控制并为挂起而停放。Core 的唤醒命令 `oac-daemon resume --control-file` 读取该文件。声明检查点支持的提供商在启动 daemon 前准备该文件所在目录:仅 Runtime 账户可访问(权限 0700,属主 UID 1000)。`suspend_control` 为 true 而 `oac-daemon connect` 无法发布该控制时,连接失败;没有任何其他设置可以启用该控制。 + ## 超时与限制 {#timeouts-and-limits} | 数值 | 适用对象 | 超出时 | diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index abcb0c8cb..aa459bb25 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: 199dc6de344085e9b8c1048e7e8808aa40353878865061236e1a919ad689e28b +source_hash: 0ffc892ad458e104e5e9eba3427429b3f82e7376bfdf3eeac0c9653cee434db1 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。Docker、microsandbox 和 E2B 实现了该协议。 @@ -54,7 +54,7 @@ source_hash: 199dc6de344085e9b8c1048e7e8808aa40353878865061236e1a919ad689e28b Docker 等没有原生可续期租约的 backend 仍遵守 Core 的 hosted expiry 和 cleanup 要求。每个 adapter 都实现 `RunCommand`,测试也执行它,但 Core 编排不调用它;只有 node transport 转发。机密命令输入通过 `Command.Stdin` 传输,不放在参数或日志中;结果保留字节顺序、有界输出和实际退出状态。 -`Bootstrap.Workspace` 和 `ResumeRequest.Workspace` 仅当提供商的 `DeploymentPolicy.Workspace` 已声明时才携带外部工作区绑定;提供商对此的义务见[挂载](workspace-provider.md#attachment)。 +`Bootstrap.Workspace` 和 `ResumeRequest.Workspace` 仅当提供商的 `DeploymentPolicy.Workspace` 已声明时才携带外部工作区绑定;提供商对此的义务见[挂载](workspace-provider.md#attachment)。Core 仅在提供商声明支持 checkpoint 时设置 `Bootstrap.SuspendControl`,提供商将其原样放入 [Runtime bootstrap](runtime-bootstrap.md)。 ### 明确的操作契约 {#explicit-operation-contracts} @@ -64,10 +64,10 @@ Docker 等没有原生可续期租约的 backend 仍遵守 Core 的 hosted expir | --- | --- | --- | | `Create`、`GetInfo`、`Renew`、`Kill`、`RunCommand` | 支持 | Allocation 生命周期与有界命令 | | `Observe` | 明确决定 | 检查所有权、只读地观测一个 allocation | -| `Initial`、`NewCompute`、`GetCompute`、`Suspend`、`Resume`、`ResumeCompute`、`KillCompute`、`DeleteSnapshot`、`RunCommandCompute` | 所有 checkpoint 方法决定一致;仅 `nodes` 注册可以支持 | 精确计算实例、捕获与恢复、保留源恢复和清理 | +| `Initial`、`NewCompute`、`GetCompute`、`RenewCompute`、`Suspend`、`Resume`、`ResumeCompute`、`KillCompute`、`DeleteSnapshot`、`RunCommandCompute` | 所有 checkpoint 方法决定一致 | 精确计算实例、捕获与恢复、保留源恢复和清理 | | `RestorePreparation`、`SavePreparation` | 两者决定一致 | 复用已完成的 Environment 准备结果 | -`Initial` 和 `NewCompute` 构造 compute reference,不分配资源;`ResumeCompute` 在暂停中止后仅解冻同一驻留实例;`RunCommandCompute` 在一个精确 compute incarnation 中运行有界命令。Core 使用 `RunCommandCompute` 在恢复后唤醒 parked daemon([`runtime_compute_wake.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute_wake.go))。 +`Initial` 和 `NewCompute` 构造 compute reference,不分配资源;`ResumeCompute` 在暂停中止后仅解冻同一驻留实例;`RenewCompute` 在提供商有原生租约时延长精确运行实例的租约,否则与 `GetCompute` 完全一样只做观察;`RunCommandCompute` 在一个精确 compute incarnation 中运行有界命令。Core 使用 `RunCommandCompute` 在恢复后唤醒 parked daemon([`runtime_compute_wake.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute_wake.go))。 `SavePreparation(reference, key)` 在准备完整完成后保存该分配初始 compute 中准备写入 `/environment` 的内容(workspace、packages 和 initialization,从不包括 Runtime 状态),`RestorePreparation(reference, key)` 把已保存的内容放回,没有有效条目时返回 `false`。`key` 是不透明的:Core 由 Project 和所有进入准备的内容推导它,从不使用 Environment、Session、Harness 或 Agent,提供商也从不解释它。存储、按 Runtime 镜像划分范围、过期和淘汰由提供商负责。条目在保存后 24 小时内有效,恢复不得使用已过期的条目。Core 在 Runtime 连接后调用 `RestorePreparation`,命中时只发送 `finalize` 操作即完成初始化。未命中时完整准备并调用 `SavePreparation`;保存错误只记录日志,不会使 Session 失败。两者都未声明的提供商始终完整准备。microsandbox 适配器在 Runtime home 下的私有目录中为每个 key 保存一个归档。 @@ -111,7 +111,7 @@ Microsandbox 在返回创建不存在回执前永久关闭初始 Create 准入 `ErrInvalid`、`ErrOwnership`、`ErrExists`、`ErrNotFound`、`ErrComputeUnconfirmed`、`ErrCommandUnconfirmed`,以及仅用于 `Resume` 的 `ErrCheckpointUnusable` 保持其定义含义。未分类原生或 transport error 表示未知,不授权重试 mutation。Core 不将提供商 diagnostics 读作生命周期事实,也不暴露原生错误文本或凭据;node transport 将错误映射为固定 code,直接 SDK 细节保持私有。 -Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity`;原样持久化 operation ID 与提供商 snapshot provenance。suspend 或 resume 的 `ObserveOnly` 仅观察上次尝试,不启动另一 capture 或 restore。`ResumeCompute` 仅解冻保留源,不冷启动已停止源。清理针对精确 compute incarnation 和 snapshot,不针对当前同名实例。只有所属节点已验证精确 incarnation 不再存在时,`GetCompute` 才返回 `ErrNotFound`;同名但 native ID 不同的实例为 `ErrOwnership`,节点或提供商不可达属于未知结果。`ComputeState.Status` 为 `stopped` 或 `crashed` 表示 adapter 已验证该精确 incarnation 的进程不再存在;其记录或磁盘可保留到 `KillCompute` 将其移除。`paused`、没有 Runtime 连接的 `running` 和未知状态均不证明进程已终止。声明 checkpoint 支持前阅读 [`runtime_compute.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute.go) 及其失败测试。 +Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity`;原样持久化 operation ID 与提供商 snapshot provenance。suspend 或 resume 的 `ObserveOnly` 仅观察上次尝试,不启动另一 capture 或 restore。`ResumeCompute` 仅解冻保留源,不冷启动已停止源。`RenewCompute` 从不复活已停止的 compute;运行中 allocation 的 Runtime 保持连接时,Core 调用它而不是 `GetCompute`,因为 `Renew` 只寻址初始 incarnation。清理针对精确 compute incarnation 和 snapshot,不针对当前同名实例。只有所属节点已验证精确 incarnation 不再存在时,`GetCompute` 才返回 `ErrNotFound`;同名但 native ID 不同的实例为 `ErrOwnership`,节点或提供商不可达属于未知结果。`ComputeState.Status` 为 `stopped` 或 `crashed` 表示 adapter 已验证该精确 incarnation 的进程不再存在;其记录或磁盘可保留到 `KillCompute` 将其移除。`paused`、没有 Runtime 连接的 `running` 和未知状态均不证明进程已终止。声明 checkpoint 支持前阅读 [`runtime_compute.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/execution/runtime_compute.go) 及其失败测试。 ### 四个独立就绪事实 {#four-distinct-readiness-facts} @@ -146,7 +146,7 @@ node 配置 `sandbox.NodeConfig` 包含 `provider`、`generation`、`installatio - `nodes` 注册仅有 `BuildLocal`,`direct` 注册仅有 `BuildDirect`;缺失、混合或未知 mode 被拒绝。 - configuration adapter 与完整 operation 声明都是必需项,因此不完整注册不能发布部分投影。声明的默认大小必须通过 `ValidatePolicy("", Policy)`。 -- Checkpoint 仅准入 `nodes` 注册,因为公共 lifecycle 只暂停 node allocation;声明 checkpoint 的 `direct` 提供商会被注册拒绝。注册不携带 suspension 数值:Core 对每个声明 checkpoint 支持的提供商应用同一个 [suspension policy](#suspension)。 +- Checkpoint 对两种模式都准入:公共 lifecycle 对 `direct` allocation 的暂停、保留、恢复和清理与 node allocation 完全一致,只是没有 node。注册不携带 suspension 数值:Core 对每个声明 checkpoint 支持的提供商应用同一个 [suspension policy](#suspension)。 configuration adapter 必须非 nil,包括其具体值。每个 `ConfigurationRequirements` 字段都需要明确有效的决定:`Credential` 和 `PublicOrigin` 为 `Required` 或 `NotRequired`,`Discovery`、`SelectionDiscovery` 和 `CredentialVerification` 使用共享 supported 或 unsupported 声明并携带安全 reason。新增 requirement field 需要明确更新验证,不继承已有决定。要求凭据不承诺支持 `VerifyCredential` 操作。这些检查证明注册完整,不证明原生 SDK 行为正确;constructor 和 adapter 契约测试仍然适用。 @@ -195,7 +195,7 @@ stateDiagram-v2 ## 检查点生命周期 {#checkpoint-lifecycle} -声明了 checkpoint 操作(`Initial`、`NewCompute`、`GetCompute`、`Suspend`、`Resume`、`ResumeCompute`、`KillCompute`、`DeleteSnapshot`、`RunCommandCompute`)的提供商,使 Core 能把空闲 allocation 暂停为快照,并在同一或另一个兼容 node 上恢复。Core 记录 allocation 的 `compute_phase`;未声明 checkpoint 支持的提供商为 `disabled`,其余阶段按下图变化。删除、归档、重置和保留期到期会在完成[Core 对提供商的保证](#what-core-guarantees-to-a-provider)中描述的清理后,从任意阶段释放 allocation。 +声明了 checkpoint 操作(`Initial`、`NewCompute`、`GetCompute`、`RenewCompute`、`Suspend`、`Resume`、`ResumeCompute`、`KillCompute`、`DeleteSnapshot`、`RunCommandCompute`)的提供商,使 Core 能把空闲 allocation 暂停为快照,并在同一或另一个兼容 node 上恢复。Core 记录 allocation 的 `compute_phase`;未声明 checkpoint 支持的提供商为 `disabled`,其余阶段按下图变化。删除、归档、重置和保留期到期会在完成[Core 对提供商的保证](#what-core-guarantees-to-a-provider)中描述的清理后,从任意阶段释放 allocation。 ```mermaid stateDiagram-v2 @@ -262,6 +262,8 @@ Core 在初始化完成、没有 root 或 Subagent Turn 处于排队、进行中 恢复用 `Resume.ObserveOnly` 观察已持久化的 target 与 operation。`RestoreAttemptClosed` 是精确 operation ID,不是通用 absence 标志:adapter 只有在持久关闭从未进入原生执行的 attempt,并隔离该 ID 的所有迟到请求后才能返回。如果 adapter 能证明当前调用在派发原生恢复之前失败,也可以关闭已持久 admitted 的 attempt。microsandbox adapter 对创建该 admission 的 fresh 请求,在派发原生恢复之前的任何失败上执行这种关闭。wire deadline 到期或未分类失败会关闭该 attempt,Core 随后持久化新的 operation ID。快照缺失、未通过身份或完整性校验,或未通过 adapter 对当前 generation 的资格检查(例如资源、workspace 或 runtime image 不匹配)时,也会关闭该 attempt,并报告为 `ErrCheckpointUnusable`。该结果是持久的:同一 operation 的 `Resume.ObserveOnly` 会再次报告它,该 operation ID 的迟到请求仍被隔离。一旦已派发原生恢复,提供商不会返回它。收到 `ErrCheckpointUnusable` 后,Core 不再恢复该快照,并[立即结束其保留](#cold-replacement-after-checkpoint-retention)。一旦已派发原生恢复,结果未知的 attempt 保留相同 target、所有权与原始 retention deadline;原生列表缺失、helper 死亡或超时都不授权重放。这种恢复可能一直不可用,直到显式删除或正常保留期到期;等待中的 input 可能达到其公共 deadline。用户删除仍需精确原生 settlement,之后才能释放资源与所有权。 +E2B adapter 使用 SDK 的原生 pause 与 `connect` 做 checkpoint。一个 sandbox 跨越 allocation 的所有 generation:`Suspend` 以内存快照暂停它,daemon 因此存活,`Resume` 重新连接同一个 sandbox。helper 的私有 receipt 记录当前 generation,所以被取代的 generation 返回 `ErrNotFound`,`KillCompute` 和 `DeleteSnapshot` 从不触碰由其他 generation 拥有的 sandbox。`CheckpointCompatibility` 指明 endpoint、安装和模板构建,不匹配的快照在任何原生调用之前即为 `ErrCheckpointUnusable`。E2B 没有独立的 workspace 文件系统,因此保留期到期走下面的未满足条件分支。 + microsandbox adapter 要求显式的私有 `checkpoint_root`,位于所有 guest 可访问文件系统之外。目录权限为 `0700`,私有 namespace marker 标识真实检查点存储。默认安装创建 node 本地私有 store。跨 node 恢复要求运维为参与 node 挂载同一个私有 store,挂载路径可以不同。每个保留的检查点都是 guest 内存与根磁盘层的未压缩原生快照,因此共享 store 要为每次挂起和跨 node 恢复承载这部分数据量。Runtime state 目录与 workspace binding 都不用于推导该 root。adapter 在恢复前再次验证检查点、外部文件系统身份和原生 execution class。恢复 RAM 与文件描述符不保证外部 TCP 连接连续,也不保证应用副作用可安全重放。 ### 检查点保留期后的冷替换 {#cold-replacement-after-checkpoint-retention} diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 607b94c3e..442f58add 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 7b5d8c6ff67f76a8d005aa2a3e1fdfdd6ea06e7856baf20a6d26e21ea1287475 +source_hash: 4c21f70ece3e6ef7f96cf4b9fda57c14c034196f51c224fcf63174ed0bc88fc1 --- 本页列出各控制台页面调用的 Core 路由,以及控制台如何限定读取范围。本页面向控制台开发者;运维人员请阅读 [Web 控制台](index.md)。[Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API 命名空间和凭据](../api/index.md) 定义了本文使用的术语。 @@ -116,7 +116,7 @@ source_hash: 7b5d8c6ff67f76a8d005aa2a3e1fdfdd6ea06e7856baf20a6d26e21ea1287475 | 移除节点 | `DELETE /core/v1/sandbox/nodes/{node_id}` | 确认后移除节点;只有 Core 确认删除后该行才会消失,随后 **清理主机** 对话框会提供主机的卸载命令(需要 root 或 sudo;对于使用不同于部署地址的地址注册的节点,还需要使用 `--force`,以跳过安装程序与 Core 的确认) | | Runtime 观测 | `GET /core/v1/sandbox/runtime-observations` | **沙箱监控**:每个项目的托管 Runtime,每项均以所属项目为标签;E2B Sandbox 对话框还会将 `observation.disk` 显示为已用量/限制值(其他位置为 null) | -E2B 部署没有节点;其 API 密钥为只写。**概览** 和 **沙箱监控** 根据部署的 `resources.allocations` 和 `resources.pending` 统计其正在运行和正在启动的 Sandbox,而托管 Runtime 行来自 Runtime 观测。两个来源独立刷新,因此控制台不会根据两者之间的差异推断保留或清理状态。 +E2B 部署没有节点;其 API 密钥为只写。**概览** 和 **沙箱监控** 根据部署的 `resources.running` 和 `resources.pending` 统计其正在运行和正在启动的 Sandbox,而托管 Runtime 行来自 Runtime 观测。两个来源独立刷新,因此控制台不会根据两者之间的差异推断保留或清理状态。 ## 写入 {#writes} diff --git a/internal/runtimebootstrap/bootstrap.go b/internal/runtimebootstrap/bootstrap.go index 5cbb87ff9..ca3aecf14 100644 --- a/internal/runtimebootstrap/bootstrap.go +++ b/internal/runtimebootstrap/bootstrap.go @@ -15,7 +15,7 @@ import ( "github.com/google/uuid" ) -const Version = 2 +const Version = 3 const MaxBytes = 16 * 1024 // PublicWorkspace is the workspace path applications and Core use for a hosted Environment. @@ -25,20 +25,27 @@ const PublicWorkspace = "/workspace" // RuntimeWorkspace is the workspace directory of the packaged Runtime layout. const RuntimeWorkspace = "/environment/workspace" +// SuspendControlFile is where the Runtime daemon publishes its suspend control. +// Its directory is private to the Runtime user. The Runtime publishes it exactly when +// the bootstrap sets SuspendControl, and Core's wake command reads it. +const SuspendControlFile = "/run/oac/daemon-suspend.json" + var ErrInvalid = errors.New("invalid Runtime bootstrap input") // Connection is a current-version launch input, not Runtime's private auth store. // CoreURL includes the machine API base path. The provider owns delivery and // file permissions; Runtime owns validation and its authentication representation. // EnvironmentID, SessionID and Workspace bind the Runtime to its Environment. +// SuspendControl is true exactly when the Provider declares checkpoint support. type Connection struct { - Version int `json:"version"` - CoreURL string `json:"core_url"` - DeviceID string `json:"device_id"` - Credential string `json:"credential"` - EnvironmentID string `json:"environment_id"` - SessionID string `json:"session_id"` - Workspace string `json:"workspace"` + Version int `json:"version"` + CoreURL string `json:"core_url"` + DeviceID string `json:"device_id"` + Credential string `json:"credential"` + EnvironmentID string `json:"environment_id"` + SessionID string `json:"session_id"` + Workspace string `json:"workspace"` + SuspendControl bool `json:"suspend_control"` } func canonicalUUID(value string) bool { @@ -106,6 +113,14 @@ func Decode(raw []byte) (Connection, error) { err = d.Decode(&c.SessionID) case "workspace": err = d.Decode(&c.Workspace) + case "suspend_control": + var enabled *bool + if err = d.Decode(&enabled); err == nil && enabled == nil { + err = ErrInvalid + } + if enabled != nil { + c.SuspendControl = *enabled + } default: return Connection{}, ErrInvalid } @@ -116,7 +131,7 @@ func Decode(raw []byte) (Connection, error) { if token, err = d.Token(); err != nil || token != json.Delim('}') { return Connection{}, ErrInvalid } - if len(seen) != 7 || d.Decode(new(any)) != io.EOF || c.Validate() != nil { + if len(seen) != 8 || d.Decode(new(any)) != io.EOF || c.Validate() != nil { return Connection{}, ErrInvalid } return c, nil diff --git a/internal/runtimebootstrap/bootstrap_test.go b/internal/runtimebootstrap/bootstrap_test.go index 524e8973b..86fe22190 100644 --- a/internal/runtimebootstrap/bootstrap_test.go +++ b/internal/runtimebootstrap/bootstrap_test.go @@ -8,7 +8,7 @@ import ( func TestConnectionRoundTrip(t *testing.T) { input := Connection{Version: Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "test-credential", - EnvironmentID: "0d1f4f1c-6c3e-4e0b-9a52-3e3d8b0e7a11", SessionID: "6a1b7c0e-2f4d-4c55-8f6e-9d2a1b3c4d5e", Workspace: RuntimeWorkspace} + EnvironmentID: "0d1f4f1c-6c3e-4e0b-9a52-3e3d8b0e7a11", SessionID: "6a1b7c0e-2f4d-4c55-8f6e-9d2a1b3c4d5e", Workspace: RuntimeWorkspace, SuspendControl: true} raw, err := input.Marshal() if err != nil { t.Fatal(err) @@ -20,11 +20,14 @@ func TestConnectionRoundTrip(t *testing.T) { } func TestConnectionRejectsAmbiguousOrForeignInput(t *testing.T) { - good := `{"version":2,"core_url":"https://core.example/api/v1","device_id":"da912024-1543-4242-a2c1-5f4f7ebbc6c7","credential":"test-secret","environment_id":"0d1f4f1c-6c3e-4e0b-9a52-3e3d8b0e7a11","session_id":"6a1b7c0e-2f4d-4c55-8f6e-9d2a1b3c4d5e","workspace":"/environment/workspace"}` + good := `{"version":3,"core_url":"https://core.example/api/v1","device_id":"da912024-1543-4242-a2c1-5f4f7ebbc6c7","credential":"test-secret","environment_id":"0d1f4f1c-6c3e-4e0b-9a52-3e3d8b0e7a11","session_id":"6a1b7c0e-2f4d-4c55-8f6e-9d2a1b3c4d5e","workspace":"/environment/workspace","suspend_control":false}` + if _, err := Decode([]byte(good)); err != nil { + t.Fatal("valid input rejected", err) + } for name, raw := range map[string]string{ "null": "null", "array": "[]", "trailing": good + "{}", - "duplicate": strings.Replace(good, `"version":2`, `"version":2,"version":2`, 1), - "unknown": strings.Replace(good, `"version":2`, `"extra":1,"version":2`, 1), + "duplicate": strings.Replace(good, `"version":3`, `"version":3,"version":3`, 1), + "unknown": strings.Replace(good, `"version":3`, `"extra":1,"version":3`, 1), "case": strings.Replace(good, "credential", "Credential", 1), "old auth": `{"server_url":"https://core.example","runtime_id":"old","runner_credential":"test-secret"}`, "oversized": strings.Repeat(" ", MaxBytes) + good, @@ -36,13 +39,14 @@ func TestConnectionRejectsAmbiguousOrForeignInput(t *testing.T) { }) } for field, values := range map[string][]any{ - "version": {nil, 0, 1, "2"}, - "core_url": {"http://user:pass@core.example/api/v1", "https://core.example/api/v1?", "https://core.example/api/v1#", "https://core.example", "https://core.example/api/v1/", "https://core.example/api/v1?q=1", ""}, - "device_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, - "credential": {nil, "", "test\nsecret", "test\x00secret"}, - "environment_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, - "session_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, - "workspace": {"", "workspace", "/", "/environment/../workspace", "/environment/workspace/"}, + "version": {nil, 0, 2, "3"}, + "suspend_control": {nil, 1, "true"}, + "core_url": {"http://user:pass@core.example/api/v1", "https://core.example/api/v1?", "https://core.example/api/v1#", "https://core.example", "https://core.example/api/v1/", "https://core.example/api/v1?q=1", ""}, + "device_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, + "credential": {nil, "", "test\nsecret", "test\x00secret"}, + "environment_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, + "session_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, + "workspace": {"", "workspace", "/", "/environment/../workspace", "/environment/workspace/"}, } { for _, value := range values { var input map[string]any diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index b8eeb6163..074141c2f 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -209,8 +209,9 @@ export type DeploymentMode = (typeof deploymentModeValues)[number]; export interface DeploymentResources { allocations: number; pending: number; + running: number; } -export const deploymentResourcesFields = ["allocations", "pending"] as const; +export const deploymentResourcesFields = ["allocations", "pending", "running"] as const; export interface DeploymentSpec { resources: SandboxResources; workspace?: Declaration; diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 126533cf9..b944851d4 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -44,11 +44,11 @@ const allocation = { state: "running", compute_phase: "running", compute_phase_changed_at: null, diagnostic: "", initialization: "ready", created_at: created, }; const unconfigured = { - credential_configured: false, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, installation_id: "", provider: "", core_url: "https://core.example", reset: null, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; + credential_configured: false, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, installation_id: "", provider: "", core_url: "https://core.example", reset: null, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, running: 0, pending: 0 }, suspension: null }; const docker = { configuration: {}, metadata: {}, ...unconfigured, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 1, preparing: 0, failed: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", - resources: { allocations: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, specification_digest: "0".repeat(64), + resources: { allocations: 2, running: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, specification_digest: "0".repeat(64), }; const microsandbox = { ...docker, provider: "microsandbox", specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192 } }, @@ -254,7 +254,7 @@ describe("hosted provider configuration", () => { const e2b = { api_key: "test-only-secret", template: "runtime:00000000-0000-0000-0000-000000000001" }; it("writes E2B configuration and generation without beta headers or browser credentials", async () => { const deployment = { - ...e2bDeployment, generation: 2, resources: { allocations: 0, pending: 0 }, + ...e2bDeployment, generation: 2, resources: { allocations: 0, running: 0, pending: 0 }, configuration: { template: e2b.template, api_url: "https://api.e2b.app", domain: "e2b.app" } , credential_configured: true, metadata: { template_build: { status: "ready", resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 24063 } } }, }; const fetch = vi.fn().mockImplementation(async () => response(deployment)); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 2af93b40f..578760864 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -160,7 +160,7 @@ function projectDeployment(value: unknown): SandboxDeployment { const suspension = deployment.suspension === null ? null : members(deployment.suspension, suspensionFields); const configured = hasOwn(deployment, "specification"); valid(strings(deployment, ["installation_id", "core_url"]) && (deployment.provider === "" || (typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider))) && isOneOf(deploymentModeValues, deployment.mode) && - [deployment.owner_epoch, deployment.generation, resources.allocations, resources.pending].every(isNonnegativeInteger) && + [deployment.owner_epoch, deployment.generation, resources.allocations, resources.running, resources.pending].every(isNonnegativeInteger) && (suspension === null || [suspension.idle_seconds, suspension.retention_seconds].every(isNonnegativeInteger)) && configured === hasOwn(deployment, "specification_digest") && (!configured || (typeof deployment.specification_digest === "string" && deployment.specification_digest !== ""))); return { diff --git a/services/core/cmd/server/managed_generation_operations.go b/services/core/cmd/server/managed_generation_operations.go index bacf142d9..085942b2a 100644 --- a/services/core/cmd/server/managed_generation_operations.go +++ b/services/core/cmd/server/managed_generation_operations.go @@ -105,6 +105,17 @@ func (p *generationRouter) ResumeCompute(ctx context.Context, r sandbox.Referenc defer done() return v.ResumeCompute(ctx, r, c) } +func (p *generationRouter) RenewCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { + if err := providercontract.Require(p, "RenewCompute"); err != nil { + return sandbox.ComputeState{}, err + } + v, done, err := p.route(ctx, r) + if err != nil { + return sandbox.ComputeState{}, err + } + defer done() + return v.RenewCompute(ctx, r, c) +} func (p *generationRouter) RestorePreparation(ctx context.Context, r sandbox.Reference, key string) (bool, error) { if err := providercontract.Require(p, "RestorePreparation"); err != nil { return false, err diff --git a/services/core/deploy/e2b/README.md b/services/core/deploy/e2b/README.md index 93a7d6c55..bb244e9f7 100644 --- a/services/core/deploy/e2b/README.md +++ b/services/core/deploy/e2b/README.md @@ -21,7 +21,7 @@ python -m venv "$HOME/.oac/build/e2b-sdk" --output "$HOME/.oac/build/e2b-template.json" ``` -[`build-template.py`](build-template.py) requires a `sha256:` image ID, a Linux amd64 image and the Runtime layout. It copies the image's `/usr/local/bin`, `/opt` and, when present, `/usr/local/codex-resources` and `/etc/codex`, together with its `HOME` and `OAC_*` environment, onto a digest-pinned `node:22.23.1-bookworm-slim` base with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip`, `ripgrep` and `util-linux`. It installs [`init.py`](init.py) and [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, runs the daemon as UID/GID 1000 (`runtime`, home `/home/runtime`) and builds with 2 vCPUs and 2048 MiB. Only the `usr`, `usr/local` and `etc` archive ancestors it creates get traversable modes; Runtime file modes, private build contexts, key inputs and the output's umask stay unchanged. +[`build-template.py`](build-template.py) requires a `sha256:` image ID, a Linux amd64 image and the Runtime layout. It copies the image's `/usr/local/bin`, `/opt` and, when present, `/usr/local/codex-resources` and `/etc/codex`, together with its `HOME` and `OAC_*` environment, onto a digest-pinned `node:22.23.1-bookworm-slim` base with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip`, `ripgrep` and `util-linux`. Rebuild a template whenever the Runtime bootstrap version changes: Create refuses a template whose contract names another version. It installs [`init.py`](init.py) and [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, runs the daemon as UID/GID 1000 (`runtime`, home `/home/runtime`) and builds with 2 vCPUs and 2048 MiB. Only the `usr`, `usr/local` and `etc` archive ancestors it creates get traversable modes; Runtime file modes, private build contexts, key inputs and the output's umask stay unchanged. The output file records `template` (the immutable `templateID:build_UUID`), the source `image`, the packaged `runtime_sha256` and the `base`. Use that exact `template` value. The template must qualify every Harness its image advertises. Install system dependencies into the image; the daemon runs as UID/GID 1000. No E2B account key, executor key or model credential belongs in a build, template environment, metadata, command argument or log. diff --git a/services/core/deploy/e2b/helper_contract_generated.py b/services/core/deploy/e2b/helper_contract_generated.py index ee81a1327..bee1ee648 100644 --- a/services/core/deploy/e2b/helper_contract_generated.py +++ b/services/core/deploy/e2b/helper_contract_generated.py @@ -1,6 +1,6 @@ # Code generated by contractgen; DO NOT EDIT. """Adapter-private wire declarations. No SDK or repository dependency.""" -ERROR_CODES = ["","invalid","ownership","exists","not_found","command_unconfirmed","unconfirmed","template_invalid","team_mismatch","unauthorized"] +ERROR_CODES = ["","invalid","ownership","exists","not_found","command_unconfirmed","unconfirmed","template_invalid","team_mismatch","unauthorized","checkpoint_unusable"] MANAGED_BOOTSTRAP_FIELDS = ["Workspace","TenantID","EnvironmentID","AllocationID","SessionID","DeviceID","InstallationID","RuntimeBootstrap"] MANAGED_BOOTSTRAP_REQUIRED_FIELDS = ["TenantID","EnvironmentID","AllocationID","SessionID","DeviceID","InstallationID","RuntimeBootstrap"] MANAGED_IDENTITY_FIELDS = ["TenantID","EnvironmentID","AllocationID","SessionID","DeviceID","InstallationID"] @@ -9,9 +9,11 @@ MAX_OUTPUT = 1048576 MAX_REQUEST = 75497472 MAX_RESPONSE = 16777216 -OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential"] +OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential","compute","renew_compute","suspend","resume","kill_compute","delete_snapshot","command_compute","resume_compute"] PROTOCOL_VERSION = 1 REFERENCE_FIELDS = ["TenantID","EnvironmentID","AllocationID"] -REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Deadline"] -RESPONSE_FIELDS = ["Version","Info","Command","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"] +REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Compute","Suspend","Resume","Snapshot","Deadline"] +RESPONSE_FIELDS = ["Version","Info","Command","State","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"] +RUNTIME_BOOTSTRAP_VERSION = 3 SDK_VERSION = "2.51.0" +SUSPEND_CONTROL_FILE = "/run/oac/daemon-suspend.json" diff --git a/services/core/deploy/e2b/managed_init.py b/services/core/deploy/e2b/managed_init.py index 3cd2e11cc..a4e6619bf 100644 --- a/services/core/deploy/e2b/managed_init.py +++ b/services/core/deploy/e2b/managed_init.py @@ -44,6 +44,11 @@ def initialize(): binding = identity(payload) shared.write_private(root / 'managed-launch.json', binding) environment = shared.prepare_runtime() + # The daemon publishes its suspend control here; Core's wake command reads it. + control = Path(contract.SUSPEND_CONTROL_FILE) + control.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chown(control.parent, 1000, 1000) + control.parent.chmod(0o700) connection = Path(environment['OAC_RUNTIME_HOME']).parent / 'runtime-bootstrap.json' shared.write_private(connection, payload['RuntimeBootstrap'], owner=1000) source.unlink() diff --git a/services/core/deploy/e2b/managed_init_test.py b/services/core/deploy/e2b/managed_init_test.py index ae6118a2c..fdfc446a9 100644 --- a/services/core/deploy/e2b/managed_init_test.py +++ b/services/core/deploy/e2b/managed_init_test.py @@ -16,9 +16,9 @@ def payload(): value = {key: str(uuid4()) for key in ['InstallationID', 'TenantID', 'EnvironmentID', 'AllocationID', 'SessionID', 'DeviceID']} - return dict(value, RuntimeBootstrap={'version': 2, 'core_url': 'https://core.example/api/v1', + return dict(value, RuntimeBootstrap={'version': 3, 'core_url': 'https://core.example/api/v1', 'device_id': value['DeviceID'], 'credential': 'private-managed-token', - 'environment_id': value['EnvironmentID'], 'session_id': value['SessionID'], 'workspace': '/environment/workspace'}) + 'environment_id': value['EnvironmentID'], 'session_id': value['SessionID'], 'workspace': '/environment/workspace', 'suspend_control': True}) class ManagedStartupTest(unittest.TestCase): @@ -63,7 +63,9 @@ def exercise(self, failed=False): image_env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'OAC_RUNTIME_HOME': str(Path(temporary) / '.oac')} with patch.object(managed_init.shared, 'ROOT', root), patch.object(managed_init.shared, 'PROFILE', profile), \ patch.object(managed_init.shared, 'prepare_runtime', return_value=image_env), \ - patch.object(managed_init.os, 'fchown'), patch.object(managed_init.subprocess, 'Popen', process): + patch.object(managed_init.os, 'fchown'), patch.object(managed_init.os, 'chown'), \ + patch.object(managed_init.contract, 'SUSPEND_CONTROL_FILE', str(Path(temporary) / 'run' / 'daemon-suspend.json')), \ + patch.object(managed_init.subprocess, 'Popen', process): if failed: with self.assertRaises(RuntimeError): managed_init.initialize() @@ -80,6 +82,7 @@ def exercise(self, failed=False): self.assertNotIn(data['RuntimeBootstrap']['credential'], json.dumps(process.call_args.kwargs['env'])) self.assertEqual(json.loads(connection_file.read_text())['environment_id'], data['EnvironmentID']) self.assertEqual(process.call_args.kwargs['user'], 1000) + self.assertTrue((Path(temporary) / 'run').is_dir()) if failed: self.assertFalse((root / 'managed-ready.json').exists()) else: diff --git a/services/core/internal/db/queries/sandbox_reset.sql b/services/core/internal/db/queries/sandbox_reset.sql index 335321ac4..cbadae68d 100644 --- a/services/core/internal/db/queries/sandbox_reset.sql +++ b/services/core/internal/db/queries/sandbox_reset.sql @@ -100,6 +100,7 @@ held AS ( SELECT sqlc.embed(d), (SELECT count(*) FROM classified WHERE NOT pending)::bigint AS allocations, (SELECT count(*) FROM classified WHERE pending)::bigint AS pending, + (SELECT count(*) FROM runtime_allocations a WHERE a.state = 'running' AND a.compute_phase IN ('running', 'disabled'))::bigint AS running, jsonb_build_object( 'busy', (SELECT count(*) FROM classified WHERE category = 'busy'), 'idle', (SELECT count(*) FROM classified WHERE category = 'idle'), diff --git a/services/core/internal/db/sqlc/sandbox_reset.sql.go b/services/core/internal/db/sqlc/sandbox_reset.sql.go index 302fe1620..4412e8214 100644 --- a/services/core/internal/db/sqlc/sandbox_reset.sql.go +++ b/services/core/internal/db/sqlc/sandbox_reset.sql.go @@ -93,6 +93,7 @@ held AS ( SELECT d.singleton, d.installation_id, d.backend_fingerprint, d.updated_at, d.provider_kind, d.owner_epoch, d.generation, d.mode, d.provider_credential, d.specification, d.reset_clear, d.reset_requested_at, d.reset_deadline_at, d.reset_forced_at, d.reset_audit, d.provider_config, d.provider_metadata, d.idle_seconds, (SELECT count(*) FROM classified WHERE NOT pending)::bigint AS allocations, (SELECT count(*) FROM classified WHERE pending)::bigint AS pending, + (SELECT count(*) FROM runtime_allocations a WHERE a.state = 'running' AND a.compute_phase IN ('running', 'disabled'))::bigint AS running, jsonb_build_object( 'busy', (SELECT count(*) FROM classified WHERE category = 'busy'), 'idle', (SELECT count(*) FROM classified WHERE category = 'idle'), @@ -117,6 +118,7 @@ type GetSandboxDeploymentSnapshotRow struct { RuntimeDeployment RuntimeDeployment `json:"runtime_deployment"` Allocations int64 `json:"allocations"` Pending int64 `json:"pending"` + Running int64 `json:"running"` Remaining []byte `json:"remaining"` Rollout []byte `json:"rollout"` } @@ -145,6 +147,7 @@ func (q *Queries) GetSandboxDeploymentSnapshot(ctx context.Context) (GetSandboxD &i.RuntimeDeployment.IdleSeconds, &i.Allocations, &i.Pending, + &i.Running, &i.Remaining, &i.Rollout, ) diff --git a/services/core/internal/deployment/nodes.go b/services/core/internal/deployment/nodes.go index 1ad434a86..2b0ab60cd 100644 --- a/services/core/internal/deployment/nodes.go +++ b/services/core/internal/deployment/nodes.go @@ -13,7 +13,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) @@ -605,7 +604,7 @@ func (s *Service) recordGenerations(tx NodeTx, d Record, n StoredNode, statuses if err != nil { return err } - checkpointSupported := adapter.Operations()["Initial"].State == providercontract.Supported + checkpointSupported := sandbox.SupportsCheckpoint(adapter.Operations()) seen := map[uint64]bool{} for _, status := range statuses { if status.Checkpoint != nil && (status.State != "ready" || !checkpointSupported || status.Checkpoint.Validate() != nil) || status.State == "ready" && checkpointSupported && status.Checkpoint == nil { diff --git a/services/core/internal/deployment/view.go b/services/core/internal/deployment/view.go index 88cf94ef7..253fa0da0 100644 --- a/services/core/internal/deployment/view.go +++ b/services/core/internal/deployment/view.go @@ -44,9 +44,11 @@ type View struct { CoreURL string `json:"core_url" binding:"required"` } -// Resources counts what still belongs to the deployment. +// Resources counts what still belongs to the deployment. Running excludes +// suspended allocations, which Allocations includes. type Resources struct { Allocations int64 `json:"allocations" binding:"required"` + Running int64 `json:"running" binding:"required"` Pending int64 `json:"pending" binding:"required"` } diff --git a/services/core/internal/execution/provider_operations_fixture_test.go b/services/core/internal/execution/provider_operations_fixture_test.go index 58b17c3a8..44acb244f 100644 --- a/services/core/internal/execution/provider_operations_fixture_test.go +++ b/services/core/internal/execution/provider_operations_fixture_test.go @@ -24,6 +24,7 @@ func (*lifecycleOnlySandbox) ProviderOperations() providercontract.Operations { "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RenewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RestorePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "SavePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, @@ -56,6 +57,9 @@ func (*lifecycleOnlySandbox) RunCommandCompute(context.Context, sandbox.Referenc func (*lifecycleOnlySandbox) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} } +func (*lifecycleOnlySandbox) RenewCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "RenewCompute", Reason: "fixture_operation_not_supported"} +} func (*lifecycleOnlySandbox) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} } diff --git a/services/core/internal/execution/runtime_compute.go b/services/core/internal/execution/runtime_compute.go index f3bc4f863..4c309e6df 100644 --- a/services/core/internal/execution/runtime_compute.go +++ b/services/core/internal/execution/runtime_compute.go @@ -60,8 +60,8 @@ func logComputeStep(ctx context.Context, owner deployment.Allocation, step strin func (r *runtimeLifecycle) enableCompute(ctx context.Context, owner deployment.Allocation) (err error) { defer func() { err = withObservationOwner(owner, err) }() p := r.config.Provider - if err := providercontract.Require(p, "Initial"); err != nil { - return err + if !sandbox.SupportsCheckpoint(p.ProviderOperations()) { + return providercontract.Require(p, "Initial") } initial, err := p.Initial(ctx, runtimeReference(owner)) if err != nil { @@ -81,8 +81,8 @@ func (r *runtimeLifecycle) enableCompute(ctx context.Context, owner deployment.A func (r *runtimeLifecycle) observeCompute(ctx context.Context, owner deployment.Allocation) (err error) { defer func() { err = withObservationOwner(owner, err) }() p := r.config.Provider - if err := providercontract.Require(p, "Initial"); err != nil { - return err + if !sandbox.SupportsCheckpoint(p.ProviderOperations()) { + return providercontract.Require(p, "Initial") } var state runtimeCompute if json.Unmarshal(owner.ComputeState, &state) != nil || state.Current.ID == "" { @@ -121,7 +121,14 @@ func (r *runtimeLifecycle) observeCompute(ctx context.Context, owner deployment. func (r *runtimeLifecycle) idleCompute(ctx context.Context, p sandbox.SandboxProvider, owner deployment.Allocation, state runtimeCompute) (err error) { defer func() { err = withObservationOwner(owner, err) }() - compute, err := p.GetCompute(ctx, runtimeReference(owner), state.Current) + // With an open Runtime connection the observation also extends the native + // lease, as the disabled path does; otherwise it only observes. + observe := p.GetCompute + if r.runtimeConnected(owner.DeviceID) { + observe = p.RenewCompute + } + peer, peerErr := authorizedRuntimePeer(ctx, r.sessions, r.registry, owner.DeviceID) + compute, err := observe(ctx, runtimeReference(owner), state.Current) if errors.Is(err, sandbox.ErrNotFound) { return r.releaseLostCompute(ctx, p, owner, state, err) } @@ -137,9 +144,8 @@ func (r *runtimeLifecycle) idleCompute(ctx context.Context, p sandbox.SandboxPro if compute.Status != "running" || !compute.BootstrapComplete { return sandbox.ErrComputeUnconfirmed } - peer, err := authorizedRuntimePeer(ctx, r.sessions, r.registry, owner.DeviceID) - if err != nil { - return err + if peerErr != nil { + return peerErr } if _, err := r.deployment.CheckRunning(ctx, owner); err != nil { return err diff --git a/services/core/internal/execution/runtime_compute_wake.go b/services/core/internal/execution/runtime_compute_wake.go index a3578a837..125d5ede8 100644 --- a/services/core/internal/execution/runtime_compute_wake.go +++ b/services/core/internal/execution/runtime_compute_wake.go @@ -6,6 +6,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" @@ -31,7 +32,7 @@ func (r *runtimeLifecycle) wakeCompute(ctx context.Context, p sandbox.SandboxPro // This idempotent control signal is fenced by guest PID/start time and the // suspension token. It cannot execute or replay an agent request. started := time.Now() - result, err := p.RunCommandCompute(ctx, runtimeReference(owner), state.Current, sandbox.Command{Args: []string{"oac-daemon", "resume", "--control-file", "/run/oac/daemon-suspend.json", "--environment-id", owner.EnvironmentID, "--suspend-id", state.SuspendID}}) + result, err := p.RunCommandCompute(ctx, runtimeReference(owner), state.Current, sandbox.Command{Args: []string{"oac-daemon", "resume", "--control-file", runtimebootstrap.SuspendControlFile, "--environment-id", owner.EnvironmentID, "--suspend-id", state.SuspendID}}) logComputeStep(ctx, owner, "wake_command", started, err) if err != nil { return err diff --git a/services/core/internal/execution/runtime_lifecycle.go b/services/core/internal/execution/runtime_lifecycle.go index ff8de1e0e..2265f6484 100644 --- a/services/core/internal/execution/runtime_lifecycle.go +++ b/services/core/internal/execution/runtime_lifecycle.go @@ -109,12 +109,9 @@ func validatedRuntimeProvider(config *RuntimeProvider, registry *runtimegateway. if copied.ProviderKind == "" || (copied.Mode != string(sandbox.DeploymentNodes) && copied.Mode != string(sandbox.DeploymentDirect)) { return RuntimeProvider{}, sandbox.ErrInvalid } - if copied.Mode == string(sandbox.DeploymentDirect) && copied.Suspension != nil { - return RuntimeProvider{}, sandbox.ErrInvalid - } if config.Suspension != nil { policy := *config.Suspension - if !sandbox.SupportsCheckpoint(config.Provider) || policy.Retention < time.Second { + if !sandbox.SupportsCheckpoint(config.Provider.ProviderOperations()) || policy.Retention < time.Second { return RuntimeProvider{}, sandbox.ErrInvalid } copied.Suspension = &policy @@ -280,6 +277,7 @@ func (r *runtimeLifecycle) provision(ctx context.Context, tenant, environment, p info, err := provider.Create(ctx, sandbox.Bootstrap{ Reference: runtimeReference(owner), SessionID: owner.SessionID, DeviceID: owner.DeviceID, Workspace: workspace, CoreURL: r.config.CoreURL, Credential: token, + SuspendControl: sandbox.SupportsCheckpoint(provider.ProviderOperations()), }) logComputeStep(ctx, owner, "create", started, err) if info.Reference == runtimeReference(owner) && info.CreateSettled && info.State == "absent" { @@ -469,7 +467,7 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat if r.config.Suspension != nil && environment.Initialization == "complete" { return r.enableCompute(ctx, owner) } - if peer, err := r.registry.LookupDevice(owner.DeviceID); err != nil || peer.IsClosed() { + if !r.runtimeConnected(owner.DeviceID) { return nil } renewed, err := provider.Renew(ctx, runtimeReference(owner)) @@ -483,6 +481,13 @@ func (r *runtimeLifecycle) observe(ctx context.Context, owner deployment.Allocat return err } +// runtimeConnected is the renewal condition: running compute keeps its native +// lease while the allocation's Runtime holds an open connection. +func (r *runtimeLifecycle) runtimeConnected(device string) bool { + peer, err := r.registry.LookupDevice(device) + return err == nil && !peer.IsClosed() +} + // Environment identity owns connectivity; preparation has an independent owner. func (r *runtimeLifecycle) clearRuntimeState(owner deployment.Allocation) { delete(r.connections, owner.EnvironmentID) diff --git a/services/core/internal/execution/runtime_lost_compute_test.go b/services/core/internal/execution/runtime_lost_compute_test.go index 365c47e27..d30cf62e1 100644 --- a/services/core/internal/execution/runtime_lost_compute_test.go +++ b/services/core/internal/execution/runtime_lost_compute_test.go @@ -33,6 +33,9 @@ type lostComputeProvider struct { func (p *lostComputeProvider) GetCompute(_ context.Context, _ sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { return p.observe(c) } +func (p *lostComputeProvider) RenewCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { + return p.GetCompute(ctx, r, c) +} func (p *lostComputeProvider) KillCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) error { p.killed = append(p.killed, c) if p.onKill != nil { diff --git a/services/core/internal/execution/runtime_pending_test.go b/services/core/internal/execution/runtime_pending_test.go index 575d32667..0d8063e2c 100644 --- a/services/core/internal/execution/runtime_pending_test.go +++ b/services/core/internal/execution/runtime_pending_test.go @@ -3,6 +3,7 @@ package execution import ( "context" "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "net/http" "net/http/httptest" "net/url" @@ -381,3 +382,19 @@ func TestAwaitingConnectionPassSharesCancellationFence(t *testing.T) { t.Fatal("connection pass escaped cancellation") } } + +// noCheckpoint declares a fixture Provider that supports no checkpoint. +func noCheckpoint() providercontract.Operations { + return providercontract.Operations{"Initial": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}} +} + +func (*connectedBootstrapProvider) ProviderOperations() providercontract.Operations { + return noCheckpoint() +} +func (*settledWorkspaceRefusal) ProviderOperations() providercontract.Operations { + return noCheckpoint() +} +func (*unknownWorkspaceCreation) ProviderOperations() providercontract.Operations { + return noCheckpoint() +} +func (*preparationProvider) ProviderOperations() providercontract.Operations { return noCheckpoint() } diff --git a/services/core/internal/persistence/postgres/deploymentpg/records.go b/services/core/internal/persistence/postgres/deploymentpg/records.go index a17f2a5a9..ea89846a9 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/records.go +++ b/services/core/internal/persistence/postgres/deploymentpg/records.go @@ -75,7 +75,7 @@ func record(d sqlc.RuntimeDeployment, cipher *credentialcrypto.Cipher, open bool } func snapshot(row sqlc.GetSandboxDeploymentSnapshotRow) (deployment.Snapshot, error) { - result := deployment.Snapshot{Record: record(row.RuntimeDeployment, nil, false), Resources: deployment.Resources{Allocations: row.Allocations, Pending: row.Pending}} + result := deployment.Snapshot{Record: record(row.RuntimeDeployment, nil, false), Resources: deployment.Resources{Allocations: row.Allocations, Running: row.Running, Pending: row.Pending}} if err := json.Unmarshal(row.Rollout, &result.Rollout); err != nil { return deployment.Snapshot{}, err } diff --git a/services/core/internal/sandbox/docker/operations.go b/services/core/internal/sandbox/docker/operations.go index 0e2ac9c0d..784193167 100644 --- a/services/core/internal/sandbox/docker/operations.go +++ b/services/core/internal/sandbox/docker/operations.go @@ -23,6 +23,7 @@ func Operations() providercontract.Operations { "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, "RunCommandCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, "ResumeCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, + "RenewCompute": {State: providercontract.Unsupported, Reason: "docker_does_not_support_checkpoints"}, "RestorePreparation": {State: providercontract.Unsupported, Reason: "docker_does_not_support_preparation_cache"}, "SavePreparation": {State: providercontract.Unsupported, Reason: "docker_does_not_support_preparation_cache"}, "Observe": {State: providercontract.Supported}, @@ -56,6 +57,9 @@ func (p *Provider) RunCommandCompute(context.Context, sandbox.Reference, sandbox func (p *Provider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: Operations()["ResumeCompute"].Reason} } +func (p *Provider) RenewCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "RenewCompute", Reason: Operations()["RenewCompute"].Reason} +} func (p *Provider) RestorePreparation(context.Context, sandbox.Reference, string) (bool, error) { return false, &providercontract.UnsupportedError{Operation: "RestorePreparation", Reason: Operations()["RestorePreparation"].Reason} } diff --git a/services/core/internal/sandbox/e2b/checkpoint.go b/services/core/internal/sandbox/e2b/checkpoint.go new file mode 100644 index 000000000..4c5242cfc --- /dev/null +++ b/services/core/internal/sandbox/e2b/checkpoint.go @@ -0,0 +1,180 @@ +package e2b + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +// One E2B sandbox spans every generation of an allocation: pause keeps the +// sandbox and resume reconnects to it. The helper's private receipt records the +// current generation, so only that generation addresses the sandbox. + +func digest(parts ...string) string { + sum := sha256.Sum256([]byte(strings.Join(parts, "\n"))) + return hex.EncodeToString(sum[:]) +} + +// compatibility names the endpoint and installation that own a paused sandbox +// and the template build it runs; a snapshot restores only under the same two. +func (c Config) compatibility() sandbox.CheckpointCompatibility { + apiURL, domain, _ := NormalizeEndpoint(c.APIURL, c.Domain) + return sandbox.CheckpointCompatibility{ArtifactDomain: digest("e2b-endpoint", c.InstallationID, apiURL, domain), ExecutionClass: digest("e2b-template", c.Template)} +} + +func (p *Provider) name(r sandbox.Reference, generation uint64) string { + sum := sha256.Sum256([]byte(p.config.InstallationID + ":" + r.TenantID + ":" + r.EnvironmentID + ":" + r.AllocationID)) + return fmt.Sprintf("oac-%x-g%d", sum[:16], generation) +} + +func (p *Provider) validSnapshot(r sandbox.Reference, s sandbox.SnapshotIdentity) bool { + return s.Compatibility.Validate() == nil && validID(s.OperationID) && s.SourceName == p.name(r, s.SourceGeneration) && + s.Reference == s.SourceName+":s-"+s.OperationID && s.SourceID != "" && s.ID != "" +} + +func (p *Provider) validCompute(r sandbox.Reference, c sandbox.Compute) bool { + if !validReference(r) || c.Name != p.name(r, c.Generation) { + return false + } + if c.Generation == 0 { + return c.RestoredFrom == nil + } + return c.RestoredFrom != nil && p.validSnapshot(r, *c.RestoredFrom) && c.RestoredFrom.SourceGeneration < c.Generation +} + +func (p *Provider) Initial(ctx context.Context, r sandbox.Reference) (sandbox.Compute, error) { + if err := ctx.Err(); err != nil { + return sandbox.Compute{}, err + } + if !validReference(r) { + return sandbox.Compute{}, sandbox.ErrInvalid + } + return sandbox.Compute{Name: p.name(r, 0)}, nil +} + +func (p *Provider) NewCompute(ctx context.Context, r sandbox.Reference, generation uint64, snapshot *sandbox.SnapshotIdentity) (sandbox.Compute, error) { + if err := ctx.Err(); err != nil { + return sandbox.Compute{}, err + } + c := sandbox.Compute{Generation: generation, Name: p.name(r, generation)} + if snapshot != nil { + value := *snapshot + c.RestoredFrom = &value + } + if !p.validCompute(r, c) { + return sandbox.Compute{}, sandbox.ErrInvalid + } + return c, nil +} + +// state sends one checkpoint request and validates the returned incarnation +// against the one the request named. +func (p *Provider) state(ctx context.Context, q Request, want sandbox.Compute) (sandbox.ComputeState, error) { + deadline, ok := ctx.Deadline() + if !ok || !p.validCompute(q.Reference, want) { + return sandbox.ComputeState{}, sandbox.ErrInvalid + } + if err := ctx.Err(); err != nil { + return sandbox.ComputeState{}, err + } + q.Version, q.Config, q.Deadline = ProtocolVersion, p.config, deadline + out, err := p.exchange(ctx, q) + if err != nil { + return sandbox.ComputeState{}, err + } + s := out.State + if s == nil { + return sandbox.ComputeState{}, sandbox.ErrComputeUnconfirmed + } + if s.RestoreAttemptClosed != "" { + if q.Resume == nil || !s.ClosesRestoreAttempt(*q.Resume) { + return sandbox.ComputeState{}, sandbox.ErrComputeUnconfirmed + } + return *s, nil + } + got := s.Compute + if got.ID == "" || got.Name != want.Name || got.Generation != want.Generation || (want.ID != "" && got.ID != want.ID) || + (got.RestoredFrom == nil) != (want.RestoredFrom == nil) || want.RestoredFrom != nil && *got.RestoredFrom != *want.RestoredFrom { + return sandbox.ComputeState{}, sandbox.ErrOwnership + } + if q.Suspend != nil { + snapshot := s.Snapshot + if snapshot == nil { + if !q.Suspend.ObserveOnly || s.SourceStopped || !s.BootstrapComplete || s.Status != "running" && s.Status != "paused" { + return sandbox.ComputeState{}, sandbox.ErrComputeUnconfirmed + } + } else if !p.validSnapshot(q.Reference, *snapshot) || snapshot.Compatibility != p.config.Compatibility || snapshot.OperationID != q.Suspend.OperationID || + snapshot.SourceID != want.ID && want.ID != "" || snapshot.SourceID != got.ID || !q.Suspend.ObserveOnly && (!s.SourceStopped || s.Status != "paused") { + return sandbox.ComputeState{}, sandbox.ErrComputeUnconfirmed + } + } + return *s, nil +} + +func (p *Provider) GetCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { + return p.state(ctx, Request{Operation: "compute", Reference: r, Compute: &c}, c) +} + +func (p *Provider) RenewCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { + return p.state(ctx, Request{Operation: "renew_compute", Reference: r, Compute: &c}, c) +} + +func (p *Provider) ResumeCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { + return p.state(ctx, Request{Operation: "resume_compute", Reference: r, Compute: &c}, c) +} + +func (p *Provider) Suspend(ctx context.Context, q sandbox.SuspendRequest) (sandbox.ComputeState, error) { + if !validID(q.OperationID) || q.Snapshot != nil && !p.validSnapshot(q.Reference, *q.Snapshot) { + return sandbox.ComputeState{}, sandbox.ErrInvalid + } + return p.state(ctx, Request{Operation: "suspend", Reference: q.Reference, Suspend: &q}, q.Source) +} + +// Resume reconnects to the paused sandbox. A snapshot from another endpoint or +// template build is unusable before any native call. +func (p *Provider) Resume(ctx context.Context, q sandbox.ResumeRequest) (sandbox.ComputeState, error) { + if q.Workspace != nil { + return sandbox.ComputeState{}, sandbox.ErrInvalid + } + if !validID(q.OperationID) || !p.validSnapshot(q.Reference, q.Snapshot) || q.Target.RestoredFrom == nil || *q.Target.RestoredFrom != q.Snapshot { + return sandbox.ComputeState{}, sandbox.ErrInvalid + } + if q.Snapshot.Compatibility != p.config.Compatibility { + return sandbox.ComputeState{}, sandbox.ErrCheckpointUnusable + } + return p.state(ctx, Request{Operation: "resume", Reference: q.Reference, Resume: &q}, q.Target) +} + +func (p *Provider) KillCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) error { + deadline, ok := ctx.Deadline() + if !ok || !p.validCompute(r, c) { + return sandbox.ErrInvalid + } + _, err := p.exchange(ctx, Request{Version: ProtocolVersion, Operation: "kill_compute", Config: p.config, Reference: r, Compute: &c, Deadline: deadline}) + return err +} + +func (p *Provider) DeleteSnapshot(ctx context.Context, r sandbox.Reference, s sandbox.SnapshotIdentity) error { + deadline, ok := ctx.Deadline() + if !ok || !validReference(r) || !p.validSnapshot(r, s) { + return sandbox.ErrInvalid + } + _, err := p.exchange(ctx, Request{Version: ProtocolVersion, Operation: "delete_snapshot", Config: p.config, Reference: r, Snapshot: &s, Deadline: deadline}) + return err +} + +func (p *Provider) RunCommandCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute, command sandbox.Command) (sandbox.CommandResult, error) { + if err := validCommand(command); err != nil { + return sandbox.CommandResult{}, err + } + deadline, ok := ctx.Deadline() + if !ok || !p.validCompute(r, c) { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + out, err := p.exchange(ctx, Request{Version: ProtocolVersion, Operation: "command_compute", Config: p.config, Reference: r, Compute: &c, Command: &command, Deadline: deadline}) + return commandResult(out, err) +} diff --git a/services/core/internal/sandbox/e2b/helper_contract.go b/services/core/internal/sandbox/e2b/helper_contract.go index f4cf30ffb..7beb4c8c0 100644 --- a/services/core/internal/sandbox/e2b/helper_contract.go +++ b/services/core/internal/sandbox/e2b/helper_contract.go @@ -20,12 +20,12 @@ const MaxCommandInputBytes = sandbox.MaxCommandInputBytes // HelperOperations declares the complete set of one-shot helper operations. func HelperOperations() []string { - return []string{"create", "inspect", "renew", "kill", "command", "validate_deployment", "observe", "list_templates", "list_builds", "verify_credential"} + return []string{"create", "inspect", "renew", "kill", "command", "validate_deployment", "observe", "list_templates", "list_builds", "verify_credential", "compute", "renew_compute", "suspend", "resume", "kill_compute", "delete_snapshot", "command_compute", "resume_compute"} } // HelperErrors are sanitized wire outcomes; an empty code denotes success. func HelperErrors() []string { - return []string{"", "invalid", "ownership", "exists", "not_found", "command_unconfirmed", "unconfirmed", "template_invalid", "team_mismatch", "unauthorized"} + return []string{"", "invalid", "ownership", "exists", "not_found", "command_unconfirmed", "unconfirmed", "template_invalid", "team_mismatch", "unauthorized", "checkpoint_unusable"} } // Validate checks the operation envelope before the helper can start. Native @@ -55,6 +55,11 @@ func (q Request) Validate() error { if !validReference(q.Reference) { return sandbox.ErrInvalid } + present := map[string]bool{"compute": q.Compute != nil, "renew_compute": q.Compute != nil, "kill_compute": q.Compute != nil, "resume_compute": q.Compute != nil, + "command_compute": q.Compute != nil && q.Command != nil, "suspend": q.Suspend != nil, "resume": q.Resume != nil, "delete_snapshot": q.Snapshot != nil} + if want, checkpoint := present[q.Operation]; checkpoint && !want { + return sandbox.ErrInvalid + } } return nil } @@ -69,12 +74,17 @@ type Request struct { Bootstrap *sandbox.Bootstrap `json:",omitempty"` RuntimeBootstrap *runtimebootstrap.Connection `json:",omitempty"` Command *sandbox.Command `json:",omitempty"` + Compute *sandbox.Compute `json:",omitempty"` + Suspend *sandbox.SuspendRequest `json:",omitempty"` + Resume *sandbox.ResumeRequest `json:",omitempty"` + Snapshot *sandbox.SnapshotIdentity `json:",omitempty"` Deadline time.Time } type Response struct { Version int Info *sandbox.Info `json:",omitempty"` Command *sandbox.CommandResult `json:",omitempty"` + State *sandbox.ComputeState `json:",omitempty"` ErrorCode string DeploymentValid bool `json:",omitempty"` TemplateBuild *TemplateBuild `json:",omitempty"` diff --git a/services/core/internal/sandbox/e2b/helper_contract_test.go b/services/core/internal/sandbox/e2b/helper_contract_test.go index a40130f89..76c2dcd18 100644 --- a/services/core/internal/sandbox/e2b/helper_contract_test.go +++ b/services/core/internal/sandbox/e2b/helper_contract_test.go @@ -66,6 +66,7 @@ func validManagedExchange(data []byte) bool { _ = json.Unmarshal(bootstrapBytes, &expected) delete(expected, "CoreURL") delete(expected, "Credential") + delete(expected, "SuspendControl") expected["InstallationID"] = nil expected["RuntimeBootstrap"] = nil if workspace, present := fields["Workspace"]; present { diff --git a/services/core/internal/sandbox/e2b/internal/contractgen/main.go b/services/core/internal/sandbox/e2b/internal/contractgen/main.go index 79d48cdd2..aaa39d639 100644 --- a/services/core/internal/sandbox/e2b/internal/contractgen/main.go +++ b/services/core/internal/sandbox/e2b/internal/contractgen/main.go @@ -67,9 +67,9 @@ func main() { write("services/core/internal/sandbox/e2b/helper_sdk_generated.go", goCode) bootstrap := fields(reflect.TypeFor[sandbox.Bootstrap]()) requiredBootstrap := fieldNames(reflect.TypeFor[sandbox.Bootstrap](), true) - requiredBootstrap = slices.DeleteFunc(requiredBootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" }) + requiredBootstrap = slices.DeleteFunc(requiredBootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" || s == "SuspendControl" }) requiredBootstrap = append(requiredBootstrap, "InstallationID", "RuntimeBootstrap") - bootstrap = slices.DeleteFunc(bootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" }) + bootstrap = slices.DeleteFunc(bootstrap, func(s string) bool { return s == "CoreURL" || s == "Credential" || s == "SuspendControl" }) bootstrap = append(bootstrap, "InstallationID", "RuntimeBootstrap") identity := fields(reflect.TypeFor[sandbox.Reference]()) for _, name := range []string{"SessionID", "DeviceID"} { @@ -81,7 +81,7 @@ func main() { identity = append(identity, "InstallationID") values := map[string]any{ "PROTOCOL_VERSION": e2b.ProtocolVersion, "SDK_VERSION": sdk, - "MAX_REQUEST": e2b.MaxRequestBytes, "MAX_RESPONSE": e2b.MaxResponseBytes, + "SUSPEND_CONTROL_FILE": runtimebootstrap.SuspendControlFile, "RUNTIME_BOOTSTRAP_VERSION": runtimebootstrap.Version, "MAX_REQUEST": e2b.MaxRequestBytes, "MAX_RESPONSE": e2b.MaxResponseBytes, "MAX_OUTPUT": e2b.MaxOutputBytes, "MAX_COMMAND_INPUT": e2b.MaxCommandInputBytes, "MAX_CREDENTIAL_REFERENCES": e2b.MaxCredentialReferences, "OPERATIONS": e2b.HelperOperations(), "ERROR_CODES": e2b.HelperErrors(), "REQUEST_FIELDS": fields(reflect.TypeFor[e2b.Request]()), "RESPONSE_FIELDS": fields(reflect.TypeFor[e2b.Response]()), @@ -131,6 +131,21 @@ func fixtures() []byte { if operation == "verify_credential" { copy.References = []sandbox.Reference{r} } + compute := sandbox.Compute{Name: "fixture", ID: "fixture-id"} + snapshot := sandbox.SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "a", ExecutionClass: "e"}, Reference: "fixture:s-op", ID: "fixture-id", OperationID: "op", SourceName: "fixture", SourceID: "fixture-id"} + switch operation { + case "compute", "renew_compute", "resume_compute", "kill_compute": + copy.Compute = &compute + case "command_compute": + copy.Compute, copy.Command = &compute, &sandbox.Command{Args: []string{"true"}} + case "suspend": + copy.Suspend = &sandbox.SuspendRequest{Reference: r, OperationID: "op", Source: compute} + case "resume": + target := sandbox.Compute{Generation: 1, Name: "fixture-g1", RestoredFrom: &snapshot} + copy.Resume = &sandbox.ResumeRequest{Reference: r, OperationID: "op", Snapshot: snapshot, Target: target} + case "delete_snapshot": + copy.Snapshot = &snapshot + } add("request", operation, true, copy) } for _, item := range []struct { @@ -182,6 +197,7 @@ func fixtures() []byte { managed := object(b) delete(managed, "CoreURL") delete(managed, "Credential") + delete(managed, "SuspendControl") managed["InstallationID"] = installation managed["RuntimeBootstrap"] = runtimebootstrap.Connection(connection) add("managed", "valid", true, object(managed)) diff --git a/services/core/internal/sandbox/e2b/operations.go b/services/core/internal/sandbox/e2b/operations.go index 1a6feee22..0fd31f1df 100644 --- a/services/core/internal/sandbox/e2b/operations.go +++ b/services/core/internal/sandbox/e2b/operations.go @@ -14,48 +14,22 @@ func Operations() providercontract.Operations { "Renew": {State: providercontract.Supported}, "Kill": {State: providercontract.Supported}, "RunCommand": {State: providercontract.Supported}, - "Initial": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "NewCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "GetCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "Suspend": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "Resume": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "KillCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "RunCommandCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, - "ResumeCompute": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_checkpoints"}, + "Initial": {State: providercontract.Supported}, + "NewCompute": {State: providercontract.Supported}, + "GetCompute": {State: providercontract.Supported}, + "Suspend": {State: providercontract.Supported}, + "Resume": {State: providercontract.Supported}, + "KillCompute": {State: providercontract.Supported}, + "DeleteSnapshot": {State: providercontract.Supported}, + "RunCommandCompute": {State: providercontract.Supported}, + "RenewCompute": {State: providercontract.Supported}, + "ResumeCompute": {State: providercontract.Supported}, "RestorePreparation": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_preparation_cache"}, "SavePreparation": {State: providercontract.Unsupported, Reason: "e2b_does_not_support_preparation_cache"}, "Observe": {State: providercontract.Supported}, } } func (*Provider) ProviderOperations() providercontract.Operations { return Operations() } -func (p *Provider) Initial(context.Context, sandbox.Reference) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "Initial", Reason: Operations()["Initial"].Reason} -} -func (p *Provider) NewCompute(context.Context, sandbox.Reference, uint64, *sandbox.SnapshotIdentity) (sandbox.Compute, error) { - return sandbox.Compute{}, &providercontract.UnsupportedError{Operation: "NewCompute", Reason: Operations()["NewCompute"].Reason} -} -func (p *Provider) GetCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "GetCompute", Reason: Operations()["GetCompute"].Reason} -} -func (p *Provider) Suspend(context.Context, sandbox.SuspendRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Suspend", Reason: Operations()["Suspend"].Reason} -} -func (p *Provider) Resume(context.Context, sandbox.ResumeRequest) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "Resume", Reason: Operations()["Resume"].Reason} -} -func (p *Provider) KillCompute(context.Context, sandbox.Reference, sandbox.Compute) error { - return &providercontract.UnsupportedError{Operation: "KillCompute", Reason: Operations()["KillCompute"].Reason} -} -func (p *Provider) DeleteSnapshot(context.Context, sandbox.Reference, sandbox.SnapshotIdentity) error { - return &providercontract.UnsupportedError{Operation: "DeleteSnapshot", Reason: Operations()["DeleteSnapshot"].Reason} -} -func (p *Provider) RunCommandCompute(context.Context, sandbox.Reference, sandbox.Compute, sandbox.Command) (sandbox.CommandResult, error) { - return sandbox.CommandResult{}, &providercontract.UnsupportedError{Operation: "RunCommandCompute", Reason: Operations()["RunCommandCompute"].Reason} -} -func (p *Provider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { - return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: Operations()["ResumeCompute"].Reason} -} func (p *Provider) RestorePreparation(context.Context, sandbox.Reference, string) (bool, error) { return false, &providercontract.UnsupportedError{Operation: "RestorePreparation", Reason: Operations()["RestorePreparation"].Reason} } diff --git a/services/core/internal/sandbox/e2b/provider.go b/services/core/internal/sandbox/e2b/provider.go index e420a406c..ef4e2dc50 100644 --- a/services/core/internal/sandbox/e2b/provider.go +++ b/services/core/internal/sandbox/e2b/provider.go @@ -22,6 +22,8 @@ type Config struct { Binary, StateDir, InstallationID, APIKey, Template, APIURL, Domain string TimeoutSeconds int Resources *sandbox.Resources + // Compatibility is derived from the other fields by NewWithCaller. + Compatibility sandbox.CheckpointCompatibility } // Discover uses the same pinned SDK helper without requiring a saved deployment. @@ -149,6 +151,7 @@ func NewWithCaller(c Config, caller Caller) (*Provider, error) { resources := *c.Resources c.Resources = &resources } + c.Compatibility = c.compatibility() return &Provider{config: c, caller: caller, now: time.Now}, nil } @@ -194,12 +197,18 @@ func (p *Provider) call(ctx context.Context, operation string, r sandbox.Referen } connection = &value } - out, err := p.caller.Call(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: p.config, Reference: r, Bootstrap: b, RuntimeBootstrap: connection, Command: command, Deadline: deadline}) + return p.exchange(ctx, Request{Version: ProtocolVersion, Operation: operation, Config: p.config, Reference: r, Bootstrap: b, RuntimeBootstrap: connection, Command: command, Deadline: deadline}) +} + +// exchange sends one validated helper request and maps its sanitized outcome. +func (p *Provider) exchange(ctx context.Context, q Request) (Response, error) { + operation, r := q.Operation, q.Reference + out, err := p.caller.Call(ctx, q) if errors.Is(err, errHelperNotStarted) { return unstarted(operation, r), sandbox.ErrComputeUnconfirmed } if err != nil || out.Version != ProtocolVersion { - if operation == "command" { + if operation == "command" || operation == "command_compute" { return Response{}, sandbox.ErrCommandUnconfirmed } return Response{}, sandbox.ErrComputeUnconfirmed @@ -211,7 +220,7 @@ func (p *Provider) call(ctx context.Context, operation string, r sandbox.Referen case "": return out, nil case "template_invalid": - return out, fmt.Errorf("%w: This E2B template lacks the current Runtime startup entry point. Build a template with this release's build-template.py and select it in the sandbox deployment.", sandbox.ErrInvalid) + return out, fmt.Errorf("%w: This E2B template lacks the current Runtime startup entry point or was built for another Runtime bootstrap version. Build a template with this release's build-template.py and select it in the sandbox deployment.", sandbox.ErrInvalid) case "team_mismatch": return out, sandbox.ErrCredentialOwnership case "unauthorized": @@ -224,6 +233,8 @@ func (p *Provider) call(ctx context.Context, operation string, r sandbox.Referen return out, sandbox.ErrExists case "not_found": return out, sandbox.ErrNotFound + case "checkpoint_unusable": + return out, sandbox.ErrCheckpointUnusable case "command_unconfirmed": return out, sandbox.ErrCommandUnconfirmed default: @@ -286,16 +297,18 @@ func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { } return err } -func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { +func validCommand(c sandbox.Command) error { if len(c.Args) == 0 || len(c.Stdin) > sandbox.MaxCommandInputBytes || c.Directory != "" && !filepath.IsAbs(c.Directory) { - return sandbox.CommandResult{}, sandbox.ErrInvalid + return sandbox.ErrInvalid } for _, arg := range c.Args { if strings.ContainsRune(arg, 0) { - return sandbox.CommandResult{}, sandbox.ErrInvalid + return sandbox.ErrInvalid } } - out, err := p.call(ctx, "command", r, nil, &c) + return nil +} +func commandResult(out Response, err error) (sandbox.CommandResult, error) { if err != nil { return sandbox.CommandResult{}, err } @@ -304,6 +317,12 @@ func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbo } return *out.Command, nil } +func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + if err := validCommand(c); err != nil { + return sandbox.CommandResult{}, err + } + return commandResult(p.call(ctx, "command", r, nil, &c)) +} // A fresh allocation Create rejected before process startup has no cloud effects. // The common Provider contract forbids replaying an earlier unknown Create. diff --git a/services/core/internal/sandbox/microsandbox/operations.go b/services/core/internal/sandbox/microsandbox/operations.go index 14f788411..acb175140 100644 --- a/services/core/internal/sandbox/microsandbox/operations.go +++ b/services/core/internal/sandbox/microsandbox/operations.go @@ -19,6 +19,7 @@ func Operations() providercontract.Operations { "DeleteSnapshot": {State: providercontract.Supported}, "RunCommandCompute": {State: providercontract.Supported}, "ResumeCompute": {State: providercontract.Supported}, + "RenewCompute": {State: providercontract.Supported}, "RestorePreparation": {State: providercontract.Supported}, "SavePreparation": {State: providercontract.Supported}, "Observe": {State: providercontract.Supported}, diff --git a/services/core/internal/sandbox/microsandbox/provider.go b/services/core/internal/sandbox/microsandbox/provider.go index 37637de66..0053b5295 100644 --- a/services/core/internal/sandbox/microsandbox/provider.go +++ b/services/core/internal/sandbox/microsandbox/provider.go @@ -261,6 +261,11 @@ func (p *Provider) ResumeCompute(ctx context.Context, r sandbox.Reference, c Com return p.state(ctx, Request{Operation: "resume_compute", Reference: r, Compute: c}) } +// RenewCompute observes: a microsandbox VM has no native lease. +func (p *Provider) RenewCompute(ctx context.Context, r sandbox.Reference, c Compute) (State, error) { + return p.GetCompute(ctx, r, c) +} + func (p *Provider) NewCompute(ctx context.Context, r sandbox.Reference, generation uint64, snapshot *SnapshotIdentity) (Compute, error) { if err := ctx.Err(); err != nil { return Compute{}, err diff --git a/services/core/internal/sandbox/node/agent.go b/services/core/internal/sandbox/node/agent.go index 6508929cd..4b3118e51 100644 --- a/services/core/internal/sandbox/node/agent.go +++ b/services/core/internal/sandbox/node/agent.go @@ -402,7 +402,7 @@ func (a *agent) executeWork(ctx context.Context, task work) { func logOperation(ctx context.Context, task work, started, finished time.Time, code string, err error) { level := slog.LevelInfo switch task.request.Operation { - case "info", "renew", "initial", "new_compute", "compute", "observe": + case "info", "renew", "initial", "new_compute", "compute", "renew_compute", "observe": if code == "" { level = slog.LevelDebug } diff --git a/services/core/internal/sandbox/node/generations.go b/services/core/internal/sandbox/node/generations.go index 89e3add8d..132720dad 100644 --- a/services/core/internal/sandbox/node/generations.go +++ b/services/core/internal/sandbox/node/generations.go @@ -354,7 +354,7 @@ func (m *GenerationManager) probeLoop() { } ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second) checkpoint, err := g.value.Probe(ctx) - if err == nil && ((sandbox.SupportsCheckpoint(g.value.Provider) && (checkpoint == nil || checkpoint.Validate() != nil)) || (!sandbox.SupportsCheckpoint(g.value.Provider) && checkpoint != nil)) { + if err == nil && ((sandbox.SupportsCheckpoint(g.value.Provider.ProviderOperations()) && (checkpoint == nil || checkpoint.Validate() != nil)) || (!sandbox.SupportsCheckpoint(g.value.Provider.ProviderOperations()) && checkpoint != nil)) { err = sandbox.ErrInvalid } cancel() diff --git a/services/core/internal/sandbox/node/node_test.go b/services/core/internal/sandbox/node/node_test.go index 113447028..5c360e01d 100644 --- a/services/core/internal/sandbox/node/node_test.go +++ b/services/core/internal/sandbox/node/node_test.go @@ -169,7 +169,7 @@ func TestLostCreateResponseDoesNotReplayAndReconnectSerializesCleanup(t *testing func TestOfflineIsUnknownAndDockerDoesNotAdvertiseCheckpoint(t *testing.T) { h := newTestHub(HubOptions{OwnerEpoch: func(context.Context) (uint64, error) { return 1, nil }}) p := h.Proxy(uuid.NewString(), docker.Operations(), 1) - if sandbox.SupportsCheckpoint(p) { + if sandbox.SupportsCheckpoint(p.ProviderOperations()) { t.Fatal("docker advertised checkpoint") } _, err := p.GetInfo(context.Background(), reference()) diff --git a/services/core/internal/sandbox/node/operations.go b/services/core/internal/sandbox/node/operations.go index 55e9ba504..e62b13f56 100644 --- a/services/core/internal/sandbox/node/operations.go +++ b/services/core/internal/sandbox/node/operations.go @@ -16,6 +16,7 @@ var operationMethods = map[string]string{ "delete_snapshot": "DeleteSnapshot", "command_compute": "RunCommandCompute", "resume_compute": "ResumeCompute", + "renew_compute": "RenewCompute", "restore_preparation": "RestorePreparation", "save_preparation": "SavePreparation", "observe": "Observe", diff --git a/services/core/internal/sandbox/node/provider_operations_fixture_test.go b/services/core/internal/sandbox/node/provider_operations_fixture_test.go index 07bfec1e1..10fe1f6d0 100644 --- a/services/core/internal/sandbox/node/provider_operations_fixture_test.go +++ b/services/core/internal/sandbox/node/provider_operations_fixture_test.go @@ -24,6 +24,7 @@ func (*fakeProvider) ProviderOperations() providercontract.Operations { "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RenewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RestorePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "SavePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, @@ -56,6 +57,9 @@ func (*fakeProvider) RunCommandCompute(context.Context, sandbox.Reference, sandb func (*fakeProvider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} } +func (*fakeProvider) RenewCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "RenewCompute", Reason: "fixture_operation_not_supported"} +} func (*fakeProvider) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} } @@ -75,6 +79,7 @@ func (*observationProvider) ProviderOperations() providercontract.Operations { "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RenewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RestorePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "SavePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "Observe": {State: providercontract.Supported}, diff --git a/services/core/internal/sandbox/node/proxy.go b/services/core/internal/sandbox/node/proxy.go index 4a8b5b34c..07d8b5618 100644 --- a/services/core/internal/sandbox/node/proxy.go +++ b/services/core/internal/sandbox/node/proxy.go @@ -166,6 +166,10 @@ func (p *provider) ResumeCompute(ctx context.Context, r sandbox.Reference, c san return p.state(ctx, request{Operation: "resume_compute", Reference: r, Compute: &c}) } +func (p *provider) RenewCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { + return p.state(ctx, request{Operation: "renew_compute", Reference: r, Compute: &c}) +} + // GenerationProvider routes every operation with allocation-owned generation, // distinct from the request's compute generation. declared is the kind's // registered operations. diff --git a/services/core/internal/sandbox/node/wire.go b/services/core/internal/sandbox/node/wire.go index c12b3cda0..b024a229c 100644 --- a/services/core/internal/sandbox/node/wire.go +++ b/services/core/internal/sandbox/node/wire.go @@ -19,7 +19,7 @@ import ( "github.com/gorilla/websocket" ) -const ProtocolVersion = 7 +const ProtocolVersion = 8 const MaxControlFrameBytes = 32 * 1024 const MaxFrameBytes = 72 * 1024 * 1024 const maxPending = 32 @@ -250,7 +250,7 @@ func (q request) validate() error { if count == 0 || count == 1 && q.Snapshot != nil { return nil } - case "compute", "kill_compute", "resume_compute": + case "compute", "kill_compute", "resume_compute", "renew_compute": if count == 1 && q.Compute != nil { return nil } @@ -345,6 +345,9 @@ func execute(ctx context.Context, p sandbox.SandboxProvider, q request) (respons case "resume_compute": state, err = p.ResumeCompute(ctx, q.Reference, *q.Compute) out.State = &state + case "renew_compute": + state, err = p.RenewCompute(ctx, q.Reference, *q.Compute) + out.State = &state case "command_compute": command, err = p.RunCommandCompute(ctx, q.Reference, *q.Compute, *q.Command) out.Command = &command diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 017965442..9b6363d81 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -50,11 +50,6 @@ func ValidateRegistration(a Adapter) error { if err := sandbox.ValidateOperations(operations); err != nil { return err } - // The common lifecycle suspends only node allocations, so only a nodes - // registration may declare checkpoint support. - if operations["Initial"].State == providercontract.Supported && a.Mode != sandbox.DeploymentNodes { - return invalid("checkpoint support outside nodes mode") - } if a.Policy.DefaultResources != nil && a.Policy.DefaultResources.ValidatePolicy("", a.Policy) != nil { return invalid("default resources") } diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 363720fcd..4d7c10cf2 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -162,13 +162,13 @@ func TestRegistrationRejectsInvalidDefaultResources(t *testing.T) { } } -// Only a nodes registration may declare checkpoint support. -func TestRegistrationCheckpointRequiresNodes(t *testing.T) { +// Checkpoint support follows the declaration in both modes. +func TestRegistrationAdmitsDirectCheckpoint(t *testing.T) { registry := Builtin() a := registry.adapters["microsandbox"] a.Mode, a.BuildLocal, a.NodeArtifacts, a.BuildDirect = "direct", nil, nil, registry.adapters["e2b"].BuildDirect - if err := ValidateRegistration(a); !errors.Is(err, providercontract.ErrContract) || !strings.Contains(err.Error(), "checkpoint") { - t.Fatal("direct checkpoint registration accepted", err) + if err := ValidateRegistration(a); err != nil { + t.Fatal("direct checkpoint registration rejected", err) } } diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index f90ce974c..d797896ed 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -90,7 +90,7 @@ func (r *Registry) SupportsCheckpoint(kind string) (bool, error) { if err != nil { return false, err } - return a.Operations()["Initial"].State == providercontract.Supported, nil + return sandbox.SupportsCheckpoint(a.Operations()), nil } // RetainedLimit keeps nodes without checkpoint support within their active capacity. @@ -99,7 +99,7 @@ func (r *Registry) RetainedLimit(kind string, active, retained int) (int, error) if err != nil { return 0, err } - if a.Mode == sandbox.DeploymentNodes && a.Operations()["Initial"].State != providercontract.Supported { + if a.Mode == sandbox.DeploymentNodes && !sandbox.SupportsCheckpoint(a.Operations()) { return active, nil } return retained, nil diff --git a/services/core/internal/sandbox/providers/registry_test.go b/services/core/internal/sandbox/providers/registry_test.go index 47a0629bb..10f5be9ba 100644 --- a/services/core/internal/sandbox/providers/registry_test.go +++ b/services/core/internal/sandbox/providers/registry_test.go @@ -19,7 +19,7 @@ func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { }{ {"docker", "nodes", "nodes", false}, {"microsandbox", "nodes", "nodes", true}, - {"e2b", "direct", "e2b", false}, + {"e2b", "direct", "e2b", true}, } { t.Run(tc.kind, func(t *testing.T) { d, err := registry.Describe(tc.kind, installation) diff --git a/services/core/internal/sandbox/runtime_bootstrap.go b/services/core/internal/sandbox/runtime_bootstrap.go index a47981658..ca8e3e34a 100644 --- a/services/core/internal/sandbox/runtime_bootstrap.go +++ b/services/core/internal/sandbox/runtime_bootstrap.go @@ -9,5 +9,6 @@ func (b Bootstrap) RuntimeConnection() runtimebootstrap.Connection { Version: runtimebootstrap.Version, CoreURL: b.CoreURL, DeviceID: b.DeviceID, Credential: b.Credential, EnvironmentID: b.EnvironmentID, SessionID: b.SessionID, Workspace: runtimebootstrap.RuntimeWorkspace, + SuspendControl: b.SuspendControl, } } diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 2e0a563a4..622b8bd69 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -62,6 +62,8 @@ type Bootstrap struct { Workspace *workspacefs.Binding `json:",omitempty"` Reference SessionID, DeviceID, CoreURL, Credential string + // SuspendControl is true exactly when the Provider declares checkpoint support. + SuspendControl bool } // Info describes compute only. Running does not establish daemon authentication, @@ -125,6 +127,10 @@ type SandboxProvider interface { 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) + // RenewCompute extends the native lease of the exact running incarnation + // where the Provider has one, and otherwise observes exactly like + // GetCompute. It never revives stopped compute. + RenewCompute(context.Context, Reference, Compute) (ComputeState, error) // The preparation cache keeps the Environment filesystem left by one finished // preparation. Key is opaque and the adapter owns storage and lifetime. Both @@ -143,7 +149,7 @@ var requiredOperations = []string{"Create", "GetInfo", "Renew", "Kill", "RunComm // checkpointOperations are all supported or all unsupported: partial cleanup or // restore support cannot safely own a compute incarnation. -var checkpointOperations = []string{"Initial", "NewCompute", "GetCompute", "Suspend", "Resume", "KillCompute", "DeleteSnapshot", "RunCommandCompute", "ResumeCompute"} +var checkpointOperations = []string{"Initial", "NewCompute", "GetCompute", "Suspend", "Resume", "KillCompute", "DeleteSnapshot", "RunCommandCompute", "ResumeCompute", "RenewCompute"} // preparationOperations are both supported or both unsupported. var preparationOperations = []string{"RestorePreparation", "SavePreparation"} @@ -268,7 +274,7 @@ func ValidateOperations(operations providercontract.Operations) error { } } for _, name := range checkpointOperations { - if operations[name].State != operations["Initial"].State { + if (operations[name].State == providercontract.Supported) != SupportsCheckpoint(operations) { return fmt.Errorf("%w: incomplete checkpoint lifecycle", providercontract.ErrContract) } } @@ -278,8 +284,10 @@ func ValidateOperations(operations providercontract.Operations) error { return nil } -func SupportsCheckpoint(p SandboxProvider) bool { - return providercontract.Require(p, "Initial") == nil +// SupportsCheckpoint is the one predicate over a declaration: the checkpoint +// operations are all supported or all unsupported, so Initial decides. +func SupportsCheckpoint(operations providercontract.Operations) bool { + return operations["Initial"].State == providercontract.Supported } // ProcessPaths locates installed adapter helpers and their private state. The diff --git a/services/core/tests/integration/provider_operations_fixture_test.go b/services/core/tests/integration/provider_operations_fixture_test.go index c7d052b56..c68ca0dc0 100644 --- a/services/core/tests/integration/provider_operations_fixture_test.go +++ b/services/core/tests/integration/provider_operations_fixture_test.go @@ -24,6 +24,7 @@ func (*lifecycleProvider) ProviderOperations() providercontract.Operations { "DeleteSnapshot": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RunCommandCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "ResumeCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, + "RenewCompute": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "RestorePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "SavePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, @@ -56,6 +57,9 @@ func (*lifecycleProvider) RunCommandCompute(context.Context, sandbox.Reference, func (*lifecycleProvider) ResumeCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "ResumeCompute", Reason: "fixture_operation_not_supported"} } +func (*lifecycleProvider) RenewCompute(context.Context, sandbox.Reference, sandbox.Compute) (sandbox.ComputeState, error) { + return sandbox.ComputeState{}, &providercontract.UnsupportedError{Operation: "RenewCompute", Reason: "fixture_operation_not_supported"} +} func (*lifecycleProvider) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) { return runtimeobs.Sample{}, &providercontract.UnsupportedError{Operation: "Observe", Reason: "fixture_operation_not_supported"} } @@ -75,6 +79,7 @@ func (*fakeCheckpointProvider) ProviderOperations() providercontract.Operations "DeleteSnapshot": {State: providercontract.Supported}, "RunCommandCompute": {State: providercontract.Supported}, "ResumeCompute": {State: providercontract.Supported}, + "RenewCompute": {State: providercontract.Supported}, "RestorePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "SavePreparation": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, "Observe": {State: providercontract.Unsupported, Reason: "fixture_operation_not_supported"}, diff --git a/services/core/tests/integration/runtime_compute_lifecycle_test.go b/services/core/tests/integration/runtime_compute_lifecycle_test.go index 9b164bd5b..2c93db85e 100644 --- a/services/core/tests/integration/runtime_compute_lifecycle_test.go +++ b/services/core/tests/integration/runtime_compute_lifecycle_test.go @@ -38,7 +38,7 @@ type fakeCheckpointProvider struct { captures, restores, captureObservations, restoreObservations int computeKills, snapshotDeletes, wakeCommands int promptFrames atomic.Int32 - quiesces, resumes atomic.Int32 + quiesces, resumes, renews atomic.Int32 loseCapture, loseRestore, rejectQuiesce bool beforeQuiesce func() } @@ -162,6 +162,10 @@ func (p *fakeCheckpointProvider) DeleteSnapshot(_ context.Context, _ sandbox.Ref delete(p.snapshots, s.OperationID) return nil } +func (p *fakeCheckpointProvider) RenewCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { + p.renews.Add(1) + return p.GetCompute(ctx, r, c) +} func (p *fakeCheckpointProvider) ResumeCompute(ctx context.Context, r sandbox.Reference, c sandbox.Compute) (sandbox.ComputeState, error) { state, err := p.GetCompute(ctx, r, c) if err != nil { @@ -273,6 +277,12 @@ type computeLifecycleFixture struct { } func newComputeLifecycleFixture(t *testing.T, maxActive, maxRetained int) *computeLifecycleFixture { + return newComputeLifecycleFixtureFor(t, "microsandbox", maxActive, maxRetained) +} + +// newComputeLifecycleFixtureFor runs the fixture on a nodes deployment, or on a +// direct one (e2b) where allocations have no node. +func newComputeLifecycleFixtureFor(t *testing.T, provider string, maxActive, maxRetained int) *computeLifecycleFixture { t.Helper() s, _ := newManagedTestStore(t) registry := runtimegateway.NewRegistry() @@ -288,13 +298,15 @@ func newComputeLifecycleFixture(t *testing.T, maxActive, maxRetained int) *compu } server.Close() }) - f := &computeLifecycleFixture{t: t, store: s, provider: p, key: webDeployment(t, s, "microsandbox"), policy: execution.RuntimeSuspensionPolicy{Retention: time.Hour}} + f := &computeLifecycleFixture{t: t, store: s, provider: p, key: webDeployment(t, s, provider), policy: execution.RuntimeSuspensionPolicy{Retention: time.Hour}} view, err := deploymentService(t, s).View(t.Context()) if err != nil { t.Fatal(err) } f.sql(`UPDATE runtime_deployment SET idle_seconds=30`) - f.node = enrollNode(t, s, view, deployment.Capacity{MaxActive: maxActive, MaxRetained: maxRetained}).NodeID + if provider == "microsandbox" { + f.node = enrollNode(t, s, view, deployment.Capacity{MaxActive: maxActive, MaxRetained: maxRetained}).NodeID + } f.start() return f } @@ -303,7 +315,9 @@ func (f *computeLifecycleFixture) start() { t.Helper() w := startWebWorker(t, f.store, f.provider.registry, f.key, f.provider, &f.policy) // The Worker's claim starts a new owner epoch, in which the node reconnects. - onlineManagerNode(t, f.store, f.node) + if f.node != "" { + onlineManagerNode(t, f.store, f.node) + } var once sync.Once stop := func() { once.Do(func() { ctx, cancel := context.WithCancel(context.Background()); cancel(); _ = w.Run(ctx) }) @@ -392,6 +406,28 @@ func TestRuntimeComputeLifecycleIdleSuspendAndQueuedSameSessionWake(t *testing.T } } +// A direct allocation has no node and follows the same suspend and wake flow. +func TestRuntimeComputeLifecycleDirectIdleSuspendAndWake(t *testing.T) { + f := newComputeLifecycleFixtureFor(t, "e2b", 1, 2) + tenant, _, env, owner := f.create() + f.complete(owner) + suspended := f.phase(tenant, env.ID, "suspended") + if f.provider.renews.Load() == 0 { + t.Fatal("running compute was observed without renewing its native lease") + } + if f.provider.captures != 1 || f.provider.computeKills != 1 || len(f.provider.snapshots) != 1 || suspended.NodeID != "" { + t.Fatal("direct capture did not release source compute") + } + f.queued(suspended) + awake := f.phase(tenant, env.ID, "running") + if awake.ID != owner.ID || f.provider.creates != 1 || f.provider.restores != 1 { + t.Fatal("direct wake replaced the allocation") + } + if awake.NodeID != "" { + t.Fatal("direct allocation gained a node") + } +} + func TestRuntimeComputeLifecycleLostCaptureAndRestoreObserveWithoutReplay(t *testing.T) { f := newComputeLifecycleFixture(t, 1, 2) tenant, _, env, owner := f.create() diff --git a/services/core/tests/integration/runtime_wake_hint_integration_test.go b/services/core/tests/integration/runtime_wake_hint_integration_test.go index aedeca68c..c468b4c11 100644 --- a/services/core/tests/integration/runtime_wake_hint_integration_test.go +++ b/services/core/tests/integration/runtime_wake_hint_integration_test.go @@ -38,6 +38,11 @@ func (p *wakeHintScanProvider) GetCompute(ctx context.Context, reference sandbox return p.fakeCheckpointProvider.GetCompute(ctx, reference, compute) } +// A running allocation with a connected Runtime is observed by RenewCompute. +func (p *wakeHintScanProvider) RenewCompute(ctx context.Context, reference sandbox.Reference, compute sandbox.Compute) (sandbox.ComputeState, error) { + return p.GetCompute(ctx, reference, compute) +} + type wakeHintIntegrationTarget struct { tenant string session sessions.Session diff --git a/services/core/tests/integration/sandbox_deployment_view_test.go b/services/core/tests/integration/sandbox_deployment_view_test.go index ca1ed70b7..9f8051f31 100644 --- a/services/core/tests/integration/sandbox_deployment_view_test.go +++ b/services/core/tests/integration/sandbox_deployment_view_test.go @@ -38,7 +38,7 @@ func TestSandboxDeploymentViewRecordsTemplateBuildAndSuspension(t *testing.T) { for _, want := range []string{ `"specification":{"resources":{"cpus":2,"memory_mib":2048}}`, `"template_build":{"status":"ready","resources":{"cpus":2,"memory_mib":2048,"root_disk_mib":24063}}`, - `"suspension":null`, + `"suspension":{"idle_seconds":300,"retention_seconds":86400}`, } { if !bytes.Contains(raw, []byte(want)) { t.Fatalf("E2B view lacks %s: %s", want, raw) diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md index 07243a1cb..6672d41e0 100644 --- a/services/core/tools/e2b-provider/README.md +++ b/services/core/tools/e2b-provider/README.md @@ -17,6 +17,12 @@ The account key is stored encrypted in Core's database and is write-only. It rea | `renew` | `Renew` | Extends the lease of the running sandbox to the configured timeout, then rereads it | | `kill` | `Kill` | Destroys every matching sandbox and confirms that none remains | | `command` | `RunCommand` | Runs one bounded command as the Runtime user on a running sandbox whose bootstrap completed; output is limited to 1 MiB per stream | +| `compute`, `renew_compute` | `GetCompute`, `RenewCompute` | One sandbox spans every generation of an allocation, and the receipt records the current generation, the pause and the resume. Any other generation is `not_found`. `renew_compute` extends a running sandbox's lease and otherwise observes like `compute` | +| `suspend` | `Suspend` | Pauses the sandbox with the SDK's native memory snapshot, so the daemon survives and the paused sandbox is the snapshot. A pause that is dispatched but not seen complete is `unconfirmed`, never running | +| `resume` | `Resume` | Records the attempt as dispatched, then reconnects with `connect`; the same operation is never dispatched twice and a failure after dispatch is `unconfirmed`. A snapshot that does not match the paused sandbox, its endpoint or its template build is `checkpoint_unusable` before any native call, durably for the operation | +| `resume_compute` | `ResumeCompute` | Thaws the source only while it is running | +| `kill_compute`, `delete_snapshot` | `KillCompute`, `DeleteSnapshot` | Destroy the sandbox only for the generation that owns it; a superseded generation owns nothing | +| `command_compute` | `RunCommandCompute` | Runs one bounded command, such as the wake command, on the running current sandbox | | `validate_deployment` | Deployment setup | Reads the template's builds and requires the exact build to be ready with the configured CPU and memory. Without configured resources the selection adopts the build's CPU and memory. Returns the build's status, CPU, memory and reported disk for Core to record; bounded to 30 seconds | | `list_templates`, `list_builds` | [Configuration discovery](../../../../contracts/agents-api/sandbox-deployment.md#configuration-discovery) | Pages the key's visible templates (`GET /v2/templates`) or one template's ready builds, with a transient key. Results are capped at 200 and write no receipt | | `observe` | Runtime observations | One allocation; see [Observations](#observations) | @@ -50,7 +56,7 @@ An unknown Create is never repeated. A Create whose connection material was lost ## Inspection and cleanup -Inspection uses SDK metadata and ID reads only. SDK `connect` is never used because it can resume paused compute. The version-pinned constructor that restores a client from saved connection material is confined to [`sdk.py`](sdk.py) and covered by a no-connect, no-create test. Resource drift fails inspection but still permits ownership-based cleanup. +Inspection uses SDK metadata and ID reads only. SDK `connect` is used only in Resume, because it resumes paused compute. The version-pinned constructor that restores a client from saved connection material is confined to [`sdk.py`](sdk.py) and covered by a no-connect, no-create test. Resource drift fails inspection but still permits ownership-based cleanup. `CreateSettled` proves that the original Create and bootstrap can no longer mutate; it is independent of `BootstrapComplete`. An empty lookup never settles an unknown Create. Kill destroys every matching sandbox, confirms that none remains and only then records a settled tombstone; it returns `State=absent` with `CreateSettled`. A settled rejected Create with no sandbox IDs proves absence without a cloud request, so GetInfo and Kill still succeed when the key is invalid. Ordinary missing compute has no such proof. diff --git a/services/core/tools/e2b-provider/helper_contract_generated.py b/services/core/tools/e2b-provider/helper_contract_generated.py index ee81a1327..bee1ee648 100644 --- a/services/core/tools/e2b-provider/helper_contract_generated.py +++ b/services/core/tools/e2b-provider/helper_contract_generated.py @@ -1,6 +1,6 @@ # Code generated by contractgen; DO NOT EDIT. """Adapter-private wire declarations. No SDK or repository dependency.""" -ERROR_CODES = ["","invalid","ownership","exists","not_found","command_unconfirmed","unconfirmed","template_invalid","team_mismatch","unauthorized"] +ERROR_CODES = ["","invalid","ownership","exists","not_found","command_unconfirmed","unconfirmed","template_invalid","team_mismatch","unauthorized","checkpoint_unusable"] MANAGED_BOOTSTRAP_FIELDS = ["Workspace","TenantID","EnvironmentID","AllocationID","SessionID","DeviceID","InstallationID","RuntimeBootstrap"] MANAGED_BOOTSTRAP_REQUIRED_FIELDS = ["TenantID","EnvironmentID","AllocationID","SessionID","DeviceID","InstallationID","RuntimeBootstrap"] MANAGED_IDENTITY_FIELDS = ["TenantID","EnvironmentID","AllocationID","SessionID","DeviceID","InstallationID"] @@ -9,9 +9,11 @@ MAX_OUTPUT = 1048576 MAX_REQUEST = 75497472 MAX_RESPONSE = 16777216 -OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential"] +OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential","compute","renew_compute","suspend","resume","kill_compute","delete_snapshot","command_compute","resume_compute"] PROTOCOL_VERSION = 1 REFERENCE_FIELDS = ["TenantID","EnvironmentID","AllocationID"] -REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Deadline"] -RESPONSE_FIELDS = ["Version","Info","Command","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"] +REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Compute","Suspend","Resume","Snapshot","Deadline"] +RESPONSE_FIELDS = ["Version","Info","Command","State","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"] +RUNTIME_BOOTSTRAP_VERSION = 3 SDK_VERSION = "2.51.0" +SUSPEND_CONTROL_FILE = "/run/oac/daemon-suspend.json" diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index 76557b500..bbf5091c2 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -1,4 +1,4 @@ -"""Five bounded SDK operations for an already authorized Core allocation, plus +"""Bounded SDK operations for an already authorized Core allocation, plus read-only deployment validation and observation.""" from concurrent.futures import ThreadPoolExecutor import json @@ -14,10 +14,26 @@ from sdk import connection_material, definitely_rejected, list_builds, list_templates, read_metrics, restore, run, sdk_options, validate_deployment, verify_team_template from state import Failure, Receipt, private_root, read_receipt from helper_contract_generated import (PROTOCOL_VERSION, OPERATIONS, REQUEST_FIELDS, REFERENCE_FIELDS, - MAX_CREDENTIAL_REFERENCES, MANAGED_BOOTSTRAP_FIELDS, MANAGED_BOOTSTRAP_REQUIRED_FIELDS) + MAX_CREDENTIAL_REFERENCES, MANAGED_BOOTSTRAP_FIELDS, MANAGED_BOOTSTRAP_REQUIRED_FIELDS, RUNTIME_BOOTSTRAP_VERSION) PREFIX = 'oac_' FIELDS = ('InstallationID', *REFERENCE_FIELDS) +PENDING_PAUSE_SETTLE_SECONDS = 600 # a pause request settles in seconds; one still unpaused after ten minutes is taken as not applied + +# Exits 78 unless the template carries the startup script and a contract for this Runtime bootstrap version. +TEMPLATE_CHECK = """import importlib.util, os, sys +root, version = sys.argv[1], int(sys.argv[2]) +script = os.path.join(root, 'managed_init.py') +try: + spec = importlib.util.spec_from_file_location('template_contract', os.path.join(root, 'helper_contract_generated.py')) + contract = importlib.util.module_from_spec(spec) + spec.loader.exec_module(contract) + current = getattr(contract, 'RUNTIME_BOOTSTRAP_VERSION', None) +except Exception: + current = None +sys.exit(78 if not os.path.isfile(script) or not os.access(script, os.R_OK) or current != version else 0) +""" +COMPUTE_OPERATIONS = ('compute', 'renew_compute', 'resume_compute', 'suspend', 'resume', 'kill_compute', 'delete_snapshot', 'command_compute') def valid_id(value): @@ -175,7 +191,7 @@ def client(self, cloud): raise Failure('unconfirmed') return restore(material, self.options()) - def inspect(self): + def inspect(self, qualify=True): if self.rejected_absence(): return None found = self.discover() @@ -186,7 +202,8 @@ def inspect(self): if len(found) != 1: raise Failure('unconfirmed') cloud = found[0] - self.qualified(cloud) + if qualify: + self.qualified(cloud) record = self.receipt.data or {} if (cloud.state == 'running' and not record.get('bootstrap_complete') and record.get('connection') and record.get('status') not in ('bootstrap_failed', 'killed')): @@ -233,15 +250,14 @@ def create(self): self.receipt.save(status='configuration_rejected', settled=True) raise # Validate the current template entry point before writing any credential. - check = run(cloud, {'Args': ['/usr/bin/python3', '-I', '-c', - "import os,sys; sys.exit(78 if not os.path.isfile('/opt/oac-e2b/managed_init.py') or not os.access('/opt/oac-e2b/managed_init.py', os.R_OK) else 0)"]}, + check = run(cloud, {'Args': ['/usr/bin/python3', '-I', '-c', TEMPLATE_CHECK, '/opt/oac-e2b', str(RUNTIME_BOOTSTRAP_VERSION)]}, self.remaining, user='root') if check['ExitCode'] != 0: self.receipt.save(status='bootstrap_failed', settled=True) raise Failure('template_invalid' if check['ExitCode'] == 78 else 'unconfirmed') payload = dict(bootstrap, InstallationID=self.config['InstallationID'], RuntimeBootstrap=self.q['RuntimeBootstrap']) - del payload['CoreURL'], payload['Credential'] + del payload['CoreURL'], payload['Credential'], payload['SuspendControl'] if (set(payload) - set(MANAGED_BOOTSTRAP_FIELDS) or not set(MANAGED_BOOTSTRAP_REQUIRED_FIELDS) <= set(payload)): raise Failure('invalid') @@ -312,6 +328,168 @@ def observe(self): return {'Status': 'unavailable'} return observed(found[0], point) + # One sandbox spans every generation. The receipt's generation says which + # incarnation owns it: pause leaves it as the snapshot and resume advances it. + def generation(self): + return (self.receipt.data or {}).get('generation', 0) + + def current(self, compute): + """The sandbox of the exact current incarnation; any other generation is absent.""" + if compute['Generation'] != self.generation(): + raise Failure('not_found') + cloud = self.inspect() + if cloud is None: + raise Failure('not_found') + if compute.get('ID') and compute['ID'] != cloud.sandbox_id: + raise Failure('ownership') + return cloud + + def state(self, compute, cloud, **extra): + status = getattr(cloud.state, 'value', cloud.state) + return dict(Compute=dict(compute, ID=cloud.sandbox_id), Status=status, + BootstrapComplete=bool((self.receipt.data or {}).get('bootstrap_complete')), **extra) + + def lease(self, cloud): + Sandbox.set_timeout(cloud.sandbox_id, self.config['TimeoutSeconds'], **self.options()) + return self.qualified(self.owns(Sandbox.get_info(cloud.sandbox_id, **self.options()))) + + def get_compute(self): + compute = self.q['Compute'] + return self.state(compute, self.current(compute)) + + def renew_compute(self): + compute = self.q['Compute'] + cloud = self.current(compute) + if cloud.state == 'running': + cloud = self.lease(cloud) + return self.state(compute, cloud) + + def resume_compute(self): + """Thaw only a running source; a paused sandbox is a snapshot, not a source.""" + compute = self.q['Compute'] + cloud = self.current(compute) + if cloud.state != 'running': + raise Failure('unconfirmed') + return self.state(compute, self.lease(cloud)) + + def command_compute(self): + cloud = self.current(self.q['Compute']) + if cloud.state != 'running' or not self.receipt.data.get('bootstrap_complete'): + raise Failure('unconfirmed') + return run(self.client(cloud), self.q['Command'], self.remaining) + + def snapshot(self, operation, source, cloud): + return {'Compatibility': self.config['Compatibility'], 'Reference': source['Name'] + ':s-' + operation, + 'ID': cloud.sandbox_id, 'Digest': '', 'CheckpointID': '', 'CheckpointRoot': '', + 'OperationID': operation, 'SourceGeneration': source['Generation'], 'SourceName': source['Name'], + 'SourceID': cloud.sandbox_id} + + def suspend(self): + request = self.q['Suspend'] + source, operation = request['Source'], request['OperationID'] + cloud = self.current(source) + if cloud.state == 'running' and not request['ObserveOnly']: + # Record the attempt before the native call; pause of a paused sandbox is a no-op. + self.receipt.save(pause={'operation_id': operation, 'status': 'pending', 'at': time.time()}) + Sandbox.pause(cloud.sandbox_id, **self.options()) + cloud = self.qualified(self.owns(Sandbox.get_info(cloud.sandbox_id, **self.options()))) + pause = (self.receipt.data or {}).get('pause') or {} + if cloud.state != 'paused' or pause.get('operation_id') != operation: + # A dispatched pause may still take effect, so running is never reported for it. + pending = pause.get('operation_id') == operation and pause.get('status') == 'pending' + if pending and request['ObserveOnly'] and cloud.state == 'running' and time.time() - pause.get('at', time.time()) > PENDING_PAUSE_SETTLE_SECONDS: + # Observation proves the long-dispatched pause did not take effect; close it as not taken. + self.receipt.save(pause={'operation_id': operation, 'status': 'not_taken'}) + return self.state(source, cloud) + if request['ObserveOnly'] and not pending: + return self.state(source, cloud) + raise Failure('unconfirmed') + self.receipt.save(pause={'operation_id': operation, 'status': 'paused'}) + return self.state(source, cloud, Snapshot=self.snapshot(operation, source, cloud), SourceStopped=True) + + def close_resume(self, operation, kind): + data = self.receipt.data or {} + closed = dict(data.get('closed', {}), **{operation: kind}) + self.receipt.save(closed=dict(list(closed.items())[-32:]), resume=None) + + def closed_state(self, target, operation): + return {'Compute': target, 'Status': 'absent', 'BootstrapComplete': False, 'RestoreAttemptClosed': operation} + + def finish_resume(self, target, operation, cloud, material=None): + values = {'generation': target['Generation'], 'resume': {'operation_id': operation, 'status': 'resumed'}} + if material is not None: + values['connection'] = material + self.receipt.save(**values) + return self.state(target, cloud) + + def resume(self): + request = self.q['Resume'] + operation, snapshot, target = request['OperationID'], request['Snapshot'], request['Target'] + data = self.receipt.data or {} + kind = data.get('closed', {}).get(operation) + if kind == 'unusable': + raise Failure('checkpoint_unusable') + if kind: + return self.closed_state(target, operation) + record = data.get('resume') or {} + if record.get('operation_id') == operation: + # Native restoration was dispatched: observe it, never dispatch again. + cloud = self.inspect() + if cloud is None: + raise Failure('not_found') + if cloud.state != 'running': + raise Failure('unconfirmed') + return self.finish_resume(target, operation, cloud) + if request['ObserveOnly']: + # No dispatch is recorded, so this attempt can still be closed durably. + self.close_resume(operation, 'attempt') + return self.closed_state(target, operation) + pause = data.get('pause') or {} + # Drift is judged below, before dispatch, as an unusable checkpoint. + cloud = self.inspect(qualify=False) + usable = (cloud is not None and cloud.state == 'paused' and pause.get('status') == 'paused' and + pause.get('operation_id') == snapshot['OperationID'] and + snapshot['SourceGeneration'] == self.generation() < target['Generation'] and + snapshot['SourceID'] == cloud.sandbox_id) + if usable: + try: + self.qualified(cloud) + except Failure: + usable = False + if not usable: + self.close_resume(operation, 'unusable') + raise Failure('checkpoint_unusable') + self.receipt.save(resume={'operation_id': operation, 'target': target['Generation'], 'status': 'dispatched'}) + try: + connected = Sandbox.connect(cloud.sandbox_id, timeout=self.config['TimeoutSeconds'], **self.options()) + except SandboxNotFoundException: + # Dispatched: the outcome is unknown and the attempt stays open. + raise Failure('unconfirmed') from None + cloud = self.qualified(self.owns(Sandbox.get_info(cloud.sandbox_id, **self.options()))) + if cloud.state != 'running': + raise Failure('unconfirmed') + return self.finish_resume(target, operation, cloud, connection_material(connected)) + + def kill_compute(self): + """Kill only the sandbox's owning incarnation; a superseded one owns nothing.""" + compute = self.q['Compute'] + data = self.receipt.data or {} + record = data.get('resume') or {} + if compute['Generation'] == self.generation() or (record.get('status') == 'dispatched' and record.get('target') == compute['Generation']): + self.kill_owned(compute.get('ID')) + + def delete_snapshot(self): + """A snapshot is the paused sandbox itself; one consumed by resume owns nothing.""" + snapshot = self.q['Snapshot'] + if snapshot['SourceGeneration'] == self.generation(): + self.kill_owned(snapshot['SourceID']) + + def kill_owned(self, sandbox_id): + ids = (self.receipt.data or {}).get('ids', []) + if sandbox_id and ids and sandbox_id not in ids: + raise Failure('ownership') + self.kill() + def metadata_for(self, reference): return {PREFIX + field.lower(): value for field, value in dict(reference, InstallationID=self.config['InstallationID']).items()} @@ -391,12 +569,25 @@ def execute(self): raise Failure('unconfirmed') result = run(self.client(cloud), self.q['Command'], self.remaining) return {'Version': PROTOCOL_VERSION, 'Command': result, 'ErrorCode': ''} + if operation in ('compute', 'renew_compute', 'resume_compute', 'suspend', 'resume'): + handler = {'compute': self.get_compute, 'renew_compute': self.renew_compute, 'resume_compute': self.resume_compute, + 'suspend': self.suspend, 'resume': self.resume}[operation] + return {'Version': PROTOCOL_VERSION, 'State': handler(), 'ErrorCode': ''} + if operation == 'command_compute': + return {'Version': PROTOCOL_VERSION, 'Command': self.command_compute(), 'ErrorCode': ''} + if operation in ('kill_compute', 'delete_snapshot'): + {'kill_compute': self.kill_compute, 'delete_snapshot': self.delete_snapshot}[operation]() + return {'Version': PROTOCOL_VERSION, 'ErrorCode': ''} cloud = {'create': self.create, 'inspect': self.inspect, 'renew': self.renew}[operation]() return {'Version': PROTOCOL_VERSION, 'Info': self.info(cloud, absent=cloud is None), 'ErrorCode': ''} except Failure as error: + if self.q['Operation'] in COMPUTE_OPERATIONS: + return {'Version': PROTOCOL_VERSION, 'ErrorCode': error.code} info = self.info() if error.code == 'not_found': info = dict(self.reference, ProviderID='', State='absent', BootstrapComplete=False, CreateSettled=False) return {'Version': PROTOCOL_VERSION, 'Info': info, 'ErrorCode': error.code} except Exception: + if self.q['Operation'] in COMPUTE_OPERATIONS: + return {'Version': PROTOCOL_VERSION, 'ErrorCode': 'unconfirmed'} return {'Version': PROTOCOL_VERSION, 'Info': self.info(), 'ErrorCode': 'unconfirmed'} diff --git a/services/core/tools/e2b-provider/provider_test.py b/services/core/tools/e2b-provider/provider_test.py index 3d22f01e1..b2a3e7877 100644 --- a/services/core/tools/e2b-provider/provider_test.py +++ b/services/core/tools/e2b-provider/provider_test.py @@ -3,7 +3,10 @@ from datetime import datetime, timedelta, timezone import json from pathlib import Path +import subprocess +import sys import tempfile +import time from types import SimpleNamespace import unittest from unittest.mock import Mock, patch @@ -12,12 +15,13 @@ from e2b import SandboxState from e2b.exceptions import AuthenticationException, SandboxNotFoundException +import provider from provider import Provider from sdk import restore, run from state import Failure, Receipt -class ProviderTest(unittest.TestCase): +class Fixture(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) @@ -27,9 +31,9 @@ def setUp(self): self.request = {'Version': 1, 'Operation': 'create', 'Config': self.config, 'Reference': self.reference, 'Deadline': (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), 'Bootstrap': dict(self.reference, SessionID=str(uuid4()), DeviceID=str(uuid4()), - CoreURL='https://core.example/api/v1', Credential='private-runtime-secret')} + CoreURL='https://core.example/api/v1', Credential='private-runtime-secret', SuspendControl=True)} self.request['RuntimeBootstrap'] = { - 'version': 2, 'core_url': self.request['Bootstrap']['CoreURL'], + 'version': 3, 'suspend_control': True, 'core_url': self.request['Bootstrap']['CoreURL'], 'device_id': self.request['Bootstrap']['DeviceID'], 'credential': self.request['Bootstrap']['Credential']} self.cloud = Mock(sandbox_id='owned-id', sandbox_domain='e2b.app', _envd_version='0.5.0', @@ -60,6 +64,8 @@ def call(self, operation): def record(self): return json.loads(next(Path(self.temporary.name).glob('*.json')).read_text()) + +class ProviderTest(Fixture): def test_create_recover_and_never_replay(self): result = self.call('create') self.assertEqual(result['ErrorCode'], '') @@ -322,5 +328,120 @@ def test_unknown_command_does_not_replay(self): client.commands.run.assert_called_once() +class TemplateCheckTest(unittest.TestCase): + def exit_code(self, files): + with tempfile.TemporaryDirectory() as root: + for name, text in files.items(): + Path(root, name).write_text(text) + return subprocess.run([sys.executable, '-I', '-c', provider.TEMPLATE_CHECK, root, '3']).returncode + + def test_template_needs_startup_script_and_current_bootstrap_version(self): + self.assertEqual(self.exit_code({'managed_init.py': '', 'helper_contract_generated.py': 'RUNTIME_BOOTSTRAP_VERSION = 3'}), 0) + self.assertEqual(self.exit_code({'managed_init.py': '', 'helper_contract_generated.py': 'RUNTIME_BOOTSTRAP_VERSION = 2'}), 78) + self.assertEqual(self.exit_code({'managed_init.py': '', 'helper_contract_generated.py': ''}), 78) + self.assertEqual(self.exit_code({'helper_contract_generated.py': 'RUNTIME_BOOTSTRAP_VERSION = 3'}), 78) + + +class CheckpointTest(Fixture): + """One sandbox spans generations: pause keeps it and connect resumes it.""" + + def setUp(self): + super().setUp() + self.assertEqual(self.call('create')['ErrorCode'], '') + self.config['Compatibility'] = {'artifact_domain': 'a', 'execution_class': 'e'} + self.op = str(uuid4()) + + def compute(self, generation=0, restored=None): + return {'Generation': generation, 'Name': 'n%d' % generation, 'ID': 'owned-id', 'RestoredFrom': restored} + + def suspend(self, observe=False): + self.request['Suspend'] = {'Reference': self.reference, 'OperationID': self.op, 'Source': self.compute(), + 'Snapshot': None, 'ObserveOnly': observe} + return self.call('suspend') + + def resume(self, snapshot, observe=False, op=None): + target = self.compute(1, snapshot) + self.request['Resume'] = {'Reference': self.reference, 'OperationID': op or self.op, 'Snapshot': snapshot, + 'Target': target, 'ObserveOnly': observe} + return self.call('resume') + + def paused(self): + self.cloud.state = SandboxState.PAUSED + + def test_suspend_pauses_once_and_resume_advances_the_generation(self): + self.api.pause.side_effect = lambda *a, **k: self.paused() + result = self.suspend() + self.assertEqual(result['ErrorCode'], '') + snapshot = result['State']['Snapshot'] + self.assertTrue(result['State']['SourceStopped']) + self.assertEqual(result['State']['Status'], 'paused') + self.assertEqual(self.suspend()['State']['Snapshot'], snapshot) + self.api.pause.assert_called_once() + self.api.connect.side_effect = lambda *a, **k: (setattr(self.cloud, 'state', 'running'), self.cloud)[1] + resumed = self.resume(snapshot) + self.assertEqual(resumed['ErrorCode'], '') + self.assertEqual(resumed['State']['Status'], 'running') + self.assertEqual(self.record()['generation'], 1) + self.api.connect.assert_called_once() + self.resume(snapshot) + self.api.connect.assert_called_once() + + def test_superseded_generation_owns_nothing(self): + self.api.pause.side_effect = lambda *a, **k: self.paused() + snapshot = self.suspend()['State']['Snapshot'] + self.api.connect.side_effect = lambda *a, **k: (setattr(self.cloud, 'state', 'running'), self.cloud)[1] + self.resume(snapshot) + self.request['Snapshot'] = snapshot + self.assertEqual(self.call('delete_snapshot')['ErrorCode'], '') + self.request['Compute'] = self.compute() + self.assertEqual(self.call('compute')['ErrorCode'], 'not_found') + self.assertEqual(self.call('kill_compute')['ErrorCode'], '') + self.api.kill.assert_not_called() + + def test_unusable_snapshot_is_durable_and_never_connects(self): + self.api.pause.side_effect = lambda *a, **k: self.paused() + snapshot = self.suspend()['State']['Snapshot'] + other = str(uuid4()) + self.assertEqual(self.resume(dict(snapshot, OperationID=other))['ErrorCode'], 'checkpoint_unusable') + self.assertEqual(self.resume(snapshot)['ErrorCode'], 'checkpoint_unusable') + self.api.connect.assert_not_called() + + def test_unseen_pause_never_reports_running(self): + self.assertEqual(self.suspend()['ErrorCode'], 'unconfirmed') + self.assertEqual(self.suspend(observe=True)['ErrorCode'], 'unconfirmed') + + def test_drift_before_dispatch_is_unusable(self): + self.api.pause.side_effect = lambda *a, **k: self.paused() + snapshot = self.suspend()['State']['Snapshot'] + self.cloud.template_id = 'drifted' + self.assertEqual(self.resume(snapshot)['ErrorCode'], 'checkpoint_unusable') + self.api.connect.assert_not_called() + + def test_failure_after_dispatch_stays_open(self): + self.api.pause.side_effect = lambda *a, **k: self.paused() + snapshot = self.suspend()['State']['Snapshot'] + self.api.connect.side_effect = SandboxNotFoundException() + self.assertEqual(self.resume(snapshot)['ErrorCode'], 'unconfirmed') + self.assertEqual(self.record()['resume']['status'], 'dispatched') + self.assertNotIn(self.op, self.record().get('closed', {})) + + def test_old_pending_pause_is_settled_by_observation(self): + self.assertEqual(self.suspend()['ErrorCode'], 'unconfirmed') + self.assertEqual(self.suspend(observe=True)['ErrorCode'], 'unconfirmed') + with patch('provider.time.time', return_value=time.time() + provider.PENDING_PAUSE_SETTLE_SECONDS + 1): + result = self.suspend(observe=True) + self.assertEqual((result['ErrorCode'], result['State']['Status']), ('', 'running')) + self.assertNotIn('Snapshot', result['State']) + self.assertEqual(self.record()['pause']['status'], 'not_taken') + + def test_renew_compute_extends_only_a_running_lease(self): + self.request['Compute'] = self.compute() + self.assertEqual(self.call('renew_compute')['ErrorCode'], '') + self.api.set_timeout.assert_called_once() + self.paused() + self.assertEqual(self.call('renew_compute')['State']['Status'], 'paused') + self.api.set_timeout.assert_called_once() + + if __name__ == '__main__': unittest.main() diff --git a/services/core/tools/e2b-provider/testdata/contract.json b/services/core/tools/e2b-provider/testdata/contract.json index 204cb3e29..f04757e41 100644 --- a/services/core/tools/e2b-provider/testdata/contract.json +++ b/services/core/tools/e2b-provider/testdata/contract.json @@ -1,46 +1,55 @@ [ -{"kind":"managed","name":"invalid-DeviceID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":null,"EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"invalid-DeviceID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"invalid","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"invalid-Extra","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","Extra":true,"InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"missing-AllocationID","payload":{"DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"missing-DeviceID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"missing-EnvironmentID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"missing-InstallationID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"invalid-DeviceID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":null,"EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"invalid-DeviceID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"invalid","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"invalid-Extra","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","Extra":true,"InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"missing-AllocationID","payload":{"DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"missing-DeviceID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"missing-EnvironmentID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"missing-InstallationID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, {"kind":"managed","name":"missing-RuntimeBootstrap","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"missing-SessionID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, -{"kind":"managed","name":"missing-TenantID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444"},"valid":false}, -{"kind":"managed","name":"valid","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":true}, -{"kind":"managed","name":"workspace-\u003cnil\u003e","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"valid":true}, -{"kind":"managed","name":"workspace-map[]","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"valid":false}, -{"kind":"request","name":"command","payload":{"Version":1,"Operation":"command","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"config-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":null,"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"create","payload":{"Version":1,"Operation":"create","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"extra","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Extra":true,"Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"inspect","payload":{"Version":1,"Operation":"inspect","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"kill","payload":{"Version":1,"Operation":"kill","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"list_builds","payload":{"Version":1,"Operation":"list_builds","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"list_templates","payload":{"Version":1,"Operation":"list_templates","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"observe","payload":{"Version":1,"Operation":"observe","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"operation-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":null,"Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"operation-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"unsupported","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"reference-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":null,"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"reference-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"renew","payload":{"Version":1,"Operation":"renew","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"validate_deployment","payload":{"Version":1,"Operation":"validate_deployment","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-0","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-32","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-33","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000021-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only"},"RuntimeBootstrap":{"version":2,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace"},"Deadline":"2099-01-01T00:00:00Z"},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":{},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":[null],"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"version-bool","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":true},"valid":false}, -{"kind":"request","name":"version-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":null},"valid":false}, -{"kind":"request","name":"version-string","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":"1"},"valid":false}, -{"kind":"request","name":"version-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"workspace-\u003cnil\u003e","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":true}, -{"kind":"request","name":"workspace-map[]","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"Config":{"APIKey":"","APIURL":"","Binary":"","Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","version":2,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"managed","name":"missing-SessionID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"TenantID":"11111111-1111-4111-8111-111111111111"},"valid":false}, +{"kind":"managed","name":"missing-TenantID","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444"},"valid":false}, +{"kind":"managed","name":"valid","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":true}, +{"kind":"managed","name":"workspace-\u003cnil\u003e","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"valid":true}, +{"kind":"managed","name":"workspace-map[]","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"valid":false}, +{"kind":"request","name":"command","payload":{"Version":1,"Operation":"command","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"command_compute","payload":{"Version":1,"Operation":"command_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Command":{"Args":["true"],"Directory":"","Stdin":null},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"compute","payload":{"Version":1,"Operation":"compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"config-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":null,"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"create","payload":{"Version":1,"Operation":"create","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"delete_snapshot","payload":{"Version":1,"Operation":"delete_snapshot","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Digest":"","CheckpointID":"","CheckpointRoot":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"extra","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Extra":true,"Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"inspect","payload":{"Version":1,"Operation":"inspect","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"kill","payload":{"Version":1,"Operation":"kill","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"kill_compute","payload":{"Version":1,"Operation":"kill_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"list_builds","payload":{"Version":1,"Operation":"list_builds","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"list_templates","payload":{"Version":1,"Operation":"list_templates","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"observe","payload":{"Version":1,"Operation":"observe","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"operation-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":null,"Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"operation-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"unsupported","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"reference-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":null,"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"reference-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"renew","payload":{"Version":1,"Operation":"renew","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"renew_compute","payload":{"Version":1,"Operation":"renew_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"resume","payload":{"Version":1,"Operation":"resume","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Resume":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Digest":"","CheckpointID":"","CheckpointRoot":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Target":{"Generation":1,"Name":"fixture-g1","ID":"","RestoredFrom":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Digest":"","CheckpointID":"","CheckpointRoot":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"}},"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"resume_compute","payload":{"Version":1,"Operation":"resume_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"suspend","payload":{"Version":1,"Operation":"suspend","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Suspend":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Source":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Snapshot":null,"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"validate_deployment","payload":{"Version":1,"Operation":"validate_deployment","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-0","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-32","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-33","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000021-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":{},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":[null],"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"version-bool","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":true},"valid":false}, +{"kind":"request","name":"version-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":null},"valid":false}, +{"kind":"request","name":"version-string","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":"1"},"valid":false}, +{"kind":"request","name":"version-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"workspace-\u003cnil\u003e","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":true}, +{"kind":"request","name":"workspace-map[]","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, {"kind":"response","name":"error-","payload":{"Version":1,"ErrorCode":""},"valid":true}, +{"kind":"response","name":"error-checkpoint_unusable","payload":{"Version":1,"ErrorCode":"checkpoint_unusable"},"valid":true}, {"kind":"response","name":"error-command_unconfirmed","payload":{"Version":1,"ErrorCode":"command_unconfirmed"},"valid":true}, {"kind":"response","name":"error-exists","payload":{"Version":1,"ErrorCode":"exists"},"valid":true}, {"kind":"response","name":"error-invalid","payload":{"Version":1,"ErrorCode":"invalid"},"valid":true}, diff --git a/services/core/tools/microsandbox-provider/README.md b/services/core/tools/microsandbox-provider/README.md index e8dec91df..ac6bac559 100644 --- a/services/core/tools/microsandbox-provider/README.md +++ b/services/core/tools/microsandbox-provider/README.md @@ -22,7 +22,7 @@ Create names the VM from a hash of the installation and allocation reference plu Workspace, staging and outputs share the `/environment` filesystem, which keeps the Runtime's cross-device and link checks intact; the layered root filesystem can report different device IDs for a directory and its upper-layer files, so it holds no workspace data. The filesystem provider owns the directory; sandbox removal never deletes it. The binding and resource rules belong to [Workspace filesystem protocol](../../../../docs/workspace-provider.md#attachment). The [Runtime bootstrap](../../../../docs/runtime-bootstrap.md) owns the Runtime directory layout. -VM creation does not run the image's entry point. The bootstrap runs as root through confidential standard input, with a two-minute limit. It creates the Runtime directories and the private control directory `/run/oac` (mode 0700, owned by UID 1000), writes the [Runtime bootstrap](../../../../docs/runtime-bootstrap.md) file to `/home/runtime/runtime-bootstrap.json`, bind-mounts `/environment/workspace` at `/workspace` and starts `oac-daemon connect --bootstrap-file` in the background as UID/GID 1000. `OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE=/run/oac/daemon-suspend.json` enables the daemon's park and wake control. The `io.oac.bootstrap` label then changes from `pending` to `complete` through the SDK's next-start modification policy, because v0.7.8 cannot update the labels of a running VM. That label confirms only these writes and the launch, not authentication or native readiness. +VM creation does not run the image's entry point. The bootstrap runs as root through confidential standard input, with a two-minute limit. It creates the Runtime directories and the private suspend control directory, writes the [Runtime bootstrap](../../../../docs/runtime-bootstrap.md) file to `/home/runtime/runtime-bootstrap.json`, bind-mounts `/environment/workspace` at `/workspace` and starts `oac-daemon connect --bootstrap-file` in the background as UID/GID 1000. The `io.oac.bootstrap` label then changes from `pending` to `complete` through the SDK's next-start modification policy, because v0.7.8 cannot update the labels of a running VM. That label confirms only these writes and the launch, not authentication or native readiness. A helper response carries `CreateSettled` with a configuration rejection only after native Create has completed, the first inspection has verified the exact compute ID and ownership, and the resource check has rejected the VM before bootstrap started. The adapter keeps the original error and validates the compute identity before passing the proof to Core. The private `initial_info` operation used by `GetInfo` and `Renew` can also return an exact initial `State` with `Status="absent"`, no native ID and `CreateSettled=true`, after typed native absence and durable closure of initial Create admission under the allocation lock. It accepts only generation zero without restored ancestry or a native ID. The adapter validates the complete receipt and returns absence without an error. Ordinary `inspect` used by `GetCompute`, uncertain Create outcomes, timeouts and ownership failures never produce this receipt; missing compute alone is not settlement. @@ -53,11 +53,11 @@ The same lock file fences a Create helper that has not yet acquired its lock, in `RunCommand` and `RunCommandCompute` pass standard input on an anonymous pipe, run as UID 1000 with a deadline and a 1 MiB limit per output stream, and return a result only after the input is fully written and the guest reports its exit. Timeouts, output overflow and missing receipts return `ErrCommandUnconfirmed`; closing an SDK exec handle does not prove that the guest process exited ([`command.go`](command.go)). Core uses `RunCommandCompute` only to run the exact wake command it authorizes: ```sh -oac-daemon resume --control-file /run/oac/daemon-suspend.json \ +oac-daemon resume --control-file CONTROL_FILE \ --environment-id ENVIRONMENT_ID --suspend-id SUSPENSION_ID ``` -The command checks the protected Environment and suspension identities and the parked daemon's PID and start time, then signals it through a Linux pidfd. The daemon reauthenticates and waits for Core's resume confirmation before it admits work. +`CONTROL_FILE` is the [suspend control file](../../../../docs/runtime-bootstrap.md#suspend-control). The command checks the protected Environment and suspension identities and the parked daemon's PID and start time, then signals it through a Linux pidfd. The daemon reauthenticates and waits for Core's resume confirmation before it admits work. ## Metrics diff --git a/services/core/tools/microsandbox-provider/bootstrap.go b/services/core/tools/microsandbox-provider/bootstrap.go index 4dd868c8e..21c294124 100644 --- a/services/core/tools/microsandbox-provider/bootstrap.go +++ b/services/core/tools/microsandbox-provider/bootstrap.go @@ -5,8 +5,10 @@ package main import ( "context" "encoding/json" + "path" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" wire "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/microsandbox" sdk "github.com/superradcompany/microsandbox/sdk/go" @@ -17,7 +19,7 @@ import ( const bootstrapScript = ` import ctypes,json,os,stat,subprocess,sys b=json.load(sys.stdin) -for p in ['/home/runtime','/home/runtime/.oac','/environment','/environment/workspace','/environment/staging','/environment/initialization','/environment/packages','/run/oac']: +for p in ['/home/runtime','/home/runtime/.oac','/environment','/environment/workspace','/environment/staging','/environment/initialization','/environment/packages',sys.argv[1]]: os.makedirs(p,mode=0o700,exist_ok=True) if not stat.S_ISDIR(os.lstat(p).st_mode): raise RuntimeError('invalid bootstrap directory') os.chmod(p,0o700);os.chown(p,1000,1000) @@ -63,7 +65,6 @@ func (b backend) create(ctx context.Context) (wire.Response, error) { sdk.WithLabels(labels), sdk.WithDetached(), sdk.WithQuietLogs(), sdk.WithNetwork(b.network()), sdk.WithEnv(map[string]string{ "HOME": "/home/runtime", "OAC_RUNTIME_HOME": "/home/runtime/.oac", - "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE": "/run/oac/daemon-suspend.json", })) if e != nil { return wire.Response{}, e @@ -91,7 +92,7 @@ func (b backend) create(ctx context.Context) (wire.Response, error) { } initialization, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() - result, e := runCommand(initialization, live, sandbox.Command{Args: []string{"/usr/bin/python3", "-I", "-S", "-c", bootstrapScript}, Stdin: data}, "0:0") + result, e := runCommand(initialization, live, sandbox.Command{Args: []string{"/usr/bin/python3", "-I", "-S", "-c", bootstrapScript, path.Dir(runtimebootstrap.SuspendControlFile)}, Stdin: data}, "0:0") if e != nil { return wire.Response{}, e } From 11cea5b12e04154122a7d326ceff175274fe11f9 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 10:39:10 +0800 Subject: [PATCH 25/39] Publish Subagent function calls on the Session stream (#851) * Publish Subagent function calls on the Session stream A Subagent's function call now publishes its function_call Item as agent.session.turn.item.added with the child Turn ID, and a tool_result naming that child Turn resolves to the root Turn holding the call, as the official SDK sends it. * Record the Subagent call event in the compatibility notes --- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/index.md | 2 +- contracts/agents-api/subagents.md | 2 +- contracts/agents-api/zh/execution-tools.md | 4 +- contracts/agents-api/zh/index.md | 4 +- contracts/agents-api/zh/subagents.md | 4 +- .../core/internal/db/queries/functions.sql | 3 + .../core/internal/db/sqlc/functions.sql.go | 21 +++++ .../postgres/sessionpg/functions.go | 15 ++++ .../internal/sessions/execution_functions.go | 35 +++++--- services/core/internal/sessions/functions.go | 23 ++++- .../core/internal/sessions/functions_test.go | 26 +++++- .../integration/function_execution_test.go | 7 +- .../subagent_function_call_test.go | 83 +++++++++++++++++++ 14 files changed, 201 insertions(+), 30 deletions(-) create mode 100644 services/core/tests/integration/subagent_function_call_test.go diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index bc955df4e..b19976692 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -30,7 +30,7 @@ A function declaration requires `name`, `description` and a JSON Schema in `para The native Harness can abandon a call before it applies a result, for example when the root Turn closes or interrupts the Subagent that made it, or when MiniMax Code's MCP client times the call out. The call then stops being a required action, the Turn continues and returns to `in_progress` when no required action is left, and a later `function_call_output` for it is rejected like a result after cancellation. An abandoned call has no application receipt. -A function call made by a Subagent is a required action of the root Turn that runs the child. Its `turn_id` is that root Turn, and its `call_id` equals the `call_id` of the child's `function_call` Item on the [Subagent routes](./subagents.md#public-reads). Answer it with `function_call_output` / `tool_result` under that `call_id` and the root Turn ID; the child's Turn ID is not a Session Turn and gets the 400 for a call of another Turn. A Subagent's HTTP MCP calls are `mcp_call` Items in the child's history. +A function call made by a Subagent is a required action of the root Turn that runs the child. Its `turn_id` is that root Turn, and its `call_id` equals the `call_id` of the child's `function_call` Item on the [Subagent routes](./subagents.md#public-reads). Answer it with `function_call_output` / `tool_result` under that `call_id` and either the root Turn ID or the child Turn ID that the call's `agent.session.turn.item.added` event names, which is what the official SDK sends; any other Turn ID gets the 400 for a call of another Turn. A Subagent's HTTP MCP calls are `mcp_call` Items in the child's history. ### Required actions and recovery diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index c0a4ee7cf..f653f559a 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -162,7 +162,7 @@ Each item is Core's deliberate or native behavior where the official service beh ### Unverified against the official service - The order of errors when one request has several faults, and error, default and payload-limit parity in general. -- A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. +- A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether Subagent child Turns and pending Environment file writes block Session deletion. - The official order between the pending-input error and an unknown result target. - Failure reasons for npm, initial-file and Skill installation have no official sample. diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index 54eafb74e..483fcefab 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -24,7 +24,7 @@ Lists use `after`, `limit` (default 20) and `order` (default `desc`) and return Child work appears only on the Subagent routes. - Session `turns.list` and `turns.retrieve` return root Turns only. A child Turn ID, as a path or a list cursor, returns the same 404 as a missing Turn; Core's 404 message differs from the official one. -- The Session event stream and the creation stream carry root work only: child Turns and their Items publish no `agent.session.turn.*` or Item content events. `agent.session.subagent.*` events and root coordination Items stay. The creation stream still ends on the root's settled idle state. +- The Session event stream and the creation stream carry root work only: child Turns and their Items publish no `agent.session.turn.*` or Item content events, except that a Subagent's function call publishes its `function_call` Item once as `agent.session.turn.item.added`, with the child Turn ID as `turn_id`. `agent.session.subagent.*` events and root coordination Items stay. The creation stream still ends on the root's settled idle state. - A child Turn's `agent_id` is the Session's Agent ID (a direct child's `parent_agent_id` and the `create_subagent_call` `agent_id`); `subagent_id` identifies the child. Nested children follow the same rule. Root Turns have `subagent_id: null`. - Session Items stay root-owned; inherited native parent transcripts are not child work. Session usage sums root Turns only. diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 8b5430064..cebd341ca 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: d88acdb94153f3974e66adbdb9443706ae47adef280cae71b03fdf896bdfe579 +source_hash: 44b540ff6827f818d8bc4cbaa6dc5fc9929bbf82a15c66edeb604909ecb6b110 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -32,7 +32,7 @@ Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 原生 Harness 可能在应用结果之前放弃调用,例如根 Turn 关闭或中断了发起该调用的 Subagent,或 MiniMax Code 的 MCP 客户端使该调用超时。该调用随即不再是待执行操作,Turn 继续运行,并在没有待执行操作时回到 `in_progress`;之后为它提交的 `function_call_output` 会像取消后的结果一样被拒绝。被放弃的调用没有应用回执。 -Subagent 发起的函数调用是运行该子级的根 Turn 的待执行操作:`turn_id` 是该根 Turn,`call_id` 等于 [Subagent 路由](subagents.md#public-reads)上该子级 `function_call` Item 的 `call_id`。使用该 `call_id` 和根 Turn ID,以 `function_call_output` / `tool_result` 作答;子级的 Turn ID 不是 Session Turn,会得到“另一个 Turn 的调用”的 400。Subagent 的 HTTP MCP 调用是该子级历史中的 `mcp_call` Item。 +Subagent 发起的函数调用是运行该子级的根 Turn 的待执行操作:`turn_id` 是该根 Turn,`call_id` 等于 [Subagent 路由](subagents.md#public-reads)上该子级 `function_call` Item 的 `call_id`。使用该 `call_id`,以及根 Turn ID 或该调用的 `agent.session.turn.item.added` 事件所标明的子级 Turn ID(官方 SDK 发送的即是后者),以 `function_call_output` / `tool_result` 作答;其他 Turn ID 会得到“另一个 Turn 的调用”的 400。Subagent 的 HTTP MCP 调用是该子级历史中的 `mcp_call` Item。 ### 待执行操作与恢复 {#required-actions-and-recovery} diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 053bd7ea6..cfa35b171 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 025c67e723ae2e055c4674f5c721cb51bd2b59899f907ccc05b27ce70360f142 +source_hash: ae8f903f342adedb090dc95ff325796adef0c25f6ff1e67478b3c312901b28ee --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -165,7 +165,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh ### 尚未与官方服务核实的内容 {#unverified-against-the-official-service} - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 -- Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 +- Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - Subagent 子级 Turns 和待处理的 Environment file 写入是否会阻止 Session 删除。 - 官方服务在待处理输入错误与未知结果目标之间的先后顺序。 - npm、initial-file 和 Skill 安装的失败原因没有官方样本。 diff --git a/contracts/agents-api/zh/subagents.md b/contracts/agents-api/zh/subagents.md index 761b4fc9e..b05ad9895 100644 --- a/contracts/agents-api/zh/subagents.md +++ b/contracts/agents-api/zh/subagents.md @@ -1,7 +1,7 @@ --- title: "子 Agent" source: contracts/agents-api/subagents.md -source_hash: d8341bfd7747152dea23afc930cf5fe07ff1ac4a58f503cb0e2ec5fe86060cb9 +source_hash: 11575ce73f7f00cb2fefd775d97899ac2439f04e0655a4980269bdb982740ad7 --- 启用 `multi_agent.enabled` 后,Harness 可以启动原生子 Agent。Core 通过锁定版本的 SDK(见 [`upstream.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))中的六项 Subagent 读取操作公开它们,并根据适配器观察结果记录它们。当 `multi_agent.enabled=false` 时,Runtime 会移除原生子 Agent 工具。[Harness capabilities](harness-capabilities.md) 列出各 Harness 在哪些组合中支持子 Agent。 @@ -26,7 +26,7 @@ source_hash: d8341bfd7747152dea23afc930cf5fe07ff1ac4a58f503cb0e2ec5fe86060cb9 子级工作仅出现在子 Agent 路由上。 - Session 的 `turns.list` 和 `turns.retrieve` 仅返回根级 Turns。将子级 Turn ID 作为路径参数或列表游标使用时,会返回与不存在的 Turn 相同的 404;Core 的 404 消息与官方消息不同。 -- Session 事件流和创建流仅传输根级工作:子级 Turns 及其 Items 不会发布任何 `agent.session.turn.*` 或 Item 内容事件。`agent.session.subagent.*` 事件和根级协调 Items 仍会保留。创建流仍会在根级收敛到 idle 状态时结束。 +- Session 事件流和创建流仅传输根级工作:子级 Turns 及其 Items 不会发布任何 `agent.session.turn.*` 或 Item 内容事件,但 Subagent 的函数调用会将其 `function_call` Item 作为 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID。`agent.session.subagent.*` 事件和根级协调 Items 仍会保留。创建流仍会在根级收敛到 idle 状态时结束。 - 子级 Turn 的 `agent_id` 是 Session 的 Agent ID(即直接子级的 `parent_agent_id`,也是 `create_subagent_call` 的 `agent_id`);`subagent_id` 标识该子级。嵌套子级遵循相同规则。根级 Turns 带有 `subagent_id: null`。 - Session Items 始终归根级所有;继承的原生父级对话记录不属于子级工作。Session 用量仅汇总根级 Turns。 diff --git a/services/core/internal/db/queries/functions.sql b/services/core/internal/db/queries/functions.sql index 783d67234..e9b6298bb 100644 --- a/services/core/internal/db/queries/functions.sql +++ b/services/core/internal/db/queries/functions.sql @@ -23,6 +23,9 @@ SELECT EXISTS ( SELECT 1 FROM function_calls WHERE session_id = sqlc.arg(session_id) AND call_id = sqlc.arg(call_id) )::boolean AS found; +-- name: FindFunctionCallTurn :one +SELECT turn_id, executor_call_id FROM function_calls WHERE session_id = $1 AND call_id = $2; + -- name: MatchFunctionResult :one SELECT (result IS NOT NULL)::boolean AS submitted, COALESCE(result = sqlc.arg(result)::jsonb, false)::boolean AS matches, withdrawn diff --git a/services/core/internal/db/sqlc/functions.sql.go b/services/core/internal/db/sqlc/functions.sql.go index 887a84cf7..fcb3aeb2b 100644 --- a/services/core/internal/db/sqlc/functions.sql.go +++ b/services/core/internal/db/sqlc/functions.sql.go @@ -55,6 +55,27 @@ func (q *Queries) CreateFunctionCall(ctx context.Context, arg CreateFunctionCall return result.RowsAffected(), nil } +const findFunctionCallTurn = `-- name: FindFunctionCallTurn :one +SELECT turn_id, executor_call_id FROM function_calls WHERE session_id = $1 AND call_id = $2 +` + +type FindFunctionCallTurnParams struct { + SessionID pgtype.UUID `json:"session_id"` + CallID string `json:"call_id"` +} + +type FindFunctionCallTurnRow struct { + TurnID pgtype.UUID `json:"turn_id"` + ExecutorCallID string `json:"executor_call_id"` +} + +func (q *Queries) FindFunctionCallTurn(ctx context.Context, arg FindFunctionCallTurnParams) (FindFunctionCallTurnRow, error) { + row := q.db.QueryRow(ctx, findFunctionCallTurn, arg.SessionID, arg.CallID) + var i FindFunctionCallTurnRow + err := row.Scan(&i.TurnID, &i.ExecutorCallID) + return i, err +} + const functionItemResult = `-- name: FunctionItemResult :one SELECT result FROM function_calls WHERE session_id = $1 AND turn_id = $2 AND call_id = $3 diff --git a/services/core/internal/persistence/postgres/sessionpg/functions.go b/services/core/internal/persistence/postgres/sessionpg/functions.go index 3ee5cb2d8..2f4b90131 100644 --- a/services/core/internal/persistence/postgres/sessionpg/functions.go +++ b/services/core/internal/persistence/postgres/sessionpg/functions.go @@ -71,6 +71,21 @@ func (t *SessionTx) FindResultTurn(ctx context.Context, turn string) (sessions.T return TurnFromRow(row), true, nil } +func (t *SessionTx) FindCallTurn(ctx context.Context, call string) (sessions.Turn, string, bool, error) { + row, err := t.q.FindFunctionCallTurn(ctx, sqlc.FindFunctionCallTurnParams{SessionID: t.session, CallID: call}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.Turn{}, "", false, nil + } + if err != nil { + return sessions.Turn{}, "", false, err + } + root, err := t.q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: t.session, ID: row.TurnID}) + if err != nil { + return sessions.Turn{}, "", false, err + } + return TurnFromRow(root), row.ExecutorCallID, true, nil +} + func (t *SessionTx) MatchFunctionResult(ctx context.Context, turn, call string, result json.RawMessage) (sessions.FunctionResultMatch, error) { id, err := parseID(turn) if err != nil { diff --git a/services/core/internal/sessions/execution_functions.go b/services/core/internal/sessions/execution_functions.go index 9495cc8fe..1cbe24700 100644 --- a/services/core/internal/sessions/execution_functions.go +++ b/services/core/internal/sessions/execution_functions.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" ) @@ -95,33 +97,35 @@ func decideFunctionWithdrawal(turn Turn, call FunctionCall) (bool, error) { } // resolveFunctionCall gives a call a Subagent made the identity of the child's -// function Item. A child the Session has not published, or a malformed native -// identity, is ErrInvalidInput. -func resolveFunctionCall(ctx context.Context, tx NativeSubagents, call FunctionCall) (FunctionCall, error) { +// function Item, and returns the child Turn's ID, empty for a root call. A +// child the Session has not published, or a malformed native identity, is +// ErrInvalidInput. +func resolveFunctionCall(ctx context.Context, tx NativeSubagents, call FunctionCall) (FunctionCall, string, error) { if call.Subagent == "" && call.SubagentTurn == "" { - return call, nil + return call, "", nil } if !validNativeIdentity(call.Subagent) || !validNativeIdentity(call.SubagentTurn) { - return call, ErrInvalidInput + return call, "", ErrInvalidInput } child, err := visibleSubagent(ctx, tx, call.Subagent) if err != nil { - return call, err + return call, "", err } - call.CallID = items.Identity(items.Identity(child.ID, "turn:"+call.SubagentTurn), "tool:"+call.ExecutorCallID) - return call, nil + turn := items.Identity(child.ID, "turn:"+call.SubagentTurn) + call.CallID = items.Identity(turn, "tool:"+call.ExecutorCallID) + return call, turn, nil } // RecordFunctionCall records a call an executor reported for its Turn and // journals the Turn's required actions with it. A call a Subagent made takes -// the identity of the child's function Item. A retry of the recorded call -// changes nothing. +// the identity of the child's function Item and is published as that Item. A +// retry of the recorded call changes nothing. func (o *ExecutionOperations) RecordFunctionCall(ctx context.Context, tenant, session, turn string, call FunctionCall) error { if err := validateFunctionCall(call); err != nil { return err } return o.storage.WithFunctionTurn(ctx, tenant, session, turn, func(ctx context.Context, tx FunctionTx, current Turn) error { - call, err := resolveFunctionCall(ctx, tx, call) + call, childTurn, err := resolveFunctionCall(ctx, tx, call) if err != nil { return err } @@ -136,6 +140,13 @@ func (o *ExecutionOperations) RecordFunctionCall(ctx context.Context, tenant, se if err := tx.CreateFunctionCall(ctx, current.ID, call); err != nil { return err } + if childTurn != "" { + // The official SDK answers calls from this event; child Items publish nothing else. + item := v1.Item{ID: call.CallID, TurnID: childTurn, Type: "function_call", Status: "in_progress", Name: call.Name, CallID: call.CallID, Arguments: call.Arguments} + if err := tx.AppendChanges(ctx, ItemChanges(items.Change{Item: item}, nil)...); err != nil { + return err + } + } return settleFunctionState(ctx, tx, current) }) } @@ -189,7 +200,7 @@ func (o *ExecutionOperations) WithdrawFunctionCall(ctx context.Context, tenant, return ErrInvalidInput } return o.storage.WithFunctionTurn(ctx, tenant, session, turn, func(ctx context.Context, tx FunctionTx, current Turn) error { - call, err := resolveFunctionCall(ctx, tx, call) + call, _, err := resolveFunctionCall(ctx, tx, call) if err != nil { return err } diff --git a/services/core/internal/sessions/functions.go b/services/core/internal/sessions/functions.go index 5810ef9d4..91376775c 100644 --- a/services/core/internal/sessions/functions.go +++ b/services/core/internal/sessions/functions.go @@ -6,7 +6,10 @@ import ( "fmt" "strings" + "github.com/google/uuid" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" ) @@ -87,6 +90,9 @@ type FunctionResultTx interface { // FindResultTurn reads the Session's Turn that a result names and reports // whether the Session has it. A malformed ID names no Turn. FindResultTurn(ctx context.Context, turn string) (Turn, bool, error) + // FindCallTurn reads the Turn that holds the Session's call, with the + // call's executor identity, and reports whether the Session has the call. + FindCallTurn(ctx context.Context, call string) (Turn, string, bool, error) // MatchFunctionResult compares result with the Turn's call. MatchFunctionResult(ctx context.Context, turn, call string, result json.RawMessage) (FunctionResultMatch, error) // SubmitFunctionResult saves the result of the Turn's call. @@ -99,13 +105,28 @@ type FunctionResultTx interface { // and returns that Turn, which the result's input joins. It never creates a // Turn. A result whose Turn does not have the call is // ErrFunctionCallTurnMismatch when another Turn of the Session has it and -// ErrUnknownFunctionCall otherwise; the answer depends only on the Session's +// ErrUnknownFunctionCall otherwise; a Subagent Turn that made the call stands +// for the root Turn that holds it; the answer depends only on the Session's // own calls. func AdmitFunctionResult(ctx context.Context, tx FunctionResultTx, input FunctionResultInput) (Turn, error) { turn, found, err := tx.FindResultTurn(ctx, input.TurnID) if err != nil { return Turn{}, err } + if _, malformed := uuid.Parse(input.TurnID); !found && malformed == nil { + // A result may name the Subagent Turn that made the call, as the + // call's Session event does. The child's function Item identity is + // derived from its Turn and the call's executor identity, so a match + // proves the call was made there; the result joins the root Turn + // holding the call. + holder, executor, held, err := tx.FindCallTurn(ctx, input.CallID) + if err != nil { + return Turn{}, err + } + if held && items.Identity(input.TurnID, "tool:"+executor) == input.CallID { + turn, found = holder, true + } + } if found { match, err := tx.MatchFunctionResult(ctx, turn.ID, input.CallID, input.Result) if err != nil { diff --git a/services/core/internal/sessions/functions_test.go b/services/core/internal/sessions/functions_test.go index c220b5c48..38726c9fa 100644 --- a/services/core/internal/sessions/functions_test.go +++ b/services/core/internal/sessions/functions_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "reflect" "strings" "testing" @@ -17,6 +18,7 @@ type fakeFunctionTx struct { loadPendingFunctionCalls func() ([]FunctionCall, error) findResultTurn func() (Turn, bool, error) + findCallTurn func() (Turn, string, bool, error) matchFunctionResult func() (FunctionResultMatch, error) submitFunctionResult func() error hasFunctionCall func() (bool, error) @@ -42,6 +44,11 @@ func (f *fakeFunctionTx) FindResultTurn(_ context.Context, turn string) (Turn, b return f.findResultTurn() } +func (f *fakeFunctionTx) FindCallTurn(_ context.Context, call string) (Turn, string, bool, error) { + f.record("FindCallTurn", f.findCallTurn != nil, call) + return f.findCallTurn() +} + func (f *fakeFunctionTx) MatchFunctionResult(_ context.Context, turn, call string, result json.RawMessage) (FunctionResultMatch, error) { f.record("MatchFunctionResult", f.matchFunctionResult != nil, turn, call, string(result)) return f.matchFunctionResult() @@ -199,6 +206,19 @@ func TestAdmitFunctionResult(t *testing.T) { } assertCalls(t, f.fakeTx, find, match, submit) }) + t.Run("a Subagent Turn stands for the root Turn holding its call", func(t *testing.T) { + const child = "7c1ad0f2-5e0c-4e5e-9b43-0d1c4c6a1f10" + call := items.Identity(child, "tool:native") + for name, executor := range map[string]string{"made there": "native", "made elsewhere": "other"} { + f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, nil }, + findCallTurn: func() (Turn, string, bool, error) { return waiting, executor, true, nil }, hasFunctionCall: returns(true), + matchFunctionResult: returns(FunctionResultMatch{Recorded: true}), submitFunctionResult: done} + turn, err := AdmitFunctionResult(t.Context(), f, FunctionResultInput{TurnID: child, CallID: call, Result: input.Result}) + if (name == "made there") != (err == nil) || (err == nil && turn.ID != testTurn) || (err != nil && !errors.Is(err, ErrFunctionCallTurnMismatch)) { + t.Errorf("%s: %v %v", name, turn, err) + } + } + }) t.Run("identical retry keeps the saved result", func(t *testing.T) { completed := turnWith(TurnCompleted) f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}, findResultTurn: found(completed), matchFunctionResult: returns(FunctionResultMatch{Recorded: true, Submitted: true, Matches: true})} @@ -235,13 +255,13 @@ func TestAdmitFunctionResult(t *testing.T) { "unknown Turn and call": {false, false, ErrUnknownFunctionCall}, } { f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}, hasFunctionCall: returns(test.elsewhere), matchFunctionResult: returns(FunctionResultMatch{}), - findResultTurn: func() (Turn, bool, error) { return waiting, test.turnFound, nil }} + findResultTurn: func() (Turn, bool, error) { return waiting, test.turnFound, nil }, findCallTurn: func() (Turn, string, bool, error) { return Turn{}, "", false, nil }} if _, err := AdmitFunctionResult(t.Context(), f, input); !errors.Is(err, test.want) { t.Errorf("%s: %v", name, err) } want := []string{find, match, "HasFunctionCall call"} if !test.turnFound { - want = []string{find, "HasFunctionCall call"} + want = []string{find, "FindCallTurn call", "HasFunctionCall call"} } assertCalls(t, f.fakeTx, want...) } @@ -251,7 +271,7 @@ func TestAdmitFunctionResult(t *testing.T) { "find": {fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, errStorage }}, "match": {fakeTx: &fakeTx{t: t}, findResultTurn: found(waiting), matchFunctionResult: func() (FunctionResultMatch, error) { return FunctionResultMatch{}, errStorage }}, "submit": {fakeTx: &fakeTx{t: t}, findResultTurn: found(waiting), matchFunctionResult: returns(FunctionResultMatch{Recorded: true}), submitFunctionResult: func() error { return errStorage }}, - "classify": {fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, nil }, + "classify": {fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, nil }, findCallTurn: func() (Turn, string, bool, error) { return Turn{}, "", false, nil }, hasFunctionCall: func() (bool, error) { return false, errStorage }}, } { if _, err := AdmitFunctionResult(t.Context(), f, input); !errors.Is(err, errStorage) { diff --git a/services/core/tests/integration/function_execution_test.go b/services/core/tests/integration/function_execution_test.go index f81acf33c..e4a163f26 100644 --- a/services/core/tests/integration/function_execution_test.go +++ b/services/core/tests/integration/function_execution_test.go @@ -141,12 +141,9 @@ func TestExecutionSubagentFunctionCallIsARequiredActionOfTheRootTurn(t *testing. if err != nil || state.LastTurn.ID != input.TurnID || state.LastTurn.Status != sessions.TurnWaiting || len(childItems.Data) != 1 || childItems.Data[0].CallID != action.CallID { t.Fatal(state, childItems, err) } - // The child Turn is not a Session Turn, so only the root Turn answers the call. + // The official SDK answers with the child Turn ID of the call's event. answer := json.RawMessage(`{"success":true,"output":"saved"}`) - if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, items.Identity(child.ID, "turn:child-turn"), action.CallID, answer); !errors.Is(err, sessions.ErrFunctionCallTurnMismatch) { - t.Fatal("the child Turn answered the call", err) - } - if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, input.TurnID, action.CallID, answer); err != nil { + if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, items.Identity(child.ID, "turn:child-turn"), action.CallID, answer); err != nil { t.Fatal(err) } var reply proto.FunctionResultPayload diff --git a/services/core/tests/integration/subagent_function_call_test.go b/services/core/tests/integration/subagent_function_call_test.go new file mode 100644 index 000000000..1497a3baa --- /dev/null +++ b/services/core/tests/integration/subagent_function_call_test.go @@ -0,0 +1,83 @@ +package integration + +import ( + "encoding/json" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" +) + +func TestSubagentFunctionCallIsPublishedAndAnsweredByChildTurn(t *testing.T) { + s, _ := testStore(t) + w := executionWriter(t, s) + ops := sessionExecution(t, w.lease) + ctx := t.Context() + tenant, session := newSubagentSession(t, s) + host, err := sessionService(t, s).CreateDevice(ctx, tenant, "child call", runtimedevice.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = ops.BindSessionDevice(ctx, tenant, session.ID, host.ID); err != nil { + t.Fatal(err) + } + root := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, w, tenant, session.ID, root, sessions.TurnQueued, sessions.TurnInProgress) + started := int64(101000) + turn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn", Status: sessions.TurnInProgress, CreatedAtMS: started, StartedAtMS: &started} + if err = ops.AppendTurnEvents(ctx, tenant, session.ID, root, 1, []sessions.ExecutionEvent{subagentIdentityEvent("child", "root", 100), subagentFact(proto.TypeSubagentTurn, turn)}); err != nil { + t.Fatal(err) + } + child, err := s.GetSubagentIdentity(ctx, tenant, session.ID, "child") + if err != nil { + t.Fatal(err) + } + turns, err := sessionAdapter(s).ListSubagentTurns(ctx, tenant, session.ID, child.ID, "", 20, true) + if err != nil || len(turns.Data) != 1 { + t.Fatal(turns, err) + } + childTurn := turns.Data[0].ID + before, err := sessionAdapter(s).SessionEventCursor(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + call := sessions.FunctionCall{CallID: "ignored", ExecutorCallID: "native-call", Name: "lookup", Arguments: json.RawMessage(`{"ticket":1}`), Subagent: "child", SubagentTurn: "native-turn"} + for range 2 { + if err = ops.RecordFunctionCall(ctx, tenant, session.ID, root, call); err != nil { + t.Fatal(err) + } + } + callID := items.Identity(childTurn, "tool:native-call") + changes, err := sessionAdapter(s).ListSessionEvents(ctx, tenant, session.ID, before) + if err != nil { + t.Fatal(err) + } + added := 0 + for _, change := range changes { + event := change.Event + if event.Type != "agent.session.turn.item.added" { + continue + } + added++ + item := event.Item + arguments, _ := json.Marshal(item.Arguments) + if event.TurnID != childTurn || item.Type != "function_call" || item.ID != callID || item.CallID != callID || item.TurnID != childTurn || item.Name != "lookup" || string(arguments) != `{"ticket":1}` || item.Status != "in_progress" { + t.Fatal("child function_call event", event) + } + } + if added != 1 { + t.Fatal("retry published again or nothing", added, changes) + } + assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 1) + // The official SDK answers with the child Turn ID the event names. + if err = SubmitFixtureFunctionResult(ctx, s, tenant, session.ID, childTurn, callID, json.RawMessage(`{"success":true,"output":"ok"}`)); err != nil { + t.Fatal(err) + } + if err = ops.ConfirmFunctionResult(ctx, tenant, session.ID, root, callID); err != nil { + t.Fatal(err) + } + assertFunctionState(t, s, tenant, session.ID, sessions.TurnInProgress, 0) +} From aa0702adae3edfe7c5e8b7bd5c7680f8e0f8bf42 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 10:48:07 +0800 Subject: [PATCH 26/39] Fix official API gaps in setup, MCP, event batches and mcode instructions (#852) * Accept an empty packages.system so a read Environment configuration round-trips * Raise the events per request bound to the official 16384 * Declare the MiniMax Code instructions limit and check it at admission * Record the official API native differences in the compatibility ledger * Wrap the input batch comment * Refresh the compatibility translation hash --- apps/daemon/internal/agent/mcode/options.go | 3 --- .../internal/agent/mcode/options_test.go | 1 - contracts/agents-api/environments.md | 4 +-- contracts/agents-api/harness-capabilities.md | 2 +- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/index.md | 7 +++++- contracts/agents-api/sessions-events.md | 4 +-- contracts/agents-api/zh/environments.md | 6 ++--- .../agents-api/zh/harness-capabilities.md | 4 +-- contracts/agents-api/zh/harness-onboarding.md | 4 +-- contracts/agents-api/zh/index.md | 9 +++++-- contracts/agents-api/zh/sessions-events.md | 6 ++--- internal/harnessconfig/harness.go | 6 +++++ internal/harnessconfig/mcode/declaration.go | 2 ++ .../internal/api/environment_packages_test.go | 11 +++++--- .../api/environment_preparation_input_test.go | 2 +- .../core/internal/api/environment_setup.go | 25 +++++++++++++------ services/core/internal/api/inputs.go | 4 +-- .../internal/execution/harness_admission.go | 4 +++ .../execution/harness_admission_test.go | 17 +++++++++++++ services/core/internal/sessions/inputs.go | 18 ++++++------- .../core/internal/sessions/inputs_test.go | 14 +++++++++-- 22 files changed, 108 insertions(+), 47 deletions(-) diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 703fdf0d7..8fb9e5fce 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -51,9 +51,6 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload, workspac } opts := req.AgentOptions prompt := optionString(opts, "system_prompt") - if len(prompt) > 32*1024 { - return result, fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") - } if err := os.WriteFile(filepath.Join(result.DataDir, "AGENTS.md"), []byte(prompt), 0o600); err != nil { return result, err } diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index 989475c9b..027b5a961 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -67,7 +67,6 @@ func TestOptionsRejectDroppedContext(t *testing.T) { name string edit func(*proto.PromptRequestPayload) }{ - {"oversized instructions", func(r *proto.PromptRequestPayload) { r.AgentOptions["system_prompt"] = strings.Repeat("x", 32*1024+1) }}, {"missing model", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model") }}, {"missing provider", func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }}, } diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 267de102c..44e56e033 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -106,7 +106,7 @@ The application owns the machine. It creates the Session with a clean absolute ` ## Input admission -Session input goes through `POST /agents/sessions/{id}/events` in ordered batches of 1–64 events. On an Environment-bearing Session, Core reserves input that cannot start yet until the Environment is connected and prepared. +Session input goes through `POST /agents/sessions/{id}/events` in ordered batches of 1–16384 events. On an Environment-bearing Session, Core reserves input that cannot start yet until the Environment is connected and prepared. A valid node-backed creation can wait for compute capacity after committing. Initial input uses the same reservation and deadline while waiting; creating without input still requests Environment preparation. [Placement and generation selection](./sandbox-deployment.md#generation-ownership-and-rollout) define the scheduling rules. @@ -222,7 +222,7 @@ Executor admission validates only the frozen descriptor. The preparation owner m The daemon runs as its launching account and never uses sudo or raises its permissions. Only user-directory dependencies install during preparation. - System dependencies must be preinstalled in the managed image or by the owner of a self-hosted machine. A missing executable or library fails the operation that needs it. -- `packages.system` is rejected in Templates and inline configuration, including a null or empty list (400, param `packages.system`). Package responses still carry the official required `system: []`. +- `packages.system` accepts null or an empty list, meaning no system packages, and rejects a nonempty list in Templates and inline configuration (400, param `packages.system`). Package responses carry the official required `system: []`, so a read configuration can be sent back unchanged. - npm installs into a local prefix and Python/pip into a local target under the Runtime package directory; Node/npm and Python/pip must already be installed. Their dependencies are visible to native tools in every working directory. - Setup commands run with Bash; on Windows, Git Bash is required and no other shell substitutes. The default working directory is `/workspace`. - On Windows, npm installation and stdio MCP commands named `npm` or `npx` (including their `.cmd` shims) run through npm's JavaScript entry point with Node, without an extra shell. diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index ad446300e..ee52be4d1 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -59,7 +59,7 @@ These operations need a workspace, so they apply to hosted and self-hosted place | [Initial files, setup commands, Skills and Plugins](./environments.md#runtime-capability-preparation) | Verified: hosted, self-hosted | Verified: self-hosted; admitted: hosted | Verified: self-hosted; admitted: hosted | | Capability directories | Admitted | Admitted | Admitted | | npm and Python packages | Admitted | Admitted | Admitted | -| `packages.system` | Rejected | Rejected | Rejected | +| `packages.system` | Rejected when nonempty | Rejected when nonempty | Rejected when nonempty | | Network `disabled` or `restricted` | Rejected | Rejected | Rejected | | [Plugin MCP over stdio](./environments.md#plugin-mcp) | Verified: hosted, self-hosted | Verified: self-hosted; admitted: hosted | Verified: self-hosted; admitted: hosted | | Plugin MCP over HTTP | Admitted, with literal headers or HTTPS bearer | Admitted, with literal headers or HTTPS bearer | Verified: self-hosted; admitted: hosted; literal headers or HTTPS bearer | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index e44277e89..695c28a4f 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -185,7 +185,7 @@ The declaration holds: - the public `Kind` and display `Label`; - the model `Configuration` ([native model configuration](#native-model-configuration)); - `Capabilities`, a `proto.AgentKindCapabilities` that states the most the Harness can do on a capable host, with every field `Supported` or `Unsupported` and the zero value invalid ([Capability declarations](../../docs/runtime-protocol.md#capability-declarations)); -- the supported `Placements` and public `MCPOrigins`, and whether whitespace-only text is accepted; +- the supported `Placements` and public `MCPOrigins`, whether whitespace-only text is accepted, and `MaxInstructionsBytes`, the native byte limit on an Agent's instructions (zero declares none), which admission rejects above; - `Execution`, the official execution option values the adapter maps to native settings: web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. Core admission and the Runtime reject an undeclared value with the same check ([tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling)). Core always sends the official default text verbosity, so `TextVerbosities` must include `medium`; - optional pure restrictions, `ValidateConfiguration`, `ValidateTools`, `ValidateMessageInput` and `ValidateFunctionResult`, that add native limits to the common checks. `ValidateMessageInput` runs only for messages that carry images, after the `MessageImages` capability check and before anything is stored, and decides which `image_url` values the Harness accepts; Core checks only that the reference is not blank. A nil validator means the common checks suffice. They cannot query business data, decrypt credentials or control native processes. Configuration restrictions run first, and a tool-decoding error precedes `ValidateTools`. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index f653f559a..260f76dd9 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -76,9 +76,11 @@ Each item is Core's deliberate or native behavior where the official service beh - Claude Code with live search on `environment: none` has only `WebSearch`, so search yields `search` actions and no page opens ([web search](./execution-tools.md#web-search-and-programmatic-tool-calling)). - An omitted reasoning effort stays null instead of taking the model's default. - Codex maps programmatic tool calling to its native `code_mode`, whose model-written scripts call the other tools; the feature is under development upstream. +- Codex rejects programmatic tool calling together with `multi_agent`. - A Session's `agent.tools` omits `tool_search` declarations. - Core rejects an inline `authorization` on a Session MCP transport, an `Authorization` entry in `headers` and a nonempty stdio `env`: credentials come only from Vaults (`credential_id`) and environment values only from the Environment (`env_vars`) ([MCP](./execution-tools.md#mcp)). - A `stdio` MCP server needs a workspace placement and the origin `environment`. Claude Code starts the server in its workspace directory, so `cwd` must equal it, with its whole process environment, which also holds the model provider key and other servers' Vault bearer tokens, and it needs `allowed_tools` null. MiniMax Code starts the server in its private home directory, so it rejects every `stdio` server ([MCP](./execution-tools.md#mcp)). +- Core rejects nonempty MCP `request_metadata`, a `stdio` server whose `connection_origin` is `service`, and a blank `server_label`, an empty `credential_id` and an empty `allowed_tools` name, which the pinned schema allows without defining their meaning (400 with the reason). - Claude rejects an MCP `server_url` or `headers` value containing `${`, because the native client expands `${VAR}` in both from a process environment that holds Core-held credentials. - Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returns 200. @@ -130,6 +132,8 @@ Each item is Core's deliberate or native behavior where the official service beh - A function result that cancellation prevents from being applied never appears as an Item. - Pinned Codex can lose command output emitted before its stream subscription. - Claude Code and MiniMax Code report no public usage. +- `agent_message` and `interrupt_subagent_call` Items are accepted by the Runtime protocol, but no Harness adapter produces them. +- Native file changes have no official Item type ([Turns and Items](./sessions-events.md#turns-and-items)). - MiniMax Code receives a message that starts with `/` with a trailing blank line, because ACP otherwise runs it as an operator command. - Core gives no crash-safe or exactly-once guarantee for native side effects; claimed work fails on restart without replay. - `file_id` and `detail` on images are rejected; each Harness declares which `image_url` values it accepts ([message content](./message-content.md#images)). @@ -139,12 +143,13 @@ Each item is Core's deliberate or native behavior where the official service beh - Hosted workspace admission is limited by the [tenant capacity policy](../../docs/configuration.md#tenant-capacity) and the [input reservation deadline](./environments.md#reservations). - MiniMax Code does not declare `retained_native_history`, so it cannot run on microsandbox, which keeps every Environment on workspace storage; Core rejects the Session ([Harness capabilities](./harness-capabilities.md)). - Only `enabled` networking runs, so Sessions that need `disabled` or `restricted` are rejected ([restricted network policy](./environments.md#restricted-network)). -- `packages.system` is rejected; system packages must be preinstalled. +- A nonempty `packages.system` is rejected; system packages must be preinstalled. - Cold continuation does not restore process memory, background processes or temporary system-disk changes, and completed setup is not replayed ([Environment persistence](./environments.md#runtime-capability-preparation)). On compatible microsandbox nodes with retained NFS storage, Codex and Claude Code are qualified for [checkpoint transfer](../../docs/sandbox-provider.md#checkpoint-transfer) and for continuation after [confirmed compute loss](../../docs/sandbox-provider.md#confirmed-compute-loss). Takeover of an unconfirmed old writer, host-crash durability and arbitrary crash-damaged history are unqualified. **Files and Environment files** - Files accept only `purpose=user_data`; other purposes, `expires_after` and the Uploads API are not supported. +- The Skills create route accepts a single `files` part, but the pinned SDK sends no part when `files` is one file instead of a list ([upload a bundle](./source-files.md#upload-a-bundle)). - An Environment file write whose outcome is uncertain is never retried or recovered automatically; it blocks further writes and messages to the Session. **Vaults and Credentials** diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index b56910b2a..f5261daa0 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -43,9 +43,9 @@ A Session stays usable after a Turn fails: new input starts a new Turn. Later re ## Send input -`POST /v1/agents/sessions/{session_id}/events` takes an ordered array of 1 to 64 events: `agent.session.input.message`, `agent.session.input.cancel` and `agent.session.input.tool_result`. The whole batch is admitted atomically, and the response is 202 with no body once the batch is stored, before any Harness reads it. Admission never confirms native application. +`POST /v1/agents/sessions/{session_id}/events` takes an ordered array of 1 to 16384 events: `agent.session.input.message`, `agent.session.input.cancel` and `agent.session.input.tool_result`. The whole batch is admitted atomically, and the response is 202 with no body once the batch is stored, before any Harness reads it. Admission never confirms native application. -- **Limits.** The request body is at most 1 MiB, and the stored input of one request at most 512 KiB. +- **Limits.** The request body is at most 4 MiB, and the stored input of one request at most 2 MiB. - **Null batch.** An explicit `null` for `events` is invalid. - **Empty batch.** `{"events": []}` checks that the Session exists and returns 202. It creates no Turn, Item or retry identity. - **Retries.** An `Idempotency-Key` of up to 128 bytes identifies the whole ordered batch. The same key and batch return 202 again without admitting anything twice; the same key with another batch returns 409 `idempotency_conflict`. A request without a key is always new. diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index a663bb140..11257f0be 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 6b609956acde1748bfc1762ba5428959a88de8b42cbcaa9297fb89c1c2c013bb +source_hash: 826b3d0c74ea82af5c4f7e6094b71257a7e56fba896d5fc8a58234bc3bed5249 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -108,7 +108,7 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会 ## 输入接纳 {#input-admission} -Session 输入通过 `POST /agents/sessions/{id}/events` 以每次 1–64 个事件的有序批次提交。对于带 Environment 的 Session,Core 会将尚不能启动的输入预留,直到 Environment 完成连接和准备。 +Session 输入通过 `POST /agents/sessions/{id}/events` 以每次 1–16384 个事件的有序批次提交。对于带 Environment 的 Session,Core 会将尚不能启动的输入预留,直到 Environment 完成连接和准备。 有效的节点模式创建可以在提交后等待计算容量。初始输入在等待期间仍使用同一 reservation 和期限;无输入创建也会请求 Environment 准备。[Placement 与代次选择](./sandbox-deployment.md#generation-ownership-and-rollout) 定义调度规则。 @@ -224,7 +224,7 @@ Executor 接纳仅验证已冻结的描述符。准备所有者会在调用原 daemon 以启动它的账户身份运行,绝不使用 sudo 或提升权限。准备过程中只会安装用户目录中的依赖项。 - 系统依赖必须预先安装在托管镜像中,或由自托管机器的所有者安装。缺少可执行文件或库时,需要该依赖的操作会失败。 -- 模板和内联配置都会拒绝 `packages.system`,包括 null 或空列表(400,param `packages.system`)。软件包响应仍会包含官方要求的 `system: []`。 +- `packages.system` 接受 null 或空列表(表示没有系统软件包),模板和内联配置会拒绝非空列表(400,param `packages.system`)。软件包响应包含官方要求的 `system: []`,因此读取到的配置可以原样回传。 - npm 会安装到本地 prefix,Python/pip 会安装到 Runtime 软件包目录下的本地 target;Node/npm 和 Python/pip 必须已经安装。其依赖项可被每个工作目录中的原生工具看到。 - 设置命令使用 Bash 运行;在 Windows 上必须使用 Git Bash,且不能由其他 shell 替代。默认工作目录为 `/workspace`。 - 在 Windows 上,npm 安装以及名为 `npm` 或 `npx` 的 stdio MCP 命令(包括其 `.cmd` shim)会通过 Node 调用 npm 的 JavaScript 入口点运行,而不经过额外的 shell。 diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index 0470a3c18..89012f5be 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: 507c77c74145cac386ac8dd0e231d5701821c00490c0c5db2d25f052911a3107 +source_hash: bd03ebf9e556b71091fed42d1ba52b2002207962ccc4f7c3765104c82bddc3b5 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -61,7 +61,7 @@ Claude 结构化输出要求 medium verbosity,可与 `multi_agent`、Skill、P | [初始文件、设置命令、Skill 与 Plugin](environments.md#runtime-capability-preparation) | 已验证:托管、自托管 | 已验证:自托管;已准入:托管 | 已验证:自托管;已准入:托管 | | 能力目录 | 已准入 | 已准入 | 已准入 | | npm 与 Python 包 | 已准入 | 已准入 | 已准入 | -| `packages.system` | 已拒绝 | 已拒绝 | 已拒绝 | +| `packages.system` | 非空时拒绝 | 非空时拒绝 | 非空时拒绝 | | 网络 `disabled` 或 `restricted` | 已拒绝 | 已拒绝 | 已拒绝 | | [stdio Plugin MCP](environments.md#plugin-mcp) | 已验证:托管、自托管 | 已验证:自托管;已准入:托管 | 已验证:自托管;已准入:托管 | | HTTP Plugin MCP | 已准入,字面值头或 HTTPS bearer | 已准入,字面值头或 HTTPS bearer | 已验证:自托管;已准入:托管;字面值头或 HTTPS bearer | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index c2cab3c52..9df78c657 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: b3bd31e1272d7788db048b986cae774f4bc52902e91669ef7a87f86594014e18 +source_hash: 7fc635c711d04760883093996eb6415dde15c5cb42f3c5aed24f8f3cc20e87ab --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -187,7 +187,7 @@ Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-res - 公共 `Kind` 和显示用的 `Label`; - 模型 `Configuration`([原生模型配置](#native-model-configuration)); - `Capabilities`,即 `proto.AgentKindCapabilities`,表示 Harness 在能力完备的主机上最多能做什么;每个字段都是 `Supported` 或 `Unsupported`,零值无效([能力声明](../../../docs/zh/runtime-protocol.md#capability-declarations)); -- 受支持的 `Placements` 和公共 `MCPOrigins`,以及是否接受仅含空白的文本; +- 受支持的 `Placements` 和公共 `MCPOrigins`、是否接受仅含空白的文本,以及 `MaxInstructionsBytes`(原生对 Agent instructions 的字节上限,零表示没有上限,超出时准入会拒绝); - `Execution`,即适配器映射到原生设置的官方执行选项取值:web search 模式及是否接受搜索设置、推理强度和摘要、服务层级、程序化工具调用取值和文本详细程度。Core 准入和 Runtime 用同一检查拒绝未声明的取值([工具策略](execution-tools.md#web-search-and-programmatic-tool-calling))。Core 总会发送官方默认文本详细程度,因此 `TextVerbosities` 必须包含 `medium`; - 可选的纯限制 `ValidateConfiguration`、`ValidateTools`、`ValidateMessageInput` 和 `ValidateFunctionResult`,在通用检查之外添加原生限制。`ValidateMessageInput` 仅对携带图像的消息运行,在 `MessageImages` 能力检查之后、任何存储之前执行,决定 Harness 接受哪些 `image_url`;Core 只检查引用不为空白。验证函数为 nil 表示通用检查已足够。它们不能查询业务数据、解密凭据或控制原生进程。配置限制最先执行,工具解码错误先于 `ValidateTools`。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index cfa35b171..24f7e3d51 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: ae8f903f342adedb090dc95ff325796adef0c25f6ff1e67478b3c312901b28ee +source_hash: 4bccb4aaa93b5773708c46915447791443532cc3d0a9705124ee9e5812c5bf05 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -79,9 +79,11 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Claude Code 在 `environment: none` 上启用 live 搜索时只有 `WebSearch`,因此搜索只产生 `search` 动作,不会打开页面([网页搜索](./execution-tools.md#web-search-and-programmatic-tool-calling))。 - 未指定推理强度时会保持为 null,而不是采用模型的默认值。 - Codex 把程序化工具调用映射到原生 `code_mode`,由模型编写的脚本调用其他工具;该功能在上游仍处于开发中。 +- Codex 拒绝同时使用程序化工具调用和 `multi_agent`。 - Session 的 `agent.tools` 会省略 `tool_search` 声明。 - Core 拒绝 Session MCP 传输中的内联 `authorization`、`headers` 中的 `Authorization` 条目以及非空的 stdio `env`:凭据只来自 Vault(`credential_id`),环境值只来自 Environment(`env_vars`)([MCP](execution-tools.md#mcp))。 - `stdio` MCP 服务器需要工作区部署位置和 `environment` 来源。Claude Code 在其工作区目录中启动服务器,因此 `cwd` 必须与之相同;服务器继承整个进程环境,其中也有模型提供方密钥和其他服务器的 Vault bearer 令牌;且 `allowed_tools` 须为 null。MiniMax Code 在其私有主目录中启动服务器,因此拒绝所有 `stdio` 服务器([MCP](execution-tools.md#mcp))。 +- Core 拒绝非空的 MCP `request_metadata`、`connection_origin` 为 `service` 的 `stdio` 服务器,以及空白的 `server_label`、空的 `credential_id` 和空的 `allowed_tools` 名称;固定版本的 schema 允许这些值但没有定义其含义(400,并说明原因)。 - Claude 会拒绝包含 `${` 的 MCP `server_url` 或 `headers` 值,因为原生客户端会从持有 Core 所持凭据的进程环境中展开这两处的 `${VAR}`。 - 在 events 202 之后立即删除 Session 会返回 409,因为 Core 会在同一事务中准入该 Turn;官方服务返回 200。 @@ -133,6 +135,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 如果取消导致函数结果无法应用,该结果将永远不会作为 Item 出现。 - 固定版本的 Codex 可能会丢失在订阅其流之前发出的命令输出。 - Claude Code 和 MiniMax Code 都不报告公共用量。 +- Runtime 协议接受 `agent_message` 和 `interrupt_subagent_call` Item,但没有 Harness 适配器会产生它们。 +- 原生文件变更没有对应的官方 Item 类型([Turns and Items](sessions-events.md#turns-and-items))。 - 以 `/` 开头的消息到达 MiniMax Code 时末尾多一个空行,否则 ACP 会将其作为操作员命令执行。 - 对于原生副作用,Core 不提供崩溃安全或恰好一次保证;已认领的工作若不重放,会在重启后失败。 - 图像上的 `file_id` 和 `detail` 会被拒绝;每个 Harness 声明自己接受哪些 `image_url`([消息内容](message-content.md#images))。 @@ -142,12 +146,13 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Hosted 工作区准入受[租户容量策略](../../../docs/zh/configuration.md#tenant-capacity)及既有[输入预留截止时间](environments.md#reservations)限制。 - MiniMax Code 未声明 `retained_native_history`,因此无法在 microsandbox 上运行(其每个 Environment 都使用工作区存储);Core 会拒绝该 Session([Harness 能力](./harness-capabilities.md))。 - 只有 `enabled` 网络可以运行,因此需要 `disabled` 或 `restricted` 的 Session 会被拒绝([restricted network policy](environments.md#restricted-network))。 -- `packages.system` 会被拒绝;系统软件包必须预先安装。 +- 非空的 `packages.system` 会被拒绝;系统软件包必须预先安装。 - 冷续接不恢复进程内存、后台进程或临时系统盘修改,已完成的 setup 不会重放(参见 [Environment 持久化](environments.md#runtime-capability-preparation))。在使用保留 NFS 存储的兼容 microsandbox 节点上,Codex 和 Claude Code 已通过[检查点转移](../../../docs/zh/sandbox-provider.md#checkpoint-transfer)以及[已确认的计算丢失](../../../docs/zh/sandbox-provider.md#confirmed-compute-loss)后续接的资格验证。接管未确认停止的旧写入者、主机崩溃时的持久性及任意崩溃受损历史的完整性仍未通过资格验证。 **Files 和 Environment files** - Files 仅接受 `purpose=user_data`;不支持其他 `purpose` 值、`expires_after` 和 Uploads API。 +- Skills 创建路由接受单个 `files` 部分,但当 `files` 是单个文件而不是列表时,固定版本的 SDK 不会发送任何部分([上传 bundle](source-files.md#upload-a-bundle))。 - 结果不确定的 Environment file 写入不会自动重试或恢复;它会阻止后续写入以及向 Session 发送消息。 **Vaults 和 Credentials** diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 2ccb30548..a0ba61cb4 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: 45b743284c8a03aa016c1b874f6dbeb578e7ec7afcbc565bdcafba333481c950 +source_hash: 2984a8001807449fb923cecbd50fae872c1de9a4a1575ebb055d155b32c361ed --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -45,9 +45,9 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 ## 发送输入 {#send-input} -`POST /v1/agents/sessions/{session_id}/events` 接收包含 1 到 64 个事件的有序数组:`agent.session.input.message`、`agent.session.input.cancel` 和 `agent.session.input.tool_result`。整个批次以原子方式接纳;批次存储后且在任何 Harness 读取它之前,响应为无正文的 202。接纳绝不表示已在原生侧应用。 +`POST /v1/agents/sessions/{session_id}/events` 接收包含 1 到 16384 个事件的有序数组:`agent.session.input.message`、`agent.session.input.cancel` 和 `agent.session.input.tool_result`。整个批次以原子方式接纳;批次存储后且在任何 Harness 读取它之前,响应为无正文的 202。接纳绝不表示已在原生侧应用。 -- **限制。** 请求正文最多为 1 MiB,单次请求存储的输入最多为 512 KiB。 +- **限制。** 请求正文最多为 4 MiB,单次请求存储的输入最多为 2 MiB。 - **空值批次。** 将 `events` 显式设为 `null` 无效。 - **空批次。** `{"events": []}` 会检查 Session 是否存在并返回 202。它不会创建 Turn、Item 或重试标识。 - **重试。** 最长 128 字节的 `Idempotency-Key` 标识整个有序批次。相同密钥和批次会再次返回 202,而不会重复接纳任何内容;相同密钥搭配另一个批次会返回 409 `idempotency_conflict`。不带密钥的请求始终是新请求。 diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 356020490..51838d690 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -97,6 +97,9 @@ type Declaration struct { // text. Unsupported Harnesses reject them at admission; Core never trims or // pads model input to fit a Harness. WhitespaceOnlyText proto.CapabilitySupport + // MaxInstructionsBytes is the native limit on an Agent's instructions in + // bytes; zero declares none. Admission rejects longer instructions. + MaxInstructionsBytes int // ValidateConfiguration adds pure restrictions on a saved Agent and // Environment. ValidateConfiguration func(Admission) error @@ -142,6 +145,9 @@ func (d Declaration) ValidateDeclaration() error { if d.WhitespaceOnlyText != proto.CapabilitySupported && d.WhitespaceOnlyText != proto.CapabilityUnsupported { return errors.New("harnessconfig: whitespace-only text support must be declared") } + if d.MaxInstructionsBytes < 0 { + return errors.New("harnessconfig: invalid instructions limit") + } if len(d.Placements) == 0 || !validChoices(d.Placements, "none", "openai_hosted", "self_hosted") { return errors.New("harnessconfig: invalid placements") } diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index e0f6330ac..b302695dd 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -67,6 +67,8 @@ var Declaration = harnessconfig.Declaration{ Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPOrigins: []string{"service", "environment"}, WhitespaceOnlyText: proto.CapabilityUnsupported, + // Native MiniMax Code ignores global instructions (AGENTS.md) over 32 KiB. + MaxInstructionsBytes: 32 * 1024, ValidateTools: func(functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { if err := harnessconfig.RequireObjectSchemas(functions); err != nil { return err diff --git a/services/core/internal/api/environment_packages_test.go b/services/core/internal/api/environment_packages_test.go index 454c45983..bd350dec2 100644 --- a/services/core/internal/api/environment_packages_test.go +++ b/services/core/internal/api/environment_packages_test.go @@ -10,7 +10,7 @@ import ( ) func TestSystemPackagesRejectedAtEveryInput(t *testing.T) { - for _, value := range []string{`null`, `[]`, `["jq"]`} { + for _, value := range []string{`["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) { @@ -29,8 +29,13 @@ func TestSystemPackagesRejectedAtEveryInput(t *testing.T) { } }) } - if _, err := decodeTemplateInput([]byte(`{"packages":{"npm":["semver"],"python":["packaging"]}}`)); err != nil { - t.Fatal("supported package managers rejected", err) + for _, system := range []string{``, `,"system":null`, `,"system":[]`} { + if _, err := decodeTemplateInput([]byte(`{"packages":{"npm":["semver"],"python":["packaging"]` + system + `}}`)); err != nil { + t.Fatal("supported package managers or an empty system list rejected", system, err) + } + if _, err := decodeSessionEnvironment(json.RawMessage(`{"type":"openai_hosted","packages":{"npm":["semver"]` + system + `}}`)); err != nil { + t.Fatal("Session rejected an empty system list", system, err) + } } } diff --git a/services/core/internal/api/environment_preparation_input_test.go b/services/core/internal/api/environment_preparation_input_test.go index 8904e087c..659049161 100644 --- a/services/core/internal/api/environment_preparation_input_test.go +++ b/services/core/internal/api/environment_preparation_input_test.go @@ -46,7 +46,7 @@ func TestPreparationExtensionRejectsAmbiguousAndPlacementInput(t *testing.T) { `{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"workspace_directory":"/other"}}}`, `{"environment":{"type":"self_hosted","workspace_directory":"/work","capability_directories":[]},"x_agents_core":{"environment":{"capability_directories":[]}}}`, `{"environment":{"type":"openai_hosted","env":null},"x_agents_core":{"environment":{"env":{"KEY":"value"}}}}`, - `{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"packages":{"system":[]}}}}`, + `{"environment":{"type":"self_hosted","workspace_directory":"/work"},"x_agents_core":{"environment":{"packages":{"system":["jq"]}}}}`, } { var request decodedSessionRequest if err := json.Unmarshal([]byte(input), &request); err != nil { diff --git a/services/core/internal/api/environment_setup.go b/services/core/internal/api/environment_setup.go index 1ec72d077..8e0958841 100644 --- a/services/core/internal/api/environment_setup.go +++ b/services/core/internal/api/environment_setup.go @@ -16,10 +16,20 @@ var errSystemPackages = &fieldError{ 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 - } + if json.Unmarshal(raw, &packages) != nil { + return nil + } + // The official schema reads back `system: []`, so null and an empty list mean none. + value, supplied := packages["system"] + if !supplied { + return nil + } + var system []json.RawMessage + if json.Unmarshal(value, &system) != nil { + return sessions.ErrInvalidInput + } + if len(system) != 0 { + return errSystemPackages } return nil } @@ -67,10 +77,11 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (environmentconfi } if value, ok := fields["packages"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { var input struct { - NPM []*string `json:"npm"` - Python []*string `json:"python"` + NPM []*string `json:"npm"` + Python []*string `json:"python"` + System json.RawMessage `json:"system"` // checked by rejectSystemPackages } - if decodeInputObject(value, &input, "npm", "python") != nil { + if decodeInputObject(value, &input, "npm", "python", "system") != nil { return result, sessions.ErrInvalidInput } for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python} { diff --git a/services/core/internal/api/inputs.go b/services/core/internal/api/inputs.go index 8f38a4404..98d476978 100644 --- a/services/core/internal/api/inputs.go +++ b/services/core/internal/api/inputs.go @@ -21,7 +21,7 @@ type InputAdmission interface { } func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObject(w, r) + raw, ok := readJSONObjectLimit(w, r, 4<<20, "Request exceeds 4 MiB.") if !ok { return } @@ -77,7 +77,7 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { } func executionInputs(events []json.RawMessage) ([]sessions.Input, error) { - if len(events) == 0 || len(events) > 64 { + if len(events) == 0 || len(events) > 16384 { return nil, sessions.ErrInvalidInput } inputs := make([]sessions.Input, 0, len(events)) diff --git a/services/core/internal/execution/harness_admission.go b/services/core/internal/execution/harness_admission.go index c4a4a995c..6065a82aa 100644 --- a/services/core/internal/execution/harness_admission.go +++ b/services/core/internal/execution/harness_admission.go @@ -3,6 +3,7 @@ package execution import ( "encoding/json" "errors" + "fmt" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -40,6 +41,9 @@ func validateDeclaredConfiguration(declaration harnessconfig.Declaration, snapsh if snapshot.Agent.Text.Format.Type == "json_schema" && !declaration.Capabilities.StructuredOutput.IsSupported() { return errors.New("Structured output is not supported by this engine.") } + if limit := declaration.MaxInstructionsBytes; limit > 0 && snapshot.Agent.Instructions != nil && len(*snapshot.Agent.Instructions) > limit { + return fmt.Errorf("Instructions exceed this engine's limit of %d bytes.", limit) + } if declaration.ValidateConfiguration != nil { if err := declaration.ValidateConfiguration(admissionOf(snapshot)); err != nil { return admissionError(err) diff --git a/services/core/internal/execution/harness_admission_test.go b/services/core/internal/execution/harness_admission_test.go index 26264aea6..e3be4c12c 100644 --- a/services/core/internal/execution/harness_admission_test.go +++ b/services/core/internal/execution/harness_admission_test.go @@ -3,6 +3,7 @@ package execution import ( "encoding/json" "errors" + "strings" "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -28,6 +29,22 @@ func TestAcceptedEnginePlacements(t *testing.T) { } } +func TestDeclaredInstructionsLimit(t *testing.T) { + session := func(engine string, size int) error { + raw := json.RawMessage(`{"agent":{"model":"fixture","instructions":"` + strings.Repeat("x", size) + `"},"environment":{"type":"none"}}`) + return (Policy{}).ValidateSessionConfiguration(engine, raw) + } + if err := session("mcode", 32*1024); err != nil { + t.Fatal("limit-sized instructions rejected", err) + } + if err := session("mcode", 32*1024+1); err == nil { + t.Fatal("instructions over the declared limit accepted") + } + if err := session("codex", 32*1024+1); err != nil { + t.Fatal("a Harness without a declared limit rejected instructions", err) + } +} + func TestAdditionalDeclarationUsesCommonAdmission(t *testing.T) { configurationChecked, toolsChecked, resultChecked := false, false, false declaration := harnesstest.Declaration("fixture", func(d *harnessconfig.Declaration) { diff --git a/services/core/internal/sessions/inputs.go b/services/core/internal/sessions/inputs.go index c2159017d..48aae4155 100644 --- a/services/core/internal/sessions/inputs.go +++ b/services/core/internal/sessions/inputs.go @@ -39,21 +39,21 @@ func ValidateInputKey(key string) error { return nil } -// ValidateInputs checks and normalizes an input batch: 1..64 messages, -// cancellations and function results whose nonempty JSON object payloads total -// at most 512 KiB, an empty cancellation payload and a well-formed function -// result. It returns the normalized batch and its encoding, the batch's retry -// identity. +// ValidateInputs checks and normalizes an input batch: 1..16384 messages (the +// official events bound), cancellations and function results whose nonempty +// JSON object payloads total at most 2 MiB, which fits 16384 minimal messages, +// an empty cancellation payload and a well-formed function result. It returns +// the normalized batch and its encoding, the batch's retry identity. func ValidateInputs(inputs []Input) ([]Input, json.RawMessage, error) { - if len(inputs) == 0 || len(inputs) > 64 { - return nil, nil, fmt.Errorf("%w: input batch must contain 1..64 events", ErrInvalidInput) + if len(inputs) == 0 || len(inputs) > 16384 { + return nil, nil, fmt.Errorf("%w: input batch must contain 1..16384 events", ErrInvalidInput) } batch := make([]Input, len(inputs)) size := 0 for i, input := range inputs { size += len(input.Payload) - if size > 512*1024 || len(input.Payload) == 0 || (input.Kind != "message" && input.Kind != "cancel" && input.Kind != "tool_result") { - return nil, nil, fmt.Errorf("%w: input payloads must be nonempty and total at most 512 KiB", ErrInvalidInput) + if size > 2<<20 || len(input.Payload) == 0 || (input.Kind != "message" && input.Kind != "cancel" && input.Kind != "tool_result") { + return nil, nil, fmt.Errorf("%w: input payloads must be nonempty and total at most 2 MiB", ErrInvalidInput) } payload, err := jsonobject.Normalize(input.Payload) if err != nil { diff --git a/services/core/internal/sessions/inputs_test.go b/services/core/internal/sessions/inputs_test.go index d847b3f82..c2884b5d0 100644 --- a/services/core/internal/sessions/inputs_test.go +++ b/services/core/internal/sessions/inputs_test.go @@ -136,12 +136,12 @@ func TestValidateInputs(t *testing.T) { } for name, inputs := range map[string][]Input{ "no inputs": nil, - "65 inputs": make([]Input, 65), + "16385 inputs": make([]Input, 16385), "unsupported kind": {messageInput("ok"), {Kind: "unsupported", Payload: json.RawMessage(`{}`)}}, "missing payload": {{Kind: "message"}}, "array payload": {{Kind: "message", Payload: json.RawMessage(`[]`)}}, "cancel with a target": {{Kind: "cancel", Payload: json.RawMessage(`{"target":"other"}`)}}, - "over 512 KiB": {messageInput(strings.Repeat("x", 300*1024)), messageInput(strings.Repeat("y", 300*1024))}, + "over 2 MiB": {messageInput(strings.Repeat("x", 1200*1024)), messageInput(strings.Repeat("y", 1200*1024))}, } { if _, _, err := ValidateInputs(inputs); !errors.Is(err, ErrInvalidInput) { t.Errorf("%s: %v", name, err) @@ -149,6 +149,16 @@ func TestValidateInputs(t *testing.T) { } } +func TestValidateInputsFitsTheOfficialMaximum(t *testing.T) { + inputs := make([]Input, 16384) + for i := range inputs { + inputs[i] = messageInput("a") + } + if _, _, err := ValidateInputs(inputs); err != nil { + t.Fatal(err) + } +} + func TestValidateMessageInputs(t *testing.T) { if _, encoded, err := validateMessageInputs([]Input{messageInput("hi")}); err != nil || string(encoded) != `[{"kind":"message","payload":`+hi+`}]` { t.Fatalf("batch %s, %v", encoded, err) From 13cd5c25a7f0099d2b730f745d159d6cb0386008 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 10:52:29 +0800 Subject: [PATCH 27/39] Report CPU only as the Runtime history ratio (#850) * Report CPU only as the Runtime history ratio * Drop the observation CPU from the sandbox runtime fixtures --- apps/web/e2e/data/routes.mjs | 6 ++-- .../features/metrics/SandboxMetricsPage.tsx | 15 ++-------- .../features/metrics/sandbox-runtime.test.ts | 12 ++++---- .../src/features/metrics/sandbox-runtime.ts | 6 ++-- .../src/features/overview/test-fixtures.ts | 1 - .../sessions/SessionRuntimeSection.tsx | 5 +--- apps/web/src/i18n/locales/en/metrics.ts | 1 - apps/web/src/i18n/locales/en/sessions.ts | 3 +- apps/web/src/i18n/locales/zh-CN/metrics.ts | 1 - apps/web/src/i18n/locales/zh-CN/sessions.ts | 3 +- contracts/agents-api/core.openapi.yaml | 29 ------------------- .../agents-api/runtime-observability-api.md | 16 ---------- contracts/agents-api/sandbox-deployment.md | 2 +- .../agents-api/v1/runtime_observations.go | 7 ----- .../zh/runtime-observability-api.md | 18 +----------- contracts/agents-api/zh/sandbox-deployment.md | 4 +-- docs/web/index.md | 2 +- docs/zh/web/index.md | 4 +-- .../agents-client/src/admin-client.test.ts | 2 -- packages/agents-client/src/client.test.ts | 16 ---------- packages/agents-client/src/client.ts | 22 ++------------ .../agents-client/src/generated/core-api.ts | 12 ++------ packages/agents-client/src/types.ts | 5 ++-- .../core/internal/api/runtime_observations.go | 3 -- .../internal/api/runtime_observations_test.go | 13 ++++----- 25 files changed, 35 insertions(+), 173 deletions(-) diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index 8f585d2a8..adddd9dea 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -100,11 +100,11 @@ export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "http }); const observations = sessions.map((session) => { const base = { id: session.id, object: "agent.runtime_observation", session_id: session.id, resolved_at: now }; - if (session.environment.type === "none") return { ...base, environment_id: null, mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, cpu: null, memory: null }; - if (session.environment.type === "self_hosted") return { ...base, environment_id: session.environment.id, mode: "self_hosted", provider_type: null, instance: { kind: "self_hosted_connection", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, cpu: null, memory: null }; + if (session.environment.type === "none") return { ...base, environment_id: null, mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, memory: null }; + if (session.environment.type === "self_hosted") return { ...base, environment_id: session.environment.id, mode: "self_hosted", provider_type: null, instance: { kind: "self_hosted_connection", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, memory: null }; const allocation = allocations.find((entry) => entry.session_id === session.id); const sleeping = allocation.compute_phase === "suspended"; - return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, cpu: sleeping ? null : { usage_seconds_total: 600 + Math.floor(rand() * 4000), capacity_cores: 2, utilization_ratio: null }, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } }; + return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } }; }); // Added after everything else is generated, so the seeded data above does not change. agents.push(...providerAgentDefinitions.map((definition, index) => ({ diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index b0f5160d4..e2af69bff 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -23,7 +23,7 @@ import { TableSkeleton } from "../../components/Skeleton"; import { useFailureToast } from "../../components/Toast"; import { ListToolbar, listSummary, NameCell, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; -import { formatBytes, formatClock, formatCompact, formatDuration, formatInteger, formatPercent, MISSING } from "../../lib/format"; +import { formatBytes, formatClock, formatCompact, formatDuration, formatInteger, MISSING } from "../../lib/format"; import { projectClient, ProjectName, useProjects } from "../../lib/projects"; import { coreError } from "../../lib/core-error"; import { loadRuntimeDurableSnapshot, RUNTIME_DURABLE_RANGES, RuntimeDurableHistoryIncompleteError, type RuntimeDurableRange } from "../dashboard/runtime-history"; @@ -277,7 +277,6 @@ function RuntimeTable({ rows, showProject, showNode, onOpen }: { rows: HostedRun {t("sandbox.status")}{t("sandbox.statusHelp")} {showProject ? {tCommon("project.column")} : null} {showNode ? {t("sandbox.node")} : null} - {t("sandbox.cpu")} {t("sandbox.memory")} {t("sandbox.uptime")} {t("sandbox.tokens")} @@ -288,7 +287,6 @@ function RuntimeTable({ rows, showProject, showNode, onOpen }: { rows: HostedRun const { observation, session } = row; const observed = observation.status === "observed"; const sampleGap = runtimeSampleGap(observation); - const cpu = observed ? observation.cpu : null; const memory = observed ? observation.memory : null; const open = () => onOpen(observation.session_id); return ( @@ -299,16 +297,7 @@ function RuntimeTable({ rows, showProject, showNode, onOpen }: { rows: HostedRun {showProject ? : null} {showNode ? : null} - {sampleGap ? {t(`sandbox.reason.${sampleGap}`)} : <> - - {cpu?.utilization_ratio != null ? ( - // Providers that report only a utilization ratio: show it as a share of the sandbox's CPU. - - - {formatPercent(cpu.utilization_ratio, locale)} - - ) : {MISSING}} - + {sampleGap ? {t(`sandbox.reason.${sampleGap}`)} : <> {memory?.usage_bytes != null ? ( diff --git a/apps/web/src/features/metrics/sandbox-runtime.test.ts b/apps/web/src/features/metrics/sandbox-runtime.test.ts index c5edb27d6..b73c657a4 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.test.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.test.ts @@ -5,7 +5,7 @@ import type { SandboxAllocation } from "@oac/agents-client"; import { hostedObservation, node, session } from "../overview/test-fixtures"; import { hostedRuntimeRows, hostedRuntimeUsage, loadHostedRuntimes, matchesRuntime, runtimeSampleGap, runtimeSnapshot } from "./sandbox-runtime"; -const none = { ...hostedObservation("plain", "p1"), mode: "none", instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", cpu: null, memory: null } as unknown as ReturnType; +const none = { ...hostedObservation("plain", "p1"), mode: "none", instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", memory: null } as unknown as ReturnType; describe("loadHostedRuntimes", () => { it("keeps hosted observations, reads their Sessions through their project and bounds the reads", async () => { @@ -37,8 +37,8 @@ describe("hosted Runtime projections", () => { const load = { observations: [ hostedObservation("a", "p1"), - hostedObservation("b", "p2", { lifecycle_state: "sleeping", status: "observed", cpu: null, memory: null }), - hostedObservation("c", "p2", { lifecycle_state: "pending", status: "unavailable", reason: "allocation_pending", cpu: null, memory: null, observed_at: null }), + hostedObservation("b", "p2", { lifecycle_state: "sleeping", status: "observed", memory: null }), + hostedObservation("c", "p2", { lifecycle_state: "pending", status: "unavailable", reason: "allocation_pending", memory: null, observed_at: null }), ], sessions: new Map([["a", session("a", { metadata: { title: "Nightly build" } })], ["b", session("b")]]), unread: 0, @@ -56,10 +56,10 @@ describe("hosted Runtime projections", () => { expect(one.sessions.map((entry) => entry.id)).toEqual(["b"]); }); - it("sums only reported CPU and memory and counts lifecycle states", () => { + it("sums only reported memory and counts lifecycle states", () => { expect(hostedRuntimeUsage(load.observations)).toEqual({ hosted: 3, active: 1, sleeping: 1, pending: 1, observed: 2, - cpuCapacityCores: 2, memoryUsageBytes: 100, memoryLimitBytes: 400, + memoryUsageBytes: 100, memoryLimitBytes: 400, }); }); @@ -77,7 +77,7 @@ describe("hosted Runtime projections", () => { }); it("names a missing sample only for a sandbox Core records as active", () => { - const missing = { status: "unavailable", observed_at: null, started_at: null, cpu: null, memory: null } as const; + const missing = { status: "unavailable", observed_at: null, started_at: null, memory: null } as const; expect(runtimeSampleGap(hostedObservation("a", "p", { ...missing, reason: "sample_timeout" }))).toBe("sample_timeout"); expect(runtimeSampleGap(hostedObservation("b", "p", { ...missing, lifecycle_state: "sleeping", reason: "runtime_not_running" }))).toBeNull(); expect(runtimeSampleGap(hostedObservation("c", "p"))).toBeNull(); diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index ce03cb329..768f45716 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -9,7 +9,7 @@ import { type OwnedRuntimeObservation } from "../../lib/admin-view"; * Hosted Runtimes across every project: the observations come from the Web * API's deployment-wide list (each labelled with its project); the Sessions * they belong to are read by ID through their project, bounded. Only - * Core-managed hosted sandboxes report CPU and memory. + * Core-managed hosted sandboxes report memory. */ /** Most hosted Sessions read by ID on one load; the rest are listed by ID only. */ @@ -97,9 +97,8 @@ export interface HostedRuntimeUsage { sleeping: number; /** Transitioning or waiting for an allocation. */ pending: number; - /** Runtimes with a current CPU and memory sample. */ + /** Runtimes with a current memory sample. */ observed: number; - cpuCapacityCores: number | null; memoryUsageBytes: number | null; memoryLimitBytes: number | null; } @@ -114,7 +113,6 @@ export function hostedRuntimeUsage(observations: readonly OwnedRuntimeObservatio sleeping: hosted.filter((observation) => observation.lifecycle_state === "sleeping").length, pending: hosted.filter((observation) => observation.lifecycle_state === "pending" || observation.lifecycle_state === "transitioning").length, observed: observed.length, - cpuCapacityCores: sumKnown(observed.map((observation) => observation.cpu?.capacity_cores)), memoryUsageBytes: sumKnown(observed.map((observation) => observation.memory?.usage_bytes)), memoryLimitBytes: sumKnown(observed.map((observation) => observation.memory?.limit_bytes)), }; diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index ba4ce669d..98229483c 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -51,7 +51,6 @@ export function hostedObservation(sessionId: string, projectId: string, override environment_id: `env_${sessionId}`, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: `alloc_${sessionId}`, device_id: null, connection_generation: null }, lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: 100, observed_at: 1_000, started_at: 400, - cpu: { usage_seconds_total: 10, capacity_cores: 2, utilization_ratio: null }, memory: { usage_bytes: 100, limit_bytes: 400 }, project_id: projectId, ...overrides, diff --git a/apps/web/src/features/sessions/SessionRuntimeSection.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.tsx index 9799a20ee..0488f5e8e 100644 --- a/apps/web/src/features/sessions/SessionRuntimeSection.tsx +++ b/apps/web/src/features/sessions/SessionRuntimeSection.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { failedLast, useFailureToast } from "../../components/Toast"; import { DashboardSkeleton } from "../../components/Skeleton"; import { Kpi, KpiStrip, Section, SegmentedControl } from "../../components/console-ui"; -import { formatBytes, formatDateTime, formatPercent, formatRelative, MISSING } from "../../lib/format"; +import { formatBytes, formatDateTime, formatRelative, MISSING } from "../../lib/format"; import { coreError } from "../../lib/core-error"; import { RuntimeCharts } from "../metrics/RuntimeCharts"; import { runtimeSampleGap } from "../metrics/sandbox-runtime"; @@ -83,9 +83,7 @@ export function SessionRuntimeSection({ // The same state and sample reason as Sandbox metrics' Hosted Runtimes. const stateLabel = observation?.lifecycle_state ? tMetrics(`sandbox.lifecycle.${observation.lifecycle_state}`) : MISSING; const sampleGap = observation ? runtimeSampleGap(observation) : null; - const cpu = observation?.status === "observed" ? observation.cpu : null; const memory = observation?.status === "observed" ? observation.memory : null; - const cpuValue = cpu?.utilization_ratio != null ? formatPercent(cpu.utilization_ratio, locale) : MISSING; const memoryValue = memory?.usage_bytes != null ? memory.limit_bytes != null ? `${formatBytes(memory.usage_bytes)} / ${formatBytes(memory.limit_bytes)}` : formatBytes(memory.usage_bytes) : MISSING; @@ -107,7 +105,6 @@ export function SessionRuntimeSection({ > - {formatRelative(observedAt, Math.floor(Date.now() / 1000), locale)}} tone={sampleGap ? "warning" : undefined} /> diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 4ada3db8e..66e191a70 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -211,7 +211,6 @@ export const metrics = { runtimeUnavailable: "Runtime observations are unavailable. {{reason}}", runtimeStale: "Refresh failed; showing the previous observations. {{reason}}", runtimePartial: "Session details were not read for {{unread}} Runtimes (over the read limit) and could not be read for {{failed}}. Those rows show the Session ID only.", - cpu: "CPU", cores: "{{value}} cores", memory: "Memory", disk: "Disk", diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index 0f14224dc..2ac68318a 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -118,9 +118,8 @@ export const sessions = { }, runtime: { title: "Runtime", - help: "CPU and memory of this Session's hosted sandbox as observed by Core, and the samples Core retained for the chosen range.", + help: "Current memory of this Session's hosted sandbox as observed by Core, and the CPU and memory samples Core retained for the chosen range.", state: "State", - cpu: "CPU", memory: "Memory", observed: "Observed", range: "Time range", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 4d9405e28..b17eccf8e 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -211,7 +211,6 @@ export const metrics = { runtimeUnavailable: "Runtime 观测不可用。{{reason}}", runtimeStale: "刷新失败,显示的是上次的观测。{{reason}}", runtimePartial: "有 {{unread}} 个 Runtime 超过读取上限、{{failed}} 个读取失败,未读取 Session 详情,这些行只显示 Session ID。", - cpu: "CPU", cores: "{{value}} 核", memory: "内存", disk: "磁盘", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index b7b5c9bea..f8ffc6a85 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -120,9 +120,8 @@ export const sessions = { }, runtime: { title: "Runtime", - help: "Core 观测到的此 Session 托管沙箱的 CPU 和内存,以及所选时间范围内 Core 保留的采样。", + help: "Core 观测到的此 Session 托管沙箱当前内存,以及所选时间范围内 Core 保留的 CPU 和内存采样。", state: "状态", - cpu: "CPU", memory: "内存", observed: "观测时间", range: "时间范围", diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 967570464..0a95caad4 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -66,10 +66,6 @@ definitions: minimum: 0 type: integer x-nullable: true - cpu: - allOf: - - $ref: '#/definitions/v1.RuntimeCPUObservation' - x-nullable: true disk: allOf: - $ref: '#/definitions/api.RuntimeDiskObservation' @@ -143,7 +139,6 @@ definitions: type: string required: - allocation_created_at - - cpu - disk - environment_id - id @@ -2694,25 +2689,6 @@ definitions: required: - type type: object - v1.RuntimeCPUObservation: - properties: - capacity_cores: - minimum: 5e-324 - type: number - x-nullable: true - usage_seconds_total: - minimum: 0 - type: number - x-nullable: true - utilization_ratio: - minimum: 0 - type: number - x-nullable: true - required: - - capacity_cores - - usage_seconds_total - - utilization_ratio - type: object v1.RuntimeHistory: properties: coverage: @@ -3056,10 +3032,6 @@ definitions: minimum: 0 type: integer x-nullable: true - cpu: - allOf: - - $ref: '#/definitions/v1.RuntimeCPUObservation' - x-nullable: true environment_id: format: uuid type: string @@ -3126,7 +3098,6 @@ definitions: type: string required: - allocation_created_at - - cpu - environment_id - id - instance diff --git a/contracts/agents-api/runtime-observability-api.md b/contracts/agents-api/runtime-observability-api.md index 70a22d616..d5066a752 100644 --- a/contracts/agents-api/runtime-observability-api.md +++ b/contracts/agents-api/runtime-observability-api.md @@ -49,11 +49,6 @@ The list has one row for every Session of every Project that is not deleted, inc "resolved_at": 1789953021, "observed_at": 1789953020, "started_at": 1789951220, - "cpu": { - "usage_seconds_total": 482.75, - "capacity_cores": 2.0, - "utilization_ratio": null - }, "memory": { "usage_bytes": 805306368, "limit_bytes": 2147483648 @@ -95,7 +90,6 @@ This returns one `RuntimeObservation`, without `disk`. It accepts no query param | `resolved_at` | integer | Unix seconds when Core resolved this row. | | `observed_at` | integer or null | Unix seconds of the provider sample; null without a sample. | | `started_at` | integer or null | Unix seconds when the current compute incarnation started. | -| `cpu` | object or null | Null when no CPU value was observed. | | `memory` | object or null | Null when no memory value was observed. | `lifecycle_state` comes from Core's allocation records, never from the sample: @@ -117,16 +111,6 @@ This returns one `RuntimeObservation`, without `disk`. It accepts no query param | `device_id` | The Runtime device bound to the managed allocation, when there is one; null otherwise. | | `connection_generation` | Always null: Core does not observe self-hosted connections. | -### `cpu` - -All fields are finite nonnegative numbers or null. Zero is an observed zero; null is unavailable. - -| Field | Meaning | -| --- | --- | -| `usage_seconds_total` | Cumulative CPU seconds of the current compute incarnation (Docker, microsandbox). | -| `capacity_cores` | Configured CPU capacity, greater than zero. | -| `utilization_ratio` | Provider-reported share of `capacity_cores` (E2B), not clamped; null for providers that report cumulative CPU time. | - ### `memory` `usage_bytes` and `limit_bytes` are safe JSON integers or null. Zero usage is observed zero; `limit_bytes` is at least 1, and an unknown or unlimited limit is null. diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 22f6c0740..c9dd555ee 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -227,7 +227,7 @@ Some fields keep one name across providers but differ in meaning, or do not appl | Node `retained`, `snapshots`, `max_retained` | Suspended sandboxes are `retained` minus `active`; `snapshots` also includes retained snapshots during restore or cleanup | Not applicable | Docker never suspends: `retained` equals `active`, `snapshots` is 0 and `max_retained` equals `max_active` | | Node `host.available_disk_bytes` | Free space on the filesystem of the node state directory; sandbox disks have their own quotas | Not applicable | Free space on the filesystem of the node state directory, not a container's disk | | Allocation `compute_phase`, `compute_phase_changed_at` | Includes `suspended`; the timestamp describes the current phase, not snapshot expiry | Not applicable: no node allocations | Always `disabled`, counted as running until release; the time is the allocation's creation | -| Runtime observation `cpu`, `memory` | From the VM: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | From E2B metrics: `cpu.utilization_ratio` and `capacity_cores`, memory usage and limit; no cumulative CPU time | From Docker stats: `cpu.usage_seconds_total`, CPU and memory limits, memory usage | +| Runtime observation `memory` | From the VM: memory limit and usage | From E2B metrics: memory usage and limit | From Docker stats: memory limit and usage | | Runtime observation `disk` | `null` | E2B `diskUsed` and `diskTotal`; `null` when the template does not report them | `null`: no disk quota | | Runtime observation `lifecycle_state: sleeping` | While suspended | Never | Never | | Runtime history CPU | Derived from cumulative CPU time | Mean of the utilization ratios E2B reported in each bucket | Derived from cumulative CPU time | diff --git a/contracts/agents-api/v1/runtime_observations.go b/contracts/agents-api/v1/runtime_observations.go index cc8c6084d..adefd888c 100644 --- a/contracts/agents-api/v1/runtime_observations.go +++ b/contracts/agents-api/v1/runtime_observations.go @@ -15,7 +15,6 @@ type RuntimeObservation struct { ResolvedAt int64 `json:"resolved_at" binding:"required" minimum:"0"` ObservedAt *int64 `json:"observed_at" extensions:"x-nullable" binding:"required" minimum:"0"` StartedAt *int64 `json:"started_at" extensions:"x-nullable" binding:"required" minimum:"0"` - CPU *RuntimeCPUObservation `json:"cpu" extensions:"x-nullable" binding:"required"` Memory *RuntimeMemoryObservation `json:"memory" extensions:"x-nullable" binding:"required"` } @@ -26,12 +25,6 @@ type RuntimeInstance struct { ConnectionGeneration *string `json:"connection_generation" extensions:"x-nullable" binding:"required" format:"uuid"` } -type RuntimeCPUObservation struct { - UsageSecondsTotal *float64 `json:"usage_seconds_total" extensions:"x-nullable" binding:"required" minimum:"0"` - CapacityCores *float64 `json:"capacity_cores" extensions:"x-nullable" binding:"required" minimum:"5e-324"` - UtilizationRatio *float64 `json:"utilization_ratio" extensions:"x-nullable" binding:"required" minimum:"0"` -} - type RuntimeMemoryObservation struct { UsageBytes *uint64 `json:"usage_bytes" extensions:"x-nullable" binding:"required" minimum:"0"` LimitBytes *uint64 `json:"limit_bytes" extensions:"x-nullable" binding:"required" minimum:"1"` diff --git a/contracts/agents-api/zh/runtime-observability-api.md b/contracts/agents-api/zh/runtime-observability-api.md index 8c45bda02..e03bd4b29 100644 --- a/contracts/agents-api/zh/runtime-observability-api.md +++ b/contracts/agents-api/zh/runtime-observability-api.md @@ -1,7 +1,7 @@ --- title: "Runtime 遥测 API" source: contracts/agents-api/runtime-observability-api.md -source_hash: 17bdd8f0821388294d9454d9b6640a33df850756d34c40d0ee5b1551173fef07 +source_hash: 7dcff114daa6c74d1ee08709f2f68d1b8d97c7225991669fb6431c684e34c26b --- Core 通过 `/core/v1` 下的只读管理员路由报告托管 Runtime 和沙箱节点所使用的信息:当前 Runtime 观测值、单个 Session 的已存储 Runtime 历史记录,以及沙箱节点的主机观测值和历史记录。读取操作绝不创建、唤醒、续期或更改计算资源,也绝不向历史记录添加样本。[Runtime observability](runtime-observability.md) 定义了 Core 如何采集和保留这些值;[Console API usage](../../../docs/zh/web/console-api-usage.md) 列出了读取这些值的 Web 页面。 @@ -51,11 +51,6 @@ Authorization: Bearer "resolved_at": 1789953021, "observed_at": 1789953020, "started_at": 1789951220, - "cpu": { - "usage_seconds_total": 482.75, - "capacity_cores": 2.0, - "utilization_ratio": null - }, "memory": { "usage_bytes": 805306368, "limit_bytes": 2147483648 @@ -97,7 +92,6 @@ Authorization: Bearer | `resolved_at` | integer | Core 解析此行时的 Unix 秒数。 | | `observed_at` | integer 或 null | 提供商样本的 Unix 秒数;无样本时为 null。 | | `started_at` | integer 或 null | 当前计算实例代次启动时的 Unix 秒数。 | -| `cpu` | object 或 null | 未观测到 CPU 值时为 null。 | | `memory` | object 或 null | 未观测到内存值时为 null。 | `lifecycle_state` 来自 Core 的分配记录,绝不来自样本: @@ -119,16 +113,6 @@ Authorization: Bearer | `device_id` | 存在时,为绑定到托管分配的 Runtime 设备;其他情况下为 null。 | | `connection_generation` | 始终为 null:Core 不观测自托管连接。 | -### `cpu` {#cpu} - -所有字段均为有限非负数或 null。0 表示观测到的零;null 表示不可用。 - -| 字段 | 含义 | -| --- | --- | -| `usage_seconds_total` | 当前计算实例代次的累计 CPU 秒数(Docker、microsandbox)。 | -| `capacity_cores` | 已配置的 CPU 容量,大于零。 | -| `utilization_ratio` | 提供商报告的 `capacity_cores` 占比(E2B),不进行钳制;对于报告累计 CPU 时间的提供商为 null。 | - ### `memory` {#memory} `usage_bytes` 和 `limit_bytes` 是 JSON 安全整数或 null。使用量为零表示观测到零;`limit_bytes` 至少为 1,未知或无限制的限额为 null。 diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 8ddf657b2..3487b491e 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 673ae35810faa99f2a3fa480f6b487b4b8a3e7a21a47a4b1fa9c22df6bc53845 +source_hash: 279d5e0a8587dd866ff19f4d653fc91ab3f6026cf043bca57af104c34ecfc45c --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -232,7 +232,7 @@ Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。 | 节点 `retained`、`snapshots`、`max_retained` | 已暂停沙箱数为 `retained` 减去 `active`;`snapshots` 还包含恢复或清理期间保留的快照 | 不适用 | Docker 绝不暂停:`retained` 等于 `active`,`snapshots` 为 0,`max_retained` 等于 `max_active` | | 节点 `host.available_disk_bytes` | 节点状态目录所在文件系统的可用空间;沙箱磁盘有自己的配额 | 不适用 | 节点状态目录所在文件系统的可用空间,而不是容器的磁盘 | | 分配 `compute_phase`、`compute_phase_changed_at` | 包含 `suspended`;时间表示当前阶段的起点,不是快照到期时间 | 不适用:没有节点分配 | 始终为 `disabled`,在释放前计为运行中;该时间为分配创建时间 | -| Runtime 观测 `cpu`、`memory` | 来自 VM:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | 来自 E2B 指标:`cpu.utilization_ratio` 和 `capacity_cores`、内存使用量和限制;无累计 CPU 时间 | 来自 Docker stats:`cpu.usage_seconds_total`、CPU 和内存限制、内存使用量 | +| Runtime 观测 `memory` | 来自 VM:内存限制和使用量 | 来自 E2B 指标:内存使用量和限制 | 来自 Docker stats:内存限制和使用量 | | Runtime 观测 `disk` | `null` | E2B `diskUsed` 和 `diskTotal`;模板未报告时为 `null` | `null`:无磁盘配额 | | Runtime 观测 `lifecycle_state: sleeping` | 暂停期间 | 从不 | 从不 | | Runtime 历史 CPU | 根据累计 CPU 时间派生 | 每个时间桶中 E2B 所报告使用率比值的平均值 | 根据累计 CPU 时间派生 | diff --git a/docs/web/index.md b/docs/web/index.md index df93900a9..186bb186e 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -21,7 +21,7 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Monitor | Overview | Service status, running Sessions, **Sandbox capacity** and work needing attention; 24-hour Session activity; Core and its nodes as a topology, each opening its page; Needs attention: failed Sessions and Sessions waiting for their caller, active in the last 24 hours, each with its one-line reason; usage by Project | | Monitor | Core metrics | Whether Core can run Turns: execution slots, the Turn queue and its p95 wait, interrupted Turns, connected daemons, database latency, CPU and resident memory against their limits, background jobs; Go heap, goroutines and the database pool under **Diagnostics** | | Monitor | Agent metrics | Requests, errors, duration, tokens, models, tools, Agents and API keys over 1 h, 6 h, 24 h or 7 d | -| Monitor | Sandbox metrics | Hosted Runtimes across Projects over time, active against total, each with its status (active, suspended, changing state, waiting for allocation or stopped) and its CPU and memory observations; an E2B deployment adds the sandboxes Core holds in E2B's cloud | +| Monitor | Sandbox metrics | Hosted Runtimes across Projects over time, active against total, each with its status (active, suspended, changing state, waiting for allocation or stopped) and its memory observation and CPU and memory history; an E2B deployment adds the sandboxes Core holds in E2B's cloud | | Monitor | Request log | Recent HTTP requests to Core, their status, elapsed time and safe error codes; [scope and retention](../../contracts/agents-api/admin-api.md#request-log) | | Monitor | Session log | Every Session, and each Session's read-only conversation, trace and Turns with the classified reason of a failure and its next step; a self-hosted Session's page also manages its executor credentials and gives the command that connects a host | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion, with the Project and the creating key of each resource | diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md index 80d3906d0..a1e140659 100644 --- a/docs/zh/web/index.md +++ b/docs/zh/web/index.md @@ -1,7 +1,7 @@ --- title: "Web 控制台" source: docs/web/index.md -source_hash: c28ab8e8f10d5cb7ac6aea42ef4f9a111007a3833bf7949c037b2ce71a50ac19 +source_hash: eacc43b23569a21ee55d858602846ec93626432c81acba0792a94b0e1f0e1848 --- Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥登录,查看健康状态和用量,检查应用创建的内容,并管理项目、密钥、节点和设置。应用使用项目 API 密钥调用 `/v1`,不使用 Web。 @@ -23,7 +23,7 @@ Web 是 OpenAgentCore 安装的管理员控制台:运维人员用 Core 密钥 | 监控 | 概览 | 服务状态、运行中的 Session、**沙箱容量**和待处理工作;24 小时 Session 活动;以拓扑展示 Core 与节点,各自打开对应页面;需要处理:过去 24 小时内活跃的失败或等待调用方的 Session,每行附一行原因;按项目统计的用量 | | 监控 | Core 监控 | Core 能否运行 Turn:执行槽位、Turn 队列及其 p95 等待时间、被中断的 Turn、已连接守护进程、数据库延迟、CPU 与常驻内存及其限制、后台任务;Go 堆、goroutine 和数据库连接池位于 **诊断** | | 监控 | Agent 监控 | 1 小时、6 小时、24 小时或 7 天的请求、错误、耗时、token、模型、工具、Agent 和 API 密钥 | -| 监控 | 沙箱监控 | 各项目托管 Runtime 随时间的变化,显示活跃数量与总数,以及各自的状态(活跃、已挂起、切换中、等待分配或已停止)和 CPU、内存观测;E2B 部署还显示 Core 在 E2B 云中持有的沙箱 | +| 监控 | 沙箱监控 | 各项目托管 Runtime 随时间的变化,显示活跃数量与总数,以及各自的状态(活跃、已挂起、切换中、等待分配或已停止)、内存观测和 CPU、内存历史;E2B 部署还显示 Core 在 E2B 云中持有的沙箱 | | 监控 | 请求日志 | 到达 Core 的近期 HTTP 请求、状态、耗时和安全错误码;[范围及保留方式](../../../contracts/agents-api/zh/admin-api.md#request-log) | | 监控 | Session 日志 | 全部 Session;各 Session 的只读对话、跟踪、Turn、失败分类原因及下一步操作;自托管 Session 页面还管理执行器凭据并提供主机连接命令 | | 资源 | Agent、Environment 模板、Skills、文件、Vault | 检查和允许的删除操作,显示各资源所属项目及创建密钥 | diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index c6827ddad..541650322 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -439,7 +439,6 @@ describe("AdminClient project monitoring", () => { instance: { kind: "managed_allocation", allocation_id: resourceId, device_id: keyId, connection_generation: null }, lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: 10, resolved_at: 30, observed_at: 20, started_at: 10, - cpu: { usage_seconds_total: 0, capacity_cores: 2, utilization_ratio: null }, memory: { usage_bytes: 0, limit_bytes: 1024 }, disk: null, }; @@ -462,7 +461,6 @@ describe("AdminClient project monitoring", () => { instance: { kind: "managed_allocation", allocation_id: resourceId, device_id: keyId, connection_generation: null }, lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: 10, resolved_at: 30, observed_at: 20, started_at: 10, - cpu: { usage_seconds_total: null, capacity_cores: 2, utilization_ratio: 0.1955 }, memory: { usage_bytes: 183836672, limit_bytes: 2079141888 }, disk: { usage_bytes: 1593188352, limit_bytes: 23511863296 }, }; diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index f9a1d817d..108f866d9 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -133,11 +133,6 @@ function runtimeObservation(overrides: Record = {}): Record { await expect(client.retrieveRuntimeObservation(runtimeProjectId, runtimeSessionId)).resolves.toMatchObject({ id: runtimeSessionId, - cpu: { usage_seconds_total: 0 }, memory: { usage_bytes: 0 }, }); expect(String(calls[0]?.input)).toBe( @@ -2756,7 +2750,6 @@ describe("OpenAIAgentsClient", () => { allocation_created_at: null, observed_at: null, started_at: null, - cpu: null, memory: null, }); const client = new AdminClient({ fetch: recordingFetch(jsonResponse(value), []) }); @@ -2769,15 +2762,6 @@ describe("OpenAIAgentsClient", () => { ["invalid status/reason", () => ({ ...runtimeObservation(), status: "observed", reason: "sample_timeout" })], ["invalid lifecycle state", () => ({ ...runtimeObservation(), lifecycle_state: "paused" })], ["invalid mode/instance", () => ({ ...runtimeObservation(), mode: "none" })], - ["negative CPU", () => ({ ...runtimeObservation(), cpu: { - usage_seconds_total: -1, capacity_cores: 2, utilization_ratio: null, - } })], - ["non-numeric CPU", () => ({ ...runtimeObservation(), cpu: { - usage_seconds_total: "NaN", capacity_cores: 2, utilization_ratio: null, - } })], - ["zero CPU capacity", () => ({ ...runtimeObservation(), cpu: { - usage_seconds_total: 1, capacity_cores: 0, utilization_ratio: null, - } })], ["unsafe memory", () => ({ ...runtimeObservation(), memory: { usage_bytes: Number.MAX_SAFE_INTEGER + 1, limit_bytes: 1024, } })], diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index de493c103..b1984d011 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -24,7 +24,7 @@ import { verbosityResourceValues, } from "./generated/public-api"; import { - runtimeCPUObservationFields, runtimeInstanceFields, runtimeMemoryObservationFields, runtimeObservationFields, + runtimeInstanceFields, runtimeMemoryObservationFields, runtimeObservationFields, runtimeObservationLifecycleStateValues, runtimeObservationModeValues, runtimeObservationReasonValues, runtimeObservationStatusValues, } from "./generated/core-api"; import { projectTokenUsage } from "./usage-projection"; @@ -931,7 +931,7 @@ export function projectRuntimeObservation(value: unknown, expectedSessionId?: st observedAt > value.resolved_at )) || (!observed && ( - observedAt !== null || startedAt !== null || value.cpu !== null || value.memory !== null + observedAt !== null || startedAt !== null || value.memory !== null )) || (value.status === "unsupported" && ( (!isNone && !isSelfHosted) || value.reason !== "runtime_mode_not_observable" @@ -943,22 +943,6 @@ export function projectRuntimeObservation(value: unknown, expectedSessionId?: st (allocationCreatedAt !== null && allocationCreatedAt > value.resolved_at) ) return invalidRuntimeObservation(); - let cpu: RuntimeObservation["cpu"] = null; - if (value.cpu !== null) { - if (!observed || !isRecord(value.cpu) || !exactFields(value.cpu, runtimeCPUObservationFields)) { - return invalidRuntimeObservation(); - } - cpu = { - usage_seconds_total: nullableRuntimeNumber(value.cpu.usage_seconds_total), - capacity_cores: nullableRuntimeNumber(value.cpu.capacity_cores), - utilization_ratio: nullableRuntimeNumber(value.cpu.utilization_ratio), - }; - if ( - Object.values(cpu).every((entry) => entry === null) || - (cpu.capacity_cores !== null && cpu.capacity_cores === 0) - ) return invalidRuntimeObservation(); - } - let memory: RuntimeObservation["memory"] = null; if (value.memory !== null) { if (!observed || !isRecord(value.memory) || !exactFields(value.memory, runtimeMemoryObservationFields)) { @@ -983,7 +967,7 @@ export function projectRuntimeObservation(value: unknown, expectedSessionId?: st status: value.status, reason: value.reason as RuntimeObservation["reason"], lifecycle_state: value.lifecycle_state as RuntimeObservation["lifecycle_state"], allocation_created_at: allocationCreatedAt, resolved_at: value.resolved_at, - observed_at: observedAt, started_at: startedAt, cpu, memory, + observed_at: observedAt, started_at: startedAt, memory, } as RuntimeObservation; } diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 074141c2f..be0433809 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -26,7 +26,6 @@ export interface AdminRuntimeObservation { export const adminRuntimeObservationFields = ["observation", "project_id"] as const; export interface AdminRuntimeObservationDetail { allocation_created_at: number | null; - cpu: RuntimeCPUObservation | null; disk: RuntimeDiskObservation | null; environment_id: string | null; id: string; @@ -43,7 +42,7 @@ export interface AdminRuntimeObservationDetail { started_at: number | null; status: AdminRuntimeObservationDetailStatus; } -export const adminRuntimeObservationDetailFields = ["allocation_created_at", "cpu", "disk", "environment_id", "id", "instance", "lifecycle_state", "memory", "mode", "object", "observed_at", "provider_type", "reason", "resolved_at", "session_id", "started_at", "status"] as const; +export const adminRuntimeObservationDetailFields = ["allocation_created_at", "disk", "environment_id", "id", "instance", "lifecycle_state", "memory", "mode", "object", "observed_at", "provider_type", "reason", "resolved_at", "session_id", "started_at", "status"] as const; export const adminRuntimeObservationDetailLifecycleStateValues = ["active", "sleeping", "transitioning", "pending", "stopped"] as const; export type AdminRuntimeObservationDetailLifecycleState = (typeof adminRuntimeObservationDetailLifecycleStateValues)[number]; export const adminRuntimeObservationDetailModeValues = ["none", "self_hosted", "openai_hosted"] as const; @@ -674,12 +673,6 @@ export interface RolloutNodes { export const rolloutNodesFields = ["failed", "preparing", "ready", "unknown"] as const; export const rolloutStateValues = ["settled", "preparing"] as const; export type RolloutState = (typeof rolloutStateValues)[number]; -export interface RuntimeCPUObservation { - capacity_cores: number | null; - usage_seconds_total: number | null; - utilization_ratio: number | null; -} -export const runtimeCPUObservationFields = ["capacity_cores", "usage_seconds_total", "utilization_ratio"] as const; export interface RuntimeDiskObservation { limit_bytes: number | null; usage_bytes: number | null; @@ -782,7 +775,6 @@ export interface RuntimeMemoryObservation { export const runtimeMemoryObservationFields = ["limit_bytes", "usage_bytes"] as const; export interface RuntimeObservation { allocation_created_at: number | null; - cpu: RuntimeCPUObservation | null; environment_id: string | null; id: string; instance: RuntimeInstance; @@ -798,7 +790,7 @@ export interface RuntimeObservation { started_at: number | null; status: RuntimeObservationStatus; } -export const runtimeObservationFields = ["allocation_created_at", "cpu", "environment_id", "id", "instance", "lifecycle_state", "memory", "mode", "object", "observed_at", "provider_type", "reason", "resolved_at", "session_id", "started_at", "status"] as const; +export const runtimeObservationFields = ["allocation_created_at", "environment_id", "id", "instance", "lifecycle_state", "memory", "mode", "object", "observed_at", "provider_type", "reason", "resolved_at", "session_id", "started_at", "status"] as const; export const runtimeObservationLifecycleStateValues = ["active", "sleeping", "transitioning", "pending", "stopped"] as const; export type RuntimeObservationLifecycleState = (typeof runtimeObservationLifecycleStateValues)[number]; export const runtimeObservationModeValues = ["none", "self_hosted", "openai_hosted"] as const; diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 11f39200f..bf9d10b2e 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -14,7 +14,7 @@ import type { TextParam, TextResource, TokenUsageResource, TurnResource, VaultCredentialResource, VaultResource, VaultStatusParam, } from "./generated/public-api"; import type { - CoreHarness as CoreHarnessResource, ModelConfigurationSupport as ModelConfigurationSupportResource, RuntimeCPUObservation, + CoreHarness as CoreHarnessResource, ModelConfigurationSupport as ModelConfigurationSupportResource, RuntimeMemoryObservation, RuntimeObservationLifecycleState, RuntimeObservationReason, } from "./generated/core-api"; @@ -735,7 +735,7 @@ export interface CreateSessionStreamOptions extends StreamOptions { // Generated /core/v1 types keep their schema names in ./generated/core-api. export type { - HarnessModelConfiguration, RuntimeCPUObservation, RuntimeHistory, RuntimeMemoryObservation, RuntimeObservationReason, SessionExecutionConfiguration, + HarnessModelConfiguration, RuntimeHistory, RuntimeMemoryObservation, RuntimeObservationReason, SessionExecutionConfiguration, } from "./generated/core-api"; export type RuntimeUnavailableReason = Exclude; @@ -764,7 +764,6 @@ export interface RuntimeObservedObservation extends RuntimeObservationBase { allocation_created_at: number | null; observed_at: number; started_at: number | null; - cpu: RuntimeCPUObservation | null; memory: RuntimeMemoryObservation | null; } diff --git a/services/core/internal/api/runtime_observations.go b/services/core/internal/api/runtime_observations.go index 2d8e94606..7af9eadae 100644 --- a/services/core/internal/api/runtime_observations.go +++ b/services/core/internal/api/runtime_observations.go @@ -132,9 +132,6 @@ func runtimeObservationResponse(observation runtimeobs.Observation) (v1.RuntimeO startedAt := observation.Sample.StartedAt.Unix() result.StartedAt = &startedAt } - if observation.Sample.CPUUsageSecondsTotal != nil || observation.Sample.CPUCapacityCores != nil || observation.Sample.CPUUtilizationRatio != nil { - result.CPU = &v1.RuntimeCPUObservation{UsageSecondsTotal: observation.Sample.CPUUsageSecondsTotal, CapacityCores: observation.Sample.CPUCapacityCores, UtilizationRatio: observation.Sample.CPUUtilizationRatio} - } if observation.Sample.MemoryUsageBytes != nil || observation.Sample.MemoryLimitBytes != nil { result.Memory = &v1.RuntimeMemoryObservation{UsageBytes: observation.Sample.MemoryUsageBytes, LimitBytes: observation.Sample.MemoryLimitBytes} } diff --git a/services/core/internal/api/runtime_observations_test.go b/services/core/internal/api/runtime_observations_test.go index b7cf4670f..1ac74c8ee 100644 --- a/services/core/internal/api/runtime_observations_test.go +++ b/services/core/internal/api/runtime_observations_test.go @@ -85,7 +85,7 @@ func TestRuntimeObservationRoutesUseSessionIdentityAndExactNullability(t *testin t.Fatalf("observation returned %d: %s", response.Code, response.Body) } var value v1.RuntimeObservation - if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.ID != sessionID || value.SessionID != sessionID || value.Instance.Kind != "none" || value.EnvironmentID != nil || value.ProviderType != nil || value.ObservedAt != nil || value.CPU != nil || value.Memory != nil || value.Reason == nil || *value.Reason != "runtime_mode_not_observable" { + if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.ID != sessionID || value.SessionID != sessionID || value.Instance.Kind != "none" || value.EnvironmentID != nil || value.ProviderType != nil || value.ObservedAt != nil || value.Memory != nil || value.Reason == nil || *value.Reason != "runtime_mode_not_observable" { t.Fatalf("invalid unsupported observation: %s", response.Body) } } @@ -104,16 +104,15 @@ func TestRuntimeObservationRoutesRejectQueries(t *testing.T) { } func TestRuntimeObservationResponsePreservesObservedZero(t *testing.T) { - zeroCPU := float64(0) zeroMemory := uint64(0) now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) sessionID, environmentID := uuid.NewString(), uuid.NewString() value, err := runtimeObservationResponse(runtimeobs.Observation{ Target: runtimeobs.Target{SessionID: sessionID, EnvironmentID: environmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), DeviceID: uuid.NewString(), AllocationState: "running", ComputePhase: "running", AllocationCreatedAt: now.Add(-time.Hour)}}, Status: runtimeobs.StatusObserved, ProviderType: "docker", ResolvedAt: now, - Sample: &runtimeobs.Sample{ObservedAt: now, CPUUsageSecondsTotal: &zeroCPU, MemoryUsageBytes: &zeroMemory}, + Sample: &runtimeobs.Sample{ObservedAt: now, MemoryUsageBytes: &zeroMemory}, }) - if err != nil || value.LifecycleState == nil || *value.LifecycleState != "active" || value.CPU == nil || value.CPU.UsageSecondsTotal == nil || *value.CPU.UsageSecondsTotal != 0 || value.Memory == nil || value.Memory.UsageBytes == nil || *value.Memory.UsageBytes != 0 { + if err != nil || value.LifecycleState == nil || *value.LifecycleState != "active" || value.Memory == nil || value.Memory.UsageBytes == nil || *value.Memory.UsageBytes != 0 { t.Fatalf("observed zero was lost: %+v %v", value, err) } } @@ -170,15 +169,14 @@ func TestRuntimeObservationResponseRejectsTimesOutsidePublicContract(t *testing. func timePointer(value time.Time) *time.Time { return &value } -func TestAdminRuntimeObservationProjectsReportedUtilizationAndDisk(t *testing.T) { +func TestAdminRuntimeObservationProjectsDisk(t *testing.T) { now := time.Date(2026, 9, 25, 10, 31, 37, 0, time.UTC) - ratio, cores := .1955, 2.0 memoryUsed, memoryTotal, diskUsed, diskTotal := uint64(183836672), uint64(2079141888), uint64(1593188352), uint64(23511863296) observation := runtimeobs.Observation{ Target: runtimeobs.Target{SessionID: uuid.NewString(), EnvironmentID: uuid.NewString(), Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), AllocationState: "running", ComputePhase: "disabled", AllocationCreatedAt: now.Add(-time.Minute)}}, Status: runtimeobs.StatusObserved, ProviderType: "e2b", ResolvedAt: now, - Sample: &runtimeobs.Sample{ObservedAt: now, StartedAt: timePointer(now.Add(-time.Minute)), CPUUtilizationRatio: &ratio, CPUCapacityCores: &cores, + Sample: &runtimeobs.Sample{ObservedAt: now, StartedAt: timePointer(now.Add(-time.Minute)), MemoryUsageBytes: &memoryUsed, MemoryLimitBytes: &memoryTotal, DiskUsageBytes: &diskUsed, DiskLimitBytes: &diskTotal}, } value, err := adminRuntimeObservationResponse(observation) @@ -187,7 +185,6 @@ func TestAdminRuntimeObservationProjectsReportedUtilizationAndDisk(t *testing.T) } raw, _ := json.Marshal(value) for _, want := range []string{ - `"cpu":{"usage_seconds_total":null,"capacity_cores":2,"utilization_ratio":0.1955}`, `"memory":{"usage_bytes":183836672,"limit_bytes":2079141888}`, `"disk":{"usage_bytes":1593188352,"limit_bytes":23511863296}`, } { From d7a2eb300e4e43e00da4c00ebf61674d4f05e4bf Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 24 Oct 2026 15:54:05 +0800 Subject: [PATCH 28/39] fix(e2b): accept optional observation domains safely --- services/core/tools/e2b-provider/README.md | 2 +- .../e2b-provider/observation_domain_test.py | 104 ++++++++++++++++++ services/core/tools/e2b-provider/provider.py | 12 +- 3 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 services/core/tools/e2b-provider/observation_domain_test.py diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md index b872e1f84..10a98a695 100644 --- a/services/core/tools/e2b-provider/README.md +++ b/services/core/tools/e2b-provider/README.md @@ -50,7 +50,7 @@ An unknown Create is never repeated. A Create whose connection material was lost ## Inspection and cleanup -Inspection uses SDK metadata and ID reads only. SDK `connect` is never used because it can resume paused compute. The version-pinned constructor that restores a client from saved connection material is confined to [`sdk.py`](sdk.py) and covered by a no-connect, no-create test. Resource drift fails inspection but still permits ownership-based cleanup. +Inspection accepts an omitted `SandboxInfo.sandbox_domain`, as allowed by the pinned SDK; an explicit domain must match the configured domain or its subdomains. Create connection objects and clients restored from saved connection material must always have a matching domain before envd access. Ownership, template and resource checks apply regardless of the observation domain. Inspection uses SDK metadata and ID reads only. SDK `connect` is never used because it can resume paused compute. The version-pinned constructor that restores a client from saved connection material is confined to [`sdk.py`](sdk.py) and covered by a no-connect, no-create test. Resource drift fails inspection but still permits ownership-based cleanup. `CreateSettled` proves that the original Create and bootstrap can no longer mutate; it is independent of `BootstrapComplete`. An empty lookup never settles an unknown Create. Kill destroys every matching sandbox, confirms that none remains and only then records a settled tombstone; it returns `State=absent` with `CreateSettled`. A settled rejected Create with no sandbox IDs proves absence without a cloud request, so GetInfo and Kill still succeed when the key is invalid. Ordinary missing compute has no such proof. diff --git a/services/core/tools/e2b-provider/observation_domain_test.py b/services/core/tools/e2b-provider/observation_domain_test.py new file mode 100644 index 000000000..57a1a9ef6 --- /dev/null +++ b/services/core/tools/e2b-provider/observation_domain_test.py @@ -0,0 +1,104 @@ +"""Optional SDK observation metadata never relaxes connection validation.""" +from pathlib import Path +from types import SimpleNamespace +import unittest + +import provider_test + + +class ObservationDomainTest(unittest.TestCase): + setUp = provider_test.ProviderTest.setUp + call = provider_test.ProviderTest.call + record = provider_test.ProviderTest.record + + def observed(self, **changes): + fields = dict(sandbox_id=self.cloud.sandbox_id, sandbox_domain=None, + metadata=self.cloud.metadata, template_id=self.cloud.template_id, + cpu_count=2, memory_mb=2048, state=self.cloud.state) + fields.update(changes) + return SimpleNamespace(**fields) + + def test_create_and_inspect_missing_observation_domain_use_saved_connection(self): + self.config['Resources'] = {'cpus': 2, 'memory_mib': 2048} + self.api.get_info.side_effect = lambda *a, **k: self.observed() + self.assertEqual(self.call('create')['ErrorCode'], '') + self.assertTrue(self.call('inspect')['Info']['BootstrapComplete']) + self.assertEqual(self.call('renew')['ErrorCode'], '') + self.assertEqual(self.record()['connection']['sandbox_domain'], 'e2b.app') + self.api.connect.assert_not_called() + self.api.create.assert_called_once() + + def test_paused_inspection_without_domain_does_not_wake(self): + self.api.get_info.side_effect = lambda *a, **k: self.observed() + self.assertEqual(self.call('create')['ErrorCode'], '') + self.cloud.state = 'paused' + result = self.call('inspect') + self.assertEqual(result['ErrorCode'], '') + self.assertEqual(result['Info']['State'], 'paused') + self.api.connect.assert_not_called() + + def test_unknown_create_list_without_domain_never_recreates_or_attaches(self): + self.api.create.side_effect = TimeoutError('unknown create') + self.assertEqual(self.call('create')['ErrorCode'], 'unconfirmed') + self.api.list.return_value = SimpleNamespace( + has_next=True, next_items=lambda **kwargs: [self.observed()]) + # One page exhausts the SDK paginator. + def page(**kwargs): + self.api.list.return_value.has_next = False + return [self.observed()] + self.api.list.return_value.next_items = page + result = self.call('inspect') + self.assertEqual(result['ErrorCode'], '') + self.assertFalse(result['Info']['BootstrapComplete']) + self.api.create.assert_called_once() + self.api.connect.assert_not_called() + self.cloud.files.write.assert_not_called() + + def test_missing_domain_does_not_bypass_identity_or_resource_checks(self): + self.config['Resources'] = {'cpus': 2, 'memory_mib': 2048} + for changes, error in (({'metadata': {}}, 'ownership'), + ({'template_id': 'other'}, 'invalid'), + ({'cpu_count': 4}, 'invalid'), + ({'memory_mb': 8192}, 'invalid')): + with self.subTest(changes=changes): + for path in Path(self.temporary.name).glob('*.json'): + path.unlink() + self.api.get_info.return_value = self.observed(**changes) + self.assertEqual(self.call('create')['ErrorCode'], error) + self.cloud.files.write.assert_not_called() + self.api.connect.assert_not_called() + + def test_explicit_invalid_observation_domain_rejected_before_credentials(self): + for domain in ('foreign.example', 'e2b.app.foreign.example', '', 3): + with self.subTest(domain=domain): + for path in Path(self.temporary.name).glob('*.json'): + path.unlink() + self.api.get_info.return_value = self.observed(sandbox_domain=domain) + self.assertEqual(self.call('create')['ErrorCode'], 'ownership') + self.cloud.files.write.assert_not_called() + self.api.connect.assert_not_called() + + def test_rejected_connection_cannot_reenter_through_missing_observation_domain(self): + for domain in ('foreign.example', None): + with self.subTest(domain=domain): + for path in Path(self.temporary.name).glob('*.json'): + path.unlink() + self.cloud.sandbox_domain = domain + self.api.get_info.return_value = self.observed() + self.assertEqual(self.call('create')['ErrorCode'], 'ownership') + self.assertEqual(self.call('inspect')['ErrorCode'], 'ownership') + self.cloud.files.read.assert_not_called() + self.cloud.files.write.assert_not_called() + self.api.connect.assert_not_called() + + def test_missing_actual_connection_domain_is_still_rejected(self): + self.api.get_info.return_value = self.observed() + self.cloud.sandbox_domain = None + self.assertEqual(self.call('create')['ErrorCode'], 'ownership') + self.api.get_info.assert_not_called() + self.cloud.files.write.assert_not_called() + self.api.connect.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index 76557b500..df96827c8 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -127,15 +127,18 @@ def owns(self, cloud): raise Failure('ownership') return cloud - def check_domain(self, cloud): + def check_domain(self, sandbox_domain): domain = self.options()['domain'] - sandbox_domain = cloud.sandbox_domain if not isinstance(sandbox_domain, str) or not ( sandbox_domain == domain or sandbox_domain.endswith('.' + domain)): raise Failure('ownership') def qualified(self, cloud): - self.check_domain(cloud) + # SandboxInfo omits the optional domain on the official API. It is an + # observation, not connection material; Create and saved clients still + # require a qualified domain before any envd access. + if cloud.sandbox_domain is not None: + self.check_domain(cloud.sandbox_domain) template = self.config['Template'].split(':', 1)[0] if cloud.template_id not in (template, self.config['Template']): raise Failure('invalid') @@ -173,6 +176,7 @@ def client(self, cloud): material = (self.receipt.data or {}).get('connection') if not material or material['sandbox_id'] != cloud.sandbox_id: raise Failure('unconfirmed') + self.check_domain(material['sandbox_domain']) return restore(material, self.options()) def inspect(self): @@ -222,7 +226,7 @@ def create(self): raise Failure('unconfirmed') from None self.receipt.save(status='created', ids=[cloud.sandbox_id], connection=connection_material(cloud)) # A create response must not steer envd traffic to an unrelated host. - self.check_domain(cloud) + self.check_domain(cloud.sandbox_domain) # SDK Create returns connection material, but no metadata or resources. # Read its exact ID before writing credentials, even when Core adopts the # template's resources and does not supply explicit limits. From 61e414ba851f98b2e85ea7b397672797c1cafa8b Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 24 Oct 2026 16:01:33 +0800 Subject: [PATCH 29/39] fix(e2b): retain rejected bootstrap state during inspection --- .../tools/e2b-provider/observation_domain_test.py | 14 ++++++++++++++ services/core/tools/e2b-provider/provider.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/services/core/tools/e2b-provider/observation_domain_test.py b/services/core/tools/e2b-provider/observation_domain_test.py index 57a1a9ef6..644e35e30 100644 --- a/services/core/tools/e2b-provider/observation_domain_test.py +++ b/services/core/tools/e2b-provider/observation_domain_test.py @@ -91,6 +91,20 @@ def test_rejected_connection_cannot_reenter_through_missing_observation_domain(s self.cloud.files.write.assert_not_called() self.api.connect.assert_not_called() + def test_rejected_observation_cannot_resume_bootstrap_on_later_missing_domain(self): + self.api.get_info.return_value = self.observed(sandbox_domain='foreign.example') + self.assertEqual(self.call('create')['ErrorCode'], 'ownership') + self.assertEqual(self.record()['status'], 'configuration_rejected') + self.api.get_info.return_value = self.observed() + result = self.call('inspect') + self.assertEqual(result['ErrorCode'], '') + self.assertFalse(result['Info']['BootstrapComplete']) + self.assertTrue(result['Info']['CreateSettled']) + self.assertEqual(self.record()['status'], 'configuration_rejected') + self.cloud.files.read.assert_not_called() + self.cloud.files.write.assert_not_called() + self.api.connect.assert_not_called() + def test_missing_actual_connection_domain_is_still_rejected(self): self.api.get_info.return_value = self.observed() self.cloud.sandbox_domain = None diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index df96827c8..e2e3c0a90 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -193,7 +193,7 @@ def inspect(self): self.qualified(cloud) record = self.receipt.data or {} if (cloud.state == 'running' and not record.get('bootstrap_complete') and record.get('connection') - and record.get('status') not in ('bootstrap_failed', 'killed')): + and record.get('status') not in ('configuration_rejected', 'bootstrap_failed', 'killed')): try: receipt = json.loads(self.client(cloud).files.read('/root/.oac/e2b/managed-ready.json', user='root', request_timeout=self.remaining())) From 4839f50553f3be7d1e31482e5d9bb391b1c467d4 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 24 Oct 2026 19:29:16 +0800 Subject: [PATCH 30/39] ci: allow production image builds without deployment --- .github/workflows/deploy.yml | 8 +++++++- deploy/kubernetes/README.md | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a7d9e8c7f..106083183 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,6 +10,11 @@ name: core-deploy on: workflow_dispatch: inputs: + build_only: + description: Build and push images without changing the production installation + required: false + default: false + type: boolean source_branch: description: Source branch deployed to the existing production installation required: true @@ -61,7 +66,7 @@ jobs: exit 1 fi echo "revision=$revision" >> "$GITHUB_OUTPUT" - echo "Deploying $SOURCE_BRANCH at \`$revision\`" >> "$GITHUB_STEP_SUMMARY" + echo "Selected $SOURCE_BRANCH at \`$revision\`" >> "$GITHUB_STEP_SUMMARY" build: needs: prepare @@ -175,6 +180,7 @@ jobs: run: docker logout "${REGISTRY_HOST%%/*}" || true deploy: + if: ${{ !inputs.build_only }} needs: [prepare, build] runs-on: ${{ vars.OAC_DEPLOY_RUNNER || 'ubuntu-22.04' }} environment: production diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md index a3d13d421..3d8581226 100644 --- a/deploy/kubernetes/README.md +++ b/deploy/kubernetes/README.md @@ -32,6 +32,8 @@ Both images always come from one commit, so the console never talks to a Core of Merge the deployment workflow into `main`. Run **Actions** → **core-deploy** → **Run workflow** from `main` and select `source_branch` (`main` by default or `beta`), then give it a release tag or a full commit SHA already merged into that source branch. Both choices replace the same production Core and Web; `beta` does not create a separate environment. Review the [beta integration ledger](https://github.com/sandbaseai/OpenAgentCore/blob/beta/deploy/kubernetes/BETA_CHANGELOG.md) on the beta branch before selecting a beta revision. Keep the workflow branch set to `main`, including when deploying beta. The run builds both images, pushes them, applies the secrets and the environment, rolls Core out and then Web, and checks that both Services have a ready endpoint. It does not create an Ingress or configure DNS or certificates. Route `/v1` and `/api/v1` to `oac-core:8091`, and all other paths to `oac-web:8080`, in the `openagentcore` namespace. After configuring HTTPS, check `/healthz`, verify that unauthenticated `/v1/agents` returns `401`, and qualify a fresh E2B Session and Turn. +To prepare images before a separately coordinated data transfer, select `build_only`. The workflow still validates the source revision and builds and pushes both images, but skips the entire deployment job, including cluster access, Secret updates and rollout. The default is `false`, which performs the deployment described above. A successful build-only run does not establish that production is running that revision. + **A deployment is an outage.** Core's single replica stops before its replacement starts, and the replacement migrates the schema before it listens, so the Agents API, the machine routes and every running Session are unavailable for the rollout. Deploy in a window you can afford to lose. The Pod that takes over also needs the previous one's leased database connection to be gone; until it is, `AcquireLease` fails and Core exits, and the rollout depends on a restart landing inside the 15-minute deadline. To roll back within the same compatible installation schema, run the workflow again with the earlier commit. A source rollback does not undo migrations and cannot cross the migration-ledger or folded-baseline boundary. Restore a compatible database and state snapshot through a separately approved recovery procedure when required. From 423b5f381e2b1a3d24e0baa5b737a1fd06d55e02 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 22:02:40 +0800 Subject: [PATCH 31/39] Fix the E5 review findings (#853) * Fix the E5 review findings Keep the events bound at 64 per request and 512 KiB of stored input and record the gap: the input reservation check, request deadlines and the Claude Code per-Turn message list do not hold larger batches. Record the Claude Code 64-message Turn limit and the MiniMax Code instructions limit, name Codex for apply_patch, state why request_metadata is rejected, note the Subagent function_call exception on the stream, restore the typed packages.system error for a non-array value and test both Turn IDs answering a Subagent call. * Define the events bound once and settle the review wording MaxInputBatch is the one definition of the 64-event bound, and a test reserves a full batch on an Environment Session so the validator and the reservation check cannot drift apart. The events bound moves beside its peer differences, the Claude Code limit links its owning rule, the request_metadata message matches the documented reason, packages.system docs name every rejected value, and the stream rules drop a child snapshot sentence that no event produces. --- contracts/agents-api/environments.md | 4 ++-- contracts/agents-api/harness-capabilities.md | 3 ++- contracts/agents-api/index.md | 10 +++++---- contracts/agents-api/sessions-events.md | 10 ++++----- contracts/agents-api/zh/environments.md | 6 ++--- .../agents-api/zh/harness-capabilities.md | 5 +++-- contracts/agents-api/zh/index.md | 12 +++++----- contracts/agents-api/zh/sessions-events.md | 12 +++++----- .../internal/api/environment_packages_test.go | 2 +- .../core/internal/api/environment_setup.go | 5 +---- services/core/internal/api/inputs.go | 4 ++-- .../core/internal/api/mcp_configuration.go | 2 +- services/core/internal/sessions/inputs.go | 22 +++++++++++-------- .../core/internal/sessions/inputs_test.go | 14 ++---------- .../integration/function_execution_test.go | 4 ++-- .../core/tests/integration/inputs_test.go | 13 +++++++++++ 16 files changed, 69 insertions(+), 59 deletions(-) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 44e56e033..a5e397587 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -106,7 +106,7 @@ The application owns the machine. It creates the Session with a clean absolute ` ## Input admission -Session input goes through `POST /agents/sessions/{id}/events` in ordered batches of 1–16384 events. On an Environment-bearing Session, Core reserves input that cannot start yet until the Environment is connected and prepared. +Session input goes through `POST /agents/sessions/{id}/events` in ordered batches of 1–64 events. On an Environment-bearing Session, Core reserves input that cannot start yet until the Environment is connected and prepared. A valid node-backed creation can wait for compute capacity after committing. Initial input uses the same reservation and deadline while waiting; creating without input still requests Environment preparation. [Placement and generation selection](./sandbox-deployment.md#generation-ownership-and-rollout) define the scheduling rules. @@ -222,7 +222,7 @@ Executor admission validates only the frozen descriptor. The preparation owner m The daemon runs as its launching account and never uses sudo or raises its permissions. Only user-directory dependencies install during preparation. - System dependencies must be preinstalled in the managed image or by the owner of a self-hosted machine. A missing executable or library fails the operation that needs it. -- `packages.system` accepts null or an empty list, meaning no system packages, and rejects a nonempty list in Templates and inline configuration (400, param `packages.system`). Package responses carry the official required `system: []`, so a read configuration can be sent back unchanged. +- `packages.system` accepts null or an empty list, meaning no system packages, and rejects any other value in Templates and inline configuration (400, param `packages.system`). Package responses carry the official required `system: []`, so a read configuration can be sent back unchanged. - npm installs into a local prefix and Python/pip into a local target under the Runtime package directory; Node/npm and Python/pip must already be installed. Their dependencies are visible to native tools in every working directory. - Setup commands run with Bash; on Windows, Git Bash is required and no other shell substitutes. The default working directory is `/workspace`. - On Windows, npm installation and stdio MCP commands named `npm` or `npx` (including their `.cmd` shims) run through npm's JavaScript entry point with Node, without an extra shell. diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index ee52be4d1..83cc92f0f 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -20,6 +20,7 @@ Placements are `none` (no Environment), hosted (`openai_hosted`) and self-hosted | --- | --- | --- | --- | | Text Turns, active input, cancellation, restart and continuation | Verified on all placements | Verified on all placements | Verified on all placements | | [Files and Artifacts](./environment-files.md) | Verified: hosted, self-hosted | Verified: hosted, self-hosted | Verified: hosted, self-hosted | +| Agent `instructions` | Admitted | Admitted | At most 32 KiB; longer instructions are rejected | | [Whitespace-only message text](./message-content.md) | Admitted; delivered unchanged | Admitted; native drops it, and an all-blank message becomes `(no content)` | Rejected | | [Message images](./message-content.md#images) | PNG and JPEG data URLs: verified on all placements; other data URLs: admitted; remote URLs: rejected | PNG and JPEG data URLs: verified on all placements; remote URLs and GIF and WebP data URLs: admitted | Rejected | | `reasoning.effort` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`: admitted | `low`, `medium`, `high`, `xhigh`, `max`: admitted | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`: admitted | @@ -59,7 +60,7 @@ These operations need a workspace, so they apply to hosted and self-hosted place | [Initial files, setup commands, Skills and Plugins](./environments.md#runtime-capability-preparation) | Verified: hosted, self-hosted | Verified: self-hosted; admitted: hosted | Verified: self-hosted; admitted: hosted | | Capability directories | Admitted | Admitted | Admitted | | npm and Python packages | Admitted | Admitted | Admitted | -| `packages.system` | Rejected when nonempty | Rejected when nonempty | Rejected when nonempty | +| `packages.system` other than null or `[]` | Rejected | Rejected | Rejected | | Network `disabled` or `restricted` | Rejected | Rejected | Rejected | | [Plugin MCP over stdio](./environments.md#plugin-mcp) | Verified: hosted, self-hosted | Verified: self-hosted; admitted: hosted | Verified: self-hosted; admitted: hosted | | Plugin MCP over HTTP | Admitted, with literal headers or HTTPS bearer | Admitted, with literal headers or HTTPS bearer | Verified: self-hosted; admitted: hosted; literal headers or HTTPS bearer | diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 260f76dd9..a1b26c7d9 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -80,13 +80,14 @@ Each item is Core's deliberate or native behavior where the official service beh - A Session's `agent.tools` omits `tool_search` declarations. - Core rejects an inline `authorization` on a Session MCP transport, an `Authorization` entry in `headers` and a nonempty stdio `env`: credentials come only from Vaults (`credential_id`) and environment values only from the Environment (`env_vars`) ([MCP](./execution-tools.md#mcp)). - A `stdio` MCP server needs a workspace placement and the origin `environment`. Claude Code starts the server in its workspace directory, so `cwd` must equal it, with its whole process environment, which also holds the model provider key and other servers' Vault bearer tokens, and it needs `allowed_tools` null. MiniMax Code starts the server in its private home directory, so it rejects every `stdio` server ([MCP](./execution-tools.md#mcp)). -- Core rejects nonempty MCP `request_metadata`, a `stdio` server whose `connection_origin` is `service`, and a blank `server_label`, an empty `credential_id` and an empty `allowed_tools` name, which the pinned schema allows without defining their meaning (400 with the reason). +- Core rejects nonempty MCP `request_metadata`, which no Harness can send, as well as a `stdio` server whose `connection_origin` is `service`, a blank `server_label`, an empty `credential_id` and an empty `allowed_tools` name, which the pinned schema allows without defining their meaning (400 with the reason). - Claude rejects an MCP `server_url` or `headers` value containing `${`, because the native client expands `${VAR}` in both from a process environment that holds Core-held credentials. - Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returns 200. **Input, events and history** ([Sessions, events and history](./sessions-events.md), [message content](./message-content.md)) - Input to a `none` Session is admitted synchronously; Core does not emulate the official asynchronous admission window. +- An events request takes at most 64 events ([send input](./sessions-events.md#send-input)); the official limit is 16384. - A function result that resumes a waiting Turn emits `turn.in_progress`, and cancelling a Turn that waits on a function result emits an interim `agent.session.in_progress`. - A function call the native Harness abandons, such as a Subagent call dropped when the root closes or interrupts the child or a MiniMax Code call past its 120 s timeout, is withdrawn: it stops being a required action and a later result for it returns 409 `conflict_error`. The official API has no withdrawal. - Attaching to the stream mid-Turn sends no catch-up Item snapshots. @@ -122,7 +123,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Configuration and tools** - A `reasoning.effort`, `reasoning.summary`, `service_tier`, `text.verbosity`, `web_search` mode or setting or `programmatic_tool_calling` value that the selected Harness does not declare is saved but rejected at Session admission ([Harness capabilities](./harness-capabilities.md#execution-and-input)). -- Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no image input. +- Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no image input and accepts at most 32 KiB of `instructions`. - Model-derived reasoning defaults are not resolved. **Execution and history** @@ -133,7 +134,8 @@ Each item is Core's deliberate or native behavior where the official service beh - Pinned Codex can lose command output emitted before its stream subscription. - Claude Code and MiniMax Code report no public usage. - `agent_message` and `interrupt_subagent_call` Items are accepted by the Runtime protocol, but no Harness adapter produces them. -- Native file changes have no official Item type ([Turns and Items](./sessions-events.md#turns-and-items)). +- Codex file changes have no official Item type ([Turns and Items](./sessions-events.md#turns-and-items)). +- Claude Code runs at most 64 messages in one Turn, counting its opening input ([Runtime boundary](./message-content.md#runtime-boundary)); a Turn that receives more fails with `internal_error`. - MiniMax Code receives a message that starts with `/` with a trailing blank line, because ACP otherwise runs it as an operator command. - Core gives no crash-safe or exactly-once guarantee for native side effects; claimed work fails on restart without replay. - `file_id` and `detail` on images are rejected; each Harness declares which `image_url` values it accepts ([message content](./message-content.md#images)). @@ -143,7 +145,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Hosted workspace admission is limited by the [tenant capacity policy](../../docs/configuration.md#tenant-capacity) and the [input reservation deadline](./environments.md#reservations). - MiniMax Code does not declare `retained_native_history`, so it cannot run on microsandbox, which keeps every Environment on workspace storage; Core rejects the Session ([Harness capabilities](./harness-capabilities.md)). - Only `enabled` networking runs, so Sessions that need `disabled` or `restricted` are rejected ([restricted network policy](./environments.md#restricted-network)). -- A nonempty `packages.system` is rejected; system packages must be preinstalled. +- `packages.system` other than null or an empty list is rejected; system packages must be preinstalled. - Cold continuation does not restore process memory, background processes or temporary system-disk changes, and completed setup is not replayed ([Environment persistence](./environments.md#runtime-capability-preparation)). On compatible microsandbox nodes with retained NFS storage, Codex and Claude Code are qualified for [checkpoint transfer](../../docs/sandbox-provider.md#checkpoint-transfer) and for continuation after [confirmed compute loss](../../docs/sandbox-provider.md#confirmed-compute-loss). Takeover of an unconfirmed old writer, host-crash durability and arbitrary crash-damaged history are unqualified. **Files and Environment files** diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index f5261daa0..1fbd4bf5b 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -43,9 +43,9 @@ A Session stays usable after a Turn fails: new input starts a new Turn. Later re ## Send input -`POST /v1/agents/sessions/{session_id}/events` takes an ordered array of 1 to 16384 events: `agent.session.input.message`, `agent.session.input.cancel` and `agent.session.input.tool_result`. The whole batch is admitted atomically, and the response is 202 with no body once the batch is stored, before any Harness reads it. Admission never confirms native application. +`POST /v1/agents/sessions/{session_id}/events` takes an ordered array of 1 to 64 events: `agent.session.input.message`, `agent.session.input.cancel` and `agent.session.input.tool_result`. The whole batch is admitted atomically, and the response is 202 with no body once the batch is stored, before any Harness reads it. Admission never confirms native application. -- **Limits.** The request body is at most 4 MiB, and the stored input of one request at most 2 MiB. +- **Limits.** The request body is at most 1 MiB, and the stored input of one request at most 512 KiB. - **Null batch.** An explicit `null` for `events` is invalid. - **Empty batch.** `{"events": []}` checks that the Session exists and returns 202. It creates no Turn, Item or retry identity. - **Retries.** An `Idempotency-Key` of up to 128 bytes identifies the whole ordered batch. The same key and batch return 202 again without admitting anything twice; the same key with another batch returns 409 `idempotency_conflict`. A request without a key is always new. @@ -109,7 +109,7 @@ A retry with the same `Idempotency-Key` and `stream: true` returns 201 with only - **Lifetime.** The stream stays open across Turns and after a Turn fails. It ends when the Session is deleted or after the terminal `agent.session.failed` of an [Environment initialization failure](#environment-initialization-failure); a stream opened after that failure stays open. A keepalive comment is sent every 15 seconds. - **Buffer.** Core keeps at most 256 events and 64 MiB of events per Session, plus one larger event when needed. A reader that falls behind the buffer receives an `error` event with type `server_error` and code `stream_interrupted`, and the stream closes. A socket write that blocks for five seconds also closes it. Execution never waits for a reader. - **Key recheck.** An open stream checks the original Project key at most once per second while idle and before sending output. Revoking the key or archiving the Project closes the stream, and so does an authentication failure. A recheck uses the normal five-second authentication timeout and sends no Session data while it waits. Bytes already sent cannot be recalled. -- **Root work only.** Child Turns and child Items publish no Session events; `agent.session.subagent.*` events and root coordination Items do. Read child work through the [Subagent resources](./subagents.md). +- **Root work only.** Child Turns and child Items publish no Session events, except a Subagent's `function_call` Item, published once as `item.added`; `agent.session.subagent.*` events and root coordination Items do. Read child work through the [Subagent resources](./subagents.md). ### Event rules @@ -119,7 +119,7 @@ A retry with the same `Idempotency-Key` and `stream: true` returns 201 with only - `item.added` and `item.done` always carry `output_index`, null for input Items. Function results emit `item.added` only; `item.done` is for agent output. - An assistant message follows one sequence: `item.added` in progress with empty `content`, `content_part.added` with empty text, `output_text.delta` events, `output_text.done`, `content_part.done` and `item.done`. A message first observed complete, such as structured output, sends its whole text in one `output_text.delta`, byte for byte. The completed text replaces the accumulated deltas. - Codex command output streams as `agent.output.command_execution_output.delta` with the command's Item ID and output index. Native output quotas and text conversion apply, so the deltas are not a byte-exact capture; the completed Item is authoritative. -- A reasoning Item follows one sequence: `item.added` in progress with an empty `summary`; for each `summary_text` part in order, `reasoning_summary_part.added` with empty text, `reasoning_summary_text.delta` events, then `reasoning_summary_text.done` and `reasoning_summary_part.done` once the next part starts or the Item ends; then `item.done`. `reasoning_summary_part.done` has `status` `incomplete` for a part a cancelled or failed Turn cut off, otherwise null. A child Turn snapshot that rewrites earlier text reports it only in the done events. +- A reasoning Item follows one sequence: `item.added` in progress with an empty `summary`; for each `summary_text` part in order, `reasoning_summary_part.added` with empty text, `reasoning_summary_text.delta` events, then `reasoning_summary_text.done` and `reasoning_summary_part.done` once the next part starts or the Item ends; then `item.done`. `reasoning_summary_part.done` has `status` `incomplete` for a part a cancelled or failed Turn cut off, otherwise null. - A cancelled or failed Turn marks its unfinished Items `incomplete` and keeps their partial content. - A function call stays in `required_actions` until the Harness applies its result, or cancellation or the Turn's end removes it. A repeated notification emits no new state. @@ -136,7 +136,7 @@ Turn and Item lists take `after`, `limit` and `order` ([list rules](./wire-seman | `message` | User or assistant content parts. `phase` is the Harness's phase when it reports one (`commentary`, `final_answer`), otherwise null | | `command_execution` | Command, reported output, exit code, duration and working directory | | `mcp_call` | Server and tool identity, arguments, structured result or error | -| `function_call`, `function_call_output` | A linked call and its result. The result always carries `output` and `error`, null when the submission omitted them; stored results keep the submitted field presence. Native file changes appear as an `apply_patch` function call with the changes as arguments and no invented result | +| `function_call`, `function_call_output` | A linked call and its result. The result always carries `output` and `error`, null when the submission omitted them; stored results keep the submitted field presence. Codex file changes appear as an `apply_patch` function call with the changes as arguments and no invented result | | `web_search_call` | The supported action fields (`search`, `open_page`, `find_in_page`, `other`); Claude Code reports `search` for `WebSearch` and `open_page` for `WebFetch` | | `reasoning` | One native reasoning item, each native part one `summary_text` part, present only when the Agent sets `reasoning.summary`; with it null (the default) no `reasoning` Items or reasoning events appear for any Turn, root or child. [OpenAI compatibility](./index.md#differences-from-openai) describes the text. Status can be null on a child Turn's Item | | `agent_message` and the Subagent coordination calls | See [Subagents](./subagents.md). `agent_message` has no status | diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 11257f0be..7d0a82934 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 826b3d0c74ea82af5c4f7e6094b71257a7e56fba896d5fc8a58234bc3bed5249 +source_hash: eb7282383dcac86723189824b0391c7dd50336257db922a1fce2b2f2d2153049 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -108,7 +108,7 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会 ## 输入接纳 {#input-admission} -Session 输入通过 `POST /agents/sessions/{id}/events` 以每次 1–16384 个事件的有序批次提交。对于带 Environment 的 Session,Core 会将尚不能启动的输入预留,直到 Environment 完成连接和准备。 +Session 输入通过 `POST /agents/sessions/{id}/events` 以每次 1–64 个事件的有序批次提交。对于带 Environment 的 Session,Core 会将尚不能启动的输入预留,直到 Environment 完成连接和准备。 有效的节点模式创建可以在提交后等待计算容量。初始输入在等待期间仍使用同一 reservation 和期限;无输入创建也会请求 Environment 准备。[Placement 与代次选择](./sandbox-deployment.md#generation-ownership-and-rollout) 定义调度规则。 @@ -224,7 +224,7 @@ Executor 接纳仅验证已冻结的描述符。准备所有者会在调用原 daemon 以启动它的账户身份运行,绝不使用 sudo 或提升权限。准备过程中只会安装用户目录中的依赖项。 - 系统依赖必须预先安装在托管镜像中,或由自托管机器的所有者安装。缺少可执行文件或库时,需要该依赖的操作会失败。 -- `packages.system` 接受 null 或空列表(表示没有系统软件包),模板和内联配置会拒绝非空列表(400,param `packages.system`)。软件包响应包含官方要求的 `system: []`,因此读取到的配置可以原样回传。 +- `packages.system` 接受 null 或空列表(表示没有系统软件包),模板和内联配置会拒绝其他任何值(400,param `packages.system`)。软件包响应包含官方要求的 `system: []`,因此读取到的配置可以原样回传。 - npm 会安装到本地 prefix,Python/pip 会安装到 Runtime 软件包目录下的本地 target;Node/npm 和 Python/pip 必须已经安装。其依赖项可被每个工作目录中的原生工具看到。 - 设置命令使用 Bash 运行;在 Windows 上必须使用 Git Bash,且不能由其他 shell 替代。默认工作目录为 `/workspace`。 - 在 Windows 上,npm 安装以及名为 `npm` 或 `npx` 的 stdio MCP 命令(包括其 `.cmd` shim)会通过 Node 调用 npm 的 JavaScript 入口点运行,而不经过额外的 shell。 diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index 89012f5be..9ffc01685 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: bd03ebf9e556b71091fed42d1ba52b2002207962ccc4f7c3765104c82bddc3b5 +source_hash: 8913afc18b99b847850a857e387e1a230085525ce5f1b28abd5a440abc9dcf16 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -22,6 +22,7 @@ source_hash: bd03ebf9e556b71091fed42d1ba52b2002207962ccc4f7c3765104c82bddc3b5 | --- | --- | --- | --- | | 文本 Turn、活动输入、取消、重启与继续 | 所有位置已验证 | 所有位置已验证 | 所有位置已验证 | | [文件与 Artifact](environment-files.md) | 已验证:托管、自托管 | 已验证:托管、自托管 | 已验证:托管、自托管 | +| Agent `instructions` | 已准入 | 已准入 | 最多 32 KiB;更长的 instructions 会被拒绝 | | [仅空白消息文本](message-content.md) | 已准入;原样交付 | 已准入;原生会丢弃它,全空白消息变为 `(no content)` | 已拒绝 | | [消息图像](message-content.md#images) | PNG、JPEG data URL:所有位置已验证;其他 data URL:已准入;远程 URL:已拒绝 | PNG、JPEG data URL:所有位置已验证;远程 URL 及 GIF、WebP data URL:已准入 | 已拒绝 | | `reasoning.effort` | `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`:已准入 | `low`、`medium`、`high`、`xhigh`、`max`:已准入 | `none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`:已准入 | @@ -61,7 +62,7 @@ Claude 结构化输出要求 medium verbosity,可与 `multi_agent`、Skill、P | [初始文件、设置命令、Skill 与 Plugin](environments.md#runtime-capability-preparation) | 已验证:托管、自托管 | 已验证:自托管;已准入:托管 | 已验证:自托管;已准入:托管 | | 能力目录 | 已准入 | 已准入 | 已准入 | | npm 与 Python 包 | 已准入 | 已准入 | 已准入 | -| `packages.system` | 非空时拒绝 | 非空时拒绝 | 非空时拒绝 | +| null 或 `[]` 以外的 `packages.system` | 已拒绝 | 已拒绝 | 已拒绝 | | 网络 `disabled` 或 `restricted` | 已拒绝 | 已拒绝 | 已拒绝 | | [stdio Plugin MCP](environments.md#plugin-mcp) | 已验证:托管、自托管 | 已验证:自托管;已准入:托管 | 已验证:自托管;已准入:托管 | | HTTP Plugin MCP | 已准入,字面值头或 HTTPS bearer | 已准入,字面值头或 HTTPS bearer | 已验证:自托管;已准入:托管;字面值头或 HTTPS bearer | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 24f7e3d51..880190c94 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 4bccb4aaa93b5773708c46915447791443532cc3d0a9705124ee9e5812c5bf05 +source_hash: 685e26e496e369c1eb2b1b093ec2e26e6fb6fbdf082ec1b877d9c638b0ac17e6 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -83,13 +83,14 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Session 的 `agent.tools` 会省略 `tool_search` 声明。 - Core 拒绝 Session MCP 传输中的内联 `authorization`、`headers` 中的 `Authorization` 条目以及非空的 stdio `env`:凭据只来自 Vault(`credential_id`),环境值只来自 Environment(`env_vars`)([MCP](execution-tools.md#mcp))。 - `stdio` MCP 服务器需要工作区部署位置和 `environment` 来源。Claude Code 在其工作区目录中启动服务器,因此 `cwd` 必须与之相同;服务器继承整个进程环境,其中也有模型提供方密钥和其他服务器的 Vault bearer 令牌;且 `allowed_tools` 须为 null。MiniMax Code 在其私有主目录中启动服务器,因此拒绝所有 `stdio` 服务器([MCP](execution-tools.md#mcp))。 -- Core 拒绝非空的 MCP `request_metadata`、`connection_origin` 为 `service` 的 `stdio` 服务器,以及空白的 `server_label`、空的 `credential_id` 和空的 `allowed_tools` 名称;固定版本的 schema 允许这些值但没有定义其含义(400,并说明原因)。 +- Core 拒绝非空的 MCP `request_metadata`,因为没有 Harness 能发送它;同样拒绝 `connection_origin` 为 `service` 的 `stdio` 服务器,以及空白的 `server_label`、空的 `credential_id` 和空的 `allowed_tools` 名称,固定版本的 schema 允许这些值但没有定义其含义(400,并说明原因)。 - Claude 会拒绝包含 `${` 的 MCP `server_url` 或 `headers` 值,因为原生客户端会从持有 Core 所持凭据的进程环境中展开这两处的 `${VAR}`。 - 在 events 202 之后立即删除 Session 会返回 409,因为 Core 会在同一事务中准入该 Turn;官方服务返回 200。 **输入、事件和历史**([Sessions, events and history](sessions-events.md)、[message content](message-content.md)) - 提交给 `none` Session 的输入会同步完成准入;Core 不会模拟官方异步准入窗口。 +- 一次 events 请求最多包含 64 个事件([发送输入](sessions-events.md#send-input));官方上限为 16384。 - 用于恢复等待中 Turn 的函数结果会发出 `turn.in_progress`;取消正在等待函数结果的 Turn 会发出临时 `agent.session.in_progress`。 - 原生 Harness 放弃的函数调用(例如根关闭或中断子级时被丢弃的 Subagent 调用,或超过 120 秒超时的 MiniMax Code 调用)会被撤回:它不再是待执行操作,之后为它提交结果会返回 409 `conflict_error`。官方 API 没有撤回机制。 - 在 Turn 中途接入流时,不会发送补发 Item 快照。 @@ -125,7 +126,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **配置和工具** - 所选 Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`text.verbosity`、`web_search` 模式或设置、`programmatic_tool_calling` 取值,会被保存,但在 Session 准入时被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 -- Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不支持图像输入。 +- Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不支持图像输入,且 `instructions` 最多 32 KiB。 - 由模型推导出的推理默认值不会被解析确定。 **执行和历史** @@ -136,7 +137,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 固定版本的 Codex 可能会丢失在订阅其流之前发出的命令输出。 - Claude Code 和 MiniMax Code 都不报告公共用量。 - Runtime 协议接受 `agent_message` 和 `interrupt_subagent_call` Item,但没有 Harness 适配器会产生它们。 -- 原生文件变更没有对应的官方 Item 类型([Turns and Items](sessions-events.md#turns-and-items))。 +- Codex 文件变更没有对应的官方 Item 类型([Turns and Items](sessions-events.md#turns-and-items))。 +- Claude Code 一个 Turn 最多处理 64 条消息(含开头输入,见 [Runtime 边界](message-content.md#runtime-boundary));收到更多消息的 Turn 以 `internal_error` 失败。 - 以 `/` 开头的消息到达 MiniMax Code 时末尾多一个空行,否则 ACP 会将其作为操作员命令执行。 - 对于原生副作用,Core 不提供崩溃安全或恰好一次保证;已认领的工作若不重放,会在重启后失败。 - 图像上的 `file_id` 和 `detail` 会被拒绝;每个 Harness 声明自己接受哪些 `image_url`([消息内容](message-content.md#images))。 @@ -146,7 +148,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Hosted 工作区准入受[租户容量策略](../../../docs/zh/configuration.md#tenant-capacity)及既有[输入预留截止时间](environments.md#reservations)限制。 - MiniMax Code 未声明 `retained_native_history`,因此无法在 microsandbox 上运行(其每个 Environment 都使用工作区存储);Core 会拒绝该 Session([Harness 能力](./harness-capabilities.md))。 - 只有 `enabled` 网络可以运行,因此需要 `disabled` 或 `restricted` 的 Session 会被拒绝([restricted network policy](environments.md#restricted-network))。 -- 非空的 `packages.system` 会被拒绝;系统软件包必须预先安装。 +- 除 null 或空列表以外的 `packages.system` 都会被拒绝;系统软件包必须预先安装。 - 冷续接不恢复进程内存、后台进程或临时系统盘修改,已完成的 setup 不会重放(参见 [Environment 持久化](environments.md#runtime-capability-preparation))。在使用保留 NFS 存储的兼容 microsandbox 节点上,Codex 和 Claude Code 已通过[检查点转移](../../../docs/zh/sandbox-provider.md#checkpoint-transfer)以及[已确认的计算丢失](../../../docs/zh/sandbox-provider.md#confirmed-compute-loss)后续接的资格验证。接管未确认停止的旧写入者、主机崩溃时的持久性及任意崩溃受损历史的完整性仍未通过资格验证。 **Files 和 Environment files** diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index a0ba61cb4..f44880d08 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: 2984a8001807449fb923cecbd50fae872c1de9a4a1575ebb055d155b32c361ed +source_hash: cc519d898ede4653a4e8abd0d59da6da9d88e2da1587870a75d5b1bab8809fe0 --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -45,9 +45,9 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 ## 发送输入 {#send-input} -`POST /v1/agents/sessions/{session_id}/events` 接收包含 1 到 16384 个事件的有序数组:`agent.session.input.message`、`agent.session.input.cancel` 和 `agent.session.input.tool_result`。整个批次以原子方式接纳;批次存储后且在任何 Harness 读取它之前,响应为无正文的 202。接纳绝不表示已在原生侧应用。 +`POST /v1/agents/sessions/{session_id}/events` 接收包含 1 到 64 个事件的有序数组:`agent.session.input.message`、`agent.session.input.cancel` 和 `agent.session.input.tool_result`。整个批次以原子方式接纳;批次存储后且在任何 Harness 读取它之前,响应为无正文的 202。接纳绝不表示已在原生侧应用。 -- **限制。** 请求正文最多为 4 MiB,单次请求存储的输入最多为 2 MiB。 +- **限制。** 请求正文最多为 1 MiB,单次请求存储的输入最多为 512 KiB。 - **空值批次。** 将 `events` 显式设为 `null` 无效。 - **空批次。** `{"events": []}` 会检查 Session 是否存在并返回 202。它不会创建 Turn、Item 或重试标识。 - **重试。** 最长 128 字节的 `Idempotency-Key` 标识整个有序批次。相同密钥和批次会再次返回 202,而不会重复接纳任何内容;相同密钥搭配另一个批次会返回 409 `idempotency_conflict`。不带密钥的请求始终是新请求。 @@ -111,7 +111,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - **生命周期。** 流会跨 Turn 保持打开,并在 Turn 失败后继续存在。流会在 Session 被删除时,或在 [Environment 初始化失败](#environment-initialization-failure)产生终止 `agent.session.failed` 后结束;在该失败之后打开的流会保持打开。每 15 秒发送一次 keepalive 注释。 - **缓冲区。** Core 为每个 Session 最多保留 256 个事件和 64 MiB 事件,必要时还会额外容纳一个更大的事件。读取方落后于缓冲区时,会收到一个 `error` 事件,其 type 为 `server_error`、code 为 `stream_interrupted`,随后流会关闭。套接字写入阻塞五秒也会关闭流。执行过程绝不会等待读取方。 - **密钥重新检查。** 打开的流在空闲期间以及发送输出前,每秒最多重新检查一次原始项目 API 密钥。撤销密钥或归档项目会关闭流,身份验证失败也会关闭流。重新检查使用正常的五秒身份验证超时,并且等待期间不会发送任何 Session 数据。已经发送的字节无法收回。 -- **仅根工作。** 子 Turn 和子 Item 不发布 Session 事件;`agent.session.subagent.*` 事件和根协调 Item 会发布。请通过 [Subagent 资源](subagents.md)读取子工作。 +- **仅根工作。** 子 Turn 和子 Item 不发布 Session 事件,Subagent 的 `function_call` Item 除外,它只以 `item.added` 发布一次;`agent.session.subagent.*` 事件和根协调 Item 会发布。请通过 [Subagent 资源](subagents.md)读取子工作。 ### 事件规则 {#event-rules} @@ -121,7 +121,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - `item.added` 和 `item.done` 始终携带 `output_index`;输入 Item 的该值为 null。函数结果只发出 `item.added`;`item.done` 用于代理输出。 - 助手消息遵循以下单一顺序:先发送 `content` 为空的进行中 `item.added`,再发送文本为空的 `content_part.added`,然后发送 `output_text.delta` 事件、`output_text.done`、`content_part.done` 和 `item.done`。首次观察时已完整的消息(例如结构化输出)会在一个 `output_text.delta` 中逐字节发送全部文本。完整文本会替换此前累积的增量。 - Codex 命令输出以 `agent.output.command_execution_output.delta` 流式传输,并携带命令的 Item ID 和输出索引。原生输出配额和文本转换规则适用,因此这些增量并非逐字节捕获的原始结果;完整 Item 才是权威结果。 -- reasoning Item 遵循以下单一顺序:先发送 `summary` 为空的进行中 `item.added`;然后按顺序对每个 `summary_text` 部分发送文本为空的 `reasoning_summary_part.added` 和 `reasoning_summary_text.delta` 事件,并在下一个部分开始或 Item 结束时发送 `reasoning_summary_text.done` 和 `reasoning_summary_part.done`;最后发送 `item.done`。被取消或失败的 Turn 截断的部分,其 `reasoning_summary_part.done` 的 `status` 为 `incomplete`,否则为 null。子 Turn 快照改写了先前文本时,新文本只出现在 done 事件中。 +- reasoning Item 遵循以下单一顺序:先发送 `summary` 为空的进行中 `item.added`;然后按顺序对每个 `summary_text` 部分发送文本为空的 `reasoning_summary_part.added` 和 `reasoning_summary_text.delta` 事件,并在下一个部分开始或 Item 结束时发送 `reasoning_summary_text.done` 和 `reasoning_summary_part.done`;最后发送 `item.done`。被取消或失败的 Turn 截断的部分,其 `reasoning_summary_part.done` 的 `status` 为 `incomplete`,否则为 null。 - 已取消或失败的 Turn 会将其未完成的 Item 标记为 `incomplete`,并保留其部分内容。 - 函数调用会一直保留在 `required_actions` 中,直到 Harness 应用其结果,或取消操作或 Turn 结束将其移除。重复通知不会发出新状态。 @@ -138,7 +138,7 @@ Turn 和 Item 列表接受 `after`、`limit` 和 `order`([列表规则](wire-s | `message` | 用户或助手内容片段。Harness 报告阶段时,`phase` 为其阶段(`commentary`、`final_answer`),否则为 null | | `command_execution` | 命令、报告的输出、退出代码、持续时间和工作目录 | | `mcp_call` | 服务器和工具标识、参数、结构化结果或错误 | -| `function_call`、`function_call_output` | 关联的调用及其结果。结果始终携带 `output` 和 `error`;提交时省略这些字段则值为 null。存储的结果会保留提交时字段是否存在的信息。原生文件更改显示为 `apply_patch` 函数调用,更改内容作为参数,不会生成虚构结果 | +| `function_call`、`function_call_output` | 关联的调用及其结果。结果始终携带 `output` 和 `error`;提交时省略这些字段则值为 null。存储的结果会保留提交时字段是否存在的信息。Codex 文件更改显示为 `apply_patch` 函数调用,更改内容作为参数,不会生成虚构结果 | | `web_search_call` | 支持的操作字段(`search`、`open_page`、`find_in_page`、`other`);Claude Code 以 `search` 报告 `WebSearch`,以 `open_page` 报告 `WebFetch` | | `reasoning` | 一个原生推理 item,每个原生部分对应一个 `summary_text` 部分,仅在 Agent 设置了 `reasoning.summary` 时出现;为 null(默认)时,任何 Turn(根或子)都没有 `reasoning` Item 和 reasoning 事件。文本内容见 [OpenAI 兼容性](index.md#differences-from-openai)。子 Turn 的 Item 状态可以为 null | | `agent_message` 和 Subagent 协调调用 | 见 [Subagents](subagents.md)。`agent_message` 没有状态 | diff --git a/services/core/internal/api/environment_packages_test.go b/services/core/internal/api/environment_packages_test.go index bd350dec2..83f7d7219 100644 --- a/services/core/internal/api/environment_packages_test.go +++ b/services/core/internal/api/environment_packages_test.go @@ -10,7 +10,7 @@ import ( ) func TestSystemPackagesRejectedAtEveryInput(t *testing.T) { - for _, value := range []string{`["jq"]`} { + for _, value := range []string{`["jq"]`, `"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) { diff --git a/services/core/internal/api/environment_setup.go b/services/core/internal/api/environment_setup.go index 8e0958841..45ddccfb8 100644 --- a/services/core/internal/api/environment_setup.go +++ b/services/core/internal/api/environment_setup.go @@ -25,10 +25,7 @@ func rejectSystemPackages(raw json.RawMessage) error { return nil } var system []json.RawMessage - if json.Unmarshal(value, &system) != nil { - return sessions.ErrInvalidInput - } - if len(system) != 0 { + if json.Unmarshal(value, &system) != nil || len(system) != 0 { return errSystemPackages } return nil diff --git a/services/core/internal/api/inputs.go b/services/core/internal/api/inputs.go index 98d476978..a55265be1 100644 --- a/services/core/internal/api/inputs.go +++ b/services/core/internal/api/inputs.go @@ -21,7 +21,7 @@ type InputAdmission interface { } func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { - raw, ok := readJSONObjectLimit(w, r, 4<<20, "Request exceeds 4 MiB.") + raw, ok := readJSONObject(w, r) if !ok { return } @@ -77,7 +77,7 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { } func executionInputs(events []json.RawMessage) ([]sessions.Input, error) { - if len(events) == 0 || len(events) > 16384 { + if len(events) == 0 || len(events) > sessions.MaxInputBatch { return nil, sessions.ErrInvalidInput } inputs := make([]sessions.Input, 0, len(events)) diff --git a/services/core/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go index cc9927111..23829dcdb 100644 --- a/services/core/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -51,7 +51,7 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { return nil, errors.New("MCP credential_id must be null or a nonempty string, and stdio servers take none.") } if len(input.RequestMetadata) != 0 { - return nil, errors.New("Nonempty MCP request_metadata is not supported yet.") + return nil, errors.New("No engine sends MCP request_metadata.") } var allowed *[]string if input.AllowedTools != nil { diff --git a/services/core/internal/sessions/inputs.go b/services/core/internal/sessions/inputs.go index 48aae4155..c4bb79be9 100644 --- a/services/core/internal/sessions/inputs.go +++ b/services/core/internal/sessions/inputs.go @@ -39,21 +39,25 @@ func ValidateInputKey(key string) error { return nil } -// ValidateInputs checks and normalizes an input batch: 1..16384 messages (the -// official events bound), cancellations and function results whose nonempty -// JSON object payloads total at most 2 MiB, which fits 16384 minimal messages, -// an empty cancellation payload and a well-formed function result. It returns -// the normalized batch and its encoding, the batch's retry identity. +// MaxInputBatch is the most events one request admits; the input reservation +// check in the baseline migration holds the same bound. +const MaxInputBatch = 64 + +// ValidateInputs checks and normalizes an input batch: 1..MaxInputBatch messages, +// cancellations and function results whose nonempty JSON object payloads total +// at most 512 KiB, an empty cancellation payload and a well-formed function +// result. It returns the normalized batch and its encoding, the batch's retry +// identity. func ValidateInputs(inputs []Input) ([]Input, json.RawMessage, error) { - if len(inputs) == 0 || len(inputs) > 16384 { - return nil, nil, fmt.Errorf("%w: input batch must contain 1..16384 events", ErrInvalidInput) + if len(inputs) == 0 || len(inputs) > MaxInputBatch { + return nil, nil, fmt.Errorf("%w: input batch must contain 1..%d events", ErrInvalidInput, MaxInputBatch) } batch := make([]Input, len(inputs)) size := 0 for i, input := range inputs { size += len(input.Payload) - if size > 2<<20 || len(input.Payload) == 0 || (input.Kind != "message" && input.Kind != "cancel" && input.Kind != "tool_result") { - return nil, nil, fmt.Errorf("%w: input payloads must be nonempty and total at most 2 MiB", ErrInvalidInput) + if size > 512*1024 || len(input.Payload) == 0 || (input.Kind != "message" && input.Kind != "cancel" && input.Kind != "tool_result") { + return nil, nil, fmt.Errorf("%w: input payloads must be nonempty and total at most 512 KiB", ErrInvalidInput) } payload, err := jsonobject.Normalize(input.Payload) if err != nil { diff --git a/services/core/internal/sessions/inputs_test.go b/services/core/internal/sessions/inputs_test.go index c2884b5d0..d847b3f82 100644 --- a/services/core/internal/sessions/inputs_test.go +++ b/services/core/internal/sessions/inputs_test.go @@ -136,12 +136,12 @@ func TestValidateInputs(t *testing.T) { } for name, inputs := range map[string][]Input{ "no inputs": nil, - "16385 inputs": make([]Input, 16385), + "65 inputs": make([]Input, 65), "unsupported kind": {messageInput("ok"), {Kind: "unsupported", Payload: json.RawMessage(`{}`)}}, "missing payload": {{Kind: "message"}}, "array payload": {{Kind: "message", Payload: json.RawMessage(`[]`)}}, "cancel with a target": {{Kind: "cancel", Payload: json.RawMessage(`{"target":"other"}`)}}, - "over 2 MiB": {messageInput(strings.Repeat("x", 1200*1024)), messageInput(strings.Repeat("y", 1200*1024))}, + "over 512 KiB": {messageInput(strings.Repeat("x", 300*1024)), messageInput(strings.Repeat("y", 300*1024))}, } { if _, _, err := ValidateInputs(inputs); !errors.Is(err, ErrInvalidInput) { t.Errorf("%s: %v", name, err) @@ -149,16 +149,6 @@ func TestValidateInputs(t *testing.T) { } } -func TestValidateInputsFitsTheOfficialMaximum(t *testing.T) { - inputs := make([]Input, 16384) - for i := range inputs { - inputs[i] = messageInput("a") - } - if _, _, err := ValidateInputs(inputs); err != nil { - t.Fatal(err) - } -} - func TestValidateMessageInputs(t *testing.T) { if _, encoded, err := validateMessageInputs([]Input{messageInput("hi")}); err != nil || string(encoded) != `[{"kind":"message","payload":`+hi+`}]` { t.Fatalf("batch %s, %v", encoded, err) diff --git a/services/core/tests/integration/function_execution_test.go b/services/core/tests/integration/function_execution_test.go index e4a163f26..4889c2143 100644 --- a/services/core/tests/integration/function_execution_test.go +++ b/services/core/tests/integration/function_execution_test.go @@ -141,9 +141,9 @@ func TestExecutionSubagentFunctionCallIsARequiredActionOfTheRootTurn(t *testing. if err != nil || state.LastTurn.ID != input.TurnID || state.LastTurn.Status != sessions.TurnWaiting || len(childItems.Data) != 1 || childItems.Data[0].CallID != action.CallID { t.Fatal(state, childItems, err) } - // The official SDK answers with the child Turn ID of the call's event. + // The root Turn answers the call; the child Turn ID answer has its own test. answer := json.RawMessage(`{"success":true,"output":"saved"}`) - if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, items.Identity(child.ID, "turn:child-turn"), action.CallID, answer); err != nil { + if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, input.TurnID, action.CallID, answer); err != nil { t.Fatal(err) } var reply proto.FunctionResultPayload diff --git a/services/core/tests/integration/inputs_test.go b/services/core/tests/integration/inputs_test.go index 36dd59666..91302ff3f 100644 --- a/services/core/tests/integration/inputs_test.go +++ b/services/core/tests/integration/inputs_test.go @@ -94,6 +94,19 @@ func reserveEnvironmentInput(t *testing.T, s *Store, tenant, session, key string return got } +// The largest batch the events route admits fits an Environment reservation. +func TestEnvironmentInputReservationHoldsTheLargestBatch(t *testing.T) { + s, _ := testStore(t) + tenant, session := environmentInputSession(t, s) + inputs := make([]sessions.Input, sessions.MaxInputBatch) + for i := range inputs { + inputs[i] = messageInput("input") + } + if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "largest", inputs); err != nil { + t.Fatal(err) + } +} + // cancelEnvironmentInput cancels the Session's pending Environment input as // Session cancellation does, then reads the reservation back. func cancelEnvironmentInput(ctx context.Context, s *Store, tenant, session, reservation string) (sessions.EnvironmentInputReservation, error) { From f5ecb85bc657e771038c729179ba53906be84d37 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 22:05:22 +0800 Subject: [PATCH 32/39] Make snapshot evidence provider-private (#854) * Make snapshot evidence provider-private * Require every microsandbox snapshot receipt part * Read the E2B helper version from the contract in its tests * Answer with E2B helper version 2 in Core server tests * Name the snapshot field Evidence and bound it in a test "Receipt" already names the durable ownership receipt in the Sandbox Provider protocol, so the provider-private field becomes Evidence. A test covers its 4096-byte bound, and the E2B helper README no longer repeats the boundary version that helper_contract.go owns. --- docs/sandbox-provider.md | 2 +- docs/zh/sandbox-provider.md | 4 +- .../cmd/server/managed_generations_test.go | 4 +- .../server/managed_setup_preflight_test.go | 10 +- .../deploy/e2b/helper_contract_generated.py | 2 +- .../internal/execution/runtime_compute.go | 4 +- .../core/internal/sandbox/e2b/checkpoint.go | 2 +- .../internal/sandbox/e2b/helper_contract.go | 2 +- .../sandbox/e2b/installed_paths_test.go | 2 +- .../core/internal/sandbox/e2b/process_test.go | 8 +- .../internal/sandbox/microsandbox/identity.go | 9 +- .../sandbox/microsandbox/provider_test.go | 15 ++- .../internal/sandbox/microsandbox/types.go | 2 +- services/core/internal/sandbox/node/wire.go | 2 +- .../core/internal/sandbox/sandbox_provider.go | 17 +++- .../internal/sandbox/sandbox_provider_test.go | 12 +++ .../runtime_compute_lifecycle_test.go | 2 +- services/core/tools/e2b-provider/README.md | 2 +- .../e2b-provider/helper_contract_generated.py | 2 +- .../tools/e2b-provider/observation_test.py | 3 +- services/core/tools/e2b-provider/provider.py | 2 +- .../core/tools/e2b-provider/provider_test.py | 3 +- .../core/tools/e2b-provider/state_test.py | 3 +- .../tools/e2b-provider/testdata/contract.json | 94 +++++++++---------- .../tools/microsandbox-provider/snapshot.go | 4 +- .../snapshot_observation_test.go | 4 +- 26 files changed, 131 insertions(+), 85 deletions(-) diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index f47f4f749..84c96ca1a 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -254,7 +254,7 @@ A Provider relies on these rules: ### Checkpoint transfer -A checkpoint-capable generation reports `CheckpointCompatibility`: opaque `ArtifactDomain` and `ExecutionClass` tokens. Every verified `SnapshotIdentity` carries the same qualification. Core compares these values without interpreting CPU features, filesystem paths or storage implementations. A restore destination must be online and ready for the allocation's immutable deployment generation, match both tokens, and have active capacity plus a retained slot when moving from another node. The allocation, Device and Session identities remain unchanged. The Session and deployment transaction commits the destination route, placement and exact restore intent before target-side native work. No capacity or compatible destination leaves the retained snapshot owned until its configured deadline. The target must already hold that exact generation; Core does not automatically prepare historical generations on new nodes. A node keeps a superseded generation's provider until its checkpoint retention obligations end. +A checkpoint-capable generation reports `CheckpointCompatibility`: opaque `ArtifactDomain` and `ExecutionClass` tokens. Every verified `SnapshotIdentity` carries the same qualification. It also carries an opaque `Evidence` of at most 4096 bytes that only the Provider interprets; Core and the node proxy store and return it unchanged. Core compares these values without interpreting CPU features, filesystem paths or storage implementations. A restore destination must be online and ready for the allocation's immutable deployment generation, match both tokens, and have active capacity plus a retained slot when moving from another node. The allocation, Device and Session identities remain unchanged. The Session and deployment transaction commits the destination route, placement and exact restore intent before target-side native work. No capacity or compatible destination leaves the retained snapshot owned until its configured deadline. The target must already hold that exact generation; Core does not automatically prepare historical generations on new nodes. A node keeps a superseded generation's provider until its checkpoint retention obligations end. `Suspend` may report `SourceStopped` only after publishing a verified full checkpoint durably, stopping the exact source compute and settling source-local capture and cleanup obligations. Core persists the snapshot before marking it suspended; native absence alone is insufficient. `Suspend.ObserveOnly` may finish checkpoint publication and source cleanup for an already verified snapshot of the same operation, but cannot capture another snapshot or stop a source that has resumed running. If a previously published checkpoint is missing or damaged, `ObserveOnly` may return its exact durable ownership receipt with `Status: unknown` and `SourceStopped: false` for cleanup; that receipt proves neither current checkpoint usability nor source absence. `Resume` still verifies the checkpoint before execution. After the source-settlement barrier, source-node unavailability does not prevent restoration. Deletion and ordinary expiry can transfer an idle suspended allocation's cleanup route to an online node in the same artifact domain; deletion does not require execution-class compatibility. The cleanup destination must already serve the same immutable generation and have retained capacity. Without such a node, cleanup stays pending and ownership is not released; operators must restore an eligible node or its capacity. An unknown running or restoring writer never moves merely because its node is unreachable. diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index aa459bb25..940992c5d 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: 0ffc892ad458e104e5e9eba3427429b3f82e7376bfdf3eeac0c9653cee434db1 +source_hash: 590c360468ffa40fef877d3ef5cb2b9ee7f213d7e2aeb2ad28df515d965629d4 --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。Docker、microsandbox 和 E2B 实现了该协议。 @@ -256,7 +256,7 @@ Core 在初始化完成、没有 root 或 Subagent Turn 处于排队、进行中 ### 检查点转移 {#checkpoint-transfer} -支持检查点的 generation 报告 `CheckpointCompatibility`,包含不透明的 `ArtifactDomain` 与 `ExecutionClass` token。每个已验证 `SnapshotIdentity` 携带同样的资格。Core 只比较这些值,不解释 CPU 特性、文件系统路径或存储实现。恢复目标必须在线、对 allocation 的不可变 deployment generation 已就绪、两个 token 均匹配,并有 active 容量;跨 node 时还需 retained slot。allocation、Device 和 Session 身份保持不变。Session 与 deployment 事务先提交目标路由、placement 和精确 restore intent,再执行目标侧原生操作。没有容量或兼容目标时,保留快照的所有权一直持续到配置期限。目标必须已持有该精确 generation;Core 不会在新节点自动准备历史 generation。节点会保留被取代的 generation 的提供商,直到检查点保留义务结束。 +支持检查点的 generation 报告 `CheckpointCompatibility`,包含不透明的 `ArtifactDomain` 与 `ExecutionClass` token。每个已验证 `SnapshotIdentity` 携带同样的资格。它还携带最长 4096 字节的不透明 `Evidence`,只有提供商解释它;Core 与 node 代理原样存储并返回。Core 只比较这些值,不解释 CPU 特性、文件系统路径或存储实现。恢复目标必须在线、对 allocation 的不可变 deployment generation 已就绪、两个 token 均匹配,并有 active 容量;跨 node 时还需 retained slot。allocation、Device 和 Session 身份保持不变。Session 与 deployment 事务先提交目标路由、placement 和精确 restore intent,再执行目标侧原生操作。没有容量或兼容目标时,保留快照的所有权一直持续到配置期限。目标必须已持有该精确 generation;Core 不会在新节点自动准备历史 generation。节点会保留被取代的 generation 的提供商,直到检查点保留义务结束。 `Suspend` 只有在持久发布已验证的完整检查点、停止精确 source compute,并结清 source 本地 capture 与 cleanup 义务后,才能报告 `SourceStopped`。Core 先持久化快照,再标记 suspended;仅原生资源不存在并不足够。`Suspend.ObserveOnly` 可以为同一 operation 已验证的快照完成检查点发布和源清理,但不能重新捕获快照,也不能停止已经恢复 running 的源。此前已发布的检查点缺失或损坏时,`ObserveOnly` 可以返回其精确持久 ownership receipt,标记 `Status: unknown` 和 `SourceStopped: false`,供清理使用;该 receipt 不证明检查点当前可用,也不证明源不存在。`Resume` 仍在执行前验证检查点。完成源结清屏障后,source node 不可用不再阻止恢复。删除和正常到期可以将空闲 suspended allocation 的清理路由转给同一 artifact domain 内的在线 node;删除不要求 execution class 匹配。清理目标必须已提供同一不可变 generation,且有 retained 容量。没有这样的 node 时,清理保持 pending,不释放所有权;运维需恢复符合条件的 node 或其容量。未知 running 或 restoring writer 不会仅因 node 不可达而迁移。 diff --git a/services/core/cmd/server/managed_generations_test.go b/services/core/cmd/server/managed_generations_test.go index 9a639d41c..5f0d7cf3e 100644 --- a/services/core/cmd/server/managed_generations_test.go +++ b/services/core/cmd/server/managed_generations_test.go @@ -29,7 +29,7 @@ q=json.load(sys.stdin) with (pathlib.Path(q['Config']['StateDir'])/'requests').open('a') as f: f.write(json.dumps(q)+'\n') info=dict(q['Reference'],State='running',ProviderID='owned',CreateSettled=True) if q['Operation']=='kill': info['State']='absent' -print(json.dumps({'Version':1,'Info':info})) +print(json.dumps({'Version':2,'Info':info})) ` if err := os.WriteFile(helper, []byte(script), 0700); err != nil { t.Fatal(err) @@ -132,7 +132,7 @@ code = '' if k == 'revoked': code = 'unauthorized' elif k == 'unconfirmed': code = 'unconfirmed' elif not (k.startswith('team-a') and template in ('owned-a', 'new-a') or k == 'team-b' and template == 'public-b'): code = 'team_mismatch' -result = {'Version': 1, 'ErrorCode': code} +result = {'Version': 2, 'ErrorCode': code} if not code: result['DeploymentValid'] = True if q['Operation'] == 'validate_deployment': diff --git a/services/core/cmd/server/managed_setup_preflight_test.go b/services/core/cmd/server/managed_setup_preflight_test.go index dc977e41a..884b89385 100644 --- a/services/core/cmd/server/managed_setup_preflight_test.go +++ b/services/core/cmd/server/managed_setup_preflight_test.go @@ -19,7 +19,7 @@ import ( func TestE2BRejectedSpecificationHasSafeActionableDiagnostic(t *testing.T) { helper := filepath.Join(t.TempDir(), "provider") - if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"invalid\"}'\n"), 0700); err != nil { + if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"invalid\"}'\n"), 0700); err != nil { t.Fatal(err) } state := filepath.Join(t.TempDir(), "e2b") @@ -49,12 +49,12 @@ op = q['Operation'] with (pathlib.Path(q['Config']['StateDir']) / 'operations').open('a') as log: log.write(op + '\n') if op == 'validate_deployment': - print(json.dumps({'Version': 1, 'ErrorCode': 'invalid'})) + print(json.dumps({'Version': 2, 'ErrorCode': 'invalid'})) else: info = dict(q['Reference'], ProviderID='owned-compute', State='stopped', CreateSettled=True) if op == 'kill': info.update(ProviderID='', State='absent') - print(json.dumps({'Version': 1, 'Info': info})) + print(json.dumps({'Version': 2, 'Info': info})) ` if err := os.WriteFile(helper, []byte(script), 0700); err != nil { t.Fatal(err) @@ -101,7 +101,7 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) { } helper := filepath.Join(t.TempDir(), "provider") requests := filepath.Join(state, "requests") - script := "#!/bin/sh\ncat >>" + requests + "\necho >>" + requests + "\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"\",\"DeploymentValid\":true,\"TemplateBuild\":{\"Status\":\"ready\",\"CPUs\":4,\"MemoryMiB\":4096,\"RootDiskMiB\":24063}}'\n" + script := "#!/bin/sh\ncat >>" + requests + "\necho >>" + requests + "\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"\",\"DeploymentValid\":true,\"TemplateBuild\":{\"Status\":\"ready\",\"CPUs\":4,\"MemoryMiB\":4096,\"RootDiskMiB\":24063}}'\n" if err := os.WriteFile(helper, []byte(script), 0700); err != nil { t.Fatal(err) } @@ -133,7 +133,7 @@ func TestE2BCandidateAdoptsTemplateBuildForOmittedResources(t *testing.T) { func TestInitialE2BPublicTemplateOutsideTeamIsRejected(t *testing.T) { helper := filepath.Join(t.TempDir(), "provider") - if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"ErrorCode\":\"team_mismatch\"}'\n"), 0700); err != nil { + if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"ErrorCode\":\"team_mismatch\"}'\n"), 0700); err != nil { t.Fatal(err) } state := filepath.Join(t.TempDir(), "e2b") diff --git a/services/core/deploy/e2b/helper_contract_generated.py b/services/core/deploy/e2b/helper_contract_generated.py index bee1ee648..a5c7e38ee 100644 --- a/services/core/deploy/e2b/helper_contract_generated.py +++ b/services/core/deploy/e2b/helper_contract_generated.py @@ -10,7 +10,7 @@ MAX_REQUEST = 75497472 MAX_RESPONSE = 16777216 OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential","compute","renew_compute","suspend","resume","kill_compute","delete_snapshot","command_compute","resume_compute"] -PROTOCOL_VERSION = 1 +PROTOCOL_VERSION = 2 REFERENCE_FIELDS = ["TenantID","EnvironmentID","AllocationID"] REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Compute","Suspend","Resume","Snapshot","Deadline"] RESPONSE_FIELDS = ["Version","Info","Command","State","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"] diff --git a/services/core/internal/execution/runtime_compute.go b/services/core/internal/execution/runtime_compute.go index 4c309e6df..e3f2b2be7 100644 --- a/services/core/internal/execution/runtime_compute.go +++ b/services/core/internal/execution/runtime_compute.go @@ -271,7 +271,7 @@ func (r *runtimeLifecycle) captureCompute(ctx context.Context, p sandbox.Sandbox } return r.wakeCompute(ctx, p, next, state) } - if result.Snapshot.Compatibility.Validate() != nil { + if result.Snapshot.Validate() != nil { return sandbox.ErrOwnership } state.Snapshot = result.Snapshot @@ -345,7 +345,7 @@ func (r *runtimeLifecycle) expire(ctx context.Context, owner deployment.Allocati func (r *runtimeLifecycle) restoreCompute(ctx context.Context, p sandbox.SandboxProvider, owner deployment.Allocation, state runtimeCompute, observeOnly bool) (err error) { defer func() { err = withObservationOwner(owner, err) }() - if state.Snapshot == nil || state.Snapshot.Compatibility.Validate() != nil || state.Rollback || state.RestoreID == "" { + if state.Snapshot == nil || state.Snapshot.Validate() != nil || state.Rollback || state.RestoreID == "" { return sandbox.ErrOwnership } if state.Target == nil { diff --git a/services/core/internal/sandbox/e2b/checkpoint.go b/services/core/internal/sandbox/e2b/checkpoint.go index 4c5242cfc..8a2380edc 100644 --- a/services/core/internal/sandbox/e2b/checkpoint.go +++ b/services/core/internal/sandbox/e2b/checkpoint.go @@ -32,7 +32,7 @@ func (p *Provider) name(r sandbox.Reference, generation uint64) string { } func (p *Provider) validSnapshot(r sandbox.Reference, s sandbox.SnapshotIdentity) bool { - return s.Compatibility.Validate() == nil && validID(s.OperationID) && s.SourceName == p.name(r, s.SourceGeneration) && + return s.Validate() == nil && validID(s.OperationID) && s.SourceName == p.name(r, s.SourceGeneration) && s.Reference == s.SourceName+":s-"+s.OperationID && s.SourceID != "" && s.ID != "" } diff --git a/services/core/internal/sandbox/e2b/helper_contract.go b/services/core/internal/sandbox/e2b/helper_contract.go index 7beb4c8c0..f220a0954 100644 --- a/services/core/internal/sandbox/e2b/helper_contract.go +++ b/services/core/internal/sandbox/e2b/helper_contract.go @@ -11,7 +11,7 @@ import ( //go:generate go run ./internal/contractgen // This adapter-private boundary is documented in tools/e2b-provider/README.md. -const ProtocolVersion = 1 +const ProtocolVersion = 2 const MaxOutputBytes = 1024 * 1024 const MaxRequestBytes = 72 * 1024 * 1024 const MaxResponseBytes = 16 * 1024 * 1024 diff --git a/services/core/internal/sandbox/e2b/installed_paths_test.go b/services/core/internal/sandbox/e2b/installed_paths_test.go index 41635e915..9d4d4af86 100644 --- a/services/core/internal/sandbox/e2b/installed_paths_test.go +++ b/services/core/internal/sandbox/e2b/installed_paths_test.go @@ -31,7 +31,7 @@ func TestConfigurationDiscoveryUsesSuppliedProcessPaths(t *testing.T) { if err := os.MkdirAll(filepath.Dir(binary), 0700); err != nil { t.Fatal(err) } - if err := os.WriteFile(binary, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":1,\"Templates\":[]}'\n"), 0700); err != nil { + if err := os.WriteFile(binary, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"Version\":2,\"Templates\":[]}'\n"), 0700); err != nil { t.Fatal(err) } input := sandbox.ConfigurationDiscoveryInput{Credential: json.RawMessage(`{"api_key":"synthetic-key"}`)} diff --git a/services/core/internal/sandbox/e2b/process_test.go b/services/core/internal/sandbox/e2b/process_test.go index 2d15997f4..a2e90a5b4 100644 --- a/services/core/internal/sandbox/e2b/process_test.go +++ b/services/core/internal/sandbox/e2b/process_test.go @@ -12,7 +12,7 @@ import ( ) func TestProcessWireRejectsUnknownAndTrailingOutput(t *testing.T) { - for _, output := range []string{`{"Version":1,"Secret":"private"}`, `{"Version":1} {"Version":1}`} { + for _, output := range []string{`{"Version":2,"Secret":"private"}`, `{"Version":2} {"Version":2}`} { root := t.TempDir() binary := filepath.Join(root, "helper") if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+output+"'\n"), 0700); err != nil { @@ -29,7 +29,7 @@ func TestTimeoutDoesNotTerminateOwnedHelper(t *testing.T) { marker := filepath.Join(root, "settled") // The private test path contains no shell syntax; the real adapter never puts // credentials or request contents in argv or inherited environment. - script := "#!/bin/sh\nsleep 0.15\ntouch '" + marker + "'\nprintf '%s' '{\"Version\":1}'\n" + script := "#!/bin/sh\nsleep 0.15\ntouch '" + marker + "'\nprintf '%s' '{\"Version\":2}'\n" if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } @@ -50,7 +50,7 @@ func TestTimeoutDoesNotTerminateOwnedHelper(t *testing.T) { func TestEnvironmentDropsProviderSelectorsAndCredentials(t *testing.T) { root := t.TempDir() binary := filepath.Join(root, "helper") - script := "#!/bin/sh\nif test -n \"${E2B_API_KEY:-}${E2B_API_URL:-}${PRIVATE_MODEL_KEY:-}\"; then exit 1; fi\nprintf '%s' '{\"Version\":1}'\n" + script := "#!/bin/sh\nif test -n \"${E2B_API_KEY:-}${E2B_API_URL:-}${PRIVATE_MODEL_KEY:-}\"; then exit 1; fi\nprintf '%s' '{\"Version\":2}'\n" if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } @@ -67,7 +67,7 @@ func TestCredentialFenceWaitsForActualHelperExitAfterCancellation(t *testing.T) binary := filepath.Join(root, "helper") marker := filepath.Join(root, "started") finish := filepath.Join(root, "finish") - script := "#!/bin/sh\ntouch '" + marker + "'\nwhile ! test -f '" + finish + "'; do sleep 0.01; done\nprintf '%s' '{\"Version\":1}'\n" + script := "#!/bin/sh\ntouch '" + marker + "'\nwhile ! test -f '" + finish + "'; do sleep 0.01; done\nprintf '%s' '{\"Version\":2}'\n" if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/microsandbox/identity.go b/services/core/internal/sandbox/microsandbox/identity.go index ddcc8d185..c9becac36 100644 --- a/services/core/internal/sandbox/microsandbox/identity.go +++ b/services/core/internal/sandbox/microsandbox/identity.go @@ -71,8 +71,15 @@ func ValidateCompute(c Config, r sandbox.Reference, v Compute) error { } return nil } + +// SnapshotEvidence encodes the native digest, checkpoint ID and checkpoint root; the root is last because it may contain spaces. +func SnapshotEvidence(digest, checkpointID, root string) string { + return digest + " " + checkpointID + " " + root +} + func ValidateSnapshot(c Config, r sandbox.Reference, s SnapshotIdentity) error { - if s.Compatibility.Validate() != nil || !ValidReference(r) || !validID(s.OperationID) || s.Reference != SnapshotReference(c, r, s.OperationID) || s.SourceName != Name(c, r, s.SourceGeneration) || s.SourceID == "" || s.ID == "" || s.Digest == "" || s.CheckpointID == "" || s.CheckpointRoot == "" { + receipt := strings.SplitN(s.Evidence, " ", 3) + if s.Validate() != nil || !ValidReference(r) || !validID(s.OperationID) || s.Reference != SnapshotReference(c, r, s.OperationID) || s.SourceName != Name(c, r, s.SourceGeneration) || s.SourceID == "" || s.ID == "" || len(receipt) != 3 || receipt[0] == "" || receipt[1] == "" || receipt[2] == "" { return sandbox.ErrInvalid } return nil diff --git a/services/core/internal/sandbox/microsandbox/provider_test.go b/services/core/internal/sandbox/microsandbox/provider_test.go index 753ad9593..04ba9abf1 100644 --- a/services/core/internal/sandbox/microsandbox/provider_test.go +++ b/services/core/internal/sandbox/microsandbox/provider_test.go @@ -26,7 +26,7 @@ func testRef() sandbox.Reference { func testSnapshot() SnapshotIdentity { c, r := testConfig(), testRef() op := "55555555-5555-4555-8555-555555555555" - return SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "domain", ExecutionClass: "class"}, Reference: SnapshotReference(c, r, op), ID: "snap_exact", Digest: "digest", CheckpointID: "checkpoint", CheckpointRoot: "root", OperationID: op, SourceName: Name(c, r, 0), SourceID: "local:4"} + return SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "domain", ExecutionClass: "class"}, Reference: SnapshotReference(c, r, op), ID: "snap_exact", Evidence: SnapshotEvidence("digest", "checkpoint", "root"), OperationID: op, SourceName: Name(c, r, 0), SourceID: "local:4"} } func deadline(t *testing.T) context.Context { t.Helper() @@ -167,3 +167,16 @@ func TestImageMustUseCompletePinnedOCIManifestDigest(t *testing.T) { } } } + +func TestSnapshotEvidenceIsRequired(t *testing.T) { + c, r, s := testConfig(), testRef(), testSnapshot() + if err := ValidateSnapshot(c, r, s); err != nil { + t.Fatal(err) + } + for _, receipt := range []string{"digest", "digest /root", "digest checkpoint "} { + s.Evidence = receipt + if ValidateSnapshot(c, r, s) == nil { + t.Fatal("malformed receipt accepted", receipt) + } + } +} diff --git a/services/core/internal/sandbox/microsandbox/types.go b/services/core/internal/sandbox/microsandbox/types.go index 06274f3b6..c2bc43434 100644 --- a/services/core/internal/sandbox/microsandbox/types.go +++ b/services/core/internal/sandbox/microsandbox/types.go @@ -9,7 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -const ProtocolVersion = 7 +const ProtocolVersion = 8 const SDKVersion = "v0.7.8" const MaxOutputBytes = 1024 * 1024 const MaxRequestBytes = 72 * 1024 * 1024 diff --git a/services/core/internal/sandbox/node/wire.go b/services/core/internal/sandbox/node/wire.go index b024a229c..0ed199e09 100644 --- a/services/core/internal/sandbox/node/wire.go +++ b/services/core/internal/sandbox/node/wire.go @@ -19,7 +19,7 @@ import ( "github.com/gorilla/websocket" ) -const ProtocolVersion = 8 +const ProtocolVersion = 9 const MaxControlFrameBytes = 32 * 1024 const MaxFrameBytes = 72 * 1024 * 1024 const maxPending = 32 diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 622b8bd69..c54875eb3 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -177,22 +177,33 @@ func (c CheckpointCompatibility) Validate() error { return nil } +// MaxSnapshotEvidenceBytes bounds SnapshotIdentity.Evidence. +const MaxSnapshotEvidenceBytes = 4096 + // SnapshotIdentity is provider evidence from a verified full snapshot. Core // persists it unchanged and records consumption separately; it never invents // paths, checksums, native checkpoint fields, or source identity. +// Evidence is the Provider's private verification evidence: Core and the node +// proxy store and return it unchanged and never interpret it. type SnapshotIdentity struct { Compatibility CheckpointCompatibility Reference string ID string - Digest string - CheckpointID string - CheckpointRoot string + Evidence string OperationID string SourceGeneration uint64 SourceName string SourceID string } +// Validate checks the fields Core relies on. +func (s SnapshotIdentity) Validate() error { + if len(s.Evidence) > MaxSnapshotEvidenceBytes { + return ErrInvalid + } + return s.Compatibility.Validate() +} + type ComputeState struct { Compute Compute // Status is the native state of Compute. "stopped" and "crashed" mean the diff --git a/services/core/internal/sandbox/sandbox_provider_test.go b/services/core/internal/sandbox/sandbox_provider_test.go index 1cbde45ab..fa3782980 100644 --- a/services/core/internal/sandbox/sandbox_provider_test.go +++ b/services/core/internal/sandbox/sandbox_provider_test.go @@ -3,6 +3,7 @@ package sandbox import ( "reflect" "slices" + "strings" "testing" ) @@ -22,3 +23,14 @@ func TestOperationGroupsPartitionTheInterface(t *testing.T) { t.Fatalf("SandboxProvider operations %v, groups %v", methods, groups) } } + +func TestSnapshotEvidenceIsBounded(t *testing.T) { + s := SnapshotIdentity{Compatibility: CheckpointCompatibility{ArtifactDomain: "domain", ExecutionClass: "class"}, Evidence: strings.Repeat("e", MaxSnapshotEvidenceBytes)} + if s.Validate() != nil { + t.Fatal("bounded evidence rejected") + } + s.Evidence += "e" + if s.Validate() == nil { + t.Fatal("oversized evidence accepted") + } +} diff --git a/services/core/tests/integration/runtime_compute_lifecycle_test.go b/services/core/tests/integration/runtime_compute_lifecycle_test.go index 2c93db85e..f697455c0 100644 --- a/services/core/tests/integration/runtime_compute_lifecycle_test.go +++ b/services/core/tests/integration/runtime_compute_lifecycle_test.go @@ -92,7 +92,7 @@ func (p *fakeCheckpointProvider) Suspend(_ context.Context, q sandbox.SuspendReq if _, exists := p.snapshots[q.OperationID]; exists { return sandbox.ComputeState{}, errors.New("capture replayed") } - p.snapshots[q.OperationID] = sandbox.SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}, Reference: "snapshot-" + q.OperationID, ID: uuid.NewString(), Digest: "verified", CheckpointID: "checkpoint", CheckpointRoot: "private", OperationID: q.OperationID, SourceGeneration: q.Source.Generation, SourceName: q.Source.Name, SourceID: q.Source.ID} + p.snapshots[q.OperationID] = sandbox.SnapshotIdentity{Compatibility: sandbox.CheckpointCompatibility{ArtifactDomain: "fixture-store", ExecutionClass: "fixture-runtime"}, Reference: "snapshot-" + q.OperationID, ID: uuid.NewString(), Evidence: "verified checkpoint private", OperationID: q.OperationID, SourceGeneration: q.Source.Generation, SourceName: q.Source.Name, SourceID: q.Source.ID} state.Status = "paused" p.computes[q.Source.Name] = state if p.loseCapture { diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md index 6672d41e0..131cc60d7 100644 --- a/services/core/tools/e2b-provider/README.md +++ b/services/core/tools/e2b-provider/README.md @@ -36,7 +36,7 @@ Compatible endpoints must return the SDK 2.51.0 template-list and template-build Run `go generate ./services/core/internal/sandbox/e2b` from the repository root after changing these declarations. `make check-e2b-provider` and the Go adapter tests reject stale projections; both languages consume generated valid and invalid exchanges covering wire types, extra fields, operation/reference bounds and managed-bootstrap fields. The helper build copies those fixtures with its source before running the pinned-SDK suite. -The boundary has version 1. The request and credentials arrive on standard input; standard output carries one bounded response with a sanitized error code. The helper removes ambient `E2B_*` and `PYTHON*` variables and calls the SDK only with the request's explicit API origin and sandbox domain. Each receipt is bound to those selectors, and a receipt without them belongs to the official endpoints (`https://api.e2b.app`, `e2b.app`). An endpoint change keeps earlier generations on their original API and sandbox domain; the candidate key must verify all retained ownership before an online switch. +The request and credentials arrive on standard input; standard output carries one bounded response with a sanitized error code. The helper removes ambient `E2B_*` and `PYTHON*` variables and calls the SDK only with the request's explicit API origin and sandbox domain. Each receipt is bound to those selectors, and a receipt without them belongs to the official endpoints (`https://api.e2b.app`, `e2b.app`). An endpoint change keeps earlier generations on their original API and sandbox domain; the candidate key must verify all retained ownership before an online switch. ## Receipts and state directory diff --git a/services/core/tools/e2b-provider/helper_contract_generated.py b/services/core/tools/e2b-provider/helper_contract_generated.py index bee1ee648..a5c7e38ee 100644 --- a/services/core/tools/e2b-provider/helper_contract_generated.py +++ b/services/core/tools/e2b-provider/helper_contract_generated.py @@ -10,7 +10,7 @@ MAX_REQUEST = 75497472 MAX_RESPONSE = 16777216 OPERATIONS = ["create","inspect","renew","kill","command","validate_deployment","observe","list_templates","list_builds","verify_credential","compute","renew_compute","suspend","resume","kill_compute","delete_snapshot","command_compute","resume_compute"] -PROTOCOL_VERSION = 1 +PROTOCOL_VERSION = 2 REFERENCE_FIELDS = ["TenantID","EnvironmentID","AllocationID"] REQUEST_FIELDS = ["Version","Operation","Config","Reference","References","Bootstrap","RuntimeBootstrap","Command","Compute","Suspend","Resume","Snapshot","Deadline"] RESPONSE_FIELDS = ["Version","Info","Command","State","ErrorCode","DeploymentValid","TemplateBuild","Templates","Builds","Observation"] diff --git a/services/core/tools/e2b-provider/observation_test.py b/services/core/tools/e2b-provider/observation_test.py index ef204d6d7..3a773ca24 100644 --- a/services/core/tools/e2b-provider/observation_test.py +++ b/services/core/tools/e2b-provider/observation_test.py @@ -7,6 +7,7 @@ from provider import Provider from provider_test import ProviderTest from state import Failure +from helper_contract_generated import PROTOCOL_VERSION class ObservationTest(ProviderTest): @@ -15,7 +16,7 @@ def observe(self, reference=None): try: return Provider(request).execute() except Failure as error: - return {'Version': 1, 'ErrorCode': error.code} + return {'Version': PROTOCOL_VERSION, 'ErrorCode': error.code} def listing(self, *pages): pages = [list(page) for page in pages] or [[]] diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index bbf5091c2..cb0464334 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -380,7 +380,7 @@ def command_compute(self): def snapshot(self, operation, source, cloud): return {'Compatibility': self.config['Compatibility'], 'Reference': source['Name'] + ':s-' + operation, - 'ID': cloud.sandbox_id, 'Digest': '', 'CheckpointID': '', 'CheckpointRoot': '', + 'ID': cloud.sandbox_id, 'Evidence': '', 'OperationID': operation, 'SourceGeneration': source['Generation'], 'SourceName': source['Name'], 'SourceID': cloud.sandbox_id} diff --git a/services/core/tools/e2b-provider/provider_test.py b/services/core/tools/e2b-provider/provider_test.py index b2a3e7877..41db53950 100644 --- a/services/core/tools/e2b-provider/provider_test.py +++ b/services/core/tools/e2b-provider/provider_test.py @@ -19,6 +19,7 @@ from provider import Provider from sdk import restore, run from state import Failure, Receipt +from helper_contract_generated import PROTOCOL_VERSION class Fixture(unittest.TestCase): @@ -28,7 +29,7 @@ def setUp(self): self.reference = {key: str(uuid4()) for key in ['TenantID', 'EnvironmentID', 'AllocationID']} self.config = {'StateDir': self.temporary.name, 'InstallationID': str(uuid4()), 'APIKey': 'private-account-secret', 'Template': 'test:' + str(uuid4()), 'TimeoutSeconds': 120} - self.request = {'Version': 1, 'Operation': 'create', 'Config': self.config, + self.request = {'Version': PROTOCOL_VERSION, 'Operation': 'create', 'Config': self.config, 'Reference': self.reference, 'Deadline': (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), 'Bootstrap': dict(self.reference, SessionID=str(uuid4()), DeviceID=str(uuid4()), CoreURL='https://core.example/api/v1', Credential='private-runtime-secret', SuspendControl=True)} diff --git a/services/core/tools/e2b-provider/state_test.py b/services/core/tools/e2b-provider/state_test.py index 1c8bbe3fd..f6506d736 100644 --- a/services/core/tools/e2b-provider/state_test.py +++ b/services/core/tools/e2b-provider/state_test.py @@ -12,13 +12,14 @@ from provider import Provider from state import Failure, Receipt +from helper_contract_generated import PROTOCOL_VERSION class StateTest(unittest.TestCase): def setUp(self): self.root = tempfile.TemporaryDirectory() self.addCleanup(self.root.cleanup) - self.request = {'Version': 1, 'Operation': 'kill', + self.request = {'Version': PROTOCOL_VERSION, 'Operation': 'kill', 'Config': {'StateDir': self.root.name, 'InstallationID': str(uuid4()), 'APIKey': 'test'}, 'Reference': {key: str(uuid4()) for key in ['TenantID', 'EnvironmentID', 'AllocationID']}, 'Deadline': (datetime.now(timezone.utc) + timedelta(seconds=2)).isoformat()} diff --git a/services/core/tools/e2b-provider/testdata/contract.json b/services/core/tools/e2b-provider/testdata/contract.json index f04757e41..1fe242ec6 100644 --- a/services/core/tools/e2b-provider/testdata/contract.json +++ b/services/core/tools/e2b-provider/testdata/contract.json @@ -12,55 +12,55 @@ {"kind":"managed","name":"valid","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111"},"valid":true}, {"kind":"managed","name":"workspace-\u003cnil\u003e","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"valid":true}, {"kind":"managed","name":"workspace-map[]","payload":{"AllocationID":"33333333-3333-4333-8333-333333333333","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","InstallationID":"66666666-6666-4666-8666-666666666666","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"SessionID":"44444444-4444-4444-8444-444444444444","TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"valid":false}, -{"kind":"request","name":"command","payload":{"Version":1,"Operation":"command","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"command_compute","payload":{"Version":1,"Operation":"command_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Command":{"Args":["true"],"Directory":"","Stdin":null},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"compute","payload":{"Version":1,"Operation":"compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"config-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":null,"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"create","payload":{"Version":1,"Operation":"create","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"delete_snapshot","payload":{"Version":1,"Operation":"delete_snapshot","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Digest":"","CheckpointID":"","CheckpointRoot":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"extra","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Extra":true,"Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"inspect","payload":{"Version":1,"Operation":"inspect","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"kill","payload":{"Version":1,"Operation":"kill","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"kill_compute","payload":{"Version":1,"Operation":"kill_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"list_builds","payload":{"Version":1,"Operation":"list_builds","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"list_templates","payload":{"Version":1,"Operation":"list_templates","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"observe","payload":{"Version":1,"Operation":"observe","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"operation-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":null,"Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"operation-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"unsupported","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"reference-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":null,"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"reference-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"renew","payload":{"Version":1,"Operation":"renew","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"renew_compute","payload":{"Version":1,"Operation":"renew_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"resume","payload":{"Version":1,"Operation":"resume","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Resume":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Digest":"","CheckpointID":"","CheckpointRoot":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Target":{"Generation":1,"Name":"fixture-g1","ID":"","RestoredFrom":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Digest":"","CheckpointID":"","CheckpointRoot":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"}},"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"resume_compute","payload":{"Version":1,"Operation":"resume_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"suspend","payload":{"Version":1,"Operation":"suspend","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Suspend":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Source":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Snapshot":null,"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"validate_deployment","payload":{"Version":1,"Operation":"validate_deployment","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-0","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-32","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, -{"kind":"request","name":"verify_credential-count-33","payload":{"Version":1,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000021-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":{},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":[null],"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, +{"kind":"request","name":"command","payload":{"Version":2,"Operation":"command","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"command_compute","payload":{"Version":2,"Operation":"command_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Command":{"Args":["true"],"Directory":"","Stdin":null},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"compute","payload":{"Version":2,"Operation":"compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"config-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":null,"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"create","payload":{"Version":2,"Operation":"create","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"delete_snapshot","payload":{"Version":2,"Operation":"delete_snapshot","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"extra","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Extra":true,"Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"inspect","payload":{"Version":2,"Operation":"inspect","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"kill","payload":{"Version":2,"Operation":"kill","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"kill_compute","payload":{"Version":2,"Operation":"kill_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"list_builds","payload":{"Version":2,"Operation":"list_builds","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"list_templates","payload":{"Version":2,"Operation":"list_templates","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"observe","payload":{"Version":2,"Operation":"observe","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"operation-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":null,"Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"operation-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"unsupported","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"reference-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":null,"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"reference-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"renew","payload":{"Version":2,"Operation":"renew","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"renew_compute","payload":{"Version":2,"Operation":"renew_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"resume","payload":{"Version":2,"Operation":"resume","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Resume":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Snapshot":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"},"Target":{"Generation":1,"Name":"fixture-g1","ID":"","RestoredFrom":{"Compatibility":{"artifact_domain":"a","execution_class":"e"},"Reference":"fixture:s-op","ID":"fixture-id","Evidence":"","OperationID":"op","SourceGeneration":0,"SourceName":"fixture","SourceID":"fixture-id"}},"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"resume_compute","payload":{"Version":2,"Operation":"resume_compute","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Compute":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"suspend","payload":{"Version":2,"Operation":"suspend","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Suspend":{"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"OperationID":"op","Source":{"Generation":0,"Name":"fixture","ID":"fixture-id","RestoredFrom":null},"Snapshot":null,"ObserveOnly":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"validate_deployment","payload":{"Version":2,"Operation":"validate_deployment","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-0","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-32","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":true}, +{"kind":"request","name":"verify_credential-count-33","payload":{"Version":2,"Operation":"verify_credential","Config":{"Binary":"","StateDir":"","InstallationID":"66666666-6666-4666-8666-666666666666","APIKey":"","Template":"","APIURL":"","Domain":"","TimeoutSeconds":0,"Resources":null,"Compatibility":{"artifact_domain":"","execution_class":""}},"Reference":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333"},"References":[{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000001-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000002-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000003-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000004-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000005-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000006-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000007-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000008-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000009-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000000f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000010-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000011-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000012-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000013-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000014-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000015-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000016-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000017-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000018-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000019-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001a-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001b-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001c-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001d-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001e-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"0000001f-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000020-3333-4333-8333-333333333333"},{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"00000021-3333-4333-8333-333333333333"}],"Bootstrap":{"TenantID":"11111111-1111-4111-8111-111111111111","EnvironmentID":"22222222-2222-4222-8222-222222222222","AllocationID":"33333333-3333-4333-8333-333333333333","SessionID":"44444444-4444-4444-8444-444444444444","DeviceID":"55555555-5555-4555-8555-555555555555","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","SuspendControl":false},"RuntimeBootstrap":{"version":3,"core_url":"https://core.example/api/v1","device_id":"55555555-5555-4555-8555-555555555555","credential":"fixture-only","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","workspace":"/environment/workspace","suspend_control":false},"Deadline":"2099-01-01T00:00:00Z"},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":{},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":"invalid","RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"request","name":"verify_credential-references-type","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"verify_credential","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"References":[null],"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, {"kind":"request","name":"version-bool","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":true},"valid":false}, {"kind":"request","name":"version-null","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":null},"valid":false}, {"kind":"request","name":"version-string","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":"1"},"valid":false}, -{"kind":"request","name":"version-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, -{"kind":"request","name":"workspace-\u003cnil\u003e","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":true}, -{"kind":"request","name":"workspace-map[]","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":1},"valid":false}, -{"kind":"response","name":"error-","payload":{"Version":1,"ErrorCode":""},"valid":true}, -{"kind":"response","name":"error-checkpoint_unusable","payload":{"Version":1,"ErrorCode":"checkpoint_unusable"},"valid":true}, -{"kind":"response","name":"error-command_unconfirmed","payload":{"Version":1,"ErrorCode":"command_unconfirmed"},"valid":true}, -{"kind":"response","name":"error-exists","payload":{"Version":1,"ErrorCode":"exists"},"valid":true}, -{"kind":"response","name":"error-invalid","payload":{"Version":1,"ErrorCode":"invalid"},"valid":true}, -{"kind":"response","name":"error-not_found","payload":{"Version":1,"ErrorCode":"not_found"},"valid":true}, -{"kind":"response","name":"error-ownership","payload":{"Version":1,"ErrorCode":"ownership"},"valid":true}, -{"kind":"response","name":"error-team_mismatch","payload":{"Version":1,"ErrorCode":"team_mismatch"},"valid":true}, -{"kind":"response","name":"error-template_invalid","payload":{"Version":1,"ErrorCode":"template_invalid"},"valid":true}, -{"kind":"response","name":"error-unauthorized","payload":{"Version":1,"ErrorCode":"unauthorized"},"valid":true}, -{"kind":"response","name":"error-unconfirmed","payload":{"Version":1,"ErrorCode":"unconfirmed"},"valid":true}, -{"kind":"response","name":"invalid-ErrorCode","payload":{"ErrorCode":"unknown","Version":1},"valid":false}, -{"kind":"response","name":"invalid-ErrorCode","payload":{"ErrorCode":1,"Version":1},"valid":false}, -{"kind":"response","name":"invalid-Extra","payload":{"ErrorCode":"","Extra":true,"Version":1},"valid":false}, +{"kind":"request","name":"version-unknown","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111"},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":3},"valid":false}, +{"kind":"request","name":"workspace-\u003cnil\u003e","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":null},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":true}, +{"kind":"request","name":"workspace-map[]","payload":{"Bootstrap":{"AllocationID":"33333333-3333-4333-8333-333333333333","CoreURL":"https://core.example/api/v1","Credential":"fixture-only","DeviceID":"55555555-5555-4555-8555-555555555555","EnvironmentID":"22222222-2222-4222-8222-222222222222","SessionID":"44444444-4444-4444-8444-444444444444","SuspendControl":false,"TenantID":"11111111-1111-4111-8111-111111111111","Workspace":{}},"Config":{"APIKey":"","APIURL":"","Binary":"","Compatibility":{"artifact_domain":"","execution_class":""},"Domain":"","InstallationID":"66666666-6666-4666-8666-666666666666","Resources":null,"StateDir":"","Template":"","TimeoutSeconds":0},"Deadline":"2099-01-01T00:00:00Z","Operation":"create","Reference":{"AllocationID":"33333333-3333-4333-8333-333333333333","EnvironmentID":"22222222-2222-4222-8222-222222222222","TenantID":"11111111-1111-4111-8111-111111111111"},"RuntimeBootstrap":{"core_url":"https://core.example/api/v1","credential":"fixture-only","device_id":"55555555-5555-4555-8555-555555555555","environment_id":"22222222-2222-4222-8222-222222222222","session_id":"44444444-4444-4444-8444-444444444444","suspend_control":false,"version":3,"workspace":"/environment/workspace"},"Version":2},"valid":false}, +{"kind":"response","name":"error-","payload":{"Version":2,"ErrorCode":""},"valid":true}, +{"kind":"response","name":"error-checkpoint_unusable","payload":{"Version":2,"ErrorCode":"checkpoint_unusable"},"valid":true}, +{"kind":"response","name":"error-command_unconfirmed","payload":{"Version":2,"ErrorCode":"command_unconfirmed"},"valid":true}, +{"kind":"response","name":"error-exists","payload":{"Version":2,"ErrorCode":"exists"},"valid":true}, +{"kind":"response","name":"error-invalid","payload":{"Version":2,"ErrorCode":"invalid"},"valid":true}, +{"kind":"response","name":"error-not_found","payload":{"Version":2,"ErrorCode":"not_found"},"valid":true}, +{"kind":"response","name":"error-ownership","payload":{"Version":2,"ErrorCode":"ownership"},"valid":true}, +{"kind":"response","name":"error-team_mismatch","payload":{"Version":2,"ErrorCode":"team_mismatch"},"valid":true}, +{"kind":"response","name":"error-template_invalid","payload":{"Version":2,"ErrorCode":"template_invalid"},"valid":true}, +{"kind":"response","name":"error-unauthorized","payload":{"Version":2,"ErrorCode":"unauthorized"},"valid":true}, +{"kind":"response","name":"error-unconfirmed","payload":{"Version":2,"ErrorCode":"unconfirmed"},"valid":true}, +{"kind":"response","name":"invalid-ErrorCode","payload":{"ErrorCode":"unknown","Version":2},"valid":false}, +{"kind":"response","name":"invalid-ErrorCode","payload":{"ErrorCode":1,"Version":2},"valid":false}, +{"kind":"response","name":"invalid-Extra","payload":{"ErrorCode":"","Extra":true,"Version":2},"valid":false}, {"kind":"response","name":"invalid-Version","payload":{"ErrorCode":"","Version":true},"valid":false} ] diff --git a/services/core/tools/microsandbox-provider/snapshot.go b/services/core/tools/microsandbox-provider/snapshot.go index 13ade20fe..155dc841f 100644 --- a/services/core/tools/microsandbox-provider/snapshot.go +++ b/services/core/tools/microsandbox-provider/snapshot.go @@ -80,7 +80,7 @@ func (b backend) openSnapshot(ctx context.Context, operation string, source wire return nil, wire.SnapshotIdentity{}, sandbox.ErrOwnership } compatibility := sandbox.CheckpointCompatibility{ArtifactDomain: artifact.Labels()["io.oac.artifact_domain"], ExecutionClass: artifact.Labels()["io.oac.execution_class"]} - return artifact, wire.SnapshotIdentity{Compatibility: compatibility, Reference: wire.SnapshotReference(b.q.Config, b.q.Reference, operation), ID: artifact.ID(), Digest: artifact.Digest(), CheckpointID: state.Checkpoint.CheckpointID, OperationID: operation, SourceName: source.Name, SourceID: source.ID, SourceGeneration: source.Generation}, nil + return artifact, wire.SnapshotIdentity{Compatibility: compatibility, Reference: wire.SnapshotReference(b.q.Config, b.q.Reference, operation), ID: artifact.ID(), OperationID: operation, SourceName: source.Name, SourceID: source.ID, SourceGeneration: source.Generation}, nil } // inspectSnapshot verifies a fresh capture before its identity is published. @@ -96,7 +96,7 @@ func (b backend) inspectSnapshot(ctx context.Context, operation string, source w if report.Digest != artifact.Digest() || report.Checkpoint == nil || report.Checkpoint.Root == "" || report.Checkpoint.Kind == "" { return nil, wire.SnapshotIdentity{}, sandbox.ErrOwnership } - identity.CheckpointRoot = report.Checkpoint.Root + identity.Evidence = wire.SnapshotEvidence(artifact.Digest(), artifact.State().Checkpoint.CheckpointID, report.Checkpoint.Root) if wire.ValidateSnapshot(b.q.Config, b.q.Reference, identity) != nil { return nil, wire.SnapshotIdentity{}, sandbox.ErrOwnership } diff --git a/services/core/tools/microsandbox-provider/snapshot_observation_test.go b/services/core/tools/microsandbox-provider/snapshot_observation_test.go index de907c8e9..97560eecf 100644 --- a/services/core/tools/microsandbox-provider/snapshot_observation_test.go +++ b/services/core/tools/microsandbox-provider/snapshot_observation_test.go @@ -181,7 +181,7 @@ func TestLostCaptureResponseStillExposesOwnedIdentityWhenCheckpointIsDamaged(t * if err != nil { t.Fatal(err) } - snapshot := wire.SnapshotIdentity{Compatibility: compatibility, Reference: wire.SnapshotReference(request.Config, request.Reference, operation), ID: "exact-snapshot", Digest: "digest", CheckpointID: "checkpoint", CheckpointRoot: "root", OperationID: operation, SourceName: source.Name, SourceID: source.ID} + snapshot := wire.SnapshotIdentity{Compatibility: compatibility, Reference: wire.SnapshotReference(request.Config, request.Reference, operation), ID: "exact-snapshot", Evidence: wire.SnapshotEvidence("digest", "checkpoint", "root"), OperationID: operation, SourceName: source.Name, SourceID: source.ID} dir := b.checkpointDirectory(operation) if err = privateDirectory(dir); err != nil { t.Fatal(err) @@ -254,7 +254,7 @@ func TestUnusableCheckpointClosesAdmissionDurably(t *testing.T) { if err != nil { t.Fatal(err) } - q.Snapshot = wire.SnapshotIdentity{Compatibility: compatibility, Reference: wire.SnapshotReference(request.Config, request.Reference, q.OperationID), ID: "id", Digest: "digest", CheckpointID: "checkpoint", CheckpointRoot: "root", OperationID: q.OperationID, SourceName: wire.Name(request.Config, request.Reference, 0), SourceID: "local:source"} + q.Snapshot = wire.SnapshotIdentity{Compatibility: compatibility, Reference: wire.SnapshotReference(request.Config, request.Reference, q.OperationID), ID: "id", Evidence: wire.SnapshotEvidence("digest", "checkpoint", "root"), OperationID: q.OperationID, SourceName: wire.Name(request.Config, request.Reference, 0), SourceID: "local:source"} dir := b.checkpointDirectory(q.OperationID) if err = privateDirectory(dir); err != nil { t.Fatal(err) From c895deada0af65769f7e9d4fc35d1ab2552841de Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 24 Oct 2026 23:31:08 +0800 Subject: [PATCH 33/39] Fix E2B hosted startup on fresh installations (#855) A real E2B run on a fresh Compose installation found two adapter faults that together kept every E2B Session from starting: - Nothing created the adapter's private /state/e2b since the Compose installer replaced the Python one, so selecting E2B failed with 503 execution_unavailable. The adapter now creates the directory (0700) when Core loads the Provider. - The E2B API omits the domain of a sandbox on the configured one. SDK Sandbox objects fill it in, SandboxInfo leaves None, so every check of GetInfo (create qualification, inspect, renew, resume) rejected an owned sandbox as foreign. An omitted domain now counts as the configured one; a reported foreign domain is still refused. --- .../sandbox/e2b/installed_paths_test.go | 24 +++++++++++++++++++ .../core/internal/sandbox/e2b/provider.go | 4 ++++ services/core/tools/e2b-provider/README.md | 4 ++-- services/core/tools/e2b-provider/provider.py | 4 +++- .../core/tools/e2b-provider/provider_test.py | 7 ++++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/services/core/internal/sandbox/e2b/installed_paths_test.go b/services/core/internal/sandbox/e2b/installed_paths_test.go index 9d4d4af86..181fc65fc 100644 --- a/services/core/internal/sandbox/e2b/installed_paths_test.go +++ b/services/core/internal/sandbox/e2b/installed_paths_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" ) func TestInstalledPathsRejectMissingAndRelativeRoots(t *testing.T) { @@ -43,3 +44,26 @@ func TestConfigurationDiscoveryUsesSuppliedProcessPaths(t *testing.T) { t.Fatalf("missing roots: %v", err) } } + +func TestBuildDirectCreatesItsPrivateStateDirectory(t *testing.T) { + paths := sandbox.ProcessPaths{ArtifactRoot: t.TempDir(), StateRoot: t.TempDir()} + binary, state, err := InstalledPaths(paths) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(binary), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + selection := sandbox.Selection{Provider: "e2b", Configuration: &DeploymentConfiguration{APIKey: "synthetic-key", Template: "test:" + uuid.NewString()}} + for range 2 { + if _, err := BuildDirect(sandbox.DirectConfig{ProcessPaths: paths, InstallationID: uuid.NewString(), Selection: selection}); err != nil { + t.Fatal(err) + } + } + if info, err := os.Stat(state); err != nil || info.Mode().Perm() != 0700 { + t.Fatalf("state directory: %v %v", info, err) + } +} diff --git a/services/core/internal/sandbox/e2b/provider.go b/services/core/internal/sandbox/e2b/provider.go index ef4e2dc50..e9431f444 100644 --- a/services/core/internal/sandbox/e2b/provider.go +++ b/services/core/internal/sandbox/e2b/provider.go @@ -167,6 +167,10 @@ func newDirect(c sandbox.DirectConfig) (*Provider, error) { if err != nil { return nil, err } + // The adapter owns its private directory under the state root. + if err := os.Mkdir(state, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return nil, errors.New("E2B provider cannot create its private state directory") + } // Only a candidate that omitted its resources has none; its validation // reads them from the template build before the candidate is rebuilt. var resources *sandbox.Resources diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md index 131cc60d7..053ae426e 100644 --- a/services/core/tools/e2b-provider/README.md +++ b/services/core/tools/e2b-provider/README.md @@ -13,7 +13,7 @@ The account key is stored encrypted in Core's database and is write-only. It rea | Helper operation | Core use | Behavior | | --- | --- | --- | | `create` | `Create` | Creates the sandbox once and runs `managed_init.py`; see [Create](#create) | -| `inspect` | `GetInfo` | Reads the sandbox by recorded ID, or by ownership metadata when no ID is recorded, and checks ownership, domain, template and resources | +| `inspect` | `GetInfo` | Reads the sandbox by recorded ID, or by ownership metadata when no ID is recorded, and checks ownership, domain (an omitted domain is the configured one), template and resources | | `renew` | `Renew` | Extends the lease of the running sandbox to the configured timeout, then rereads it | | `kill` | `Kill` | Destroys every matching sandbox and confirms that none remains | | `command` | `RunCommand` | Runs one bounded command as the Runtime user on a running sandbox whose bootstrap completed; output is limited to 1 MiB per stream | @@ -40,7 +40,7 @@ The request and credentials arrive on standard input; standard output carries on ## Receipts and state directory -The state directory is the fixed `/state/e2b` ([private Provider state](../../../../docs/configuration.md#compose-installations)). It must be owned by the helper's user and grant no group or other access, and it lives on durable private storage across Core restarts. Its receipts hold SDK connection material, ownership identities and one-shot creation claims; they are not Core execution state. Losing the directory cannot authorize recreation or successful cleanup; never delete receipts after an uncertain call. +The state directory is the fixed `/state/e2b` ([private Provider state](../../../../docs/configuration.md#compose-installations)), which Core creates when it loads the Provider. It must be owned by the helper's user and grant no group or other access, and it lives on durable private storage across Core restarts. Its receipts hold SDK connection material, ownership identities and one-shot creation claims; they are not Core execution state. Losing the directory cannot authorize recreation or successful cleanup; never delete receipts after an uncertain call. A helper holds its allocation's lock until the SDK operation returns, even after Core's caller times out. Core tracks the helper's actual exit, and a credential change waits for running helpers without killing them. Helper exit never proves that a remote Create settled. Core serializes lifecycle requests per allocation and never replays Create. diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index cb0464334..050debbbe 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -145,7 +145,9 @@ def owns(self, cloud): def check_domain(self, cloud): domain = self.options()['domain'] - sandbox_domain = cloud.sandbox_domain + # The API may omit the domain of a sandbox on the configured one: SDK + # Sandbox objects then fill in that domain, SandboxInfo leaves None. + sandbox_domain = domain if cloud.sandbox_domain is None else cloud.sandbox_domain if not isinstance(sandbox_domain, str) or not ( sandbox_domain == domain or sandbox_domain.endswith('.' + domain)): raise Failure('ownership') diff --git a/services/core/tools/e2b-provider/provider_test.py b/services/core/tools/e2b-provider/provider_test.py index 41db53950..de49699d7 100644 --- a/services/core/tools/e2b-provider/provider_test.py +++ b/services/core/tools/e2b-provider/provider_test.py @@ -143,6 +143,13 @@ def test_custom_endpoint_reaches_unknown_create_discovery(self): self.assertEqual(self.api.list.call_args.kwargs['api_url'], self.config['APIURL']) self.assertEqual(self.api.list.call_args.kwargs['domain'], self.config['Domain']) + def test_info_without_a_domain_is_on_the_configured_one(self): + self.api.get_info.return_value = SimpleNamespace( + sandbox_id='owned-id', sandbox_domain=None, state='running', metadata=self.cloud.metadata, + template_id=self.cloud.template_id, cpu_count=self.cloud.cpu_count, memory_mb=self.cloud.memory_mb) + self.assertEqual(self.call('create')['ErrorCode'], '') + self.assertEqual(self.call('renew')['ErrorCode'], '') + def test_create_refuses_foreign_data_plane_before_envd(self): self.cloud.sandbox_domain = 'foreign.example' self.assertEqual(self.call('create')['ErrorCode'], 'ownership') From 5776774de881a509445bdf5b11fde63307ef13f4 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sun, 25 Oct 2026 10:38:04 +0800 Subject: [PATCH 34/39] Project never-connected self_hosted Environments as disconnected (#857) * Project never-connected self_hosted Environments as disconnected * Expect disconnected in the self_hosted official-client tests --- contracts/agents-api/environments.md | 2 +- contracts/agents-api/zh/environments.md | 4 ++-- services/core/internal/api/environments.go | 7 ++++++- services/core/internal/api/environments_test.go | 2 +- .../core/tests/official_environment_initial_failure.py | 2 +- services/core/tests/official_environment_retrieve.py | 6 +++--- services/core/tests/official_self_hosted_initial.py | 2 +- 7 files changed, 15 insertions(+), 10 deletions(-) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index a5e397587..623ab7bb6 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -53,7 +53,7 @@ stateDiagram-v2 Session environment events carry Session and Environment identity and an optional Turn identity, and never configuration, credentials, registration IDs or revisions. A Session's `required_actions` can contain `{type: "environment_connection", environment_id}`, separate from function calls. Environment connection, Session status and Turn status are independent. -Core creates the Environment record in the Session creation transaction; the Session upsert picks the retry winner, and retries never create or repair an Environment. The Environment derives its Project and immutable configuration from the owning Session. Its first state is `pending`. After the Session is deleted, reads hide the Environment while Core keeps the record for settlement and cleanup. A Session with `none` has no Environment. +Core creates the Environment record in the Session creation transaction; the Session upsert picks the retry winner, and retries never create or repair an Environment. The Environment derives its Project and immutable configuration from the owning Session. Its first state is `pending`; a `self_hosted` Environment reads `disconnected` until its first connection. After the Session is deleted, reads hide the Environment while Core keeps the record for settlement and cleanup. A Session with `none` has no Environment. A managed outer Environment must exclude broader application credentials and other tenants’ secrets. diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 7d0a82934..d5a0d4d64 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: eb7282383dcac86723189824b0391c7dd50336257db922a1fce2b2f2d2153049 +source_hash: 15550304b85596e09cbeb4cc6d7253a4bdd64ffd6adc58a539511e0c939f7ad6 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -55,7 +55,7 @@ stateDiagram-v2 Session environment 事件携带 Session 和 Environment 的身份标识以及可选的 Turn 身份标识,绝不携带配置、凭据、注册 ID 或修订号。Session 的 `required_actions` 可以包含 `{type: "environment_connection", environment_id}`,这与函数调用相互独立。Environment 连接、Session 状态和 Turn 状态相互独立。 -Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会选出重试的胜者,而重试绝不创建或修复 Environment。Environment 从所属 Session 派生其项目和不可变配置。其首个状态为 `pending`。Session 删除后,读取会隐藏 Environment,而 Core 仍保留该记录,用于结算和清理。Session 为 `none` 时没有 Environment。 +Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会选出重试的胜者,而重试绝不创建或修复 Environment。Environment 从所属 Session 派生其项目和不可变配置。其首个状态为 `pending`;`self_hosted` Environment 在首次连接前读作 `disconnected`。Session 删除后,读取会隐藏 Environment,而 Core 仍保留该记录,用于结算和清理。Session 为 `none` 时没有 Environment。 托管的外层 Environment 必须排除权限范围更广的应用程序凭据和其他租户的机密。 diff --git a/services/core/internal/api/environments.go b/services/core/internal/api/environments.go index 9c75d471a..22d7e071a 100644 --- a/services/core/internal/api/environments.go +++ b/services/core/internal/api/environments.go @@ -55,6 +55,11 @@ func environmentResponse(environment sessions.Environment) (v1.EnvironmentInfo, default: return v1.EnvironmentInfo{}, errors.New("unsupported stored environment resource status") } + status := environment.Status + if status == "pending" && configuration.Type == "self_hosted" { + // A self_hosted Environment that has never connected reads disconnected. + status = "disconnected" + } files := configuration.Files if files == nil { files = []json.RawMessage{} @@ -66,7 +71,7 @@ func environmentResponse(environment sessions.Environment) (v1.EnvironmentInfo, configuration.Plugins = []json.RawMessage{} } return v1.EnvironmentInfo{ - ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: environment.Status, + ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: status, Files: files, Plugins: configuration.Plugins, Skills: configuration.Skills, }, nil } diff --git a/services/core/internal/api/environments_test.go b/services/core/internal/api/environments_test.go index f7a8db708..22d1cc59e 100644 --- a/services/core/internal/api/environments_test.go +++ b/services/core/internal/api/environments_test.go @@ -61,7 +61,7 @@ func TestEnvironmentResourceExactProjectionWithoutExecution(t *testing.T) { t.Fatal("resource read failed", w.Code, w.Body) } want := map[string]any{ - "id": f.environment.ID, "object": "agent.environment", "type": "self_hosted", "status": status, + "id": f.environment.ID, "object": "agent.environment", "type": "self_hosted", "status": strings.Replace(status, "pending", "disconnected", 1), "files": []any{}, "plugins": []any{}, "skills": []any{}, } if !reflect.DeepEqual(got, want) || f.calls != 1 || f.tenant != f.environment.TenantID || f.id != f.environment.ID { diff --git a/services/core/tests/official_environment_initial_failure.py b/services/core/tests/official_environment_initial_failure.py index 80a6aadc2..4ad06c051 100644 --- a/services/core/tests/official_environment_initial_failure.py +++ b/services/core/tests/official_environment_initial_failure.py @@ -77,7 +77,7 @@ def observe(name): assert list(api.beta.agents.sessions.turns.list(session_id)) == [] assert list(api.beta.agents.sessions.items.list(session_id)) == [] environment = api.beta.agents.environments.retrieve(settings["environment_id"]) - assert environment.status == "pending" + assert environment.status == "disconnected" for suffix in ("", "/events", "/turns", "/items"): assert raw.get("/agents/sessions/" + session_id + suffix, headers={"Authorization": "Bearer " + settings["foreign_token"]}).status_code == 404 print(json.dumps({"status": "private_initial_failure_public_reads_and_live_events_verified", "session_id": session_id, diff --git a/services/core/tests/official_environment_retrieve.py b/services/core/tests/official_environment_retrieve.py index 0e71ffa3b..4b47dccbb 100644 --- a/services/core/tests/official_environment_retrieve.py +++ b/services/core/tests/official_environment_retrieve.py @@ -53,14 +53,14 @@ def client(key): other = foreign.beta.agents.sessions.create(**creation, extra_body=PROVIDER) result = {"environment_id": session.environment.id, "deleted_environment_id": removed.environment.id, "foreign_environment_id": other.environment.id} - verify_environment(api.beta.agents.environments.retrieve(removed.environment.id).to_dict(), removed.environment.id, "pending") + verify_environment(api.beta.agents.environments.retrieve(removed.environment.id).to_dict(), removed.environment.id, "disconnected") api.beta.agents.sessions.delete(removed.id) assert list(api.beta.agents.sessions.turns.list(session.id)) == [] assert list(api.beta.agents.sessions.items.list(session.id)) == [] environment_id = result["environment_id"] endpoint = base + "/v1/agents/environments/" + environment_id - expected = verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "pending") + expected = verify_environment(api.beta.agents.environments.retrieve(environment_id).to_dict(), environment_id, "disconnected") assert peer.beta.agents.environments.retrieve(environment_id).to_dict() == expected raw = api.beta.agents.environments.with_raw_response.retrieve(environment_id.upper()) assert raw.status_code == 200 and raw.http_response.json() == expected and raw.parse().to_dict() == expected @@ -112,7 +112,7 @@ def rejected(url, status, code, request_headers=headers, method="GET"): for method in ("POST", "PATCH", "DELETE"): rejected(endpoint, 405, "unsupported_operation", method=method) assert api.beta.agents.environments.retrieve(environment_id).to_dict() == expected - verify_environment(foreign.beta.agents.environments.retrieve(result["foreign_environment_id"]).to_dict(), result["foreign_environment_id"], "pending") + verify_environment(foreign.beta.agents.environments.retrieve(result["foreign_environment_id"]).to_dict(), result["foreign_environment_id"], "disconnected") print(json.dumps(result)) diff --git a/services/core/tests/official_self_hosted_initial.py b/services/core/tests/official_self_hosted_initial.py index 2e52b57ba..42dba418b 100644 --- a/services/core/tests/official_self_hosted_initial.py +++ b/services/core/tests/official_self_hosted_initial.py @@ -181,7 +181,7 @@ def observe(name): raise AssertionError("public initial failure observer failed") from failures[0] assert observations["sdk"] == observations["raw"] assert retry(case, "failed") == observations["sdk"]["session"] - assert api.beta.agents.environments.retrieve(case["environment_id"]).status == "pending" + assert api.beta.agents.environments.retrieve(case["environment_id"]).status == "disconnected" for retained in cases[1:]: assert retry(retained) == retained["snapshot"] result["controlled_deadline_expiry"] = True From 331357d25a6ce3939983e7b3169cfd64cc491df0 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sun, 25 Oct 2026 10:38:17 +0800 Subject: [PATCH 35/39] Accept deleted Sessions as list cursors (#859) --- contracts/agents-api/index.md | 2 +- contracts/agents-api/wire-semantics.md | 2 +- contracts/agents-api/zh/index.md | 4 +-- contracts/agents-api/zh/wire-semantics.md | 4 +-- .../core/internal/db/queries/sessions.sql | 3 ++ .../core/internal/db/sqlc/sessions.sql.go | 21 ++++++++++++ .../postgres/sessionpg/session_reads.go | 3 +- .../postgres/sessionpg/session_reads_test.go | 34 +++++++++++++++++++ 8 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 services/core/internal/persistence/postgres/sessionpg/session_reads_test.go diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index a1b26c7d9..0c42e051b 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -61,7 +61,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Lists** ([lists](./wire-semantics.md#lists)) -- A deleted Agent or Session used as a cursor returns 404; the official service still pages from it. +- A deleted Agent used as a cursor returns 404; the official service still pages from it. - A Turn cursor from another Session, a Credential cursor equal to its Vault ID, and a Skills cursor that is not a Skill ID return 404. - Vault and Credential lists clamp a negative `limit`, as the pinned SDK describes; the official service returns 400. diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index 5b961639e..3146bff2b 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -126,7 +126,7 @@ A `limit` that is not a decimal integer, including an empty value, returns 400 ` ### Cursors -`after` names a resource of the same list, inside its already resolved parent and tenant. The parent is resolved first: a missing or foreign parent returns its 404 before the cursor is read. A cursor that does not resolve, whether random, malformed, of another type, of another parent, deleted or of another tenant, returns: +`after` names a resource of the same list, inside its already resolved parent and tenant. The parent is resolved first: a missing or foreign parent returns its 404 before the cursor is read. A cursor that does not resolve, whether random, malformed, of another type, of another parent, deleted or of another tenant, returns the response below. A deleted Session still resolves as a Sessions cursor and the page continues from its position, as on the official service; a deleted Agent does not resolve: | Lists | Response | | --- | --- | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 880190c94..816473583 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 685e26e496e369c1eb2b1b093ec2e26e6fb6fbdf082ec1b877d9c638b0ac17e6 +source_hash: d3d542a34c7cf38c798a2ec85373f9d554f83892f7c47b6773783b7dcf230c62 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -64,7 +64,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **列表**([lists](wire-semantics.md#lists)) -- 将已删除的 Agent 或 Session 用作游标时会返回 404;官方服务仍可从该游标继续分页。 +- 将已删除的 Agent 用作游标时会返回 404;官方服务仍可从该游标继续分页。 - 使用来自其他 Session 的 Turn 游标、与 Vault ID 相等的 Credential 游标,或不是 Skill ID 的 Skills 游标时,都会返回 404。 - Vault 和 Credential 列表会按照固定版本 SDK 的描述钳制负数 `limit`;官方服务返回 400。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 2c6c53efe..4c807eddd 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: ba9c6565567c1b4dcffb0c8f18731372b9e3c95e25f84dc0c3c2782c94161eb2 +source_hash: 1d087e0f29e20f091416cc7bd3293e754c610f2335e2d66456f5205706d9983a --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -128,7 +128,7 @@ Vault 和 Credential 列表接受标量 `status`、`status[]` 条目或两者, ### 游标 {#cursors} -`after` 指定同一列表中的某个资源,并且该资源必须位于已经解析出的父资源和租户内。首先解析父资源:父资源缺失或属于外部租户时,会先返回其 404,然后才读取游标。无法解析的游标,无论是随机值、格式错误、类型不同、父资源不同、已删除还是属于其他租户,均返回: +`after` 指定同一列表中的某个资源,并且该资源必须位于已经解析出的父资源和租户内。首先解析父资源:父资源缺失或属于外部租户时,会先返回其 404,然后才读取游标。无法解析的游标,无论是随机值、格式错误、类型不同、父资源不同、已删除还是属于其他租户,均返回下表中的响应。已删除的 Session 仍可作为 Sessions 游标解析,并从其位置继续分页,与官方服务一致;已删除的 Agent 无法解析: | 列表 | 响应 | | --- | --- | diff --git a/services/core/internal/db/queries/sessions.sql b/services/core/internal/db/queries/sessions.sql index 2dc88c67e..173d1ea31 100644 --- a/services/core/internal/db/queries/sessions.sql +++ b/services/core/internal/db/queries/sessions.sql @@ -13,6 +13,9 @@ RETURNING *; -- name: GetSession :one SELECT * FROM sessions WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL; +-- name: GetSessionCursor :one +SELECT created_at, id FROM sessions WHERE tenant_id = $1 AND id = $2; + -- name: ListSessions :many SELECT * FROM sessions WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL diff --git a/services/core/internal/db/sqlc/sessions.sql.go b/services/core/internal/db/sqlc/sessions.sql.go index 00b4db5e8..5d986c08a 100644 --- a/services/core/internal/db/sqlc/sessions.sql.go +++ b/services/core/internal/db/sqlc/sessions.sql.go @@ -130,6 +130,27 @@ func (q *Queries) GetSession(ctx context.Context, arg GetSessionParams) (Session return i, err } +const getSessionCursor = `-- name: GetSessionCursor :one +SELECT created_at, id FROM sessions WHERE tenant_id = $1 AND id = $2 +` + +type GetSessionCursorParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +type GetSessionCursorRow struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetSessionCursor(ctx context.Context, arg GetSessionCursorParams) (GetSessionCursorRow, error) { + row := q.db.QueryRow(ctx, getSessionCursor, arg.TenantID, arg.ID) + var i GetSessionCursorRow + err := row.Scan(&i.CreatedAt, &i.ID) + return i, err +} + const listSessions = `-- name: ListSessions :many SELECT id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id FROM sessions WHERE tenant_id = $1 AND deleted_at IS NULL diff --git a/services/core/internal/persistence/postgres/sessionpg/session_reads.go b/services/core/internal/persistence/postgres/sessionpg/session_reads.go index e38c2bd20..dc0109247 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_reads.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_reads.go @@ -39,7 +39,8 @@ func (s *Store) ListSessions(ctx context.Context, tenantID, cursor string, limit err = s.units.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) if cursor != "" { - after, err := q.GetSession(ctx, sqlc.GetSessionParams{TenantID: tenant, ID: pgunit.PathID(cursor)}) + // A deleted Session still anchors the page position. + after, err := q.GetSessionCursor(ctx, sqlc.GetSessionCursorParams{TenantID: tenant, ID: pgunit.PathID(cursor)}) if err != nil { return err } diff --git a/services/core/internal/persistence/postgres/sessionpg/session_reads_test.go b/services/core/internal/persistence/postgres/sessionpg/session_reads_test.go new file mode 100644 index 000000000..beddd9087 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/session_reads_test.go @@ -0,0 +1,34 @@ +package sessionpg + +import ( + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +func TestListSessionsPagesFromDeletedCursor(t *testing.T) { + pool := pgtest.Open(t) + store := New(pgunit.NewPool(pool), pgtest.CredentialKey(t)) + tenant := uuid.New() + var ids []string + for i := range 3 { + id := uuid.New() + exec(t, pool, `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash, configuration, created_at) + VALUES ($1, $2, 'codex', $3, 'hash', '{}', now() + $4 * interval '1 second')`, id, tenant, id.String(), i) + ids = append(ids, id.String()) + } + exec(t, pool, `UPDATE sessions SET deleted_at = now() WHERE id = $1`, ids[1]) + + page, err := store.ListSessions(t.Context(), tenant.String(), ids[1], 10, false, nil) + if err != nil || len(page.Sessions) != 1 || page.Sessions[0].ID != ids[0] { + t.Fatalf("deleted cursor: %+v %v", page, err) + } + if _, err := store.ListSessions(t.Context(), uuid.NewString(), ids[1], 10, false, nil); !errors.Is(err, sessions.ErrNotFound) { + t.Fatalf("foreign tenant cursor: %v", err) + } +} From 1fbc193a5bf50b1ccff032df75e307d44c0893f5 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sun, 25 Oct 2026 10:39:08 +0800 Subject: [PATCH 36/39] Keep requires_action while a waiting Turn cancels (#861) * Keep requires_action while a waiting Turn cancels * Expect a cancelling waiting Turn to keep its actions --- contracts/agents-api/index.md | 2 +- contracts/agents-api/sessions-events.md | 8 +-- contracts/agents-api/zh/index.md | 4 +- contracts/agents-api/zh/sessions-events.md | 10 +-- .../core/internal/api/function_state_test.go | 17 +++++ .../core/internal/api/session_response.go | 2 +- .../core/internal/db/queries/functions.sql | 2 +- .../core/internal/db/sqlc/functions.sql.go | 2 +- .../postgres/sessionpg/functions_test.go | 9 ++- .../core/internal/sessions/cancellation.go | 68 ++++--------------- .../internal/sessions/cancellation_test.go | 40 ++--------- .../internal/sessions/execution_functions.go | 3 + services/core/internal/sessions/functions.go | 9 +-- .../core/internal/sessions/functions_test.go | 7 +- .../tests/integration/function_state_test.go | 5 +- 15 files changed, 71 insertions(+), 117 deletions(-) diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 0c42e051b..ce1d4aa1d 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -88,7 +88,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Input to a `none` Session is admitted synchronously; Core does not emulate the official asynchronous admission window. - An events request takes at most 64 events ([send input](./sessions-events.md#send-input)); the official limit is 16384. -- A function result that resumes a waiting Turn emits `turn.in_progress`, and cancelling a Turn that waits on a function result emits an interim `agent.session.in_progress`. +- A function result that resumes a waiting Turn emits `turn.in_progress`. - A function call the native Harness abandons, such as a Subagent call dropped when the root closes or interrupts the child or a MiniMax Code call past its 120 s timeout, is withdrawn: it stops being a required action and a later result for it returns 409 `conflict_error`. The official API has no withdrawal. - Attaching to the stream mid-Turn sends no catch-up Item snapshots. - The Items list includes in-progress and incomplete output Items, and keeps a failed function result's submitted `output`. diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 1fbd4bf5b..5b43214e9 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -22,7 +22,7 @@ A Session's `status` and `last_active_at` derive from its latest root Turn and f | --- | --- | | `idle` | No Turn yet, or the latest Turn completed or was cancelled. Input reserved for a hosted Environment that is still provisioning also reads `idle` | | `in_progress` | The latest Turn is queued, running or waiting | -| `requires_action` | The latest Turn waits for a function result and no cancellation was requested, or input waits for a `self_hosted` machine to connect. `required_actions` lists `function_call` or `environment_connection` entries | +| `requires_action` | The latest Turn waits for a function result, including after a cancellation request until the Turn ends, or input waits for a `self_hosted` machine to connect. `required_actions` lists `function_call` or `environment_connection` entries | | `failed` | The latest Turn failed (`error` is "The execution could not complete."), input reserved for the Environment failed, initial input expired before admission, or the Environment failed to initialize (see [Environment initialization failure](#environment-initialization-failure)) | ```mermaid @@ -33,7 +33,7 @@ stateDiagram-v2 in_progress --> requires_action: awaits function result idle --> requires_action: awaits self_hosted machine requires_action --> in_progress: result or machine - requires_action --> idle: cancelled + requires_action --> idle: Turn ended in_progress --> failed: Turn failed idle --> failed: input expired or Environment failed failed --> in_progress: new input @@ -50,7 +50,7 @@ A Session stays usable after a Turn fails: new input starts a new Turn. Later re - **Empty batch.** `{"events": []}` checks that the Session exists and returns 202. It creates no Turn, Item or retry identity. - **Retries.** An `Idempotency-Key` of up to 128 bytes identifies the whole ordered batch. The same key and batch return 202 again without admitting anything twice; the same key with another batch returns 409 `idempotency_conflict`. A request without a key is always new. - **Messages.** On an idle Session a message batch starts a queued Turn. While a Turn runs, messages join it (steering); they never start a parallel Turn. Each message stays its own user Item, even when the Harness receives several as one prompt. -- **Cancellation.** A queued Turn is cancelled without a live Runtime. A running Turn is cancelled when the Runtime confirms it; completion can win that race. The Turn has stopped when it reads `cancelled`, not when the request returns. A cancellation on an idle Session with no pending input is accepted and has no effect; while an input reservation is pending, it returns 409. +- **Cancellation.** A queued Turn is cancelled without a live Runtime. A running Turn is cancelled when the Runtime confirms it; completion can win that race. The Turn has stopped when it reads `cancelled`, not when the request returns. A Turn that waits for a function result keeps the Session at `requires_action` with its `required_actions` until then, and the request emits no Session event. A cancellation on an idle Session with no pending input is accepted and has no effect; while an input reservation is pending, it returns 409. - **Function results.** `turn_id`, `call_id` and `success` are required; `output` and `error` are optional and nullable ([content rules](./message-content.md#function-results)). An identical repeated result returns 202 without another application or event. The result Item appears when the Harness applies the result; a result that cancellation prevents from being applied stays stored but produces no Item. - **Queueing.** A queued Turn starts when a Runtime that supports the Session's Harness and configuration is connected and one of Core's [`OAC_EXECUTION_CONCURRENCY`](../../docs/configuration.md#settings) work slots is free. A Session stays bound to the Runtime that first ran it. - **Execution availability.** While Core shuts down, or after its Worker loses execution ownership, requests return 503 `execution_unavailable`. @@ -121,7 +121,7 @@ A retry with the same `Idempotency-Key` and `stream: true` returns 201 with only - Codex command output streams as `agent.output.command_execution_output.delta` with the command's Item ID and output index. Native output quotas and text conversion apply, so the deltas are not a byte-exact capture; the completed Item is authoritative. - A reasoning Item follows one sequence: `item.added` in progress with an empty `summary`; for each `summary_text` part in order, `reasoning_summary_part.added` with empty text, `reasoning_summary_text.delta` events, then `reasoning_summary_text.done` and `reasoning_summary_part.done` once the next part starts or the Item ends; then `item.done`. `reasoning_summary_part.done` has `status` `incomplete` for a part a cancelled or failed Turn cut off, otherwise null. - A cancelled or failed Turn marks its unfinished Items `incomplete` and keeps their partial content. -- A function call stays in `required_actions` until the Harness applies its result, or cancellation or the Turn's end removes it. A repeated notification emits no new state. +- A function call stays in `required_actions` until the Harness applies its result or the Turn ends. A cancellation request does not remove it from a waiting Turn. A repeated notification emits no new state. ## Turns and Items diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 816473583..57a4f2e98 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: d3d542a34c7cf38c798a2ec85373f9d554f83892f7c47b6773783b7dcf230c62 +source_hash: d65d6dc5d6a8ec195950a119b667964bf5bb651c469191235779d19a7220ff92 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -91,7 +91,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 提交给 `none` Session 的输入会同步完成准入;Core 不会模拟官方异步准入窗口。 - 一次 events 请求最多包含 64 个事件([发送输入](sessions-events.md#send-input));官方上限为 16384。 -- 用于恢复等待中 Turn 的函数结果会发出 `turn.in_progress`;取消正在等待函数结果的 Turn 会发出临时 `agent.session.in_progress`。 +- 用于恢复等待中 Turn 的函数结果会发出 `turn.in_progress`。 - 原生 Harness 放弃的函数调用(例如根关闭或中断子级时被丢弃的 Subagent 调用,或超过 120 秒超时的 MiniMax Code 调用)会被撤回:它不再是待执行操作,之后为它提交结果会返回 409 `conflict_error`。官方 API 没有撤回机制。 - 在 Turn 中途接入流时,不会发送补发 Item 快照。 - Items 列表会包含进行中和未完成的输出 Items,并保留失败函数结果中已提交的 `output`。 diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index f44880d08..099ea2ed7 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: cc519d898ede4653a4e8abd0d59da6da9d88e2da1587870a75d5b1bab8809fe0 +source_hash: e8dfd2f21bdcf16832c60fb9fac2df42bc96553d7f1294dc2742c3eb455772a1 --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -24,7 +24,7 @@ Session 的 `status` 和 `last_active_at` 由其最新的根 Turn 以及仍在 | --- | --- | | `idle` | 尚无 Turn,或最新 Turn 已完成或已取消。为仍在预配的托管 Environment 预留的输入也显示为 `idle` | | `in_progress` | 最新 Turn 正在排队、运行或等待 | -| `requires_action` | 最新 Turn 正在等待函数结果且未请求取消,或输入正在等待 `self_hosted` 机器连接。`required_actions` 列出 `function_call` 或 `environment_connection` 条目 | +| `requires_action` | 最新 Turn 正在等待函数结果(请求取消后在 Turn 结束前也保持如此),或输入正在等待 `self_hosted` 机器连接。`required_actions` 列出 `function_call` 或 `environment_connection` 条目 | | `failed` | 最新 Turn 失败(`error` 为 "The execution could not complete.")、为 Environment 预留的输入失败、初始输入在接纳前到期,或 Environment 初始化失败(见 [Environment 初始化失败](#environment-initialization-failure)) | ```mermaid @@ -35,7 +35,7 @@ stateDiagram-v2 in_progress --> requires_action: awaits function result idle --> requires_action: awaits self_hosted machine requires_action --> in_progress: result or machine - requires_action --> idle: cancelled + requires_action --> idle: Turn ended in_progress --> failed: Turn failed idle --> failed: input expired or Environment failed failed --> in_progress: new input @@ -52,7 +52,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - **空批次。** `{"events": []}` 会检查 Session 是否存在并返回 202。它不会创建 Turn、Item 或重试标识。 - **重试。** 最长 128 字节的 `Idempotency-Key` 标识整个有序批次。相同密钥和批次会再次返回 202,而不会重复接纳任何内容;相同密钥搭配另一个批次会返回 409 `idempotency_conflict`。不带密钥的请求始终是新请求。 - **消息。** 在空闲 Session 上,消息批次会启动一个排队的 Turn。Turn 运行时,消息会加入其中(引导);它们绝不会启动并行 Turn。每条消息都保留为独立的用户 Item,即使 Harness 将多条消息作为同一个提示接收。 -- **取消。** 排队的 Turn 无需活动 Runtime 即可取消。正在运行的 Turn 只有在 Runtime 确认后才会取消;完成操作可能赢得该竞争。读取到 `cancelled` 时才表示该 Turn 已停止,而不是请求返回时。在没有待处理输入的情况下,对空闲 Session 执行的取消会被接受且不产生任何效果;而在输入预留待处理期间,取消会返回 409。 +- **取消。** 排队的 Turn 无需活动 Runtime 即可取消。正在运行的 Turn 只有在 Runtime 确认后才会取消;完成操作可能赢得该竞争。读取到 `cancelled` 时才表示该 Turn 已停止,而不是请求返回时。正在等待函数结果的 Turn 在此之前会让 Session 保持 `requires_action` 及其 `required_actions`,且该请求不会发出 Session 事件。在没有待处理输入的情况下,对空闲 Session 执行的取消会被接受且不产生任何效果;而在输入预留待处理期间,取消会返回 409。 - **函数结果。** `turn_id`、`call_id` 和 `success` 为必填项;`output` 和 `error` 为可选项且可为空([内容规则](message-content.md#function-results))。重复提交完全相同的结果会返回 202,不会再次应用或发出事件。Harness 应用结果时才会出现结果 Item;如果取消操作导致结果无法应用,结果仍会存储,但不会产生 Item。 - **排队。** 当一个已连接且支持该 Session Harness 和配置的 Runtime 接入,并且 Core 的 [`OAC_EXECUTION_CONCURRENCY`](../../../docs/zh/configuration.md#settings) 工作槽位有一个空闲时,排队的 Turn 才会启动。Session 始终绑定到首次运行它的 Runtime。 - **执行可用性。** Core 关闭期间,或其 Worker 失去执行所有权后,请求返回 503 `execution_unavailable`。 @@ -123,7 +123,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - Codex 命令输出以 `agent.output.command_execution_output.delta` 流式传输,并携带命令的 Item ID 和输出索引。原生输出配额和文本转换规则适用,因此这些增量并非逐字节捕获的原始结果;完整 Item 才是权威结果。 - reasoning Item 遵循以下单一顺序:先发送 `summary` 为空的进行中 `item.added`;然后按顺序对每个 `summary_text` 部分发送文本为空的 `reasoning_summary_part.added` 和 `reasoning_summary_text.delta` 事件,并在下一个部分开始或 Item 结束时发送 `reasoning_summary_text.done` 和 `reasoning_summary_part.done`;最后发送 `item.done`。被取消或失败的 Turn 截断的部分,其 `reasoning_summary_part.done` 的 `status` 为 `incomplete`,否则为 null。 - 已取消或失败的 Turn 会将其未完成的 Item 标记为 `incomplete`,并保留其部分内容。 -- 函数调用会一直保留在 `required_actions` 中,直到 Harness 应用其结果,或取消操作或 Turn 结束将其移除。重复通知不会发出新状态。 +- 函数调用会一直保留在 `required_actions` 中,直到 Harness 应用其结果,或 Turn 结束将其移除;对等待中 Turn 的取消请求不会将其移除。重复通知不会发出新状态。 ## Turn 与 Item {#turns-and-items} diff --git a/services/core/internal/api/function_state_test.go b/services/core/internal/api/function_state_test.go index 4225b8d12..afabfb87a 100644 --- a/services/core/internal/api/function_state_test.go +++ b/services/core/internal/api/function_state_test.go @@ -46,3 +46,20 @@ func TestFunctionStateEventsUseTheirOwnSnapshot(t *testing.T) { } } } + +func TestWaitingTurnReadsRequiresActionAfterCancellationRequest(t *testing.T) { + session := sessions.Session{ID: "session", CreatedAt: time.Now(), Metadata: map[string]string{}, + Configuration: json.RawMessage(`{"agent":{"id":"agent_test","model":"model","tools":[]},"environment":{"type":"none"}}`), + RequiredActions: []v1.FunctionCallAction{{Type: "function_call", CallID: "call", Name: "lookup", TurnID: "turn"}}, + LastTurn: &sessions.Turn{ID: "turn", Status: sessions.TurnWaiting, CancelRequestedAt: time.Now()}, + } + value, err := sessionResponse(session, "") + if err != nil || value.Status != "requires_action" || len(value.RequiredActions) != 1 { + t.Fatal(value, err) + } + session.LastTurn.Status = sessions.TurnCancelled + session.RequiredActions = nil + if value, err = sessionResponse(session, ""); err != nil || value.Status != "idle" { + t.Fatal(value, err) + } +} diff --git a/services/core/internal/api/session_response.go b/services/core/internal/api/session_response.go index 5f244d58f..271545cb8 100644 --- a/services/core/internal/api/session_response.go +++ b/services/core/internal/api/session_response.go @@ -53,7 +53,7 @@ func sessionResponse(session sessions.Session, executorURL string) (v1.Session, switch turn.Status { case sessions.TurnQueued, sessions.TurnInProgress, sessions.TurnWaiting: response.Status = "in_progress" - if turn.CancelRequestedAt.IsZero() && len(session.RequiredActions) > 0 { + if len(session.RequiredActions) > 0 { response.Status = "requires_action" for _, action := range session.RequiredActions { response.RequiredActions = append(response.RequiredActions, v1.RequiredAction{ diff --git a/services/core/internal/db/queries/functions.sql b/services/core/internal/db/queries/functions.sql index e9b6298bb..ac3629b0e 100644 --- a/services/core/internal/db/queries/functions.sql +++ b/services/core/internal/db/queries/functions.sql @@ -15,7 +15,7 @@ WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.turn_id = $3 AND f.call_id = -- name: ListPendingFunctionCalls :many SELECT f.* FROM function_calls f JOIN turns t ON t.session_id = f.session_id AND t.id = f.turn_id WHERE f.session_id = $1 AND f.turn_id = $2 AND NOT f.applied AND NOT f.withdrawn - AND t.status IN ('in_progress', 'waiting') AND t.cancel_requested_at IS NULL + AND t.status IN ('in_progress', 'waiting') ORDER BY f.created_at, f.call_id; -- name: SessionHasFunctionCall :one diff --git a/services/core/internal/db/sqlc/functions.sql.go b/services/core/internal/db/sqlc/functions.sql.go index fcb3aeb2b..181d2ccd7 100644 --- a/services/core/internal/db/sqlc/functions.sql.go +++ b/services/core/internal/db/sqlc/functions.sql.go @@ -132,7 +132,7 @@ func (q *Queries) GetFunctionCall(ctx context.Context, arg GetFunctionCallParams const listPendingFunctionCalls = `-- name: ListPendingFunctionCalls :many SELECT f.session_id, f.turn_id, f.call_id, f.executor_call_id, f.name, f.arguments, f.result, f.applied, f.created_at, f.withdrawn FROM function_calls f JOIN turns t ON t.session_id = f.session_id AND t.id = f.turn_id WHERE f.session_id = $1 AND f.turn_id = $2 AND NOT f.applied AND NOT f.withdrawn - AND t.status IN ('in_progress', 'waiting') AND t.cancel_requested_at IS NULL + AND t.status IN ('in_progress', 'waiting') ORDER BY f.created_at, f.call_id ` diff --git a/services/core/internal/persistence/postgres/sessionpg/functions_test.go b/services/core/internal/persistence/postgres/sessionpg/functions_test.go index 66c2aef1a..1a5c1144f 100644 --- a/services/core/internal/persistence/postgres/sessionpg/functions_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/functions_test.go @@ -206,9 +206,12 @@ func TestLoadRequiredActionsReadsPendingCalls(t *testing.T) { t.Fatal(string(encoded), err) } exec(t, pool, `UPDATE turns SET cancel_requested_at = clock_timestamp() WHERE id = $1`, f.turn) - // The Turn as the caller read it still waits; the read of its calls sees - // the requested cancellation. - if actions, err := LoadRequiredActions(t.Context(), q, f.session, TurnFromRow(row)); err != nil || actions == nil || len(actions) != 0 { + // A waiting Turn keeps its actions until it ends, also after a cancellation request. + row, err = q.GetTurn(t.Context(), sqlc.GetTurnParams{TenantID: f.tenant, SessionID: f.session, ID: f.turn}) + if err != nil { + t.Fatal(err) + } + if actions, err := LoadRequiredActions(t.Context(), q, f.session, TurnFromRow(row)); err != nil || len(actions) != 2 { t.Fatalf("cancelling Turn actions %+v %v", actions, err) } } diff --git a/services/core/internal/sessions/cancellation.go b/services/core/internal/sessions/cancellation.go index 7523e61ae..30b295f3e 100644 --- a/services/core/internal/sessions/cancellation.go +++ b/services/core/internal/sessions/cancellation.go @@ -2,34 +2,6 @@ package sessions import "context" -// turnCancellation is what a cancellation request settles beyond recording the -// request, decided from the Turn as it was before the request. -type turnCancellation int - -const ( - // cancellationRequested only records the request: a running Turn ends when - // its execution stops, and a repeated request adds nothing. - cancellationRequested turnCancellation = iota - // cancellationEndsTurn ends a queued Turn, which never started, as - // cancelled. - cancellationEndsTurn - // cancellationReportsActivity reports the Session activity of a waiting - // Turn on its first request: it no longer waits on actions. - cancellationReportsActivity -) - -// decideCancellation decides what requesting the cancellation of turn, read -// before the request, settles. -func decideCancellation(turn Turn) turnCancellation { - switch { - case turn.Status == TurnQueued: - return cancellationEndsTurn - case turn.Status == TurnWaiting && turn.CancelRequestedAt.IsZero(): - return cancellationReportsActivity - } - return cancellationRequested -} - // TurnCancellationTx is the Session transaction CancelTurn runs in. type TurnCancellationTx interface { JournalTx @@ -46,37 +18,25 @@ type TurnCancellationTx interface { } // CancelTurn requests the cancellation of turn, the Session's active Turn read -// under the Session lock, and publishes what the request settles: a queued Turn -// ends cancelled, and a waiting Turn reports its Session activity. Both read -// the Turn again after the request, which set its timestamps. +// under the Session lock. A queued Turn never started and ends cancelled, read +// again after the request, which set its timestamps. Any other Turn ends when +// its execution stops, and a waiting Turn keeps its Session activity until then. func CancelTurn(ctx context.Context, tx TurnCancellationTx, turn Turn) error { - cancellation := decideCancellation(turn) if err := tx.RequestTurnCancel(ctx, turn.ID); err != nil { return err } - switch cancellation { - case cancellationEndsTurn: - cancelled, err := tx.LoadTurn(ctx, turn.ID) - if err != nil { - return err - } - ending, err := tx.LoadEnding(ctx, turn.ID) - if err != nil { - return err - } - return tx.ApplyTurnEnd(ctx, turn.ID, EndTurn(cancelled, ending)) - case cancellationReportsActivity: - cancelling, err := tx.LoadTurn(ctx, turn.ID) - if err != nil { - return err - } - usage, err := tx.LoadUsage(ctx) - if err != nil { - return err - } - return tx.AppendChanges(ctx, ActivityChange(cancelling, usage, nil)) + if turn.Status != TurnQueued { + return nil + } + cancelled, err := tx.LoadTurn(ctx, turn.ID) + if err != nil { + return err + } + ending, err := tx.LoadEnding(ctx, turn.ID) + if err != nil { + return err } - return nil + return tx.ApplyTurnEnd(ctx, turn.ID, EndTurn(cancelled, ending)) } // CancellationTx is the Session transaction CancelWork runs in. diff --git a/services/core/internal/sessions/cancellation_test.go b/services/core/internal/sessions/cancellation_test.go index 624b83202..3e71abc34 100644 --- a/services/core/internal/sessions/cancellation_test.go +++ b/services/core/internal/sessions/cancellation_test.go @@ -5,29 +5,10 @@ import ( "errors" "reflect" "testing" - "time" ) -func TestDecideCancellation(t *testing.T) { - requested := turnWith(TurnWaiting) - requested.CancelRequestedAt = time.Unix(1700000000, 0) - for name, test := range map[string]struct { - turn Turn - want turnCancellation - }{ - "queued": {turnWith(TurnQueued), cancellationEndsTurn}, - "waiting": {turnWith(TurnWaiting), cancellationReportsActivity}, - "waiting requested": {requested, cancellationRequested}, - "in progress": {turnWith(TurnInProgress), cancellationRequested}, - } { - if got := decideCancellation(test.turn); got != test.want { - t.Errorf("%s: %v", name, got) - } - } -} - -// CancelTurn decides from the Turn read before the request, and settles with -// the Turn read after it. +// CancelTurn ends a queued Turn with the Turn read after the request, and only +// records the request for a Turn that started. func TestCancelTurn(t *testing.T) { usage := json.RawMessage(`{"input_tokens":1}`) t.Run("queued ends cancelled", func(t *testing.T) { @@ -43,22 +24,9 @@ func TestCancelTurn(t *testing.T) { t.Fatalf("end %+v", ends) } }) - t.Run("waiting reports activity", func(t *testing.T) { - cancelling := turnWith(TurnWaiting) - cancelling.CancelRequestedAt = time.Unix(1700000001, 0) - var changes []SessionChange - f := &fakeTx{t: t, requestTurnCancel: done, loadTurn: returns(cancelling), loadUsage: returns(usage), appendChanges: collect(&changes)} - if err := CancelTurn(t.Context(), f, turnWith(TurnWaiting)); err != nil { - t.Fatal(err) - } - assertCalls(t, f, "RequestTurnCancel "+testTurn, "LoadTurn "+testTurn, "LoadUsage", "AppendChanges agent.session.in_progress") - if !reflect.DeepEqual(changes, []SessionChange{ActivityChange(cancelling, usage, nil)}) { - t.Fatalf("changes %+v", changes) - } - }) - t.Run("in progress only requests", func(t *testing.T) { + t.Run("waiting only requests", func(t *testing.T) { f := &fakeTx{t: t, requestTurnCancel: done} - if err := CancelTurn(t.Context(), f, turnWith(TurnInProgress)); err != nil { + if err := CancelTurn(t.Context(), f, turnWith(TurnWaiting)); err != nil { t.Fatal(err) } assertCalls(t, f, "RequestTurnCancel "+testTurn) diff --git a/services/core/internal/sessions/execution_functions.go b/services/core/internal/sessions/execution_functions.go index 1cbe24700..ffb1e2c74 100644 --- a/services/core/internal/sessions/execution_functions.go +++ b/services/core/internal/sessions/execution_functions.go @@ -156,6 +156,9 @@ func (o *ExecutionOperations) RecordFunctionCall(ctx context.Context, tenant, se func (o *ExecutionOperations) PendingFunctionCalls(ctx context.Context, tenant, session, turn string) ([]FunctionCall, error) { calls := make([]FunctionCall, 0) err := o.storage.WithFunctionTurn(ctx, tenant, session, turn, func(ctx context.Context, tx FunctionTx, current Turn) error { + if !acceptsFunctionResults(current) { + return nil + } pending, err := tx.LoadPendingFunctionCalls(ctx, current.ID) calls = append(calls, pending...) return err diff --git a/services/core/internal/sessions/functions.go b/services/core/internal/sessions/functions.go index 91376775c..5653369e7 100644 --- a/services/core/internal/sessions/functions.go +++ b/services/core/internal/sessions/functions.go @@ -159,17 +159,18 @@ func AdmitFunctionResult(ctx context.Context, tx FunctionResultTx, input Functio // or its application receipt. type PendingFunctionCallsTx interface { // LoadPendingFunctionCalls reads the Turn's calls without an application - // receipt, in creation order, while the Turn is in progress or waiting and - // no cancellation was requested, and none otherwise. + // receipt, in creation order, while the Turn is in progress or waiting, and + // none otherwise. LoadPendingFunctionCalls(ctx context.Context, turn string) ([]FunctionCall, error) } // LoadRequiredActions returns the function calls a Turn requires the caller to // answer as public required actions: its pending calls while it accepts -// results, and none otherwise. +// results, or while it waits after a cancellation request and has not ended, +// and none otherwise. func LoadRequiredActions(ctx context.Context, tx PendingFunctionCallsTx, turn Turn) ([]v1.FunctionCallAction, error) { actions := make([]v1.FunctionCallAction, 0) - if !acceptsFunctionResults(turn) { + if !acceptsFunctionResults(turn) && turn.Status != TurnWaiting { return actions, nil } calls, err := tx.LoadPendingFunctionCalls(ctx, turn.ID) diff --git a/services/core/internal/sessions/functions_test.go b/services/core/internal/sessions/functions_test.go index 38726c9fa..246a8bb40 100644 --- a/services/core/internal/sessions/functions_test.go +++ b/services/core/internal/sessions/functions_test.go @@ -283,9 +283,10 @@ func TestAdmitFunctionResult(t *testing.T) { func TestLoadRequiredActions(t *testing.T) { calls := []FunctionCall{testCall, {CallID: "second", ExecutorCallID: "native-second", Name: "search", Arguments: json.RawMessage(`{}`), Result: json.RawMessage(`{"success":true}`)}} - for _, status := range []string{TurnInProgress, TurnWaiting} { + // A waiting Turn keeps its required actions after a cancellation request, until it ends. + for _, turn := range []Turn{turnWith(TurnInProgress), turnWith(TurnWaiting), cancelling(TurnWaiting)} { f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}, loadPendingFunctionCalls: returns(calls)} - actions, err := LoadRequiredActions(t.Context(), f, turnWith(status)) + actions, err := LoadRequiredActions(t.Context(), f, turn) if err != nil || len(actions) != 2 { t.Fatal(actions, err) } @@ -300,7 +301,7 @@ func TestLoadRequiredActions(t *testing.T) { } } } - for _, turn := range []Turn{turnWith(TurnQueued), cancelling(TurnWaiting), turnWith(TurnCompleted)} { + for _, turn := range []Turn{turnWith(TurnQueued), cancelling(TurnInProgress), turnWith(TurnCompleted)} { f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}} actions, err := LoadRequiredActions(t.Context(), f, turn) if err != nil || actions == nil || len(actions) != 0 { diff --git a/services/core/tests/integration/function_state_test.go b/services/core/tests/integration/function_state_test.go index 7d011da21..ad7aa2383 100644 --- a/services/core/tests/integration/function_state_test.go +++ b/services/core/tests/integration/function_state_test.go @@ -102,9 +102,10 @@ func TestFunctionStateCancellationAndTerminalCleanup(t *testing.T) { t.Fatal(err) } } - assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 0) + // The Turn keeps waiting with its required action, and no activity is journaled, until it ends. + assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 1) changes, err := sessionAdapter(s).ListSessionEvents(t.Context(), tenant, session.ID, before) - if err != nil || len(changes) != 1 || changes[0].Event.Type != "agent.session.in_progress" || len(changes[0].RequiredActions) != 0 || changes[0].Turn.CancelRequestedAt.IsZero() { + if err != nil || len(changes) != 0 { t.Fatal(changes, err) } } From c9991757488ea16f13b06c11625b3b40bfda3c75 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sun, 25 Oct 2026 10:39:42 +0800 Subject: [PATCH 37/39] Reject empty files and name the purpose param (#856) * Reject empty files and name the purpose param * Upload nonempty files in the File persistence tests * Name purpose for a missing file part --- apps/web/src/i18n/locales/en/files.ts | 2 +- apps/web/src/i18n/locales/zh-CN/files.ts | 2 +- contracts/agents-api/source-files.md | 4 +-- contracts/agents-api/zh/source-files.md | 6 ++--- services/core/internal/api/errors_files.go | 6 +++++ .../core/internal/api/source_files_test.go | 25 +++++++++++++++++++ .../core/internal/api/source_files_upload.go | 14 ++++++++--- services/core/internal/files/errors.go | 2 ++ services/core/internal/files/service.go | 9 ++++--- services/core/internal/files/service_test.go | 3 +++ .../postgres/filepg/filepg_test.go | 2 +- .../persistence/postgres/filepg/list_test.go | 2 +- 12 files changed, 61 insertions(+), 16 deletions(-) diff --git a/apps/web/src/i18n/locales/en/files.ts b/apps/web/src/i18n/locales/en/files.ts index bf426cc53..d4cef3623 100644 --- a/apps/web/src/i18n/locales/en/files.ts +++ b/apps/web/src/i18n/locales/en/files.ts @@ -1,7 +1,7 @@ export const files = { title: "Files", help: "Files uploaded to this project through the Files API, including uploads made by API callers. Reference a File ID from an Environment template to copy the file into the workspace of each new Session. User data files cannot be downloaded: Core rejects content requests for them.", - limits: "Uploads accept one file of at most 512 MiB with a 1–1024 byte name; empty files are allowed.", + limits: "Uploads accept one file of at most 512 MiB with a 1–1024 byte name; empty files are rejected.", actions: { upload: "Upload file", uploading: "Uploading…", diff --git a/apps/web/src/i18n/locales/zh-CN/files.ts b/apps/web/src/i18n/locales/zh-CN/files.ts index ec42f72bd..1b9cbe8c5 100644 --- a/apps/web/src/i18n/locales/zh-CN/files.ts +++ b/apps/web/src/i18n/locales/zh-CN/files.ts @@ -5,7 +5,7 @@ type TranslationShape = { [K in keyof T]: T[K] extends string ? string : Tran export const files: TranslationShape = { title: "文件", help: "通过 Files API 上传到本项目的文件,包括 API 调用方上传的文件。在 Environment 模板中引用 File ID,即可把文件复制到每个新 Session 的工作区。用户数据(user_data)文件不能下载:Core 会拒绝读取其内容。", - limits: "每次上传一个文件,最大 512 MiB,文件名 1–1024 字节;允许空文件。", + limits: "每次上传一个文件,最大 512 MiB,文件名 1–1024 字节;不接受空文件。", actions: { upload: "上传文件", uploading: "上传中…", diff --git a/contracts/agents-api/source-files.md b/contracts/agents-api/source-files.md index 4ce6d05e3..6bfe8e65f 100644 --- a/contracts/agents-api/source-files.md +++ b/contracts/agents-api/source-files.md @@ -20,8 +20,8 @@ Use a File by passing its ID to [Environment files](./environment-files.md#creat ### Upload -- Only `purpose=user_data` is accepted. Other purposes, `expires_after` and the Uploads API are not supported. -- The file may be empty and holds up to 512 MiB; the whole multipart body may exceed that by 64 KiB. A larger upload returns 413 `request_too_large`. The transfer must finish within five minutes. +- Only `purpose=user_data` is accepted. Other purposes, `expires_after` and the Uploads API are not supported. A missing or other `purpose`, or a missing `file` part, returns 400 with `param: "purpose"` and a null code. +- The file holds at least one byte and up to 512 MiB; an empty file returns 400 `File is empty.` with a null code and param; the whole multipart body may exceed that by 64 KiB. A larger upload returns 413 `request_too_large`. The transfer must finish within five minutes. - A missing, repeated or unknown part, a `Content-Encoding` or `Content-Transfer-Encoding` header, or a filename that is empty, longer than 1,024 bytes, not UTF-8 or contains NUL returns 400. Core stores nothing until the whole request validates. - Core does not deduplicate uploads. After a lost response, list Files before uploading again. diff --git a/contracts/agents-api/zh/source-files.md b/contracts/agents-api/zh/source-files.md index 3eae82672..697b11ede 100644 --- a/contracts/agents-api/zh/source-files.md +++ b/contracts/agents-api/zh/source-files.md @@ -1,7 +1,7 @@ --- title: "文件与 Skill" source: contracts/agents-api/source-files.md -source_hash: 86d63f3ea780394ed99c0f5ec9d566117140a8650cb780e4b038e4ab45155ba9 +source_hash: 32041f334a2bf3141731fab69901fc97665da225e4e03328df0db7300faa18ff --- Files(`/v1/files`)和 Skills(`/v1/skills`)是具有独立生命周期的项目资源,不依赖 Session。File 保存上传字节,Environment 通过 ID 复制它们。Skill 保存不可变、版本化的包,由模板和 Session 引用。项目的所有 API 密钥共享这些资源。 @@ -22,8 +22,8 @@ Files(`/v1/files`)和 Skills(`/v1/skills`)是具有独立生命周期的 ### 上传 {#upload} -- 仅接受 `purpose=user_data`。不支持其他用途、`expires_after` 或 Uploads API。 -- 文件可为空,最大 512 MiB;整个 multipart 正文可额外增加 64 KiB。更大上传返回 413 `request_too_large`。传输须在五分钟内完成。 +- 仅接受 `purpose=user_data`。不支持其他用途、`expires_after` 或 Uploads API。缺少 `purpose` 或取其他值,或缺少 `file` 部分时,返回 400,`param: "purpose"`,code 为 null。 +- 文件至少一个字节,最大 512 MiB;空文件返回 400 `File is empty.`,code 与 param 为 null;整个 multipart 正文可额外增加 64 KiB。更大上传返回 413 `request_too_large`。传输须在五分钟内完成。 - 部分缺失、重复或未知,存在 `Content-Encoding` 或 `Content-Transfer-Encoding` 头,或文件名为空、超过 1,024 字节、非 UTF-8、包含 NUL 时返回 400。完整请求通过验证之前 Core 不存储任何内容。 - Core 不对上传去重。响应丢失后,先列出 File 再次上传。 diff --git a/services/core/internal/api/errors_files.go b/services/core/internal/api/errors_files.go index f5de3795c..f122cf0df 100644 --- a/services/core/internal/api/errors_files.go +++ b/services/core/internal/api/errors_files.go @@ -14,7 +14,11 @@ func writeFilesError(w http.ResponseWriter, r *http.Request, err error, notFound if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) { return } + var field *fieldError switch { + case errors.As(err, &field): + // The Files routes keep their non-beta error envelope. + writeError(w, http.StatusBadRequest, "", field.message, field.param) case errors.Is(err, files.ErrNotFound): code := "not_found_error" // The Files routes keep their non-beta error envelope. @@ -24,6 +28,8 @@ func writeFilesError(w http.ResponseWriter, r *http.Request, err error, notFound writeError(w, http.StatusNotFound, code, "Resource not found.", notFoundParam...) case errors.Is(err, files.ErrTooLarge): writeContentTooLarge(w) + case errors.Is(err, files.ErrEmpty): + writeError(w, http.StatusBadRequest, "", "File is empty.") case errors.Is(err, files.ErrInvalidInput): writeError(w, http.StatusBadRequest, "invalid_request", "Invalid resource identifier or request limits.") default: diff --git a/services/core/internal/api/source_files_test.go b/services/core/internal/api/source_files_test.go index 068ec1b48..9adcd126a 100644 --- a/services/core/internal/api/source_files_test.go +++ b/services/core/internal/api/source_files_test.go @@ -206,6 +206,31 @@ func TestSourceFilesRejectIncompleteOrUnsupportedMultipart(t *testing.T) { t.Fatalf("invalid multipart accepted %v: %d", fields, status) } } + for _, test := range []struct { + fields []string + data string + param any + message string + }{ + {[]string{"file"}, "x", "purpose", "Missing required parameter: 'purpose'."}, + {[]string{"file", "purpose=batch"}, "x", "purpose", "Only purpose user_data is supported."}, + {[]string{"purpose=user_data"}, "x", "purpose", "A file part is required."}, + {[]string{"file", "purpose=user_data"}, "", nil, "File is empty."}, + } { + f := &sourceFilesFixture{} + h, _ := environmentFileCreateHandler(t, f.wire) + server := httptest.NewServer(h) + body, contentType := sourceMultipart(t, test.fields, []byte(test.data)) + status, raw := sourceRequest(t, server, "POST", "/v1/files", "files-key", contentType, body) + server.Close() + var got map[string]map[string]any + if status != 400 || json.Unmarshal(raw, &got) != nil || f.file.ID != "" { + t.Fatalf("%v: %d %s", test.fields, status, raw) + } + if e := got["error"]; e["type"] != "invalid_request_error" || e["code"] != nil || e["param"] != test.param || e["message"] != test.message { + t.Fatalf("%v error projection: %s", test.fields, raw) + } + } f := &sourceFilesFixture{} h, _ := environmentFileCreateHandler(t, f.wire) server := httptest.NewServer(h) diff --git a/services/core/internal/api/source_files_upload.go b/services/core/internal/api/source_files_upload.go index 6aa23d2e5..22237eba0 100644 --- a/services/core/internal/api/source_files_upload.go +++ b/services/core/internal/api/source_files_upload.go @@ -68,8 +68,8 @@ func readSourceUpload(r *http.Request, dst io.Writer) (files.Upload, error) { return input, files.ErrInvalidInput } value, err := io.ReadAll(io.LimitReader(part, 65)) - if err != nil || len(value) > 64 { - return input, files.ErrInvalidInput + if err != nil { + return input, sourceUploadError(err) } input.Purpose = string(value) } @@ -80,8 +80,14 @@ func readSourceUpload(r *http.Request, dst io.Writer) (files.Upload, error) { if _, err := io.CopyBuffer(io.Discard, r.Body, buffer); err != nil { return input, sourceUploadError(err) } - if !seen["file"] || !seen["purpose"] || input.Purpose != files.PurposeUserData { - return input, files.ErrInvalidInput + switch { + case !seen["purpose"]: + return input, &fieldError{param: "purpose", message: "Missing required parameter: 'purpose'."} + case input.Purpose != files.PurposeUserData: + return input, &fieldError{param: "purpose", message: "Only purpose user_data is supported."} + case !seen["file"]: + // The official service names purpose for a missing file part too. + return input, &fieldError{param: "purpose", message: "A file part is required."} } return input, nil } diff --git a/services/core/internal/files/errors.go b/services/core/internal/files/errors.go index dbd9b126b..5ad4fe430 100644 --- a/services/core/internal/files/errors.go +++ b/services/core/internal/files/errors.go @@ -11,4 +11,6 @@ var ( ErrInvalidInput = errors.New("invalid file request") // ErrTooLarge reports content beyond the operation's limit. ErrTooLarge = errors.New("file exceeds the content limit") + // ErrEmpty reports an upload without content. + ErrEmpty = errors.New("file is empty") ) diff --git a/services/core/internal/files/service.go b/services/core/internal/files/service.go index 325f7ec31..fe99ad337 100644 --- a/services/core/internal/files/service.go +++ b/services/core/internal/files/service.go @@ -25,9 +25,9 @@ type CreateCommand struct { Upload func(io.Writer) (Upload, error) } -// Create stores the File only after its complete content fits MaxBytes and its -// envelope validates. A content write that failed, including one beyond -// MaxBytes, fails the upload even when Upload ignored the write error. +// Create stores the File only after its complete content is nonempty, fits +// MaxBytes and its envelope validates. A content write that failed, including +// one beyond MaxBytes, fails the upload even when Upload ignored the write error. func (s *Service) Create(ctx context.Context, command CreateCommand) (File, error) { if command.Upload == nil { return File{}, ErrInvalidInput @@ -44,6 +44,9 @@ func (s *Service) Create(ctx context.Context, command CreateCommand) (File, erro if err := upload.Validate(); err != nil { return Upload{}, err } + if content.left == MaxBytes { + return Upload{}, ErrEmpty + } return upload, nil }) } diff --git a/services/core/internal/files/service_test.go b/services/core/internal/files/service_test.go index a3c2a1200..6b257e7b2 100644 --- a/services/core/internal/files/service_test.go +++ b/services/core/internal/files/service_test.go @@ -78,6 +78,9 @@ func TestCreateRejectsWithoutStoring(t *testing.T) { return Upload{Filename: "a", Purpose: "assistants"}, nil }, ErrInvalidInput}, "upload failure": {func(io.Writer) (Upload, error) { return Upload{}, uploadFailure }, uploadFailure}, + "empty content": {func(io.Writer) (Upload, error) { + return Upload{Filename: "a", Purpose: PurposeUserData}, nil + }, ErrEmpty}, "content beyond the limit": {func(w io.Writer) (Upload, error) { _, err := w.Write(make([]byte, 1)) if err != nil { diff --git a/services/core/internal/persistence/postgres/filepg/filepg_test.go b/services/core/internal/persistence/postgres/filepg/filepg_test.go index 01dd1664c..019e55569 100644 --- a/services/core/internal/persistence/postgres/filepg/filepg_test.go +++ b/services/core/internal/persistence/postgres/filepg/filepg_test.go @@ -80,7 +80,7 @@ func TestFilesPersistScopeAndDelete(t *testing.T) { pool := pgtest.Open(t) store, service := open(t, pool) tenant, foreign := uuid.NewString(), uuid.NewString() - for _, data := range [][]byte{{}, {0, 1, 255}, bytes.Repeat([]byte("binary\x00"), 300000)} { + for _, data := range [][]byte{{0}, {0, 1, 255}, bytes.Repeat([]byte("binary\x00"), 300000)} { file := create(t, service, t.Context(), tenant, data) if file.SizeBytes != int64(len(data)) || file.Filename != "source.bin" || file.Purpose != files.PurposeUserData || !strings.HasPrefix(file.ID, "file-") || file.CreatedAt.IsZero() { t.Fatalf("create: %+v", file) diff --git a/services/core/internal/persistence/postgres/filepg/list_test.go b/services/core/internal/persistence/postgres/filepg/list_test.go index 767788493..0e80ee271 100644 --- a/services/core/internal/persistence/postgres/filepg/list_test.go +++ b/services/core/internal/persistence/postgres/filepg/list_test.go @@ -111,7 +111,7 @@ func TestFileListPaginationIsolationAndReconnect(t *testing.T) { if err != nil || !reflect.DeepEqual(foreignPage.Files, []files.File{foreign}) || foreignPage.NextCursor != "" { t.Fatalf("project isolation: %+v, %v", foreignPage, err) } - deleted := create(t, service, t.Context(), tenant, nil) + deleted := create(t, service, t.Context(), tenant, []byte("deleted")) if err := service.Delete(t.Context(), files.DeleteCommand{TenantID: tenant, FileID: deleted.ID}); err != nil { t.Fatal(err) } From 7490e65b1c45837752db9cc10f1064aaa8faf6c6 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sun, 25 Oct 2026 10:41:43 +0800 Subject: [PATCH 38/39] Name MCP transport params and check labels (#858) * Name MCP transport params and check labels * Keep local codes outside the MCP transport and fix the Chinese admission table --- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/index.md | 2 +- contracts/agents-api/wire-semantics.md | 2 +- contracts/agents-api/zh/execution-tools.md | 4 +- contracts/agents-api/zh/index.md | 4 +- contracts/agents-api/zh/wire-semantics.md | 4 +- .../internal/api/configuration_validation.go | 30 +++++++++++ .../api/configuration_validation_test.go | 17 ++++++ .../core/internal/api/mcp_configuration.go | 54 +++++++++++++------ .../internal/api/mcp_configuration_test.go | 35 ++++++------ services/core/internal/api/saved_tools.go | 5 +- services/core/internal/api/session_agent.go | 6 +++ .../internal/api/session_credentials_test.go | 2 +- services/core/internal/api/session_tools.go | 12 +---- 14 files changed, 123 insertions(+), 56 deletions(-) diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index b19976692..7a074a728 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -90,7 +90,7 @@ Execution carries the resolved values to the Harness and validates them against } ``` -- `server_label` is nonempty and unique within the Session. The `http` and `stdio` transports are accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials or fragment. Nonempty `request_metadata` is rejected with "Nonempty MCP request_metadata is not supported yet." and an inline `authorization` with "Inline MCP authorization is not supported; use credential_id.". `headers` are non-secret, need valid HTTP names and values with names unique ignoring case, and reach the native Harness unchanged beside any bearer; an `Authorization` header is rejected, because credentials enter only through a Vault credential. Claude rejects `${` in `server_url` or a header value ([native differences](./index.md#differences-from-openai)). +- `server_label` contains only letters, digits, underscores and hyphens and is unique among the tools; a violation is a 400 `invalid_request_error` naming the offending tool's `server_label`. A rejected transport member is named the same way, such as `tools[0].transport.cwd`, and on the inline Session agent the path starts with `agent.`. The `http` and `stdio` transports are accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials or fragment. Nonempty `request_metadata` is rejected with "No engine sends MCP request_metadata." and an inline `authorization` with "Inline MCP authorization is not supported; use credential_id.". `headers` are non-secret, need valid HTTP names and values with names unique ignoring case, and reach the native Harness unchanged beside any bearer; an `Authorization` header is rejected, because credentials enter only through a Vault credential. Claude rejects `${` in `server_url` or a header value ([native differences](./index.md#differences-from-openai)). - [Public MCP connection origin](./environments.md#public-mcp-connection-origin) owns origin defaults, placement and credential authority; [Harness capabilities](./harness-capabilities.md#tools) owns per-Harness support. - Omitted or null `allowed_tools` permits every server tool; `[]` permits none. - `required: true` makes native thread creation and cold resume wait for the server to initialize; a failure stops execution without replacing retained history. It needs the Runtime's `mcp_http_required` capability. Public work can be accepted or queued during the wait. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index ce1d4aa1d..e8784cfff 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -80,7 +80,7 @@ Each item is Core's deliberate or native behavior where the official service beh - A Session's `agent.tools` omits `tool_search` declarations. - Core rejects an inline `authorization` on a Session MCP transport, an `Authorization` entry in `headers` and a nonempty stdio `env`: credentials come only from Vaults (`credential_id`) and environment values only from the Environment (`env_vars`) ([MCP](./execution-tools.md#mcp)). - A `stdio` MCP server needs a workspace placement and the origin `environment`. Claude Code starts the server in its workspace directory, so `cwd` must equal it, with its whole process environment, which also holds the model provider key and other servers' Vault bearer tokens, and it needs `allowed_tools` null. MiniMax Code starts the server in its private home directory, so it rejects every `stdio` server ([MCP](./execution-tools.md#mcp)). -- Core rejects nonempty MCP `request_metadata`, which no Harness can send, as well as a `stdio` server whose `connection_origin` is `service`, a blank `server_label`, an empty `credential_id` and an empty `allowed_tools` name, which the pinned schema allows without defining their meaning (400 with the reason). +- Core rejects nonempty MCP `request_metadata`, which no Harness can send, as well as a `stdio` server whose `connection_origin` is `service`, an empty `credential_id` and an empty `allowed_tools` name, which the pinned schema allows without defining their meaning (400 with the reason). - Claude rejects an MCP `server_url` or `headers` value containing `${`, because the native client expands `${VAR}` in both from a process environment that holds Core-held credentials. - Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returns 200. diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index 3146bff2b..182915c32 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -172,6 +172,7 @@ Agent create and update bodies and the inline `agent` of Session create are chec | Unsupported enum value | JSON path | `Invalid value: ''. Supported values are: ...` with the pinned values | | Integer below minimum | JSON path | `Invalid '': integer below minimum value. Expected a value >= 1, but got instead.` | | Repeated function name, more than one `web_search`, more than one `tool_search` | null | `duplicate function tool name: `, `duplicate web_search tool`, `duplicate tool_search tool` | +| An MCP `server_label` outside `[A-Za-z0-9_-]`, or one repeated by a later tool | `tools[].server_label` of the offending tool | A sentence stating the cause | | Function `parameters` with a string root `type` other than `object` | null | `Invalid schema for function '': schema must be a JSON Schema of 'type: "object"', got 'type: ""'.` | | `text.format` JSON schema with a string root `type` other than `object` | null | `agent.text.format.schema must have top-level type "object"; got ""`, also on Agent requests | @@ -187,7 +188,6 @@ A Session's effective configuration must also pass execution admission, which ap | --- | --- | | A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting, `programmatic_tool_calling` value or `text.verbosity` the Harness does not declare | A message naming the field, such as `reasoning.effort "max" is not supported by this Harness.`; see [tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling) and [Harness capabilities](./harness-capabilities.md#execution-and-input) | | A function name that is blank or repeated | "Function names must be nonempty and unique." | -| Two MCP servers with one label | "Execution requires distinct MCP server labels." | | A Harness that does not declare `retained_native_history`, on a sandbox provider that keeps every Environment on workspace storage ([Harness capabilities](./harness-capabilities.md)) | "This Session's harness does not retain its native history, which the sandbox provider's workspace storage requires. Use a harness that supports retained_native_history, or a sandbox provider with its own disk." | A per-Session `tools` replacement admits a Session whose saved tools would be rejected. Support for each tool and Harness is in [execution and tools](./execution-tools.md). diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index cebd341ca..df431e5a0 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: 44b540ff6827f818d8bc4cbaa6dc5fc9929bbf82a15c66edeb604909ecb6b110 +source_hash: 7783010b0968214d235d349fd60d1ee6096f0ab9d33d58397d90bb0fd935cded --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -92,7 +92,7 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi } ``` -- `server_label` 非空且在 Session 中唯一。接受 `http` 和 `stdio` 传输;`server_url` 为不带凭据或片段的绝对 HTTP 或 HTTPS URL。非空 `request_metadata` 被拒绝,消息为 "Nonempty MCP request_metadata is not supported yet.";内联 `authorization` 被拒绝,消息为 "Inline MCP authorization is not supported; use credential_id."。`headers` 为非机密标头,名称和值须为有效的 HTTP 名称和值,且名称不区分大小写唯一,会与 bearer 一同原样传给原生 Harness;`Authorization` 标头被拒绝,因为凭据只通过 Vault 凭据进入。Claude 会拒绝 `server_url` 或标头值中的 `${`([原生差异](index.md#differences-from-openai))。 +- `server_label` 只含字母、数字、下划线和连字符,且在各工具之间唯一;违反时返回 400 `invalid_request_error`,并指出出错工具的 `server_label`。被拒绝的传输成员以同样方式指出,例如 `tools[0].transport.cwd`,Session 内联 Agent 的路径以 `agent.` 开头。接受 `http` 和 `stdio` 传输;`server_url` 为不带凭据或片段的绝对 HTTP 或 HTTPS URL。非空 `request_metadata` 被拒绝,消息为 "No engine sends MCP request_metadata.";内联 `authorization` 被拒绝,消息为 "Inline MCP authorization is not supported; use credential_id."。`headers` 为非机密标头,名称和值须为有效的 HTTP 名称和值,且名称不区分大小写唯一,会与 bearer 一同原样传给原生 Harness;`Authorization` 标头被拒绝,因为凭据只通过 Vault 凭据进入。Claude 会拒绝 `server_url` 或标头值中的 `${`([原生差异](index.md#differences-from-openai))。 - [公开 MCP 连接来源](environments.md#public-mcp-connection-origin)定义来源默认值、部署位置和凭据权限;[Harness 能力](harness-capabilities.md#tools)定义各 Harness 支持范围。 - 省略或 null 的 `allowed_tools` 允许所有服务器工具;`[]` 不允许任何工具。 - `required: true` 使原生线程创建和冷恢复等待服务器初始化;失败会停止执行,不替换保留历史。它要求 Runtime 的 `mcp_http_required` 能力。等待期间公开工作可被接受或排队。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 57a4f2e98..42c5abbee 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: d65d6dc5d6a8ec195950a119b667964bf5bb651c469191235779d19a7220ff92 +source_hash: afb241cb659061b9f5c50c5db88c83080d02d07eab37ffb2f1c70d5e2386c96e --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -83,7 +83,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Session 的 `agent.tools` 会省略 `tool_search` 声明。 - Core 拒绝 Session MCP 传输中的内联 `authorization`、`headers` 中的 `Authorization` 条目以及非空的 stdio `env`:凭据只来自 Vault(`credential_id`),环境值只来自 Environment(`env_vars`)([MCP](execution-tools.md#mcp))。 - `stdio` MCP 服务器需要工作区部署位置和 `environment` 来源。Claude Code 在其工作区目录中启动服务器,因此 `cwd` 必须与之相同;服务器继承整个进程环境,其中也有模型提供方密钥和其他服务器的 Vault bearer 令牌;且 `allowed_tools` 须为 null。MiniMax Code 在其私有主目录中启动服务器,因此拒绝所有 `stdio` 服务器([MCP](execution-tools.md#mcp))。 -- Core 拒绝非空的 MCP `request_metadata`,因为没有 Harness 能发送它;同样拒绝 `connection_origin` 为 `service` 的 `stdio` 服务器,以及空白的 `server_label`、空的 `credential_id` 和空的 `allowed_tools` 名称,固定版本的 schema 允许这些值但没有定义其含义(400,并说明原因)。 +- Core 拒绝非空的 MCP `request_metadata`,因为没有 Harness 能发送它;同样拒绝 `connection_origin` 为 `service` 的 `stdio` 服务器,以及空的 `credential_id` 和空的 `allowed_tools` 名称,固定版本的 schema 允许这些值但没有定义其含义(400,并说明原因)。 - Claude 会拒绝包含 `${` 的 MCP `server_url` 或 `headers` 值,因为原生客户端会从持有 Core 所持凭据的进程环境中展开这两处的 `${VAR}`。 - 在 events 202 之后立即删除 Session 会返回 409,因为 Core 会在同一事务中准入该 Turn;官方服务返回 200。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 4c807eddd..978886b12 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: 1d087e0f29e20f091416cc7bd3293e754c610f2335e2d66456f5205706d9983a +source_hash: 851e09252847f11040524f329419587687e81d361e4e92c0a3e912d2fd887d45 --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -174,6 +174,7 @@ Agent 创建和更新正文以及 Session 创建中的内联 `agent`,会在其 | 不支持的枚举值 | JSON 路径 | `Invalid value: ''. Supported values are: ...`,后跟已锁定的值 | | 整数低于最小值 | JSON 路径 | `Invalid '': integer below minimum value. Expected a value >= 1, but got instead.` | | 重复的函数名称、多个 `web_search`、多个 `tool_search` | null | `duplicate function tool name: `、`duplicate web_search tool`、`duplicate tool_search tool` | +| MCP `server_label` 含 `[A-Za-z0-9_-]` 以外的字符,或被后面的工具重复 | 出错工具的 `tools[].server_label` | 说明原因的句子 | | Function `parameters` 的字符串根 `type` 不是 `object` | null | `Invalid schema for function '': schema must be a JSON Schema of 'type: "object"', got 'type: ""'.` | | `text.format` JSON schema 的字符串根 `type` 不是 `object` | null | `agent.text.format.schema must have top-level type "object"; got ""`,Agent 请求上也会返回此消息 | @@ -189,7 +190,6 @@ Session 的有效配置还必须通过执行准入,无论配置来自保存还 | --- | --- | | Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值或 `text.verbosity` | 指出字段的消息,例如 `reasoning.effort "max" is not supported by this Harness.`;见 [tool policy](execution-tools.md#web-search-and-programmatic-tool-calling) 和 [Harness 能力](harness-capabilities.md#execution-and-input) | | 函数名称为空或重复 | "Function names must be nonempty and unique." | -| 两个使用同一标签的 MCP 服务器 | "Execution requires distinct MCP server labels." | | 未声明 `retained_native_history` 的 Harness,运行在把每个 Environment 都放在 workspace 存储上的 sandbox provider 上([Harness 能力](harness-capabilities.md)) | "This Session's harness does not retain its native history, which the sandbox provider's workspace storage requires. Use a harness that supports retained_native_history, or a sandbox provider with its own disk." | 每 Session 的 `tools` 替换可以允许一个其已保存 tools 原本会被拒绝的 Session。每种工具和 Harness 的支持情况见 [execution and tools](execution-tools.md)。 diff --git a/services/core/internal/api/configuration_validation.go b/services/core/internal/api/configuration_validation.go index 2fb0a933f..f78dcd771 100644 --- a/services/core/internal/api/configuration_validation.go +++ b/services/core/internal/api/configuration_validation.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "regexp" "strconv" "strings" ) @@ -82,12 +83,41 @@ func validateAgentConfiguration(path string, raw json.RawMessage, root shape) er if value := fields["text"]; jsonValueKind(value) == "an object" { _ = json.Unmarshal(value, &text) } + if err := checkMCPLabels(path, tools); err != nil { + return err + } if text.Format == nil { return configurationConflict(tools, "", nil) } return configurationConflict(tools, text.Format.Type, text.Format.Schema) } +var mcpServerLabel = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// checkMCPLabels rejects an MCP server_label outside [A-Za-z0-9_-] and a label +// repeated by a later tool, naming the offending tool's server_label. +func checkMCPLabels(path string, tools []json.RawMessage) error { + seen := map[string]bool{} + for i, raw := range tools { + var tool struct { + Type string `json:"type"` + ServerLabel string `json:"server_label"` + } + if json.Unmarshal(raw, &tool) != nil || tool.Type != "mcp" { + continue + } + param := joinPath(path, fmt.Sprintf("tools[%d].server_label", i)) + if !mcpServerLabel.MatchString(tool.ServerLabel) { + return &fieldError{param: param, message: "MCP server_label may contain only letters, digits, underscores and hyphens."} + } + if seen[tool.ServerLabel] { + return &fieldError{param: param, message: "MCP server_label must be unique across the tools."} + } + seen[tool.ServerLabel] = true + } + return nil +} + func checkValue(path string, raw json.RawMessage, s shape) error { got := jsonValueKind(raw) if s.kind == anyValue || got == "null" && s.nullable { diff --git a/services/core/internal/api/configuration_validation_test.go b/services/core/internal/api/configuration_validation_test.go index 06753adbc..361cbecb0 100644 --- a/services/core/internal/api/configuration_validation_test.go +++ b/services/core/internal/api/configuration_validation_test.go @@ -115,6 +115,8 @@ func TestAgentConfigurationProtocolErrorsUseOfficialFields(t *testing.T) { {"http url", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"http"}}]`, "{p}tools[0].transport.server_url", "Missing required parameter: '{p}tools[0].transport.server_url'."}, {"transport type", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"ws"}}]`, "{p}tools[0].transport.type", "Invalid value: 'ws'. Supported values are: 'http' and 'stdio'."}, {"header value", `"tools":[{"type":"mcp","server_label":"x","transport":{"type":"http","server_url":"https://example.invalid","headers":{"h":1}}}]`, "{p}tools[0].transport.headers.h", "Invalid type for '{p}tools[0].transport.headers.h': expected a string, but got an integer instead."}, + {"mcp label characters", `"tools":[{"type":"mcp","server_label":"a b","transport":{"type":"http","server_url":"https://example.invalid"}}]`, "{p}tools[0].server_label", "MCP server_label may contain only letters, digits, underscores and hyphens."}, + {"mcp label repeated", `"tools":[{"type":"mcp","server_label":"a","transport":{"type":"http","server_url":"https://example.invalid"}},{"type":"mcp","server_label":"a","transport":{"type":"http","server_url":"https://example.invalid"}}]`, "{p}tools[1].server_label", "MCP server_label must be unique across the tools."}, {"verbosity", `"text":{"verbosity":"verbose"}`, "{p}text.verbosity", "Invalid value: 'verbose'. Supported values are: 'low', 'medium', and 'high'."}, {"text member", `"text":{"unknown":true}`, "{p}text.unknown", "Unknown parameter: '{p}text.unknown'."}, {"format type", `"text":{"format":{}}`, "{p}text.format.type", "Missing required parameter: '{p}text.format.type'."}, @@ -276,6 +278,21 @@ func TestAgentConfigurationLocalLimitsKeepCodes(t *testing.T) { } } +// Errors from the local limits on an MCP transport name the offending member. +func TestMCPTransportErrorsNameTheMember(t *testing.T) { + const http1 = `{"type":"mcp","server_label":"x","transport":{"type":"http","server_url":"https://example.invalid"}}` + for _, tc := range []struct{ tool, param, message string }{ + {`{"type":"mcp","server_label":"x","transport":{"type":"http","server_url":"ftp://example.invalid"}}`, "tools[0].transport.server_url", "MCP server_url must be an absolute HTTP(S) URL without credentials or fragment."}, + {http1 + `,{"type":"mcp","server_label":"y","transport":{"type":"http","server_url":"https://example.invalid","headers":{"authorization":"t"}}}`, "tools[1].transport.headers.authorization", "MCP headers cannot carry Authorization; use credential_id."}, + {`{"type":"mcp","server_label":"x","transport":{"type":"stdio","command":"run","cwd":"relative"}}`, "tools[0].transport.cwd", "MCP stdio servers require a nonempty command, an absolute cwd, variable names in env_vars and no NUL characters."}, + } { + for _, op := range configurationOperations()[:3] { + h, _ := configurationHandler(t, nil) + assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(`"tools":[`+tc.tool+`]`)), "invalid_request_error", ptr(op.prefix+tc.param), tc.message) + } + } +} + // C4 and SES-33: only configuration errors precede the input requirement; // accepted configurations and saved-Agent lookup keep their order. func TestSessionConfigurationValidationOrder(t *testing.T) { diff --git a/services/core/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go index 23829dcdb..0710ba2ba 100644 --- a/services/core/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/url" + "sort" "strings" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -11,9 +12,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) -// resolveMCPTool canonicalizes a declaration whose pinned shape was checked at -// decode. Core keeps its local limits. -func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { +// resolveMCPTool canonicalizes a declaration whose pinned shape and server_label +// were checked at decode. Core keeps its local limits; a rejected transport +// member is named under at, the declaration's path. +func resolveMCPTool(raw json.RawMessage, at string, saved bool) (json.RawMessage, error) { var input v1.MCPToolInput var transport struct { Type string `json:"type"` @@ -29,8 +31,8 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { if err := errors.Join(json.Unmarshal(raw, &input), json.Unmarshal(input.Transport, &transport)); err != nil { return nil, &storedDataError{err} } - if strings.TrimSpace(input.ServerLabel) == "" { - return nil, errors.New("MCP tools require type=mcp and a nonempty server_label.") + reject := func(member, message string) error { + return &fieldError{param: at + "." + member, message: message} } stdio := transport.Type == "stdio" // The official service saves an omitted or null origin on an HTTP server as @@ -66,32 +68,54 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { ConnectionOrigin: origin, CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} if stdio { if len(transport.Env) != 0 { - return nil, errors.New("Inline MCP env is not supported; declare env_vars and set the values in the Environment.") + return nil, reject("transport.env", "Inline MCP env is not supported; declare env_vars and set the values in the Environment.") } args, envVars := append([]string{}, transport.Args...), append([]string{}, transport.EnvVars...) - if !(proto.MCPStdio{Command: transport.Command, Args: args, Cwd: transport.Cwd, EnvVars: envVars}).Valid() { - return nil, errors.New("MCP stdio servers require a nonempty command, an absolute cwd, variable names in env_vars and no NUL characters.") + // Valid is the one definition of a startable declaration; each probe + // varies one member over a valid remainder to name the member at fault, + // and the whole declaration is checked last. + declaration := proto.MCPStdio{Command: transport.Command, Args: args, Cwd: transport.Cwd, EnvVars: envVars} + for _, probe := range []struct { + member string + stdio proto.MCPStdio + }{ + {"transport.command", proto.MCPStdio{Command: transport.Command, Cwd: "/"}}, + {"transport.args", proto.MCPStdio{Command: "x", Args: args, Cwd: "/"}}, + {"transport.cwd", proto.MCPStdio{Command: "x", Cwd: transport.Cwd}}, + {"transport.env_vars", proto.MCPStdio{Command: "x", Cwd: "/", EnvVars: envVars}}, + {"transport", declaration}, + } { + if !probe.stdio.Valid() { + return nil, reject(probe.member, "MCP stdio servers require a nonempty command, an absolute cwd, variable names in env_vars and no NUL characters.") + } } tool.Transport = v1.MCPTransport{Type: "stdio", Command: transport.Command, Args: &args, Cwd: transport.Cwd, EnvVars: &envVars} return json.Marshal(tool) } if transport.Type != "http" { - return nil, errors.New("MCP supports the http and stdio transports only.") + return nil, reject("transport.type", "MCP supports the http and stdio transports only.") } u, err := url.Parse(transport.ServerURL) if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" { - return nil, errors.New("MCP server_url must be an absolute HTTP(S) URL without credentials or fragment.") + return nil, reject("transport.server_url", "MCP server_url must be an absolute HTTP(S) URL without credentials or fragment.") } if transport.Authorization != nil { - return nil, errors.New("Inline MCP authorization is not supported; use credential_id.") + return nil, reject("transport.authorization", "Inline MCP authorization is not supported; use credential_id.") } + names, seen := make([]string, 0, len(transport.Headers)), map[string]bool{} for name := range transport.Headers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { if strings.EqualFold(name, "Authorization") { - return nil, errors.New("MCP headers cannot carry Authorization; use credential_id.") + return nil, reject("transport.headers."+name, "MCP headers cannot carry Authorization; use credential_id.") } - } - if !agentplugin.ValidHTTPHeaders(transport.Headers) { - return nil, errors.New("MCP headers require valid HTTP names and values, with names unique ignoring case.") + lower := strings.ToLower(name) + if seen[lower] || !agentplugin.ValidHTTPHeaders(map[string]string{name: transport.Headers[name]}) { + return nil, reject("transport.headers."+name, "MCP headers require valid HTTP names and values, with names unique ignoring case.") + } + seen[lower] = true } tool.Transport = v1.MCPTransport{Type: "http", ServerURL: transport.ServerURL} if len(transport.Headers) != 0 { diff --git a/services/core/internal/api/mcp_configuration_test.go b/services/core/internal/api/mcp_configuration_test.go index 0d4fe0ab5..98dcb3779 100644 --- a/services/core/internal/api/mcp_configuration_test.go +++ b/services/core/internal/api/mcp_configuration_test.go @@ -17,7 +17,7 @@ const publicMCP = `{"type":"mcp","server_label":"records","connection_origin":"s func TestMCPRequiredSavedAndEffectiveConfiguration(t *testing.T) { for _, value := range []string{"true", "false"} { input := strings.TrimSuffix(publicMCP, "}") + `,"required":` + value + `}` - saved, err := resolveMCPTool(json.RawMessage(input), true) + saved, err := resolveMCPTool(json.RawMessage(input), "tools[0]", true) if err != nil { t.Fatal(err) } @@ -36,7 +36,7 @@ func TestMCPRequiredSavedAndEffectiveConfiguration(t *testing.T) { func TestMCPResourceTransportProjections(t *testing.T) { for _, saved := range []bool{false, true} { - raw, err := resolveMCPTool(json.RawMessage(publicMCP), saved) + raw, err := resolveMCPTool(json.RawMessage(publicMCP), "tools[0]", saved) if err != nil { t.Fatal(err) } @@ -61,13 +61,13 @@ func TestMCPResourceTransportProjections(t *testing.T) { // explicit "service" declaration, in saved Agents and Session configuration. func TestMCPOmittedOriginIsService(t *testing.T) { for _, saved := range []bool{false, true} { - explicit, err := resolveMCPTool(json.RawMessage(publicMCP), saved) + explicit, err := resolveMCPTool(json.RawMessage(publicMCP), "tools[0]", saved) if err != nil { t.Fatal(err) } for _, origin := range []string{"", `,"connection_origin":null`} { input := `{"type":"mcp","server_label":"records"` + origin + `,"transport":{"type":"http","server_url":"https://mcp.example.test/tools"}}` - resolved, err := resolveMCPTool(json.RawMessage(input), saved) + resolved, err := resolveMCPTool(json.RawMessage(input), "tools[0]", saved) if err != nil || string(resolved) != string(explicit) { t.Fatalf("saved=%v origin %q: %s, %v; want %s", saved, origin, resolved, err, explicit) } @@ -84,7 +84,7 @@ func TestMCPStdioDeclaration(t *testing.T) { } for _, saved := range []bool{false, true} { for _, origin := range []string{"", `,"connection_origin":null`, `,"connection_origin":"environment"`} { - raw, err := resolveMCPTool(input(origin, stdio+`,"env":{}}`), saved) + raw, err := resolveMCPTool(input(origin, stdio+`,"env":{}}`), "tools[0]", saved) var tool map[string]any if err != nil || json.Unmarshal(raw, &tool) != nil || tool["connection_origin"] != "environment" || !reflect.DeepEqual(tool["transport"], map[string]any{"type": "stdio", "command": "run", "args": []any{"-x"}, "cwd": "/work", "env_vars": []any{"TOKEN"}}) { @@ -92,7 +92,7 @@ func TestMCPStdioDeclaration(t *testing.T) { } } } - raw, err := resolveMCPTool(input("", `{"type":"stdio","command":"run","args":null,"cwd":"/","env_vars":null}`), true) + raw, err := resolveMCPTool(input("", `{"type":"stdio","command":"run","args":null,"cwd":"/","env_vars":null}`), "tools[0]", true) if err != nil || !strings.Contains(string(raw), `"args":[]`) || !strings.Contains(string(raw), `"env_vars":[]`) { t.Fatalf("null lists: %s, %v", raw, err) } @@ -100,14 +100,14 @@ func TestMCPStdioDeclaration(t *testing.T) { "Inline MCP env is not supported; declare env_vars and set the values in the Environment.": input("", stdio+`,"env":{"TOKEN":"private-marker"}}`), "MCP stdio servers run in the Environment; connection_origin must be environment.": input(`,"connection_origin":"service"`, stdio+`}`), } { - if _, err := resolveMCPTool(raw, false); err == nil || err.Error() != message { + if _, err := resolveMCPTool(raw, "tools[0]", false); err == nil || err.Error() != message { t.Fatalf("want %q, got %v", message, err) } } const invalid = "MCP stdio servers require a nonempty command, an absolute cwd, variable names in env_vars and no NUL characters." for _, transport := range []string{`{"type":"stdio","command":" ","cwd":"/"}`, `{"type":"stdio","command":"run","cwd":"work"}`, `{"type":"stdio","command":"run","cwd":"/","env_vars":["A-B"]}`, `{"type":"stdio","command":"run","args":["\u0000"],"cwd":"/"}`} { - if _, err := resolveMCPTool(input("", transport), true); err == nil || err.Error() != invalid { + if _, err := resolveMCPTool(input("", transport), "tools[0]", true); err == nil || err.Error() != invalid { t.Fatalf("%s: %v", transport, err) } } @@ -123,7 +123,7 @@ func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { input["credential_id"], input["request_metadata"], input["required"] = json.RawMessage("null"), json.RawMessage("null"), json.RawMessage("false") input["transport"] = json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test/tools","authorization":null,"headers":null}`) encoded, _ := json.Marshal(input) - resolved, err := resolveMCPTool(encoded, false) + resolved, err := resolveMCPTool(encoded, "tools[0]", false) if err != nil { t.Fatal(err) } @@ -159,7 +159,7 @@ func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { } } encoded, _ := json.Marshal(input) - if _, err := resolveMCPTool(encoded, false); err == nil || strings.Contains(err.Error(), "private-marker") { + if _, err := resolveMCPTool(encoded, "tools[0]", false); err == nil || strings.Contains(err.Error(), "private-marker") { t.Fatalf("unsupported MCP input was accepted or disclosed: %v", err) } }) @@ -169,18 +169,18 @@ func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { func TestMCPHeadersAreStoredAsGiven(t *testing.T) { for _, saved := range []bool{false, true} { input := `{"type":"mcp","server_label":"records","transport":{"type":"http","server_url":"https://mcp.example.test","headers":{"X-Tenant":"a"}}}` - raw, err := resolveMCPTool(json.RawMessage(input), saved) + raw, err := resolveMCPTool(json.RawMessage(input), "tools[0]", saved) if err != nil || !strings.Contains(string(raw), `"headers":{"X-Tenant":"a"}`) { t.Fatalf("saved=%v: %s, %v", saved, raw, err) } - if _, err := resolveMCPTool(json.RawMessage(strings.Replace(input, "X-Tenant", "authorization", 1)), saved); err == nil { + if _, err := resolveMCPTool(json.RawMessage(strings.Replace(input, "X-Tenant", "authorization", 1)), "tools[0]", saved); err == nil { t.Fatal("Authorization header accepted") } } } func TestSessionMCPKeepsToolOrderAndStripsSavedHeaders(t *testing.T) { - saved, err := resolveMCPTool(json.RawMessage(publicMCP), true) + saved, err := resolveMCPTool(json.RawMessage(publicMCP), "tools[0]", true) if err != nil { t.Fatal(err) } @@ -192,13 +192,10 @@ func TestSessionMCPKeepsToolOrderAndStripsSavedHeaders(t *testing.T) { if strings.Contains(string(tools[0]), "headers") || string(tools[1]) != string(function) { t.Fatalf("Session transport or declared order changed: %s", tools) } - if _, err := resolveSessionTools([]json.RawMessage{saved, function, saved}); err == nil { - t.Fatal("duplicate MCP server labels were admitted") - } } func TestSessionReadOmitsMCPHeaders(t *testing.T) { - stored, err := resolveMCPTool(json.RawMessage(`{"type":"mcp","server_label":"records","transport":{"type":"http","server_url":"https://mcp.example.test","headers":{"X-Tenant":"a"}}}`), false) + stored, err := resolveMCPTool(json.RawMessage(`{"type":"mcp","server_label":"records","transport":{"type":"http","server_url":"https://mcp.example.test","headers":{"X-Tenant":"a"}}}`), "tools[0]", false) if err != nil { t.Fatal(err) } @@ -216,7 +213,7 @@ func TestPublicEnvironmentMCPPreservesDeclaration(t *testing.T) { for _, saved := range []bool{false, true} { for _, allowed := range []string{"null", "[]", `["prove"]`} { raw := []byte(`{"type":"mcp","server_label":"remote","connection_origin":"environment","transport":{"type":"http","server_url":"https://example.test/mcp"},"allowed_tools":` + allowed + `,"required":true,"credential_id":"selected"}`) - resolved, err := resolveMCPTool(raw, saved) + resolved, err := resolveMCPTool(raw, "tools[0]", saved) if err != nil { t.Fatal(err) } @@ -230,7 +227,7 @@ func TestPublicEnvironmentMCPPreservesDeclaration(t *testing.T) { func TestMCPServerURLKeepsQuery(t *testing.T) { const serverURL = "https://mcp.example.test/tools?tenant=a%20b" - saved, err := resolveMCPTool(json.RawMessage(strings.Replace(publicMCP, "https://mcp.example.test/tools", serverURL, 1)), true) + saved, err := resolveMCPTool(json.RawMessage(strings.Replace(publicMCP, "https://mcp.example.test/tools", serverURL, 1)), "tools[0]", true) if err != nil || !strings.Contains(string(saved), serverURL) { t.Fatalf("query URL was rejected or changed: %s, %v", saved, err) } diff --git a/services/core/internal/api/saved_tools.go b/services/core/internal/api/saved_tools.go index 6cb301e03..04cf2eb66 100644 --- a/services/core/internal/api/saved_tools.go +++ b/services/core/internal/api/saved_tools.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "errors" + "fmt" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) @@ -12,7 +13,7 @@ import ( // resolve yet. func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { tools := make([]json.RawMessage, 0, len(input)) - for _, raw := range input { + for i, raw := range input { var kind struct { Type string `json:"type"` } @@ -40,7 +41,7 @@ func resolveSavedTools(input []json.RawMessage) ([]json.RawMessage, error) { } value = resolved case "mcp": - resolved, err := resolveMCPTool(raw, true) + resolved, err := resolveMCPTool(raw, fmt.Sprintf("tools[%d]", i), true) if err != nil { return nil, err } diff --git a/services/core/internal/api/session_agent.go b/services/core/internal/api/session_agent.go index aa81f7400..5e8a12ee1 100644 --- a/services/core/internal/api/session_agent.go +++ b/services/core/internal/api/session_agent.go @@ -32,6 +32,12 @@ func resolveSessionAgent(input sessionRequest, saved *v1.SavedAgent) (v1.Agent, } resolved, err := resolveSavedAgent(request) if err != nil { + // Saved-Agent resolution names members from the request root; the inline + // agent lives under agent. + var field *fieldError + if errors.As(err, &field) && field.param != "" && input.Agent != nil { + err = &fieldError{param: "agent." + field.param, message: field.message} + } return v1.Agent{}, err } var override v1.SavedAgentConfiguration diff --git a/services/core/internal/api/session_credentials_test.go b/services/core/internal/api/session_credentials_test.go index d291f6a6f..c39771700 100644 --- a/services/core/internal/api/session_credentials_test.go +++ b/services/core/internal/api/session_credentials_test.go @@ -15,7 +15,7 @@ import ( func TestMCPCredentialReferenceIsSchemaNotAuthorization(t *testing.T) { for _, saved := range []bool{false, true} { input := strings.TrimSuffix(publicMCP, "}") + `,"credential_id":"unresolved-reference"}` - raw, err := resolveMCPTool(json.RawMessage(input), saved) + raw, err := resolveMCPTool(json.RawMessage(input), "tools[0]", saved) var output v1.MCPTool if err != nil || json.Unmarshal(raw, &output) != nil || output.CredentialID == nil || *output.CredentialID != "unresolved-reference" { t.Fatal("saved/effective schema resolved or lost the caller credential reference", err) diff --git a/services/core/internal/api/session_tools.go b/services/core/internal/api/session_tools.go index f2f02d0da..fc744566d 100644 --- a/services/core/internal/api/session_tools.go +++ b/services/core/internal/api/session_tools.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "errors" + "fmt" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) @@ -11,7 +12,6 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { tools := make([]json.RawMessage, len(input)) functions := make([]v1.FunctionToolInput, 0, len(input)) positions := make([]int, 0, len(input)) - servers := map[string]bool{} search := false controls := map[string]bool{} for i, raw := range input { @@ -43,18 +43,10 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { search = true tools[i], _ = json.Marshal(kind) case "mcp": - resolved, err := resolveMCPTool(raw, false) + resolved, err := resolveMCPTool(raw, fmt.Sprintf("tools[%d]", i), false) if err != nil { return nil, err } - var tool v1.MCPTool - if err := json.Unmarshal(resolved, &tool); err != nil { - return nil, &storedDataError{err} - } - if servers[tool.ServerLabel] { - return nil, errors.New("Execution requires distinct MCP server labels.") - } - servers[tool.ServerLabel] = true tools[i] = resolved case "function": var function v1.FunctionToolInput From bab9a78b36f440464721279fc95dbc9e0d086410 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 25 Oct 2026 13:43:49 +0800 Subject: [PATCH 39/39] Set direct capacity in lifecycle integration fixture --- .../core/tests/integration/runtime_compute_lifecycle_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/core/tests/integration/runtime_compute_lifecycle_test.go b/services/core/tests/integration/runtime_compute_lifecycle_test.go index f697455c0..0e316bc42 100644 --- a/services/core/tests/integration/runtime_compute_lifecycle_test.go +++ b/services/core/tests/integration/runtime_compute_lifecycle_test.go @@ -298,7 +298,7 @@ func newComputeLifecycleFixtureFor(t *testing.T, provider string, maxActive, max } server.Close() }) - f := &computeLifecycleFixture{t: t, store: s, provider: p, key: webDeployment(t, s, provider), policy: execution.RuntimeSuspensionPolicy{Retention: time.Hour}} + f := &computeLifecycleFixture{t: t, store: s, provider: p, key: webDeployment(t, s, provider), policy: execution.RuntimeSuspensionPolicy{Retention: time.Hour, MaxActive: maxActive, MaxRetained: maxRetained}} view, err := deploymentService(t, s).View(t.Context()) if err != nil { t.Fatal(err)