diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 70471dc31..b84d6528b 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -1,5 +1,5 @@ # Release template. The publisher pins the initialization image to this release. -# Core and Web default to latest; OAC_IMAGE_* selects another reference. +# Core, Web and the agent host default to latest; OAC_IMAGE_* selects another reference. # Docker owns data permissions on every host platform. x-ingress-image: &ingress-image ${OAC_IMAGE_INGRESS:-__OAC_INIT_IMAGE__} services: @@ -54,6 +54,7 @@ services: environment: OAC_PUBLIC_URL: &public-url ${OAC_PUBLIC_URL:-http://localhost:8080} OAC_INSTALLATION_ID_FILE: /run/oac/installation.id + OAC_AGENT_HOST_IDENTITY_FILE: &agent-host-identity /run/agent-host/identity.json OAC_DATABASE_URL: postgres://agents_api@database:5432/agents_api?sslmode=disable OAC_DATABASE_PASSWORD_FILE: /run/database/password OAC_CREDENTIAL_KEY_FILE: /run/oac/credential.key @@ -78,11 +79,36 @@ services: target: /run/database volume: {nocopy: true, subpath: secrets/database} read_only: true + - &agent-host-secrets + type: volume + source: data + target: /run/agent-host + volume: {nocopy: true, subpath: secrets/agent-host} + read_only: true - type: volume source: data target: /state volume: {nocopy: true, subpath: state} + # The agent-host container's flags are in docs/configuration.md. It shares + # Core's network namespace and reaches Core on its loopback listener; the + # Session homes in its state directory outlive the container. + agent-host: + image: ${OAC_IMAGE_AGENT_HOST:-ghcr.io/minimax-ai/openagentcore/agent-host:latest} + restart: unless-stopped + network_mode: service:core + cgroup: private + cap_add: [SYS_ADMIN, NET_ADMIN] + devices: [/dev/fuse] + security_opt: [apparmor=unconfined] + command: [agent-host, --identity-file, *agent-host-identity, --core-url, "http://127.0.0.1:8091"] + volumes: + - *agent-host-secrets + - type: volume + source: data + target: /var/lib/oac/agent-host + volume: {nocopy: true, subpath: agent-host} + web: ports: - target: 8080 diff --git a/deploy/compose/test_compose.py b/deploy/compose/test_compose.py index 4f14ef726..3956ed1d0 100644 --- a/deploy/compose/test_compose.py +++ b/deploy/compose/test_compose.py @@ -32,7 +32,7 @@ def render(cls, public_url=None): env.pop('OAC_PUBLIC_URL', None) env.pop('OAC_HOST', None) env.pop('OAC_WEB_PORT', None) - for name in ('OAC_IMAGE_CORE', 'OAC_IMAGE_WEB', 'OAC_IMAGE_INGRESS'): + for name in ('OAC_IMAGE_CORE', 'OAC_IMAGE_WEB', 'OAC_IMAGE_INGRESS', 'OAC_IMAGE_AGENT_HOST'): env.pop(name, None) env['OAC_DATA_DIR'] = '/tmp/oac-compose-fixture' if public_url is not None: @@ -53,7 +53,7 @@ def test_compose_uses_private_services_and_ordered_initialization(self): self.assertEqual(services['database']['depends_on']['init']['condition'], 'service_completed_successfully') self.assertIn('pg_isready -h 127.0.0.1', services['database']['healthcheck']['test'][1]) self.assertEqual(services['core']['depends_on']['database']['condition'], 'service_healthy') - self.assertEqual(sorted(services), ['core', 'database', 'init', 'web']) + self.assertEqual(sorted(services), ['agent-host', 'core', 'database', 'init', 'web']) for service in services.values(): self.assertNotIn('build', service) if service is not services['web']: @@ -65,6 +65,19 @@ def test_compose_uses_private_services_and_ordered_initialization(self): self.assertEqual(volume['source'], 'data') self.assertNotIn('platform', service) self.assertEqual({v['target'] for v in services['web']['volumes']}, {'/run/oac', '/node-payload'}) + agent_host = services['agent-host'] + self.assertEqual(agent_host['network_mode'], 'service:core') + self.assertEqual((agent_host['cgroup'], sorted(agent_host['cap_add']), agent_host['security_opt']), + ('private', ['NET_ADMIN', 'SYS_ADMIN'], ['apparmor=unconfined'])) + self.assertEqual([device['source'] for device in agent_host['devices']], ['/dev/fuse']) + self.assertEqual(agent_host['command'], ['agent-host', '--identity-file', '/run/agent-host/identity.json', + '--core-url', 'http://127.0.0.1:8091']) + def mounts(name): + return [(v['target'], v['volume']['subpath'], v.get('read_only', False)) for v in services[name]['volumes']] + identity = ('/run/agent-host', 'secrets/agent-host', True) + self.assertIn(identity, mounts('core')) + self.assertEqual(mounts('agent-host'), [identity, ('/var/lib/oac/agent-host', 'agent-host', False)]) + self.assertEqual(services['core']['environment']['OAC_AGENT_HOST_IDENTITY_FILE'], '/run/agent-host/identity.json') self.assertIsNone(services['core']['command']) self.assertNotIn('OAC_WEB_INSTALLATION_SOCKET', services['web']['environment']) self.assertEqual(services['init']['command'], ['/usr/local/bin/oac', 'init']) @@ -105,11 +118,13 @@ def ports(config): def test_platform_network_injection_keeps_the_file_valid(self): - # Dokploy isolated deployments attach a project network to every service. + # Dokploy attaches a project network to the services it routes to or the + # operator selects. The agent host shares Core's network and joins none. transformed = copy.deepcopy(self.compose) transformed['networks']['platform'] = {} for service in transformed['services'].values(): - service.setdefault('networks', {})['platform'] = None + if 'network_mode' not in service: + service.setdefault('networks', {})['platform'] = None subprocess.run( ['docker', 'compose', '-f', '-', 'config', '--quiet'], input=json.dumps(transformed), text=True, check=True) diff --git a/deploy/install.dev.sh b/deploy/install.dev.sh index 93579cecf..85043829d 100755 --- a/deploy/install.dev.sh +++ b/deploy/install.dev.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Start this checkout. Core, Web and the init image are built here. Node -# metadata still comes from the release named in deploy/compose/smoke-pins.json. +# Start this checkout. Core, Web, the agent host and the init image are built +# here. Node metadata still comes from the release named in +# deploy/compose/smoke-pins.json. # The published installer is install.sh. set -euo pipefail @@ -17,8 +18,10 @@ usage() { cat <<'EOF' Usage: install.dev.sh [--install-dir DIR] [--host ADDRESS] [--web-port PORT] -Builds Core, Web and the init image from this checkout and starts them. -Open http://localhost: and sign in with the printed Core key. +Builds Core, Web, the agent host and the init image from this checkout and +starts them. The agent-host build takes the Harness inputs that +scripts/build-agent-host-images.sh needs. Open http://localhost: and sign +in with the printed Core key. EOF } @@ -96,6 +99,8 @@ tag="oac-local" docker build -q --platform linux/amd64 -t "$tag/core:dev" "$build/core" >/dev/null docker build -q --platform linux/amd64 -t "$tag/web:dev" "$build/web" >/dev/null docker build -q --platform linux/amd64 -t "$tag/ingress:dev" "$build/ingress" >/dev/null +OAC_AGENT_HOST_IMAGE="$tag/agent-host:dev" OAC_SANDBOX_IMAGE="$tag/sandbox:dev" \ + bash "$repo_root/scripts/build-agent-host-images.sh" -q >/dev/null python3 - "$repo_root" "$install_dir" <<'PY' import importlib.util, json, sys @@ -119,6 +124,7 @@ OAC_WEB_PORT=$web_port OAC_IMAGE_CORE=$tag/core:dev OAC_IMAGE_WEB=$tag/web:dev OAC_IMAGE_INGRESS=$tag/ingress:dev +OAC_IMAGE_AGENT_HOST=$tag/agent-host:dev EOF ( diff --git a/docs/configuration.md b/docs/configuration.md index ded27d690..0951a30ef 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,7 +7,7 @@ Every setting of a Core installation has exactly one home, in one of three categ | Category | Examples | Home | Change it with | Takes effect | | --- | --- | --- | --- | --- | | [Process settings](#process-settings) | Public URL, ports, logging, harnesses, execution concurrency, audit retention, OAuth origins, Runtime history | `.env` in the installation directory (default `~/.oac/core`) | Edit `.env`, then run `oac apply` | `oac apply` recreates the services that read the changed settings | -| [Secrets](#compose-installations) | Database password, credential encryption key, installation ID, Core key and the Core key digest derived from it | `secrets/` in the Compose data volume, one copy each | Initialization generates them once; `oac rotate-core-key` replaces the Core key and its digest | `oac rotate-core-key` restarts Core and Web | +| [Secrets](#compose-installations) | Database password, credential encryption key, installation ID, agent-host identity, Core key and the Core key digest derived from it | `secrets/` in the Compose data volume, one copy each | Initialization generates them once; `oac rotate-core-key` replaces the Core key and its digest | `oac rotate-core-key` restarts Core and Web | | [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | Saved without a Core restart; nodes prepare Runtime changes asynchronously | Web's **System** page shows the installation's addresses, the default models, the sandbox configuration and, under **Startup settings**, the process settings Core loaded. No configuration file defines Projects or API keys. @@ -111,7 +111,9 @@ The initialization service generates secrets and the installation ID once, then | `secrets/database/` | Generated database password | PostgreSQL and Core | | `secrets/core/` | Credential encryption key, installation ID and Core key digest | Core | | `secrets/web/` | Generated Core sign-in key | Web | +| `secrets/agent-host/` | `identity.json`, the [agent host's identity](#agent-host-container) | Core and the agent host | | `state/` | Private Provider state, mounted in Core at `/state`. Each adapter owns a subdirectory; E2B uses `e2b/`, with no group or other access | Core | +| `agent-host/` | The [agent host's state directory](#agent-host-container) | The agent host; initialization checks whether it is empty | | `node-payload/` | Verified node installation metadata | Web | Initialization prepares this directory; application services receive their secret directories read-only. `docker compose exec web oac-web core-key` prints the Core key to the operator terminal without writing it to container logs. Database passwords and credential encryption keys are never printed. @@ -155,6 +157,8 @@ The container runs `oac-daemon agent-host --identity-file --core-url .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 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 forwards `/v1` and `/api/v1` to Core; it is the only service with a published port, `OAC_WEB_PORT`. No service receives a Docker socket. +The Compose project is named `oac-<10 hex digits>`. Its services are `init`, `database`, `core`, `agent-host` and `web`. Core applies database migrations when it starts. Web serves the console and forwards `/v1` and `/api/v1` to Core; it is the only service with a published port, `OAC_WEB_PORT`. No service receives a Docker socket. ## Appendix: Core environment without the installer @@ -184,6 +188,7 @@ Core reads its process environment. Compose interpolates `.env` into it and moun | `OAC_CREDENTIAL_KEY_FILE` | `/run/oac/credential.key` | | `OAC_CORE_KEY_DIGESTS_FILE` | Required. `/run/oac/core-key-digests.json`: a JSON array with the SHA-256 of the Core key | | `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`: the installation ID, a canonical UUID. It enables the sandbox deployment and node routes and requires `OAC_PUBLIC_URL`. Core refuses an ID other than the one its database recorded | +| `OAC_AGENT_HOST_IDENTITY_FILE` | `/run/agent-host/identity.json`: the [agent host's identity](#agent-host-container), whose `runtime_id` is a canonical UUID. Required with `OAC_PUBLIC_URL`, and only with it. When Core starts it registers the agent host with that ID and credential; a new credential fences the Links the old one authenticated, and a revoked agent host stays revoked | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS`, `OAC_HISTORY_SETTINGS_FILE`, `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | The matching [process settings](#settings). Web reads the three log settings too | | `OAC_PROVIDER_ROOT` | Absolute adapter artifact root. The Core image sets `/opt/oac`. Each adapter owns its helper paths beneath this root. Core serves self-hosted daemon installers from its `native-installers/` directory when that holds a `catalog.json`, after checking the catalog against its own release. Adapter state lives at `/state`, the data volume's [`state/`](#compose-installations) | diff --git a/docs/maintainers.md b/docs/maintainers.md index f59822dea..8c663d296 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -6,9 +6,9 @@ This guide is for maintainers who build and publish OpenAgentCore. To install Co ## Build a distribution -A distribution is a matched set of release assets built from one commit: the control archive (the installer, the `oac` command, and the Core, Web, ingress and PostgreSQL images), the Runtime image and node artifacts as separate files, and the native installers. +A distribution is a matched set of release assets built from one commit: the control archive (the installer, the `oac` command, and the Core, Web, ingress, agent-host and PostgreSQL images), the Runtime image and node artifacts as separate files, and the native installers. -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. +Core, Web and ingress images are published as verified Linux amd64/arm64 indexes. The arm64 control archive contains those three images; Node, hosted Runtime, agent-host 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: @@ -29,7 +29,6 @@ make build-core-distribution | `CORE_DISTRIBUTION_RELEASE_BASE_URL` | Versioned HTTPS directory that will serve the generated asset file names (never `latest`). Required unless `CORE_DISTRIBUTION_OFFLINE=1` | | `CORE_DISTRIBUTION_OFFLINE` | `1` also builds the offline archive | | `CODEX_CLI_DIR`, `MCODE_HARNESS_BUILD_DIR` | Pinned Runtime inputs from `prepare-release-runtimes.sh` | -| `CORE_DISTRIBUTION_CODEX_IMAGE`, `CORE_DISTRIBUTION_CLAUDE_IMAGE`, `CORE_DISTRIBUTION_MCODE_IMAGE` | Use existing Harness images, given as immutable `sha256:` image IDs, instead of building them; set all three or none. Each must contain the daemon built from this commit | | `OAC_NATIVE_INSTALLER_BUILD_DIR` | Native installer catalog directory; see [Native installers](#native-installers) | | `CORE_DISTRIBUTION_BUILD_DIR` | Output directory under `~/.oac`. Default: `~/.oac/build/core-distribution` | | `CORE_DISTRIBUTION_BUILD_NETWORK` | Docker build network: `default`, `host` or `none` | @@ -57,7 +56,7 @@ The catalog records the commit, the Runtime protocol version, each archive's SHA ### Runtime images and helpers -`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`. +`make build-core-distribution` builds all of these except the sandbox image. 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`. **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: @@ -100,7 +99,7 @@ export CODEX_CLI_DIR=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute bash scripts/build-agent-host-images.sh ``` -The script runs the three Runtime image builders into one context, adds the static `oac-daemon`, `oac-process-shim` and `oac-sandbox-io`, and builds both targets of `deploy/distribution/AgentHost.Dockerfile` as `OAC_AGENT_HOST_IMAGE` (default `oac-agent-host:dev`) and `OAC_SANDBOX_IMAGE` (default `oac-sandbox:dev`). Further arguments, such as `--label`, go to both `docker build` calls. The agent-host image installs each Harness in its own directory under `/opt/oac/harnesses`, and `/opt/oac/harnesses.json` is the only record of where; the agent host reads it with `agent.ManifestEnvironment`. The sandbox image has the Runtime images' base and packages and no daemon or Harness, and runs `oac-sandbox-io --bootstrap-file ` as UID/GID 1000. [Qualify the view](../contracts/agents-api/harness-onboarding.md#qualify-the-view) runs both; [Agent-host container](./configuration.md#agent-host-container) lists what the agent host needs. Neither CI nor the release builds them. +The script runs the three Runtime image builders into one context, adds the static `oac-daemon`, `oac-process-shim` and `oac-sandbox-io`, and builds both targets of `deploy/distribution/AgentHost.Dockerfile` as `OAC_AGENT_HOST_IMAGE` (default `oac-agent-host:dev`) and `OAC_SANDBOX_IMAGE` (default `oac-sandbox:dev`). Further arguments, such as `--label`, go to both `docker build` calls. The agent-host image installs each Harness in its own directory under `/opt/oac/harnesses`, and `/opt/oac/harnesses.json` is the only record of where; the agent host reads it with `agent.ManifestEnvironment`. The sandbox image has the Runtime images' base and packages and no daemon or Harness, and runs `oac-sandbox-io --bootstrap-file ` as UID/GID 1000. [Qualify the view](../contracts/agents-api/harness-onboarding.md#qualify-the-view) runs both; [Agent-host container](./configuration.md#agent-host-container) lists what the agent host needs. The distribution builds the agent-host image from the payloads its Runtime image builders prepare, and publishes it; nothing in CI or the release builds the sandbox image. **E2B helper.** @@ -144,9 +143,9 @@ Distribution and Runtime archives use `pigz` level 6 with at most four compressi ### Container registry -Version releases and manual `build-` drafts publish `ghcr.io/minimax-ai/openagentcore/:`, where `` is `core`, `web`, `runtime` or `ingress`. Core, Web and ingress indexes contain Linux amd64 and arm64 images; Runtime contains Linux amd64. Platform images use `-` tags and are loaded from the release archives. Existing version tags must match the release images and platform set. The publisher verifies every version index before updating `latest` for a stable release; prereleases and drafts leave `latest` unchanged. PostgreSQL uses its upstream image. SemVer build metadata uses `_` in place of `+` in container tags; version strings are limited to 128 characters. After verifying the images, the publisher uploads the release's `compose.yaml` and checksum list. Compose pins ingress by its index digest, and initialization checks its build revision against the Compose revision. +Version releases and manual `build-` drafts publish `ghcr.io/minimax-ai/openagentcore/:`, where `` is `core`, `web`, `runtime`, `ingress` or `agent-host`. Core, Web and ingress indexes contain Linux amd64 and arm64 images; Runtime and agent-host contain Linux amd64. Platform images use `-` tags and are loaded from the release archives. Existing version tags must match the release images and platform set. The publisher verifies every version index before updating `latest` for a stable release; prereleases and drafts leave `latest` unchanged. PostgreSQL uses its upstream image. SemVer build metadata uses `_` in place of `+` in container tags; version strings are limited to 128 characters. After verifying the images, the publisher uploads the release's `compose.yaml` and checksum list. Compose pins ingress by its index digest, and initialization checks its build revision against the Compose revision. -The combined build/publication job uses `GITHUB_TOKEN` with `packages: write`. On the first publication, GitHub creates each container package as private: a package administrator must change all four packages to **Public** in their package settings before users can pull anonymously. See [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). Verify an unauthenticated pull after changing visibility. Repository visibility alone does not make a new container package public. +The combined build/publication job uses `GITHUB_TOKEN` with `packages: write`. On the first publication, GitHub creates each container package as private: a package administrator must change all five packages to **Public** in their package settings before users can pull anonymously. See [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). Verify an unauthenticated pull after changing visibility. Repository visibility alone does not make a new container package public. GHCR and GitHub Releases do not share a transaction. A failed release may leave some matching version tags in GHCR; preserve those images and follow the draft recovery procedure below using the original artifacts. Registry failures other than a missing manifest stop publication. The job summary records digest-pinned references. These images and the rendered Compose files still require the configuration, secrets and routing described in [Configuration](./configuration.md). @@ -191,7 +190,7 @@ The planner compares the PR event's tested merge commit with its verified first `.github/actionlint.yaml` selects hygiene and lint. Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled; website workflow changes add website checks. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. -Compose template and Compose test changes select both `distribution` fixtures and the `compose` smoke job; Core, Web, shared Go packages and the image Dockerfiles also select the smoke job. Run `python3 scripts/compose-smoke.py` locally with Docker available to repeat it. The script uses a unique project, an automatically assigned loopback port and artifacts under `~/.oac/tests/`; it removes its containers and volumes on exit. CI also performs cleanup after a failed or interrupted smoke step. Diagnostics show container status without printing HTTP response bodies or sign-in keys. Core, Web and the ingress image are built from the checkout; Web serves a placeholder page instead of the console build. Build-time node metadata comes from the release pinned in `deploy/compose/smoke-pins.json`; the initialization container runs with networking disabled. The smoke matrix runs on native Linux amd64 and arm64 runners; the native matrix builds and tests the shared Core installer on Linux, macOS and Windows. +Compose template and Compose test changes select both `distribution` fixtures and the `compose` smoke job; Core, Web, the daemon, shared Go packages and the image Dockerfiles also select the smoke job. Run `python3 scripts/compose-smoke.py` locally with Docker available to repeat it. The script uses a unique project, an automatically assigned loopback port and artifacts under `~/.oac/tests/`; it removes its containers and volumes on exit. CI also performs cleanup after a failed or interrupted smoke step. Diagnostics show container status without printing HTTP response bodies or sign-in keys. Core, Web, the agent host and the ingress image are built from the checkout; Web serves a placeholder page instead of the console build, and the agent-host image has no Harness; the smoke checks that the agent host connects to Core. Build-time node metadata comes from the release pinned in `deploy/compose/smoke-pins.json`; the initialization container runs with networking disabled. The smoke matrix runs on native Linux amd64 and arm64 runners; the native matrix builds and tests the shared Core installer on Linux, macOS and Windows. Go module and workspace inputs select backend, API (including the container), native and distribution checks. Each Node module owns its manifest and lockfile. Website dependencies select website checks; Web dependencies select Web and browser checks; example dependencies select example checks; shared TypeScript client dependencies select Web, browser and example checks; Claude adapter dependencies select Harness, native and distribution checks. Shared package-manager configuration selects all Node consumers. The root TypeScript configuration selects Web and example checks; the adapter TypeScript configuration selects Harness and native checks. Each selected set includes hygiene. Mixed changes accumulate their consumers, and every job reads the same plan instead of maintaining its own path list. For example, a notification-only PR skips database, browser and native jobs, while a notification plus Core change adds backend and API checks. diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 28f534d5c..1abf64824 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 286ca5de3c5f97cf261feea1145b98cf59055c5f197fa7ad3bb02bf0e281a5b4 +source_hash: 48e9776a3ac7b42b4b651304b8bf003d26c07745b239c1567ce5e0d7fb8f0540 --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -9,7 +9,7 @@ Core 安装的每项设置都恰好只有一个归属位置,分属以下三类 | 类别 | 示例 | 归属位置 | 修改方式 | 生效方式 | | --- | --- | --- | --- | --- | | [进程设置](#process-settings) | 公共 URL、端口、日志、Harness、执行并发度、审计保留期、OAuth 来源、Runtime 历史记录 | 安装目录中的 `.env`(默认 `~/.oac/core`) | 编辑 `.env`,然后运行 `oac apply` | `oac apply` 会重新创建读取了这些已更改设置的服务 | -| [机密信息](#compose-installations) | 数据库密码、凭据加密密钥、安装 ID、Core 密钥及由其派生的 Core 密钥摘要 | Compose 数据卷中的 `secrets/`,每项一份 | 初始化时一次性生成;`oac rotate-core-key` 替换 Core 密钥及其摘要 | `oac rotate-core-key` 会重启 Core 和 Web | +| [机密信息](#compose-installations) | 数据库密码、凭据加密密钥、安装 ID、agent-host 身份、Core 密钥及由其派生的 Core 密钥摘要 | Compose 数据卷中的 `secrets/`,每项一份 | 初始化时一次性生成;`oac rotate-core-key` 替换 Core 密钥及其摘要 | `oac rotate-core-key` 会重启 Core 和 Web | | [运行时设置](#runtime-settings-web) | 沙箱后端和大小、节点、项目和密钥、默认模型、执行器凭据 | Core 的 PostgreSQL 数据库 | 在 Web 中修改,或使用 Core 密钥调用 Core API(`/core/v1`) | 保存时无需重启 Core;节点会异步准备 Runtime 变更 | Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置,并在 **Startup settings** 下以只读方式显示 Core 加载的进程设置。没有任何配置文件定义项目或 API 密钥。 @@ -115,7 +115,9 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | `secrets/database/` | 生成的数据库密码 | PostgreSQL 和 Core | | `secrets/core/` | 凭据加密密钥、安装 ID 和 Core 密钥摘要 | Core | | `secrets/web/` | 生成的 Core 登录密钥 | Web | +| `secrets/agent-host/` | `identity.json`,即 [agent host 的身份](#agent-host-container) | Core 和 agent host | | `state/` | 私有 Provider 状态,在 Core 中挂载到 `/state`。每个适配器拥有一个子目录;E2B 使用 `e2b/`,不允许组或其他用户访问 | Core | +| `agent-host/` | [agent host 的状态目录](#agent-host-container) | agent host;初始化时检查它是否为空 | | `node-payload/` | 已验证的节点安装元数据 | Web | 初始化会准备该目录;应用服务以只读方式接收各自的机密目录。`docker compose exec web oac-web core-key` 把 Core 密钥打印到运维人员终端,不写入容器日志。数据库密码和凭据加密密钥绝不打印。 @@ -159,6 +161,8 @@ agent host 需要一个委派给它的 cgroup v2 目录。它在该目录中为 agent host 为镜像的 `/opt/oac/harnesses.json` 所安装、且声明了视图的每个 Harness 提供服务;没有任何 Harness 时它也会启动并连接。它把每个 Session 的 home(含 Harness 的原生历史)保存在 `/var/lib/oac/agent-host`,该目录必须比容器存活更久,Session 才能在重启后继续。连接断开后它按退避策略重新拨号;当 Core 连续两分钟不可达时,agent host 以非零状态退出,由其监管程序重启。 +Compose 安装把 agent host 作为 `agent-host` 服务运行在 Core 的网络命名空间中,并使用 `--core-url http://127.0.0.1:8091`。它从以只读方式挂载的[数据卷](#compose-installations) `secrets/agent-host/` 读取身份,并把状态目录保存在数据卷的 `agent-host/` 中。 + 绝不要为 agent host 设置 `GODEBUG=http2debug`。设置后,Go 的 HTTP/2 实现会记录它编码的每个请求头,包括 agent host 添加的模型和 MCP 凭据。 ## 安装目录 {#installation-directory} @@ -173,7 +177,7 @@ agent host 为镜像的 `/opt/oac/harnesses.json` 所安装、且声明了视图 同级 `.lock` 目录用于同步操作并一直保留;`.staging` 保存尚未就位的安装文件。两者都不保存服务数据。Unix 上安装程序以 `0700` 创建私有目录,以 `0600` 创建配置文件。 -Compose 项目名为 `oac-<10 hex digits>`,服务包括 `init`、`database`、`core` 和 `web`。Core 启动时执行数据库迁移。Web 提供控制台并把 `/v1`、`/api/v1` 转发到 Core,是唯一发布端口(`OAC_WEB_PORT`)的服务。没有服务持有 Docker 套接字。 +Compose 项目名为 `oac-<10 hex digits>`,服务包括 `init`、`database`、`core`、`agent-host` 和 `web`。Core 启动时执行数据库迁移。Web 提供控制台并把 `/v1`、`/api/v1` 转发到 Core,是唯一发布端口(`OAC_WEB_PORT`)的服务。没有服务持有 Docker 套接字。 ## 附录:没有安装程序时的 Core 环境 {#appendix-core-environment-without-the-installer} @@ -188,6 +192,7 @@ Core 读取进程环境。Compose 将 `.env` 插值到环境中,并把机密 | `OAC_CREDENTIAL_KEY_FILE` | `/run/oac/credential.key` | | `OAC_CORE_KEY_DIGESTS_FILE` | 必填。`/run/oac/core-key-digests.json`:一个包含 Core 密钥 SHA-256 的 JSON 数组 | | `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`:安装 ID,采用规范 UUID 格式。它会启用沙箱部署和节点路由,并要求设置 `OAC_PUBLIC_URL`。如果 ID 与数据库记录的 ID 不一致,Core 会拒绝它 | +| `OAC_AGENT_HOST_IDENTITY_FILE` | `/run/agent-host/identity.json`:[agent host 的身份](#agent-host-container),其 `runtime_id` 为规范 UUID。设置 `OAC_PUBLIC_URL` 时必须设置,且只能与它一同设置。Core 启动时用该 ID 和凭据注册 agent host;新凭据会隔离旧凭据认证过的 Link,已吊销的 agent host 保持吊销 | | `OAC_EXECUTION_CONCURRENCY`、`OAC_DEFAULT_HARNESS`、`OAC_HARNESSES`、`OAC_WRITE_AUDIT_RETENTION`、`OAC_OAUTH_TRUSTED_ORIGINS`、`OAC_HISTORY_SETTINGS_FILE`、`OAC_LOG_LEVEL`、`OAC_LOG_FORMAT`、`OAC_LOG_ADD_SOURCE` | 对应的[进程设置](#settings)。Web 也读取三个日志设置 | | `OAC_PROVIDER_ROOT` | 适配器构件的绝对根目录。Core 镜像设置为 `/opt/oac`。每个适配器都拥有此根目录下的辅助路径。当其中的 `native-installers/` 目录包含 `catalog.json` 时,Core 在核对该目录清单与自身发行版后提供自托管守护进程安装程序。适配器状态位于 `/state`,即数据卷的 [`state/`](#compose-installations) | diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index 164886e4c..e4dca3fe3 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,16 +1,16 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: 5c3db1ed5d8a29741bb90770dcab336711ef06aa95a7f546c0d6586d51b8d7c4 +source_hash: 4446c6769f46b6379e6ee078b8cfa2450190321cff34faaa2525e72e47cef350 --- 本指南面向负责构建和发布 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)。 ## 构建分发包 {#build-a-distribution} -分发包是从同一个提交构建的一组相互匹配的发布资源:控制归档(安装器、`oac` 命令,以及 Core、Web、ingress 和 PostgreSQL 镜像)、作为独立文件的 Runtime 镜像和节点构件,以及原生安装器。 +分发包是从同一个提交构建的一组相互匹配的发布资源:控制归档(安装器、`oac` 命令,以及 Core、Web、ingress、agent-host 和 PostgreSQL 镜像)、作为独立文件的 Runtime 镜像和节点构件,以及原生安装器。 -Core、Web 和 ingress 镜像发布为经过校验的 Linux amd64/arm64 多架构索引。arm64 控制归档包含这三个镜像;Node、托管 Runtime 和离线包使用 Linux amd64。发行构建使用 QEMU 执行 ARM 镜像步骤,包括 E2B helper。宿主机 `oac` 从同一份实现构建为 Linux amd64/arm64、macOS amd64/arm64 和 Windows amd64 二进制;启动脚本只选择、校验并运行它们。所有版本索引校验通过后才更新浮动标签。 +Core、Web 和 ingress 镜像发布为经过校验的 Linux amd64/arm64 多架构索引。arm64 控制归档包含这三个镜像;Node、托管 Runtime、agent-host 和离线包使用 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 配套程序,然后执行构建: @@ -31,7 +31,6 @@ make build-core-distribution | `CORE_DISTRIBUTION_RELEASE_BASE_URL` | 用于提供生成的资源文件名的带版本 HTTPS 目录(绝不能使用 `latest`)。除非 `CORE_DISTRIBUTION_OFFLINE=1`,否则为必填项 | | `CORE_DISTRIBUTION_OFFLINE` | 设为 `1` 时还会构建离线归档 | | `CODEX_CLI_DIR`、`MCODE_HARNESS_BUILD_DIR` | `prepare-release-runtimes.sh` 固定的 Runtime 输入 | -| `CORE_DISTRIBUTION_CODEX_IMAGE`、`CORE_DISTRIBUTION_CLAUDE_IMAGE`、`CORE_DISTRIBUTION_MCODE_IMAGE` | 使用现有 Harness 镜像,而不是构建这些镜像;值必须是以不可变 `sha256:` 镜像 ID 表示的现有镜像。三个变量必须全部设置或全部不设置;每个镜像都必须包含由该提交构建的守护进程 | | `OAC_NATIVE_INSTALLER_BUILD_DIR` | 原生安装器目录;请参阅[原生安装器](#native-installers) | | `CORE_DISTRIBUTION_BUILD_DIR` | `~/.oac` 下的输出目录。默认值:`~/.oac/build/core-distribution` | | `CORE_DISTRIBUTION_BUILD_NETWORK` | Docker 构建网络:`default`、`host` 或 `none` | @@ -59,7 +58,7 @@ export OAC_NATIVE_INSTALLER_BUILD_DIR=OUTPUT_DIR ### Runtime 镜像和辅助程序 {#runtime-images-and-helpers} -`make build-core-distribution` 会构建以下全部内容。也可以单独构建其中一项,以测试某个 Harness 镜像或辅助程序。所有命令都必须从仓库根目录运行;默认输出位于 `${OAC_DEV_HOME:-$HOME/.oac}/build` 下。 +`make build-core-distribution` 会构建以下除沙箱镜像外的全部内容。也可以单独构建其中一项,以测试某个 Harness 镜像或辅助程序。所有命令都必须从仓库根目录运行;默认输出位于 `${OAC_DEV_HOME:-$HOME/.oac}/build` 下。 **Codex Runtime 镜像。** 在 `~/.oac` 下解压官方 npm 包 `@openai/codex@0.153.4-linux-x64`(例如使用 `npm pack --ignore-scripts` 和 `tar -xzf`),然后执行: @@ -102,7 +101,7 @@ export CODEX_CLI_DIR=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute bash scripts/build-agent-host-images.sh ``` -该脚本将三个 Runtime 镜像构建器的产物准备到同一个上下文中,加入静态的 `oac-daemon`、`oac-process-shim` 和 `oac-sandbox-io`,并将 `deploy/distribution/AgentHost.Dockerfile` 的两个目标分别构建为 `OAC_AGENT_HOST_IMAGE`(默认 `oac-agent-host:dev`)和 `OAC_SANDBOX_IMAGE`(默认 `oac-sandbox:dev`)。其余参数(例如 `--label`)会传给两次 `docker build`。agent-host 镜像把每个 Harness 安装在 `/opt/oac/harnesses` 下各自的目录中,`/opt/oac/harnesses.json` 是这些位置的唯一记录;agent host 通过 `agent.ManifestEnvironment` 读取它。沙箱镜像具有 Runtime 镜像的基础层和软件包,不含守护进程和 Harness,并以 UID/GID 1000 运行 `oac-sandbox-io --bootstrap-file `。[认定视图资格](../../contracts/agents-api/zh/harness-onboarding.md#qualify-the-view)会运行这两个镜像;[Agent-host 容器](configuration.md#agent-host-container)列出 agent host 的需求。CI 和发布流程都不构建它们。 +该脚本将三个 Runtime 镜像构建器的产物准备到同一个上下文中,加入静态的 `oac-daemon`、`oac-process-shim` 和 `oac-sandbox-io`,并将 `deploy/distribution/AgentHost.Dockerfile` 的两个目标分别构建为 `OAC_AGENT_HOST_IMAGE`(默认 `oac-agent-host:dev`)和 `OAC_SANDBOX_IMAGE`(默认 `oac-sandbox:dev`)。其余参数(例如 `--label`)会传给两次 `docker build`。agent-host 镜像把每个 Harness 安装在 `/opt/oac/harnesses` 下各自的目录中,`/opt/oac/harnesses.json` 是这些位置的唯一记录;agent host 通过 `agent.ManifestEnvironment` 读取它。沙箱镜像具有 Runtime 镜像的基础层和软件包,不含守护进程和 Harness,并以 UID/GID 1000 运行 `oac-sandbox-io --bootstrap-file `。[认定视图资格](../../contracts/agents-api/zh/harness-onboarding.md#qualify-the-view)会运行这两个镜像;[Agent-host 容器](configuration.md#agent-host-container)列出 agent host 的需求。分发构建用其 Runtime 镜像构建器准备的载荷构建并发布 agent-host 镜像;CI 和发布流程都不构建沙箱镜像。 **E2B 辅助程序。** @@ -146,9 +145,9 @@ git push origin v1.2.3 ### 容器注册表 {#container-registry} -版本发布和手动 `build-` 草稿使用 `ghcr.io/minimax-ai/openagentcore/:`,其中 `` 为 `core`、`web`、`runtime` 或 `ingress`。Core、Web 和 ingress 索引包含 Linux amd64 和 arm64 镜像,Runtime 包含 Linux amd64。各平台镜像使用 `-` 标签,从发行归档加载。已有版本标签必须与发行镜像及平台集合一致。发布器校验全部版本索引后,才为稳定版更新 `latest`;预发布版和草稿保持 `latest` 不变。PostgreSQL 使用上游镜像。容器标签中的 SemVer 构建元数据用 `_` 替换 `+`,版本字符串上限为 128 个字符。镜像校验后,发布器上传该版本的 `compose.yaml` 和校验和清单。Compose 用索引摘要固定 ingress,初始化时检查其构建版本与 Compose 版本一致。 +版本发布和手动 `build-` 草稿使用 `ghcr.io/minimax-ai/openagentcore/:`,其中 `` 为 `core`、`web`、`runtime`、`ingress` 或 `agent-host`。Core、Web 和 ingress 索引包含 Linux amd64 和 arm64 镜像,Runtime 和 agent-host 包含 Linux amd64。各平台镜像使用 `-` 标签,从发行归档加载。已有版本标签必须与发行镜像及平台集合一致。发布器校验全部版本索引后,才为稳定版更新 `latest`;预发布版和草稿保持 `latest` 不变。PostgreSQL 使用上游镜像。容器标签中的 SemVer 构建元数据用 `_` 替换 `+`,版本字符串上限为 128 个字符。镜像校验后,发布器上传该版本的 `compose.yaml` 和校验和清单。Compose 用索引摘要固定 ingress,初始化时检查其构建版本与 Compose 版本一致。 -合并的构建/发布作业使用具有 `packages: write` 权限的 `GITHUB_TOKEN`。首次发布时,GitHub 会将每个容器软件包创建为私有:软件包管理员必须先在各自的软件包设置中将全部四个软件包改为 **Public**,用户才能匿名拉取。请参阅 [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)。更改可见性后,请验证未认证拉取。仅更改仓库可见性并不会使新的容器软件包变为公开。 +合并的构建/发布作业使用具有 `packages: write` 权限的 `GITHUB_TOKEN`。首次发布时,GitHub 会将每个容器软件包创建为私有:软件包管理员必须先在各自的软件包设置中将全部五个软件包改为 **Public**,用户才能匿名拉取。请参阅 [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)。更改可见性后,请验证未认证拉取。仅更改仓库可见性并不会使新的容器软件包变为公开。 GHCR 和 GitHub Releases 不共享事务。发布失败后,GHCR 中可能仍会保留一些匹配的版本标签;请保留这些镜像,并使用原始构件按照下文的草稿恢复流程操作。除清单缺失以外,注册表故障都会停止发布。作业摘要会记录按摘要固定的引用。这些镜像和渲染后的 Compose 文件仍需要[配置](configuration.md)中描述的配置、机密和路由。 @@ -193,7 +192,7 @@ gh workflow run core-release --repo MiniMax-AI/OpenAgentCore --ref main \ `.github/actionlint.yaml` 会选择 hygiene 和 lint。已知工作流变更会选择其使用方:CI review 和 actionlint 工作流运行 hygiene 和 lint;原生工作流变更会添加原生检查;API 验收工作流变更会添加启用容器验收的 API 检查;网站工作流变更会添加网站检查。共享 Node 操作会选择使用它的每个作业以及 lint。新工作流或未分类的工作流/操作会选择完整门禁,直至在计划器中声明其使用方。计划器测试和 CI 测量脚本运行 hygiene;更改计划器本身会运行完整门禁。 -Compose 模板和 Compose 测试发生变更时,会同时选择 `distribution` 固定数据和 `compose` 冒烟作业;Core、Web、共享 Go 软件包和镜像 Dockerfile 的变更也会选择冒烟作业。安装 Docker 后,可在本地运行 `python3 scripts/compose-smoke.py` 重复该测试。该脚本使用唯一的项目、自动分配的回环端口,并将在 `~/.oac/tests/` 下生成构件;退出时移除其容器和数据卷。CI 还会在冒烟步骤失败或中断后执行清理。诊断信息会显示容器状态,但不会打印 HTTP 响应正文或登录密钥。Core、Web 和 ingress 镜像都从当前检出构建;Web 提供占位页面而不是控制台构建。构建时的节点元数据来自 `deploy/compose/smoke-pins.json` 固定的发布版本;初始化容器禁用网络运行。冒烟矩阵使用 Linux amd64 和 arm64 原生 runner;原生矩阵在 Linux、macOS 和 Windows 上构建并测试共享的 Core 安装器。 +Compose 模板和 Compose 测试发生变更时,会同时选择 `distribution` 固定数据和 `compose` 冒烟作业;Core、Web、守护进程、共享 Go 软件包和镜像 Dockerfile 的变更也会选择冒烟作业。安装 Docker 后,可在本地运行 `python3 scripts/compose-smoke.py` 重复该测试。该脚本使用唯一的项目、自动分配的回环端口,并将在 `~/.oac/tests/` 下生成构件;退出时移除其容器和数据卷。CI 还会在冒烟步骤失败或中断后执行清理。诊断信息会显示容器状态,但不会打印 HTTP 响应正文或登录密钥。Core、Web、agent host 和 ingress 镜像都从当前检出构建;Web 提供占位页面而不是控制台构建,agent-host 镜像不含 Harness;冒烟测试会检查 agent host 能连接到 Core。构建时的节点元数据来自 `deploy/compose/smoke-pins.json` 固定的发布版本;初始化容器禁用网络运行。冒烟矩阵使用 Linux amd64 和 arm64 原生 runner;原生矩阵在 Linux、macOS 和 Windows 上构建并测试共享的 Core 安装器。 Go 模块和工作区输入会选择后端、API(包括容器)、原生和分发检查。每个 Node 模块都拥有自己的清单和锁文件。网站依赖项会选择网站检查;Web 依赖项会选择 Web 和浏览器检查;示例依赖项会选择示例检查;共享 TypeScript 客户端依赖项会选择 Web、浏览器和示例检查;Claude 适配器依赖项会选择 Harness、原生和分发检查。共享包管理器配置会选择所有 Node 使用方。根 TypeScript 配置会选择 Web 和示例检查;适配器 TypeScript 配置会选择 Harness 和原生检查。每个所选集合都包含 hygiene。混合变更会累加其使用方,并且每个作业都读取同一计划,而不是维护各自的路径列表。例如,仅修改通知的 PR 会跳过数据库、浏览器和原生作业,而同时修改通知和 Core 的 PR 会添加后端和 API 检查。 diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 8102bfae5..9972681aa 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -149,39 +149,27 @@ cp -R apps/web/dist "$stage/web/dist" cp services/web/Dockerfile "$stage/web/Dockerfile" build_image web "$stage/web" -CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/" ./apps/daemon/cmd/oac-daemon ./apps/sandboxio/cmd/oac-sandbox-io +CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/" \ + ./apps/daemon/cmd/oac-daemon ./apps/daemon/cmd/oac-process-shim ./apps/sandboxio/cmd/oac-sandbox-io cp "$stage/oac-daemon" "$bundle/native/bin/oac-daemon" -codex_image="${CORE_DISTRIBUTION_CODEX_IMAGE:-}" -claude_image="${CORE_DISTRIBUTION_CLAUDE_IMAGE:-}" -mcode_image="${CORE_DISTRIBUTION_MCODE_IMAGE:-}" -if [[ -n "$codex_image$claude_image$mcode_image" ]]; then - if [[ -z "$codex_image" || -z "$claude_image" || -z "$mcode_image" ]]; then - printf 'Provide all three CORE_DISTRIBUTION_*_IMAGE inputs or none\n' >&2 - exit 1 - fi -else - : "${CODEX_CLI_DIR:?Set the extracted pinned Codex Linux x64 package directory}" - : "${MCODE_HARNESS_BUILD_DIR:?Set the existing built pinned MiniMax Code companion directory}" - export CLAUDE_SDK_BUILD_DIR="$stage/claude-sdk" - scripts/build-claude-sdk-runtime.sh - for harness in codex claude mcode; do - AGENTS_RUNTIME_BUILD_DIR="$stage/$harness" bash "scripts/build-$harness-runtime.sh" - build_image "$harness" "$stage/$harness" - done - codex_image="$(cat "$stage/codex.id")" - claude_image="$(cat "$stage/claude.id")" - mcode_image="$(cat "$stage/mcode.id")" -fi -for image in "$codex_image" "$claude_image" "$mcode_image"; do - python3 scripts/core-distribution-manifest.py verify-runtime "$image" "$stage/oac-daemon" "$stage/oac-sandbox-io" "$source_dir" -done +: "${CODEX_CLI_DIR:?Set the extracted pinned Codex Linux x64 package directory}" +: "${MCODE_HARNESS_BUILD_DIR:?Set the existing built pinned MiniMax Code companion directory}" +export CLAUDE_SDK_BUILD_DIR="$stage/claude-sdk" +scripts/build-claude-sdk-runtime.sh +# Each Harness payload is its Runtime image's context and, laid out as +# scripts/build-agent-host-images.sh lays it out, part of the agent host's. tag_suffix="${stage##*.}" for harness in codex claude mcode; do - image_variable="${harness}_image" + AGENTS_RUNTIME_BUILD_DIR="$stage/agent-host/$harness" bash "scripts/build-$harness-runtime.sh" + build_image "$harness" "$stage/agent-host/$harness" + image="$(cat "$stage/$harness.id")" + python3 scripts/core-distribution-manifest.py verify-runtime "$image" "$stage/oac-daemon" "$stage/oac-sandbox-io" "$source_dir" tag="oac-distribution:$harness-$revision-$tag_suffix" - docker image tag "${!image_variable}" "$tag" + docker image tag "$image" "$tag" image_tags+=("$tag") done +cp "$stage/oac-daemon" "$stage/oac-process-shim" "$stage/agent-host/" +build_image agent-host --target agent-host --file deploy/distribution/AgentHost.Dockerfile "$stage/agent-host" mkdir "$stage/combined" cp deploy/distribution/Runtime.Dockerfile "$stage/combined/Dockerfile" build_image runtime \ @@ -198,7 +186,7 @@ fi docker image inspect --format '{{.Id}}' "$database_image" > "$stage/database.id" docker run --rm --network none --entrypoint postgres "$(cat "$stage/database.id")" --version \ | python3 -c 'import sys; value=sys.stdin.read(); assert value.startswith("postgres (PostgreSQL) 16."), "Distribution requires PostgreSQL 16"' -for name in core web runtime database; do +for name in core web runtime database agent-host; do image="$(cat "$stage/$name.id")" python3 scripts/core-distribution-manifest.py verify-image "$image" docker image save --output "$bundle/images/$name.tar" "$image" @@ -257,7 +245,7 @@ if [[ -d "$stage/native-artifacts" ]]; then mv "$stage/native-artifacts/"* "$out mv "$bundle" "$output_dir/" # Core, Web and initialization also run natively in ARM64 Linux containers. -# Node and hosted Runtime payloads above remain linux/amd64. +# Node, hosted Runtime and agent-host payloads above remain linux/amd64. export GOARCH=arm64 arm_bundle="$stage/oac-$revision-linux-arm64" mkdir -p "$arm_bundle/images" diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 943d0c0e7..af72ee26c 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -62,7 +62,7 @@ (("deploy/install.sh", "deploy/install.ps1", "deploy/test_install.ps1"), (".sh", ".ps1"), ("native", "distribution")), (("services/core/internal/nativeinstaller/",), GO, ("native", "distribution")), (("services/core/deploy/", "services/core/tools/"), CORE, ("distribution",)), - (("apps/daemon/",), GO, ("backend", "native")), + (("apps/daemon/",), GO, ("backend", "native", "compose")), (("apps/sandboxio/",), GO, ("backend",)), (("internal/",), (*GO, ".json"), ("backend", "api", "native", "distribution", "compose")), (("internal/harnessconfig/",), (*GO, ".json"), ("web", "web-acceptance", "example", "harness")), diff --git a/scripts/compose-smoke.py b/scripts/compose-smoke.py index f2c4c9946..83548d7a7 100644 --- a/scripts/compose-smoke.py +++ b/scripts/compose-smoke.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Exercise the Compose installation in an isolated Docker project. -Core, Web and the gateway image are built from this checkout. Web serves a -placeholder page instead of the console build. Node metadata comes from the -release pinned in deploy/compose/smoke-pins.json. +Core, Web, the agent host and the gateway image are built from this checkout. +Web serves a placeholder page instead of the console build, and the agent host +has no Harness. Node metadata comes from the release pinned in +deploy/compose/smoke-pins.json. """ import hashlib @@ -17,6 +18,7 @@ import tarfile import subprocess import tempfile +import time import urllib.error import urllib.request import uuid @@ -62,7 +64,7 @@ def go_build(package, output, build_revision=revision): subprocess.run(['go', 'build', '-trimpath', '-ldflags', '-X main.buildRevision=' + build_revision, '-o', str(output), './' + package], cwd=ROOT, env=go_env, check=True) - contexts = {name: directory / ('image-' + name) for name in ('core', 'web', 'ingress')} + contexts = {name: directory / ('image-' + name) for name in ('core', 'web', 'ingress', 'agent-host')} core = contexts['core'] for name, package in (('oac-core', 'server'), ('oac-core-device', 'device'), ('oac-core-environment-key', 'environment-key'), ('oac', 'oac')): @@ -83,6 +85,18 @@ def go_build(package, output, build_revision=revision): payload_revision = prepare_pinned_payload(ingress / 'node-payload') go_build('services/core/cmd/oac', ingress / 'oac', payload_revision) (ingress / 'Dockerfile').write_bytes((ROOT / 'deploy/distribution/Ingress.Dockerfile').read_bytes()) + # The agent-host image without its Harnesses: CA roots, the daemon, the + # process shim and a manifest that installs none. Its base is Core's, + # because the agent-host base is pinned to an amd64 manifest. + agent_host = contexts['agent-host'] + for name in ('oac-daemon', 'oac-process-shim'): + go_build('apps/daemon/cmd/' + name, agent_host / name) + (agent_host / 'harnesses.json').write_text('{"node": "/usr/local/bin/node", "harnesses": {}}\n') + base = re.search(r'^FROM (\S+)', (ROOT / 'deploy/distribution/Dockerfile').read_text(), re.M).group(1) + (agent_host / 'Dockerfile').write_text( + f'FROM {base}\nRUN apt-get update && apt-get install -y --no-install-recommends ca-certificates' + ' && rm -rf /var/lib/apt/lists/*\nCOPY oac-daemon oac-process-shim /opt/oac/bin/\nCOPY harnesses.json /opt/oac/\n' + 'ENTRYPOINT ["/opt/oac/bin/oac-daemon"]\n') for path in directory.glob('image-*/**/*'): path.chmod(0o755 if path.is_dir() or os.access(path, os.X_OK) else 0o644) images = {} @@ -115,7 +129,7 @@ def main(): override.write_text(json.dumps({'services': {'init': {'network_mode': 'none'}}})) env = {**os.environ, 'COMPOSE_PROGRESS': 'plain', 'OAC_HOST': '127.0.0.1', 'OAC_WEB_PORT': '0', - **{'OAC_IMAGE_' + name.upper(): image for name, image in images.items()}} + **{'OAC_IMAGE_' + name.upper().replace('-', '_'): image for name, image in images.items()}} env.pop('OAC_PUBLIC_URL', None) command = ['docker', 'compose', '--env-file', os.devnull, '-p', project, '-f', str(rendered), '-f', str(override)] @@ -161,6 +175,14 @@ def private_logs(*keys): assert all(key not in logs for key in keys), 'Credentials appeared in container logs' return logs + def agent_host_connected(): + # Core registered the identity the agent host presents, and the agent + # host opened its views; a failed Open or registration never connects. + deadline = time.monotonic() + 60 + while 'msg="ws connected"' not in compose('logs', '--no-color', 'agent-host').decode(): + assert time.monotonic() < deadline, 'The agent host did not connect to Core' + time.sleep(1) + def terminate(_signum, _frame): raise SystemExit(1) @@ -168,6 +190,7 @@ def terminate(_signum, _frame): try: print('Starting the images with an unset public URL and an empty data directory', flush=True) compose('up', '-d', '--wait', '--wait-timeout', '600', timeout=900) + agent_host_connected() address = 'http://' + compose('port', 'web', '8080').decode().strip() key = compose('exec', '-T', 'web', '/usr/local/bin/oac-web', 'core-key').decode().strip() assert re.fullmatch(r'oac_admin_[0-9a-f]{64}', key), 'Missing generated sign-in key' @@ -222,8 +245,9 @@ def terminate(_signum, _frame): compose('down') compose('up', '-d', '--wait', '--wait-timeout', '120', timeout=180) assert compose('exec', '-T', 'web', '/usr/local/bin/oac-web', 'core-key').decode().strip() == rotated, 'Rotated key was not retained' + agent_host_connected() private_logs(key, rotated, project_key) - print('PASS: startup, origin validation, sign-in, API, upload, node installer, key rotation and persistent installation', flush=True) + print('PASS: startup, agent-host connection, origin validation, sign-in, API, upload, node installer, key rotation and persistent installation', flush=True) except BaseException: # Service status identifies failed containers without dumping secret-bearing logs. status = subprocess.run(command + ['ps', '--all'], env=env, capture_output=True, timeout=30) diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 256c33891..6e351314f 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -364,7 +364,7 @@ def node_payload(bundle, stage, revision, source_tree, artifact_base_url="", off def manifest(bundle, stage): bundle, stage = pathlib.Path(bundle), pathlib.Path(stage) metadata = json.loads((stage / "ingress/node-payload/manifest.json").read_text()) - for name in ("core", "web", "database", "ingress"): + for name in ("core", "web", "database", "ingress", "agent-host"): config, digest = image_identities(bundle / "images" / (name + ".tar"), (stage / (name + ".id")).read_text().strip()) metadata["images"][name] = config diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index cb4b78ce8..066d79090 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -105,7 +105,7 @@ def setUp(self): if logical.startswith("native/"): path.chmod(0o555) self.identities = {} - for name in ("core", "web", "runtime", "database", "ingress"): + for name in ("core", "web", "runtime", "database", "ingress", "agent-host"): self.identities[name] = image_archive(self.bundle / "images" / (name + ".tar"), name) (self.stage / (name + ".id")).write_text(self.identities[name][0] + "\n") (self.bundle / "node-install.pyz").write_bytes(b"node installer") diff --git a/scripts/publish-core-release.py b/scripts/publish-core-release.py index 8ae61caa6..e26ef58fb 100644 --- a/scripts/publish-core-release.py +++ b/scripts/publish-core-release.py @@ -77,7 +77,7 @@ def verify_draft(release, tag, revision): raise ValueError("Release draft identity changed") -IMAGE_NAMES = ("core", "web", "runtime", "ingress") +IMAGE_NAMES = ("core", "web", "runtime", "ingress", "agent-host") def registry_manifest(reference): diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py index 175bc9cd6..a4f161c86 100644 --- a/scripts/publish-core-release.test.py +++ b/scripts/publish-core-release.test.py @@ -381,7 +381,7 @@ def execute(self, command, **kwargs): self.remote_images[ref] = {'config': {'digest': self.configs[arch]}} if command[1:4] == ['buildx', 'imagetools', 'create']: ref = command[5]; name = ref.rsplit('/', 1)[1].split(':')[0] - arches = ('amd64',) if name == 'runtime' else ('amd64', 'arm64') + arches = ('amd64', 'arm64') if name in ('core', 'web', 'ingress') else ('amd64',) self.remote_images[ref] = {'manifests': [{'platform': {'os': 'linux', 'architecture': arch}, 'digest': self.digests[arch]} for arch in arches]} def output(self, command, **kwargs): @@ -398,7 +398,7 @@ def pushes(self): def test_publishes_and_reuses_verified_multiarch_indexes(self): result = self.publish() - self.assertEqual(len(self.pushes()), 7) + self.assertEqual(len(self.pushes()), 8) self.assertEqual(result['ingress']['digest'], 'ghcr.io/minimax-ai/openagentcore/ingress@' + self.index_digest) self.assertEqual(len(self.remote_images['ghcr.io/minimax-ai/openagentcore/core:v1.2.3']['manifests']), 2) self.run.reset_mock(); self.publish(); self.assertEqual(self.pushes(), []) @@ -406,8 +406,8 @@ def test_publishes_and_reuses_verified_multiarch_indexes(self): def test_latest_updates_after_all_version_indexes(self): self.publish(floating_latest=True) creates = [c.args[0][5] for c in self.run.call_args_list if c.args[0][1:4] == ['buildx', 'imagetools', 'create']] - self.assertTrue(all(ref.endswith(':v1.2.3') for ref in creates[:4])) - self.assertTrue(all(ref.endswith(':latest') for ref in creates[4:])) + self.assertTrue(all(ref.endswith(':v1.2.3') for ref in creates[:5])) + self.assertTrue(all(ref.endswith(':latest') for ref in creates[5:])) def test_conflicting_platform_prevents_every_push(self): self.remote_images['ghcr.io/minimax-ai/openagentcore/web:v1.2.3-arm64'] = {'config': {'digest': 'different'}} diff --git a/scripts/render-compose.py b/scripts/render-compose.py index cc88c4f28..f8bf149c7 100644 --- a/scripts/render-compose.py +++ b/scripts/render-compose.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Fill the Compose template with one release's node metadata. -The template is deploy/compose/compose.yaml. The initialization image is pinned; Core and Web default to latest. A release publishes the rendered file; this script does not run Docker. +The template is deploy/compose/compose.yaml. The initialization image is pinned; Core, Web and the agent host default to latest. A release publishes the rendered file; this script does not run Docker. """ import hashlib import pathlib diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 26125af41..ea78315ab 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -92,7 +92,7 @@ The Worker scans pending inputs with the same scheduling slots, Session locks, d `services/core/internal/runtimegateway` is Core's daemon connection implementation; its persistence interfaces use `services/core/internal/runtimedevice`, and the frames and validators live in the shared `internal/agentdaemon/proto`. It is a single-process registry: connectivity comes from the live registry, never a persisted online flag, and `last_seen_at` is diagnostic only. Session-to-device bindings are tenant-scoped and immutable. Revocation denies new connections and binding reads at once, and an open connection closes at its next heartbeat. -`runtimegateway.LinkAuthority` is Core's [Link](../../docs/sandbox-link-protocol.md) `Authority`. `cmd/server` builds it over `sessionpg.Store` and gives it to one `relay.New`, which mounts no route yet; the Worker revokes through that relay as `execution.Dispatcher.Links`. Every Hello, Open and renewal rereads the database. A Serve credential authenticates only its own resource while the `sandbox_resources` view marks it live, at that resource's current generation: an allocation in `creating` or `running` by its `serve_credential_hash`, or a `sandbox_enrollments` row by its executor key while the key would still authenticate for the Environment. Rotating the key advances the generation of each of its enrollments in the same transaction, so Opens and renewals for the old generation are refused and its attachments end within one lease. A key without an Environment restriction may hold several enrollments, and its holder is trusted for every Environment the key authenticates for: it may Serve any of them, replacing that enrollment's serve peer. Only a device marked `agent_host` Attaches, and its `credential_revision` is the peer's `Revision`. An attach grant is the assignment ID, epoch and resource generation followed by their keyed digest under the credential key, so Core stores none. It opens a service only while its assignment is the Session's bound assignment at that epoch, held by the peer's Runtime, and its generation is current. File gets the `world` export, Network gets every destination while the Session's network access is enabled and none otherwise, and each lease lasts one minute. Cleanup of an allocation and a release first commit, which withdraws their authority, then revoke the resource at the relay, and only then destroy the compute or send the release. +`runtimegateway.LinkAuthority` is Core's [Link](../../docs/sandbox-link-protocol.md) `Authority`. `cmd/server` builds it over `sessionpg.Store` and gives it to one `relay.New`, which the API serves at `/api/v1/sandbox-link`; the Worker revokes through that relay as `execution.Dispatcher.Links`. Every Hello, Open and renewal rereads the database. A Serve credential authenticates only its own resource while the `sandbox_resources` view marks it live, at that resource's current generation: an allocation in `creating` or `running` by its `serve_credential_hash`, or a `sandbox_enrollments` row by its executor key while the key would still authenticate for the Environment. Rotating the key advances the generation of each of its enrollments in the same transaction, so Opens and renewals for the old generation are refused and its attachments end within one lease. A key without an Environment restriction may hold several enrollments, and its holder is trusted for every Environment the key authenticates for: it may Serve any of them, replacing that enrollment's serve peer. Only a device marked `agent_host` Attaches, and its `credential_revision` is the peer's `Revision`. The deployment's agent host has no tenant: `cmd/server` registers it at startup from `OAC_AGENT_HOST_IDENTITY_FILE`, which advances the revision only for a new credential and never lifts a revocation. An attach grant is the assignment ID, epoch and resource generation followed by their keyed digest under the credential key, so Core stores none. It opens a service only while its assignment is the Session's bound assignment at that epoch, held by the peer's Runtime, and its generation is current. File gets the `world` export, Network gets every destination while the Session's network access is enabled and none otherwise, and each lease lasts one minute. Cleanup of an allocation and a release first commit, which withdraws their authority, then revoke the resource at the relay, and only then destroy the compute or send the release. A dedicated self-hosted device is bound to exactly one Environment's Session and is excluded from general device selection, even within the tenant. Enrollment creates or recovers the device and binding atomically under the Session lock; the frozen workspace and capability directories come from the Session configuration and must match the local binding. Core rechecks the persisted Environment and device binding for preparation and active reads; capability discovery never selects or authorizes a device for this placement. diff --git a/services/core/README.md b/services/core/README.md index 3ca7b19ec..02a1551b7 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -22,20 +22,22 @@ Core uses its own PostgreSQL database and account and shares no tables with an a 1. Create a development database. Core applies the migrations when it starts. -2. Create a Core key of at least 32 characters and a digest file holding its SHA-256, which Core uses to authenticate `/core/v1`: +2. Create a Core key of at least 32 characters and a digest file holding its SHA-256, which Core uses to authenticate `/core/v1`, and the agent-host identity Core registers: ```sh umask 077; mkdir -p ~/.oac/dev openssl rand -hex 32 > ~/.oac/dev/core.key printf '["%s"]\n' "$(tr -d '\n' < ~/.oac/dev/core.key | sha256sum | cut -d' ' -f1)" > ~/.oac/dev/core-key-digests.json + python3 -c 'import json, secrets, uuid; print(json.dumps({"runtime_id": str(uuid.uuid4()), "credential": secrets.token_urlsafe(32)}))' > ~/.oac/dev/agent-host.json ``` -3. Start Core. `OAC_PUBLIC_URL` enables the Runtime gateway and the Worker; without it Core executes nothing. The [Core environment table](../../docs/configuration.md#appendix-core-environment-without-the-installer) lists every variable. +3. Start Core. `OAC_PUBLIC_URL` enables the Runtime gateway and the Worker, and requires the agent-host identity; without it Core executes nothing. The [Core environment table](../../docs/configuration.md#appendix-core-environment-without-the-installer) lists every variable. ```sh OAC_DATABASE_URL='postgres://oac:…@127.0.0.1:5432/oac_dev' \ OAC_CORE_KEY_DIGESTS_FILE="$HOME/.oac/dev/core-key-digests.json" \ OAC_PUBLIC_URL=http://127.0.0.1:8091 \ + OAC_AGENT_HOST_IDENTITY_FILE="$HOME/.oac/dev/agent-host.json" \ go run ./services/core/cmd/server ``` diff --git a/services/core/cmd/oac/init.go b/services/core/cmd/oac/init.go index 9128bc68b..fd6a23c5e 100644 --- a/services/core/cmd/oac/init.go +++ b/services/core/cmd/oac/init.go @@ -24,7 +24,7 @@ var releaseMembers = []string{"manifest.json", "SHA256SUMS", "node-install.pyz", var dataOwners = []struct { name string uid int -}{{"database", 70}, {"secrets", 65532}, {"state", 65532}, {"node-payload", 65532}} +}{{"database", 70}, {"secrets", 65532}, {"state", 65532}, {"node-payload", 65532}, {"agent-host", 0}} var chown = os.Chown @@ -165,7 +165,7 @@ func initialize(root string, release releaseIdentity, fetch func() (map[string][ return err } } - for _, name := range []string{"core", "web", "database"} { + for _, name := range []string{"core", "web", "database", "agent-host"} { if err := ownedDir(filepath.Join(root, "secrets", name), 0o700, 65532); err != nil { return err } @@ -201,7 +201,7 @@ func initialize(root string, release releaseIdentity, fetch func() (map[string][ return err } nextStep("verify_empty_data") - for _, name := range []string{"database", "state"} { + for _, name := range []string{"database", "state", "agent-host"} { entries, err := os.ReadDir(filepath.Join(root, name)) if err != nil { return err @@ -256,6 +256,11 @@ func initialize(root string, release releaseIdentity, fetch func() (map[string][ {"secrets/database/password", func() string { return randomHex(32) }}, {"secrets/core/credential.key", func() string { return base64.StdEncoding.EncodeToString(randomBytes(32)) }}, {"secrets/core/installation.id", func() string { return uuid.NewString() }}, + // Core registers the agent host from this file; the agent host presents the credential as written. + {"secrets/agent-host/identity.json", func() string { + identity, _ := json.Marshal(map[string]string{"runtime_id": uuid.NewString(), "credential": base64.StdEncoding.EncodeToString(randomBytes(32))}) + return string(identity) + }}, } for _, secret := range generators { path := filepath.Join(root, secret.name) diff --git a/services/core/cmd/oac/init_test.go b/services/core/cmd/oac/init_test.go index c005a2fc3..cf24bf40e 100644 --- a/services/core/cmd/oac/init_test.go +++ b/services/core/cmd/oac/init_test.go @@ -80,7 +80,17 @@ func TestInitializeKeepsIdentityAndKeysAcrossRestarts(t *testing.T) { if _, err := uuid.Parse(strings.TrimSpace(saved["secrets/core/installation.id"])); err != nil { t.Fatal(err) } - for _, name := range []string{"secrets/web/core.key", "secrets/database/password", "secrets/core/credential.key"} { + var identity struct { + RuntimeID string `json:"runtime_id"` + Credential string `json:"credential"` + } + if err := json.Unmarshal([]byte(saved["secrets/agent-host/identity.json"]), &identity); err != nil || uuid.Validate(identity.RuntimeID) != nil { + t.Fatalf("agent-host identity: %v", err) + } + if raw, err := base64.StdEncoding.DecodeString(identity.Credential); err != nil || len(raw) != 32 { + t.Fatalf("agent-host credential: %v", err) + } + for _, name := range []string{"secrets/web/core.key", "secrets/database/password", "secrets/core/credential.key", "secrets/agent-host/identity.json"} { info, err := os.Stat(filepath.Join(root, name)) if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) { t.Fatalf("%s: %v %v", name, info.Mode(), err) @@ -250,7 +260,7 @@ func TestInitializationLogsLifecycleWithoutCredentials(t *testing.T) { t.Fatal("negative duration") } } - for _, name := range []string{"secrets/web/core.key", "secrets/database/password", "secrets/core/credential.key", "secrets/core/core-key-digests.json"} { + for _, name := range []string{"secrets/web/core.key", "secrets/database/password", "secrets/core/credential.key", "secrets/core/core-key-digests.json", "secrets/agent-host/identity.json"} { data, err := os.ReadFile(filepath.Join(root, name)) if err != nil { t.Fatal(err) diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 9ec266a87..de6439df0 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -224,6 +224,9 @@ func run(config processconfig.Config) error { var executorURL string var nativeInstaller *api.NativeInstaller if origin := config.PublicOrigin; origin != nil { + if err := sessionStore.RegisterAgentHost(ctx, config.AgentHostID, config.AgentHostCredentialHash); err != nil { + return fmt.Errorf("agent host registration failed: %w", err) + } executorURL = origin.DaemonWebSocket() links := runtimegateway.NewLinkAuthority(sessionStore) daemonHandler, registry, err = runtime.NewGateway(sessionStore, sessionService, sessionStore, links, executorURL) diff --git a/services/core/internal/db/queries/sandbox_link.sql b/services/core/internal/db/queries/sandbox_link.sql index 3126f6c34..ebf6849de 100644 --- a/services/core/internal/db/queries/sandbox_link.sql +++ b/services/core/internal/db/queries/sandbox_link.sql @@ -20,3 +20,15 @@ JOIN devices d ON d.id = b.runtime_id LEFT JOIN environments e ON e.session_id = b.session_id LEFT JOIN sandbox_resources r ON r.environment_id = e.id AND r.live WHERE b.assignment_id = $1; + +-- name: RegisterAgentHost :one +-- The deployment's agent host, with no tenant, Environment or executor key. +-- A new credential advances the revision, which fences the Links the old one +-- authenticated; a revocation stays. No row means the ID belongs to a device +-- that is not an agent host. +INSERT INTO devices (id, name, credential_hash, agent_host) +VALUES ($1, 'agent-host', sqlc.arg(credential_hash), true) +ON CONFLICT (id) DO UPDATE SET credential_hash = EXCLUDED.credential_hash, + credential_revision = devices.credential_revision + (devices.credential_hash IS DISTINCT FROM EXCLUDED.credential_hash)::int +WHERE devices.agent_host +RETURNING id; diff --git a/services/core/internal/db/sqlc/sandbox_link.sql.go b/services/core/internal/db/sqlc/sandbox_link.sql.go index 6c3767167..acc0e7e7c 100644 --- a/services/core/internal/db/sqlc/sandbox_link.sql.go +++ b/services/core/internal/db/sqlc/sandbox_link.sql.go @@ -104,3 +104,28 @@ func (q *Queries) GetSandboxServeAuthority(ctx context.Context, id pgtype.UUID) ) return i, err } + +const registerAgentHost = `-- name: RegisterAgentHost :one +INSERT INTO devices (id, name, credential_hash, agent_host) +VALUES ($1, 'agent-host', $2, true) +ON CONFLICT (id) DO UPDATE SET credential_hash = EXCLUDED.credential_hash, + credential_revision = devices.credential_revision + (devices.credential_hash IS DISTINCT FROM EXCLUDED.credential_hash)::int +WHERE devices.agent_host +RETURNING id +` + +type RegisterAgentHostParams struct { + ID pgtype.UUID `json:"id"` + CredentialHash pgtype.Text `json:"credential_hash"` +} + +// The deployment's agent host, with no tenant, Environment or executor key. +// A new credential advances the revision, which fences the Links the old one +// authenticated; a revocation stays. No row means the ID belongs to a device +// that is not an agent host. +func (q *Queries) RegisterAgentHost(ctx context.Context, arg RegisterAgentHostParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, registerAgentHost, arg.ID, arg.CredentialHash) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} diff --git a/services/core/internal/persistence/postgres/sessionpg/link.go b/services/core/internal/persistence/postgres/sessionpg/link.go index 043fa74ae..3a55129e3 100644 --- a/services/core/internal/persistence/postgres/sessionpg/link.go +++ b/services/core/internal/persistence/postgres/sessionpg/link.go @@ -3,12 +3,14 @@ package sessionpg import ( "context" "errors" + "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" ) @@ -54,6 +56,21 @@ func (s *Store) GetAgentHostCredential(ctx context.Context, runtime string) (run return runtimedevice.AgentHost{CredentialHash: row.CredentialHash, Revision: uint64(row.CredentialRevision)}, true, nil } +// RegisterAgentHost records the deployment's agent host with the digest of +// its credential. Registering it again with the same credential changes +// nothing. +func (s *Store) RegisterAgentHost(ctx context.Context, runtime, credentialHash string) error { + id, err := pgunit.ParseID(runtime) + if err != nil { + return err + } + _, err = s.units.Queries().RegisterAgentHost(ctx, sqlc.RegisterAgentHostParams{ID: id, CredentialHash: pgtype.Text{String: credentialHash, Valid: true}}) + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("agent host %s is registered as another device", runtime) + } + return err +} + // GetLinkAssignment reads an assignment as the Link authority sees it. A // malformed or unknown assignment ID has none. func (s *Store) GetLinkAssignment(ctx context.Context, assignment string) (runtimedevice.LinkAssignment, bool, error) { diff --git a/services/core/internal/processconfig/config.go b/services/core/internal/processconfig/config.go index 53a87a10d..b7082ff64 100644 --- a/services/core/internal/processconfig/config.go +++ b/services/core/internal/processconfig/config.go @@ -29,6 +29,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) @@ -53,6 +54,12 @@ type Config struct { // InstallationID comes from OAC_INSTALLATION_ID_FILE. Empty leaves the // sandbox deployment and node routes off. InstallationID string + // AgentHostID and AgentHostCredentialHash are the deployment's agent host + // from OAC_AGENT_HOST_IDENTITY_FILE, which the Runtime gateway requires; + // Core registers it at startup. The hash is the one Runtime and Link + // authentication compare. + AgentHostID string + AgentHostCredentialHash string // CredentialKey seals stored credentials. Nil when // OAC_CREDENTIAL_KEY_FILE is unset. CredentialKey *credentialcrypto.Cipher @@ -128,6 +135,15 @@ func Load() (Config, error) { if c.InstallationID != "" && c.PublicOrigin == nil { return Config{}, configError("OAC_INSTALLATION_ID_FILE requires OAC_PUBLIC_URL, the origin nodes and sandboxes use to reach Core") } + if c.AgentHostID, c.AgentHostCredentialHash, err = agentHost(); err != nil { + return Config{}, err + } + if c.AgentHostID != "" && c.PublicOrigin == nil { + return Config{}, configError("OAC_AGENT_HOST_IDENTITY_FILE requires OAC_PUBLIC_URL, the origin of the Runtime gateway the agent host connects to") + } + if c.AgentHostID == "" && c.PublicOrigin != nil { + return Config{}, configError("OAC_PUBLIC_URL requires OAC_AGENT_HOST_IDENTITY_FILE, the agent host its Runtime gateway serves") + } if c.CredentialKey, err = credentialKey(); err != nil { return Config{}, err } @@ -226,6 +242,32 @@ func installationID() (string, error) { return value, nil } +// agentHost reads the agent-host identity: its canonical Runtime ID and the +// digest of its credential, which the agent host presents verbatim. +func agentHost() (string, string, error) { + path := os.Getenv("OAC_AGENT_HOST_IDENTITY_FILE") + if path == "" { + return "", "", nil + } + raw, err := os.ReadFile(path) + if err != nil { + return "", "", configError("OAC_AGENT_HOST_IDENTITY_FILE must name a readable file") + } + var identity struct { + RuntimeID string `json:"runtime_id"` + Credential string `json:"credential"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&identity) != nil || decoder.Decode(new(any)) != io.EOF { + return "", "", configError("OAC_AGENT_HOST_IDENTITY_FILE must hold one JSON object with runtime_id and credential") + } + if id, err := uuid.Parse(identity.RuntimeID); err != nil || id == uuid.Nil || id.String() != identity.RuntimeID || strings.TrimSpace(identity.Credential) == "" { + return "", "", configError("OAC_AGENT_HOST_IDENTITY_FILE must hold a canonical UUID runtime_id and a credential") + } + return identity.RuntimeID, runtimedevice.HashCredential(identity.Credential), nil +} + func credentialKey() (*credentialcrypto.Cipher, error) { path := os.Getenv("OAC_CREDENTIAL_KEY_FILE") if path == "" { diff --git a/services/core/internal/processconfig/config_test.go b/services/core/internal/processconfig/config_test.go index d66edb2bc..a3ebfd2a6 100644 --- a/services/core/internal/processconfig/config_test.go +++ b/services/core/internal/processconfig/config_test.go @@ -10,8 +10,11 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" ) +const agentHostIdentity = `{"runtime_id": "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b", "credential": "synthetic-credential"}` + // required sets the settings Load requires and returns a file writer. func required(t *testing.T) func(name, content string) string { t.Helper() @@ -43,7 +46,7 @@ func TestLoadAppliesDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - if c.Addr != "127.0.0.1:8091" || c.PublicOrigin != nil || c.InstallationID != "" || c.CredentialKey != nil || c.CoreKeys == nil || + if c.Addr != "127.0.0.1:8091" || c.PublicOrigin != nil || c.InstallationID != "" || c.AgentHostID != "" || c.CredentialKey != nil || c.CoreKeys == nil || c.ExecutionConcurrency != 4 || c.DefaultHarness != "codex" || strings.Join(c.Harnesses, ",") != "claude_sdk,codex,mcode" || c.WriteAuditRetention != 90*24*time.Hour || c.OAuthTrustedOrigins != nil || c.NativeInstallers != "" || c.ProviderPaths.StateRoot != "/state" { t.Fatalf("%+v", c) @@ -68,16 +71,17 @@ func TestLoadRejectsInvalidValuesWithoutEchoingThem(t *testing.T) { rejects(t, "OAC_CORE_KEY_DIGESTS_FILE", "synthetic-secret") required(t) for variable, value := range map[string]string{ - "OAC_PUBLIC_URL": "https://user:synthetic-secret@core.example", - "OAC_EXECUTION_CONCURRENCY": "synthetic-secret", - "OAC_DEFAULT_HARNESS": "synthetic-secret", - "OAC_HARNESSES": "codex,synthetic-secret", - "OAC_WRITE_AUDIT_RETENTION": "synthetic-secret", - "OAC_OAUTH_TRUSTED_ORIGINS": "https://synthetic-secret.example/token", - "OAC_LOG_LEVEL": "verbose", - "OAC_INSTALLATION_ID_FILE": write("installation.id", "synthetic-secret"), - "OAC_CREDENTIAL_KEY_FILE": write("credential.key", "synthetic-secret"), - "OAC_HISTORY_SETTINGS_FILE": write("history.json", `{"secret":"synthetic-secret"}`), + "OAC_PUBLIC_URL": "https://user:synthetic-secret@core.example", + "OAC_EXECUTION_CONCURRENCY": "synthetic-secret", + "OAC_DEFAULT_HARNESS": "synthetic-secret", + "OAC_HARNESSES": "codex,synthetic-secret", + "OAC_WRITE_AUDIT_RETENTION": "synthetic-secret", + "OAC_OAUTH_TRUSTED_ORIGINS": "https://synthetic-secret.example/token", + "OAC_LOG_LEVEL": "verbose", + "OAC_INSTALLATION_ID_FILE": write("installation.id", "synthetic-secret"), + "OAC_AGENT_HOST_IDENTITY_FILE": write("identity.json", `{"runtime_id": "synthetic-secret", "credential": "c"}`), + "OAC_CREDENTIAL_KEY_FILE": write("credential.key", "synthetic-secret"), + "OAC_HISTORY_SETTINGS_FILE": write("history.json", `{"secret":"synthetic-secret"}`), } { t.Run(variable, func(t *testing.T) { t.Setenv(variable, value) @@ -87,13 +91,37 @@ func TestLoadRejectsInvalidValuesWithoutEchoingThem(t *testing.T) { t.Setenv("OAC_INSTALLATION_ID_FILE", write("valid.id", "8c5f4f5e-2c55-4c43-9a49-7f2f3f2d1d10\n")) rejects(t, "OAC_PUBLIC_URL", "") t.Setenv("OAC_PUBLIC_URL", "https://core.example") + t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("valid.json", agentHostIdentity)) if c, err := Load(); err != nil || c.InstallationID != "8c5f4f5e-2c55-4c43-9a49-7f2f3f2d1d10" { t.Fatal(c.InstallationID, err) } } +func TestAgentHostIdentityComesWithTheRuntimeGateway(t *testing.T) { + write := required(t) + t.Setenv("OAC_PUBLIC_URL", "https://core.example") + rejects(t, "OAC_AGENT_HOST_IDENTITY_FILE", "") + for _, content := range []string{ + `{"runtime_id": "2F1C4A7E-9B3D-4E5F-8A6B-1C2D3E4F5A6B", "credential": "synthetic-secret"}`, + `{"runtime_id": "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b", "credential": " "}`, + `{"runtime_id": "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b", "credential": "synthetic-secret", "name": "x"}`, + `{"runtime_id": "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b", "credential": "synthetic-secret"} {}`, + } { + t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("identity.json", content)) + rejects(t, "OAC_AGENT_HOST_IDENTITY_FILE", "synthetic-secret") + } + t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("identity.json", agentHostIdentity+"\n")) + c, err := Load() + if err != nil || c.AgentHostID != "2f1c4a7e-9b3d-4e5f-8a6b-1c2d3e4f5a6b" || c.AgentHostCredentialHash != runtimedevice.HashCredential("synthetic-credential") { + t.Fatal(c.AgentHostID, err) + } + t.Setenv("OAC_PUBLIC_URL", "") + rejects(t, "OAC_AGENT_HOST_IDENTITY_FILE", "") +} + func TestPublicURLMustBeACanonicalOrigin(t *testing.T) { - required(t) + write := required(t) + t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("identity.json", agentHostIdentity)) for _, value := range []string{"https://core.example", "https://core.example:8443", "http://127.0.0.1:8091", "http://core.example"} { t.Setenv("OAC_PUBLIC_URL", value) if c, err := Load(); err != nil || c.PublicOrigin.String() != value { @@ -208,6 +236,7 @@ func TestRuntimeHistoryFile(t *testing.T) { func TestSettingsReportEffectiveValuesAndHideHistory(t *testing.T) { write := required(t) t.Setenv("OAC_PUBLIC_URL", "https://core.example") + t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", write("identity.json", agentHostIdentity)) t.Setenv("OAC_EXECUTION_CONCURRENCY", "8") t.Setenv("OAC_LOG_LEVEL", "warn") t.Setenv("OAC_WRITE_AUDIT_RETENTION", "1440m") diff --git a/services/core/migrations/000095_sandbox_link_authority.sql b/services/core/migrations/000095_sandbox_link_authority.sql index ff220f5a8..7325afa51 100644 --- a/services/core/migrations/000095_sandbox_link_authority.sql +++ b/services/core/migrations/000095_sandbox_link_authority.sql @@ -3,7 +3,8 @@ -- resource at serve_generation. A self_hosted enrollment's resource is served -- with its executor credential while that key is authorized for the -- Environment. Only a marked agent host may Attach; credential_revision fences --- links that a rotated credential authenticated. +-- links that a rotated credential authenticated. An agent host may belong to +-- no tenant, such as the deployment's own; every other device belongs to one. ALTER TABLE runtime_allocations ADD COLUMN serve_credential_hash text CHECK (serve_credential_hash ~ '^[0-9a-f]{64}$'), ADD COLUMN serve_generation bigint NOT NULL DEFAULT 1 CHECK (serve_generation > 0); @@ -18,7 +19,8 @@ CREATE TABLE sandbox_enrollments ( ALTER TABLE devices ADD COLUMN agent_host boolean NOT NULL DEFAULT false, ADD COLUMN credential_revision bigint NOT NULL DEFAULT 1 CHECK (credential_revision > 0), - ADD CONSTRAINT devices_agent_host CHECK (NOT agent_host OR (environment_id IS NULL AND executor_key_id IS NULL)); + ALTER COLUMN tenant_id DROP NOT NULL, + ADD CONSTRAINT devices_agent_host CHECK (CASE WHEN agent_host THEN environment_id IS NULL AND executor_key_id IS NULL ELSE tenant_id IS NOT NULL END); -- Every Link resource with its Serve credential hash. A resource is live -- while that credential may Serve it. @@ -43,8 +45,10 @@ JOIN environment_executor_credentials c ON c.key_id = n.executor_key_id; -- +goose Down DROP VIEW sandbox_resources; +DELETE FROM devices WHERE tenant_id IS NULL; ALTER TABLE devices DROP CONSTRAINT devices_agent_host, + ALTER COLUMN tenant_id SET NOT NULL, DROP COLUMN credential_revision, DROP COLUMN agent_host; DROP TABLE sandbox_enrollments; diff --git a/services/core/tests/container_server.py b/services/core/tests/container_server.py index 072a6a557..1649d4ce2 100755 --- a/services/core/tests/container_server.py +++ b/services/core/tests/container_server.py @@ -20,6 +20,12 @@ "--mount", f"type=bind,source={credential_key},target=/run/credential.key,readonly", "--env", "OAC_CREDENTIAL_KEY_FILE=/run/credential.key", ]) +agent_host_identity = os.environ.get("OAC_AGENT_HOST_IDENTITY_FILE") +if agent_host_identity: + args.extend([ + "--mount", f"type=bind,source={agent_host_identity},target=/run/agent-host/identity.json,readonly", + "--env", "OAC_AGENT_HOST_IDENTITY_FILE=/run/agent-host/identity.json", + ]) for name in ("OAC_DATABASE_URL", "OAC_ADDR", "OAC_DEFAULT_HARNESS", "OAC_PUBLIC_URL"): args.extend(["--env", name]) args.append(os.environ["OAC_DEV_CORE_IMAGE"]) diff --git a/services/core/tests/integration/link_authority_test.go b/services/core/tests/integration/link_authority_test.go index b51236653..4a514dad6 100644 --- a/services/core/tests/integration/link_authority_test.go +++ b/services/core/tests/integration/link_authority_test.go @@ -10,6 +10,8 @@ import ( "encoding/json" "errors" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -26,6 +28,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/processconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -478,3 +481,59 @@ func TestLinkAuthorityDestroyedAllocation(t *testing.T) { t.Fatal("a destroyed allocation's Serve credential served", got) } } + +// TestRegisteredAgentHostAuthenticates registers the agent host from its +// identity file as Core's startup does. The Link route and the Runtime gateway +// accept its credential, a second startup changes nothing, and another +// device's ID is never taken over. +func TestRegisteredAgentHostAuthenticates(t *testing.T) { + s, _ := testStore(t) + dir := t.TempDir() + runtime, credential := uuid.NewString(), uuid.NewString() + identity, _ := json.Marshal(map[string]string{"runtime_id": runtime, "credential": credential}) + for name, content := range map[string][]byte{"identity.json": identity, "digests.json": []byte(`["` + strings.Repeat("ab", 32) + `"]`)} { + if err := os.WriteFile(filepath.Join(dir, name), content, 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("OAC_DATABASE_URL", "postgres://core@database/core") + t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", filepath.Join(dir, "digests.json")) + t.Setenv("OAC_PUBLIC_URL", "https://core.example") + t.Setenv("OAC_AGENT_HOST_IDENTITY_FILE", filepath.Join(dir, "identity.json")) + config, err := processconfig.Load() + if err != nil { + t.Fatal(err) + } + row := func() string { + var state string + if err := s.pool.QueryRow(t.Context(), `SELECT row(tenant_id IS NULL, environment_id IS NULL, executor_key_id IS NULL, agent_host, + credential_hash, credential_revision, revoked_at IS NULL, count(*) OVER ())::text FROM devices WHERE id = $1`, runtime).Scan(&state); err != nil { + t.Fatal(err) + } + return state + } + if err := sessionAdapter(s).RegisterAgentHost(t.Context(), config.AgentHostID, config.AgentHostCredentialHash); err != nil { + t.Fatal(err) + } + registered := row() + if want := "(t,t,t,t," + runtimedevice.HashCredential(credential) + ",1,t,1)"; registered != want { + t.Fatalf("registered %s, want %s", registered, want) + } + if _, err := attachLink(t, startLinkRoute(t, s), runtime, []byte(credential)); err != nil { + t.Fatal("the Link refused the agent host", err) + } + if _, err := runtimegateway.NewAuthenticator(sessionAdapter(s)).AuthenticateBearer(t.Context(), runtime, credential); err != nil { + t.Fatal("the Runtime gateway refused the agent host", err) + } + if err := sessionAdapter(s).RegisterAgentHost(t.Context(), config.AgentHostID, config.AgentHostCredentialHash); err != nil || row() != registered { + t.Fatal("a second registration changed the agent host", err) + } + + device, err := sessionService(t, s).CreateDevice(t.Context(), uuid.NewString(), "operator", runtimedevice.HashCredential(credential)) + if err != nil { + t.Fatal(err) + } + if err := sessionAdapter(s).RegisterAgentHost(t.Context(), device.ID, config.AgentHostCredentialHash); err == nil { + t.Fatal("registration took over a tenant device") + } +} diff --git a/services/core/tests/official_client.py b/services/core/tests/official_client.py index 196fc9a0f..3b40c231d 100644 --- a/services/core/tests/official_client.py +++ b/services/core/tests/official_client.py @@ -87,6 +87,10 @@ def project_bindings(directory): # Enable the real Worker/gateway admission path without connecting a daemon. # Synthetic fixture inputs remain queued; this is not live model acceptance. env["OAC_PUBLIC_URL"] = f"http://127.0.0.1:{port}" + agent_host_identity = Path(directory) / "agent-host.json" + agent_host_identity.touch(mode=0o600) + agent_host_identity.write_text(json.dumps({"runtime_id": str(uuid.uuid4()), "credential": secrets.token_urlsafe(32)})) + env["OAC_AGENT_HOST_IDENTITY_FILE"] = str(agent_host_identity) with (Path(directory) / "server.log").open("w+") as log: def start(): nonlocal process