diff --git a/docs/maintainers.md b/docs/maintainers.md index 90732cbe5..763efd745 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -89,6 +89,8 @@ The helper is written to `~/.oac/build/microsandbox-provider/oac-microsandbox-pr **microsandbox runtime.** The distribution uses the official [v0.7.2 release](https://github.com/superradcompany/microsandbox/releases/tag/v0.7.2) archive `microsandbox-linux-x86_64.tar.gz`, SHA256 `47c223e3ef5298abf05f47ed9f87981106e400d99bb3f1d042d4d6881346b18b` (`RUNTIME_ARCHIVE_SHA256` in `scripts/core-distribution-manifest.py`). The build verifies the checksum before extracting `msb` and `libkrunfw.so.5.6.1` and records both files' hashes. The helper checks those hashes on every call and never installs or upgrades them. +Native guest qualification must exercise the [microsandbox bootstrap](../services/core/tools/microsandbox-provider/README.md) and [E2B startup](../services/core/tools/e2b-provider/README.md#create) delegation as UID/GID 1000, including cancellation of descendants that start new sessions and cessation of their side effects. A container-only check does not qualify a guest kernel or its mount layout. + ### Standalone Core builds `make build-core` builds `oac-core`, `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. diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index 067eacc11..b1e9d9202 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,7 +1,7 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: a6d2d1e9846b1f22a9566048fd3821eee601010aea572c6a402436800c44491b +source_hash: 064af3f49e48941b31a82988190bc8ddbdabc02172e03cf9c991c95aead5169c --- 本指南面向负责构建和发布 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)。 @@ -91,6 +91,8 @@ make check-microsandbox-provider **microsandbox 运行时。** 分发包使用官方的 [v0.7.2 release](https://github.com/superradcompany/microsandbox/releases/tag/v0.7.2) 归档 `microsandbox-linux-x86_64.tar.gz`,SHA256 为 `47c223e3ef5298abf05f47ed9f87981106e400d99bb3f1d042d4d6881346b18b`(即 `scripts/core-distribution-manifest.py` 中的 `RUNTIME_ARCHIVE_SHA256`)。构建过程会先验证校验和,再解压 `msb` 和 `libkrunfw.so.5.6.1`,并记录这两个文件的哈希。辅助程序会在每次调用时检查这些哈希,并且绝不安装或升级它们。 +原生 guest 资格验证必须以 UID/GID 1000 检验 [microsandbox bootstrap](../../services/core/tools/microsandbox-provider/README.md) 和 [E2B 启动](../../services/core/tools/e2b-provider/README.md#create)的委派,包括取消创建新会话的后代进程并确认其副作用停止。仅在容器中检查不能证明 guest 内核及其挂载布局合格。 + ### 独立 Core 构建 {#standalone-core-builds} `make build-core` 会将 `oac-core`、`oac-core-environment-key`、`oac-node` 和 `oac` 构建到 `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core`(`OAC_DEV_CORE_BUILD_DIR` 可选择其他绝对目录)。构建过程仅将 `scripts/build-core.sh` 中列出的源文件集(Core 服务、其契约、所需的共享软件包以及根 Go 模块文件)复制到临时上下文,并使用禁用 CGO、只读模块和裁剪路径的方式构建。它不需要 Node、Docker 或其他应用程序。Core 新增共享依赖时,请将该软件包加入列表;绝不能复制整个仓库来使其完成编译。 diff --git a/services/core/deploy/e2b/managed_init.py b/services/core/deploy/e2b/managed_init.py index 8646929e5..f56373555 100644 --- a/services/core/deploy/e2b/managed_init.py +++ b/services/core/deploy/e2b/managed_init.py @@ -67,6 +67,34 @@ def prepare_sandbox(): directory.chmod(0o700) +def delegate_process_group(): + """Let the unprivileged service contain its own operation descendants.""" + mounts = Path('/proc/self/mountinfo').read_text().splitlines() + if not any(line.split(' - ')[0].split()[4] == '/sys/fs/cgroup' + and line.split(' - ')[1].split()[0] == 'cgroup2' for line in mounts): + raise RuntimeError('Sandbox process containment requires cgroup v2') + membership = [line[3:] for line in Path('/proc/self/cgroup').read_text().splitlines() + if line.startswith('0::/')] + if len(membership) != 1 or '..' in Path(membership[0]).parts: + raise RuntimeError('Invalid sandbox cgroup membership') + parent = Path('/sys/fs/cgroup') / membership[0].lstrip('/') + if str(os.getpid()) not in (parent / 'cgroup.procs').read_text().splitlines(): + raise RuntimeError('Sandbox cgroup mount does not match membership') + group = parent / 'oac-sandbox-io' + # Never reuse an existing group: an uncertain startup owns the whole VM. + group.mkdir(mode=0o700) + if (group / 'cgroup.subtree_control').read_text().strip(): + raise RuntimeError('Sandbox process group has active controllers') + (group / 'cgroup.kill').write_text('1') + group.chmod(0o700) + os.chown(group, 1000, 1000) + os.chown(group / 'cgroup.procs', 1000, 1000) + (group / 'cgroup.procs').chmod(0o600) + # This one-shot initializer has no other live children. Its child inherits + # the group before Popen drops privileges; the initializer then exits. + (group / 'cgroup.procs').write_text(str(os.getpid())) + + def initialize(): ROOT.mkdir(mode=0o700, parents=True, exist_ok=True) ROOT.chmod(0o700) @@ -83,6 +111,7 @@ def initialize(): bootstrap = HOME / 'sandbox-io-bootstrap.json' write_private(bootstrap, payload['SandboxIO'], owner=1000) source.unlink() + delegate_process_group() # Sandbox I/O is the only process started in the sandbox; its file is its # only input. with (HOME / 'sandbox-io.log').open('xb') as stream: diff --git a/services/core/deploy/e2b/managed_init_test.py b/services/core/deploy/e2b/managed_init_test.py index 0884d427d..f8fa59617 100644 --- a/services/core/deploy/e2b/managed_init_test.py +++ b/services/core/deploy/e2b/managed_init_test.py @@ -51,11 +51,17 @@ def exercise(self, failed=False): source = root / 'managed-bootstrap.json' data = payload() source.write_text(json.dumps(data)) - process = Mock(return_value=Mock(pid=456)) - if failed: - process.side_effect = RuntimeError('private process diagnostic') + + def launch(*args, **kwargs): + delegate.assert_called_once_with() + if failed: + raise RuntimeError('private process diagnostic') + return Mock(pid=456) + + process = Mock(side_effect=launch) with patch.object(managed_init, 'ROOT', root), patch.object(managed_init, 'HOME', home), \ patch.object(managed_init, 'prepare_sandbox'), \ + patch.object(managed_init, 'delegate_process_group') as delegate, \ patch.object(managed_init.os, 'fchown'), patch.object(managed_init.subprocess, 'Popen', process): if failed: with self.assertRaises(RuntimeError): @@ -90,6 +96,41 @@ def test_starts_only_sandbox_io(self): def test_unknown_start_preserves_claim_and_never_replays(self): self.exercise(failed=True) + def test_failed_delegation_never_starts_service_or_replays(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary, 'receipt') + root.mkdir() + home = Path(temporary, 'home') + home.mkdir() + (root / 'managed-bootstrap.json').write_text(json.dumps(payload())) + with patch.object(managed_init, 'ROOT', root), patch.object(managed_init, 'HOME', home), \ + patch.object(managed_init, 'prepare_sandbox'), patch.object(managed_init.os, 'fchown'), \ + patch.object(managed_init, 'delegate_process_group', side_effect=OSError('delegation unavailable')), \ + patch.object(managed_init.subprocess, 'Popen') as process: + with self.assertRaises(OSError): + managed_init.initialize() + self.assertTrue((root / 'managed-launch.json').exists()) + self.assertFalse((root / 'managed-ready.json').exists()) + with self.assertRaisesRegex(RuntimeError, 'cannot be replayed'): + managed_init.initialize() + process.assert_not_called() + + def test_delegation_rejects_unmapped_membership_before_creating_group(self): + mount = '1 0 0:1 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw' + for name, mounts, membership, processes in [ + ('wrong_filesystem', mount.replace('cgroup2', 'tmpfs'), '0::/', '123'), + ('missing_membership', mount, '', '123'), + ('outside_namespace', mount, '0::/../outside', '123'), + ('unmapped_membership', mount, '0::/workload', '456')]: + with self.subTest(name=name): + files = {'/proc/self/mountinfo': mounts, '/proc/self/cgroup': membership, + '/sys/fs/cgroup/workload/cgroup.procs': processes} + with patch.object(Path, 'read_text', lambda path: files[str(path)]), \ + patch.object(Path, 'mkdir') as mkdir, patch.object(managed_init.os, 'getpid', return_value=123): + with self.assertRaises(RuntimeError): + managed_init.delegate_process_group() + mkdir.assert_not_called() + if __name__ == '__main__': unittest.main() diff --git a/services/core/tools/e2b-provider/README.md b/services/core/tools/e2b-provider/README.md index 68f5ccd5a..d73fb4357 100644 --- a/services/core/tools/e2b-provider/README.md +++ b/services/core/tools/e2b-provider/README.md @@ -47,6 +47,8 @@ A helper holds its allocation's lock until the SDK operation returns, even after An unknown Create is never repeated. A Create whose connection material was lost can be discovered and destroyed but cannot resume bootstrap, and an unconfirmed startup requires reclaiming the whole allocation. +The guest must have cgroup v2 mounted read-write at `/sys/fs/cgroup` and support `cgroup.kill`. The root initializer validates its current membership, exclusively creates an `oac-sandbox-io` child group beneath it, delegates only that directory and its `cgroup.procs` to UID 1000, and enters it before spawning Sandbox I/O. This preserves the ancestors' resource limits and enables no resource controllers. The service inherits this membership; the [Process protocol](../../../../docs/process-protocol.md#scope-and-signals) defines cancellation scopes. File and Net operations still run as UID 1000. Existing groups and unavailable delegation fail startup without reuse or killing unknown processes. Allocation destruction reclaims the group with the VM. + ## Inspection and cleanup Inspection accepts the SDK's nullable sandbox-information domain and checks any reported domain against the configured sandbox domain. This information field never supplies connection material: Create's connection domain must match before any envd request, and restoring saved connection material repeats that check. 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. diff --git a/services/core/tools/microsandbox-provider/README.md b/services/core/tools/microsandbox-provider/README.md index 7f8aef69e..d5b97ad5d 100644 --- a/services/core/tools/microsandbox-provider/README.md +++ b/services/core/tools/microsandbox-provider/README.md @@ -24,6 +24,8 @@ VM creation does not run the image's entry point. The bootstrap runs as root wit 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. Ordinary inspection, uncertain Create outcomes, timeouts and ownership failures never produce it, and missing compute alone never proves that Create settled. +The guest must have cgroup v2 mounted read-write at `/sys/fs/cgroup` and support `cgroup.kill`. Root bootstrap validates its current membership and exclusively creates an `oac-sandbox-io` child group beneath it, preserving the VM's ancestor resource limits. It delegates only that directory and its `cgroup.procs` to UID 1000 without enabling resource controllers. The service child enters this group before dropping to UID/GID 1000 and retains the membership across exec; the [Process protocol](../../../../docs/process-protocol.md#scope-and-signals) defines cancellation scopes. File and Net operations remain unprivileged. Existing groups or unavailable delegation fail bootstrap; unknown groups are never reused or killed. Restore uses the full VM snapshot without repeating bootstrap; destroying the VM reclaims the group. + ## Checkpoint lifecycle Core persists operation IDs, source and target generations, exact identities and snapshot evidence before it depends on them. `Initial` and `NewCompute` only construct references and allocate nothing. diff --git a/services/core/tools/microsandbox-provider/bootstrap.go b/services/core/tools/microsandbox-provider/bootstrap.go index ed6bac0fd..4ae3f6e1f 100644 --- a/services/core/tools/microsandbox-provider/bootstrap.go +++ b/services/core/tools/microsandbox-provider/bootstrap.go @@ -18,6 +18,7 @@ import ( // as the sandbox user. const bootstrapScript = ` import ctypes,os,stat,subprocess,sys +from pathlib import Path data=sys.stdin.buffer.read() for p in ['/home/runtime','/environment','/environment/workspace','/environment/initialization','/environment/packages']: os.makedirs(p,mode=0o700,exist_ok=True) @@ -31,7 +32,26 @@ if not stat.S_ISDIR(os.lstat('/workspace').st_mode): raise RuntimeError('invalid libc=ctypes.CDLL(None,use_errno=True) if libc.mount(b'/environment/workspace',b'/workspace',None,4096,None)!=0: raise OSError(ctypes.get_errno(),'workspace bind mount failed') +mounts=Path('/proc/self/mountinfo').read_text().splitlines() +if not any(line.split(' - ')[0].split()[4]=='/sys/fs/cgroup' + and line.split(' - ')[1].split()[0]=='cgroup2' for line in mounts): + raise RuntimeError('Sandbox process containment requires cgroup v2') +membership=[line[3:] for line in Path('/proc/self/cgroup').read_text().splitlines() if line.startswith('0::/')] +if len(membership)!=1 or '..' in Path(membership[0]).parts: + raise RuntimeError('Invalid sandbox cgroup membership') +parent=Path('/sys/fs/cgroup')/membership[0].lstrip('/') +if str(os.getpid()) not in (parent/'cgroup.procs').read_text().splitlines(): + raise RuntimeError('Sandbox cgroup mount does not match membership') +group=parent/'oac-sandbox-io' +# Keep the VM's resource ancestors; never reuse an uncertain startup's group. +group.mkdir(mode=0o700) +if (group/'cgroup.subtree_control').read_text().strip(): + raise RuntimeError('Sandbox process group has active controllers') +(group/'cgroup.kill').write_text('1') +group.chmod(0o700);os.chown(group,1000,1000) +os.chown(group/'cgroup.procs',1000,1000);(group/'cgroup.procs').chmod(0o600) def sandbox_user(): + (group/'cgroup.procs').write_text(str(os.getpid())) os.setgroups([]);os.setgid(1000);os.setuid(1000) subprocess.Popen(['/usr/local/bin/oac-sandbox-io','--bootstrap-file',s],env={},start_new_session=True, stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,