diff --git a/deploy/distribution/AgentHost.Dockerfile b/deploy/distribution/AgentHost.Dockerfile index 4c6b2959c..c6a89664e 100644 --- a/deploy/distribution/AgentHost.Dockerfile +++ b/deploy/distribution/AgentHost.Dockerfile @@ -5,9 +5,11 @@ USER root # Sandbox tools are served by oac-sandbox-io; the caller supplies its bootstrap. FROM base AS sandbox +# Login shells keep the process environment's package search path. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ + && sed -i 's|^if \[ "$(id -u)" -eq 0 \]; then$|if [ -n "${PATH:-}" ] \&\& /usr/bin/printenv PATH >/dev/null; then\n :\nelif [ "$(/usr/bin/id -u)" -eq 0 ]; then|' /etc/profile \ && mkdir -p /environment/workspace /environment/initialization /environment/packages /workspace /home/runtime \ && chown 1000:1000 /environment/initialization /environment/packages COPY --chmod=0555 oac-sandbox-io /usr/local/bin/ diff --git a/docs/maintainers.md b/docs/maintainers.md index 8b49ea232..90732cbe5 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -68,7 +68,7 @@ export CODEX_CLI_DIR=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute bash scripts/build-agent-host-images.sh ``` -The script builds `OAC_AGENT_HOST_IMAGE` (default `oac-agent-host:dev`) and `OAC_SANDBOX_IMAGE` (default `oac-sandbox:dev`). Additional arguments, such as `--label`, go to both Docker builds. The agent-host image contains `oac-daemon`, `oac-process-shim` and each Harness under `/opt/oac/harnesses`; `/opt/oac/harnesses.json` owns their activation paths. The sandbox image contains system tools and `oac-sandbox-io`, which runs as UID/GID 1000, with no Harness or daemon. The distribution verifies its Sandbox I/O executable and ships this image through the node artifact's existing `runtime` slot. E2B templates extract Sandbox I/O from this same image. [Qualify the view](../contracts/agents-api/harness-onboarding.md#qualify-the-view) runs both images; [Agent-host container](./configuration.md#agent-host-container) owns the host requirements. +The script builds `OAC_AGENT_HOST_IMAGE` (default `oac-agent-host:dev`) and `OAC_SANDBOX_IMAGE` (default `oac-sandbox:dev`). Additional arguments, such as `--label`, go to both Docker builds. The agent-host image contains `oac-daemon`, `oac-process-shim` and each Harness under `/opt/oac/harnesses`; `/opt/oac/harnesses.json` owns their activation paths. The sandbox image contains system tools and `oac-sandbox-io`, which runs as UID/GID 1000, with no Harness or daemon. The distribution verifies its Sandbox I/O executable and ships this image through the node artifact's existing `runtime` slot. The sandbox's default login profile preserves a supplied nonempty `PATH`, so the [tool environment](../contracts/agents-api/environments.md#explicit-local-tool-environment) keeps its package commands available. Empty or unset paths retain the image's defaults; profile.d scripts and user startup files can still explicitly change them. Third-party sandbox images own their shell startup configuration. E2B templates extract Sandbox I/O and this profile from the same image. [Qualify the view](../contracts/agents-api/harness-onboarding.md#qualify-the-view) runs both images; [Agent-host container](./configuration.md#agent-host-container) owns the host requirements. **E2B helper.** diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index f096a267e..067eacc11 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,7 +1,7 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: b69ab5a1de19ef81942658187d93dfca14c325c3824e7379e88899f3e3cebf61 +source_hash: a6d2d1e9846b1f22a9566048fd3821eee601010aea572c6a402436800c44491b --- 本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 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)。 @@ -70,7 +70,7 @@ export CODEX_CLI_DIR=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute bash scripts/build-agent-host-images.sh ``` -脚本构建 `OAC_AGENT_HOST_IMAGE`(默认 `oac-agent-host:dev`)和 `OAC_SANDBOX_IMAGE`(默认 `oac-sandbox:dev`)。附加参数(例如 `--label`)传给两次 Docker 构建。agent-host 镜像包含 `oac-daemon`、`oac-process-shim`,以及 `/opt/oac/harnesses` 下的各 Harness;`/opt/oac/harnesses.json` 拥有其激活路径。沙箱镜像包含系统工具和以 UID/GID 1000 运行的 `oac-sandbox-io`,不含 Harness 或 daemon。分发包校验 Sandbox I/O 可执行文件,并通过节点构件已有的 `runtime` 槽位分发此镜像。E2B 模板从同一镜像提取 Sandbox I/O。[认定视图资格](../../contracts/agents-api/zh/harness-onboarding.md#qualify-the-view)运行两个镜像;[Agent-host 容器](configuration.md#agent-host-container)拥有宿主需求。 +脚本构建 `OAC_AGENT_HOST_IMAGE`(默认 `oac-agent-host:dev`)和 `OAC_SANDBOX_IMAGE`(默认 `oac-sandbox:dev`)。附加参数(例如 `--label`)传给两次 Docker 构建。agent-host 镜像包含 `oac-daemon`、`oac-process-shim`,以及 `/opt/oac/harnesses` 下的各 Harness;`/opt/oac/harnesses.json` 拥有其激活路径。沙箱镜像包含系统工具和以 UID/GID 1000 运行的 `oac-sandbox-io`,不含 Harness 或 daemon。分发包校验 Sandbox I/O 可执行文件,并通过节点构件已有的 `runtime` 槽位分发此镜像。沙箱的默认登录 profile 保留传入的非空 `PATH`,使[工具环境](../../contracts/agents-api/zh/environments.md#explicit-local-tool-environment)中的包命令仍然可用。路径为空或未设置时保留镜像的默认值;profile.d 脚本和用户启动文件仍可显式修改路径。第三方沙箱镜像自行负责其 shell 启动配置。E2B 模板从同一镜像提取 Sandbox I/O 和此 profile。[认定视图资格](../../contracts/agents-api/zh/harness-onboarding.md#qualify-the-view)运行两个镜像;[Agent-host 容器](configuration.md#agent-host-container)拥有宿主需求。 **E2B 辅助程序。** diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 7a2c4fade..901f0ea76 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -187,6 +187,12 @@ def verify_runtime(image, sandbox_io): actual = dict(reversed(line.split(None, 1)) for line in output.splitlines()) if actual.get(guest_path) != sha256(sandbox_io): raise ValueError("Sandbox image does not match the committed build: " + guest_path) + tool_path = "/environment/packages/npm/bin:/environment/packages/python/bin:/usr/local/bin:/usr/bin:/bin" + subprocess.run( + ["docker", "run", "--rm", "--label", "io.oac.build=distribution-verify", "--network", "none", + "--user", "1000:1000", "--env", "PATH=" + tool_path, "--entrypoint", "/bin/bash", image, + "-lc", 'test "$PATH" = "$1"', "oac-login-check", tool_path], check=True + ) def extract_runtime(archive, destination): diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 24a6bbf0a..4cb143fc5 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -104,12 +104,15 @@ def test_sandbox_executable_must_match_build_input(self): path = "/usr/local/bin/oac-sandbox-io" for digest in (distribution.sha256(executable), "0" * 64): with self.subTest(digest=digest), mock.patch.object(distribution, "verify_image"), \ - mock.patch.object(distribution.subprocess, "check_output", return_value=digest + " " + path + "\n"): + mock.patch.object(distribution.subprocess, "check_output", return_value=digest + " " + path + "\n"), \ + mock.patch.object(distribution.subprocess, "run") as login: if digest == "0" * 64: with self.assertRaisesRegex(ValueError, "Sandbox image does not match"): distribution.verify_runtime("sha256:" + "a" * 64, executable) + login.assert_not_called() else: distribution.verify_runtime("sha256:" + "a" * 64, executable) + login.assert_called_once() def test_mcode_payload_rejects_stale_companion_at_the_same_version(self): repository = pathlib.Path(__file__).resolve().parent.parent diff --git a/services/core/deploy/e2b/README.md b/services/core/deploy/e2b/README.md index f9b693755..b174bf296 100644 --- a/services/core/deploy/e2b/README.md +++ b/services/core/deploy/e2b/README.md @@ -16,7 +16,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 and a Linux amd64 image built from the `sandbox` target of [`AgentHost.Dockerfile`](../../../../deploy/distribution/AgentHost.Dockerfile). It copies only `/usr/local/bin/oac-sandbox-io` 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 [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, makes UID/GID 1000 (`runtime`, home `/home/runtime`) the template user and builds with 2 vCPUs and 2048 MiB. The archive's `usr`, `usr/local` and `usr/local/bin` ancestors have traversable modes; the executable's mode, private build contexts, key inputs and the output's umask stay unchanged. Harnesses run on the separate agent host. +[`build-template.py`](build-template.py) requires a `sha256:` image ID and a Linux amd64 image built from the `sandbox` target of [`AgentHost.Dockerfile`](../../../../deploy/distribution/AgentHost.Dockerfile). It copies `/usr/local/bin/oac-sandbox-io` and the qualified image's `/etc/profile` 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 [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, makes UID/GID 1000 (`runtime`, home `/home/runtime`) the template user and builds with 2 vCPUs and 2048 MiB. The archive's `etc`, `usr`, `usr/local` and `usr/local/bin` ancestors have traversable modes; the executable's mode, private build contexts, key inputs and the output's umask stay unchanged. Harnesses run on the separate agent host. 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. Install system dependencies into the image; sandbox processes run as UID/GID 1000. No E2B account key or model credential belongs in a build, template environment, metadata, command argument or log. diff --git a/services/core/deploy/e2b/build-template.py b/services/core/deploy/e2b/build-template.py index 6c163cf32..671b12e34 100644 --- a/services/core/deploy/e2b/build-template.py +++ b/services/core/deploy/e2b/build-template.py @@ -34,18 +34,18 @@ tree.mkdir() # These public ancestors are synthesized, not extracted from the sandbox. # Keep their archive modes independent of the caller's private umask. - for parent in ['usr', 'usr/local', 'usr/local/bin']: + for parent in ['usr', 'usr/local', 'usr/local/bin', 'etc']: directory = tree / parent directory.mkdir() directory.chmod(0o755) container = subprocess.check_output(['docker', 'create', '--label', 'io.oac.build=e2b-template', args.image], text=True).strip() try: - with tempfile.TemporaryFile() as copied: - path = '/usr/local/bin/oac-sandbox-io' - subprocess.run(['docker', 'cp', container + ':' + path, '-'], stdout=copied, check=True) - copied.seek(0) - with tarfile.open(fileobj=copied) as archive: - archive.extractall(tree / 'usr/local/bin', filter='tar') + for path in ['/usr/local/bin/oac-sandbox-io', '/etc/profile']: + with tempfile.TemporaryFile() as copied: + subprocess.run(['docker', 'cp', container + ':' + path, '-'], stdout=copied, check=True) + copied.seek(0) + with tarfile.open(fileobj=copied) as archive: + archive.extractall(tree / Path(path).parent.relative_to('/'), filter='tar') finally: subprocess.run(['docker', 'rm', container], check=True, stdout=subprocess.DEVNULL) bundle = context / 'runtime.tar.gz' diff --git a/services/core/deploy/e2b/build_template_test.py b/services/core/deploy/e2b/build_template_test.py index 6e5e57f23..9c9264b41 100644 --- a/services/core/deploy/e2b/build_template_test.py +++ b/services/core/deploy/e2b/build_template_test.py @@ -23,7 +23,8 @@ def test_public_parents_and_runtime_modes_under_both_umasks(self): key.write_text('fixture-only') key.chmod(0o600) output = root / 'private/result.json' - source_modes = {'oac-sandbox-io': 0o555} + sources = {'usr/local/bin/oac-sandbox-io': (0o555, b'fixture'), + 'etc/profile': (0o644, b'# qualified image profile\nexport PATH\n')} image = {'Architecture': 'amd64', 'Os': 'linux', 'Id': 'sha256:fixture', 'Config': {'Env': ['HOME=/home/runtime']}} @@ -38,15 +39,13 @@ def run(argv, **kwargs): return SimpleNamespace(returncode=0) self.assertEqual(argv[:2], ['docker', 'cp']) self.assertEqual(argv[-1], '-') - name = argv[2].split(':', 1)[1].rsplit('/', 1)[-1] + name = argv[2].split(':', 1)[1].lstrip('/') + mode, data = sources[name] with tarfile.open(fileobj=kwargs['stdout'], mode='w') as archive: - for path, mode in source_modes.items(): - if path != name and not path.startswith(name + '/'): - continue - entry = tarfile.TarInfo(path) - entry.mode = mode - entry.size = 7 - archive.addfile(entry, io.BytesIO(b'fixture')) + entry = tarfile.TarInfo(Path(name).name) + entry.mode = mode + entry.size = len(data) + archive.addfile(entry, io.BytesIO(data)) return SimpleNamespace(returncode=0) template = Mock() @@ -59,11 +58,12 @@ def build(instance, **kwargs): self.assertEqual(stat.S_IMODE(context.stat().st_mode), 0o700) with tarfile.open(context / 'runtime.tar.gz') as archive: modes = {m.name: stat.S_IMODE(m.mode) for m in archive.getmembers()} - for parent in ('usr', 'usr/local', 'usr/local/bin'): + for path, (mode, data) in sources.items(): + self.assertEqual(modes[path], mode) + self.assertEqual(archive.extractfile(path).read(), data) + for parent in ('usr', 'usr/local', 'usr/local/bin', 'etc'): self.assertEqual(modes[parent], 0o755) - for path, mode in source_modes.items(): - self.assertEqual(modes['usr/local/bin/' + path], mode) - self.assertEqual(set(modes), {'usr', 'usr/local', 'usr/local/bin', 'usr/local/bin/oac-sandbox-io'}) + self.assertEqual(set(modes), {'usr', 'usr/local', 'usr/local/bin', 'etc', *sources}) self.assertEqual(stat.S_IMODE((context / 'runtime.tar.gz').stat().st_mode), 0o666 & ~mask) self.assertEqual(stat.S_IMODE(key.stat().st_mode), 0o600) projection = 'helper_contract_generated.py'