diff --git a/examples/agent-compose/README.md b/examples/agent-compose/README.md index e29006c1f..aeffb3d1c 100644 --- a/examples/agent-compose/README.md +++ b/examples/agent-compose/README.md @@ -11,18 +11,30 @@ simplest to most complete. | [docker-scheduler-cron](docker-scheduler-cron/) | Managed cron scheduler control plane. | No, for `config`/`up`/`ps`/`down` | | [docker-scheduler-script-url](docker-scheduler-script-url/) | A scheduler script loaded from a relative file URL source. | No, for `config`/`up`/`ps`/`down` | | [docker-scheduler-timeout](docker-scheduler-timeout/) | End-to-end scheduled run that fires, executes the agent, and persists logs. | Yes, for the scheduled run | +| [docker-workspace-lifecycle](docker-workspace-lifecycle/) | Local workspace copy plus sandbox stop, resume, exec, and removal. | No | +| [docker-multi-agent](docker-multi-agent/) | Two independent agents using the same workspace source. | No for command runs; yes for prompts | +| [docker-env-secrets](docker-env-secrets/) | Dotenv, project/agent variables, and secret redaction. | No | +| [docker-volume-persistence](docker-volume-persistence/) | Managed volumes and read-only bind mounts. | No | +| [docker-build](docker-build/) | Build and run a guest-derived Docker image. | No | +| [docker-scheduler-script-runtime](docker-scheduler-script-runtime/) | Inline QJS, persisted scheduler state, and shell callbacks. | No | +| [boxlite-minimal](boxlite-minimal/) | Minimal BoxLite configuration template. | Only for prompt runs | +| [microsandbox-minimal](microsandbox-minimal/) | Minimal Microsandbox configuration template. | Only for prompt runs | ## Common prerequisites - Docker daemon is running. - The `agent-compose` daemon is already running. -- The `agent-compose-guest:latest` image exists locally. +- Docker can access `ghcr.io/chaitin/agent-compose-guest:latest`. -From the repository root, build the guest image if needed: +Pull the image used by the examples if needed: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` Each example has its own `README.md` with the exact commands and expected output. + +Running the BoxLite and Microsandbox examples additionally requires Linux, KVM +access, their runtime artifacts, and a binary that includes the selected +compiled driver. diff --git a/examples/agent-compose/README.zh-CN.md b/examples/agent-compose/README.zh-CN.md index c908f5f33..555a99851 100644 --- a/examples/agent-compose/README.zh-CN.md +++ b/examples/agent-compose/README.zh-CN.md @@ -10,17 +10,28 @@ | [docker-scheduler-cron](docker-scheduler-cron/) | managed cron scheduler 的控制面流程。 | `config`/`up`/`ps`/`down` 不需要 | | [docker-scheduler-script-url](docker-scheduler-script-url/) | 从相对文件 URL 来源加载 scheduler 脚本。 | `config`/`up`/`ps`/`down` 不需要 | | [docker-scheduler-timeout](docker-scheduler-timeout/) | 端到端的定时运行:触发、执行 agent 并持久化日志。 | 定时运行需要 | +| [docker-workspace-lifecycle](docker-workspace-lifecycle/) | 本地 workspace 副本及 sandbox stop、resume、exec、rm。 | 不需要 | +| [docker-multi-agent](docker-multi-agent/) | 两个独立 agent 使用同一 workspace source。 | command 不需要;prompt 需要 | +| [docker-env-secrets](docker-env-secrets/) | Dotenv、project/agent variables 和 secret 隐藏。 | 不需要 | +| [docker-volume-persistence](docker-volume-persistence/) | 托管 volume 和只读 bind mount。 | 不需要 | +| [docker-build](docker-build/) | 构建并运行基于 guest 的 Docker 镜像。 | 不需要 | +| [docker-scheduler-script-runtime](docker-scheduler-script-runtime/) | Inline QJS、持久 scheduler state 和 shell callback。 | 不需要 | +| [boxlite-minimal](boxlite-minimal/) | 最小 BoxLite 配置模板。 | prompt run 需要 | +| [microsandbox-minimal](microsandbox-minimal/) | 最小 Microsandbox 配置模板。 | prompt run 需要 | ## 通用前置条件 - Docker daemon 正在运行。 - `agent-compose` daemon 已经启动。 -- 本地存在 `agent-compose-guest:latest` 镜像。 +- Docker 能访问 `ghcr.io/chaitin/agent-compose-guest:latest`。 -如需构建 guest 镜像,在仓库根目录执行: +如需获取示例使用的镜像,执行: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` 每个示例都有自己的 `README.md`,包含完整命令和预期输出。 + +运行 BoxLite 和 Microsandbox 示例还要求 Linux、KVM 权限、对应 runtime +artifacts,以及包含所选 compiled driver 的二进制。 diff --git a/examples/agent-compose/boxlite-minimal/README.md b/examples/agent-compose/boxlite-minimal/README.md new file mode 100644 index 000000000..54a6d23c7 --- /dev/null +++ b/examples/agent-compose/boxlite-minimal/README.md @@ -0,0 +1,59 @@ +# Minimal BoxLite project + +Languages: English | [中文](README.zh-CN.md) + +This example defines a single Codex agent backed by the BoxLite driver. + +## Requirements + +Runtime use requires Linux, KVM access, BoxLite artifacts, and a binary whose +`compiled_drivers` contains `boxlite`. Check the binary before applying: + +```bash +agent-compose --json version +test -r /dev/kvm && test -w /dev/kvm +``` + +## Inspect the configuration + +```bash +agent-compose config +``` + +The normalized output should contain `driver.name: boxlite`. + +## Run on a BoxLite host + +After the requirements above are satisfied: + +```bash +agent-compose up +agent-compose run reviewer --command "uname -a" +agent-compose ps --all +agent-compose down +``` + +`run` should return `status: succeeded`, a non-empty sandbox ID, and the guest +kernel information. If the binary does not include BoxLite, the command reports +the driver as unsupported. Missing KVM access or runtime artifacts cause +BoxLite initialization to fail. + +## Normalized config output + +`agent-compose config` produces: + +```yaml +name: boxlite-minimal +agents: + - name: reviewer + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + name: boxlite + boxlite: {} +network: + mode: default +``` + +This output confirms that the project selects BoxLite. Runtime output depends +on the BoxLite artifacts and KVM environment on the host. diff --git a/examples/agent-compose/boxlite-minimal/README.zh-CN.md b/examples/agent-compose/boxlite-minimal/README.zh-CN.md new file mode 100644 index 000000000..9e2faa0dd --- /dev/null +++ b/examples/agent-compose/boxlite-minimal/README.zh-CN.md @@ -0,0 +1,58 @@ +# 最小 BoxLite project + +语言:[English](README.md) | 中文 + +本示例定义一个使用 BoxLite driver 的 Codex agent。 + +## 环境要求 + +实际运行要求 Linux、KVM 权限、BoxLite artifacts,以及 `compiled_drivers` 包含 +`boxlite` 的二进制。应用前检查: + +```bash +agent-compose --json version +test -r /dev/kvm && test -w /dev/kvm +``` + +## 查看配置 + +```bash +agent-compose config +``` + +归一化输出应包含 `driver.name: boxlite`。 + +## 在 BoxLite host 上运行 + +满足上述环境要求后执行: + +```bash +agent-compose up +agent-compose run reviewer --command "uname -a" +agent-compose ps --all +agent-compose down +``` + +`run` 应返回 `status: succeeded`、非空 sandbox ID 和 guest kernel 信息。如果二进制 +未包含 BoxLite,命令会报告 driver 不受支持;缺少 KVM 权限或 runtime artifacts +会导致 BoxLite 初始化失败。 + +## Config 归一化输出 + +`agent-compose config` 输出如下: + +```yaml +name: boxlite-minimal +agents: + - name: reviewer + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + name: boxlite + boxlite: {} +network: + mode: default +``` + +该输出表明 project 已选择 BoxLite;runtime 输出取决于 host 上的 BoxLite artifacts +和 KVM 环境。 diff --git a/examples/agent-compose/boxlite-minimal/agent-compose.yml b/examples/agent-compose/boxlite-minimal/agent-compose.yml new file mode 100644 index 000000000..026e652fb --- /dev/null +++ b/examples/agent-compose/boxlite-minimal/agent-compose.yml @@ -0,0 +1,8 @@ +name: boxlite-minimal + +agents: + reviewer: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + boxlite: {} diff --git a/examples/agent-compose/docker-build/Dockerfile b/examples/agent-compose/docker-build/Dockerfile new file mode 100644 index 000000000..2dec12c61 --- /dev/null +++ b/examples/agent-compose/docker-build/Dockerfile @@ -0,0 +1,4 @@ +ARG BASE_IMAGE=ghcr.io/chaitin/agent-compose-guest:latest +FROM ${BASE_IMAGE} +ARG EXAMPLE_MARKER=built-by-agent-compose +RUN printf '%s\n' "${EXAMPLE_MARKER}" > /opt/agent-compose-example.txt diff --git a/examples/agent-compose/docker-build/README.md b/examples/agent-compose/docker-build/README.md new file mode 100644 index 000000000..42497bd55 --- /dev/null +++ b/examples/agent-compose/docker-build/README.md @@ -0,0 +1,49 @@ +# Build a Docker guest image + +Languages: English | [中文](README.zh-CN.md) + +This example builds a guest-derived image and verifies a build argument marker. + +## Prerequisites and configuration + +Docker and the daemon must be running, and Docker must be able to obtain the +published guest base image. `build.context` is this directory, `dockerfile` +selects `Dockerfile`, `args` supplies the marker, and `tags` adds a second local +tag to the primary `image` reference. + +## Run the example +From this example directory: + +```bash +agent-compose build +agent-compose up +agent-compose run worker --command "cat /opt/agent-compose-example.txt" +agent-compose down +agent-compose rmi agent-compose-example-build:latest --force +agent-compose rmi agent-compose-example-build:local --force +``` + +The expected marker is `built-by-agent-compose`. The build requires Docker and +access to the published guest base image. The example uses fixed local tags for +clarity; automation should copy the example and substitute unique tags. + +## Expected result + +`build` must complete and create both local tags. The worker command reads the +file written during the image build and must print `built-by-agent-compose`. +After `down`, both `rmi` commands remove the tutorial images. Avoid these fixed +tags in concurrent automation; use a unique tag for each build. + +## Example output + +A successful image build and guest run produces output like: + +```console +status=succeeded +run=a023773553771e0be8d51fb1a983c37e66c2712697b37e9119be7ba4ccc04ef8 +sandbox=78459590803602e1945bdac9e3c74a1d9a656c29f57b6169bab757d1779b1d7e +built-by-agent-compose +``` + +Generated run and sandbox IDs differ. Concurrent automation should substitute +a unique image tag. diff --git a/examples/agent-compose/docker-build/README.zh-CN.md b/examples/agent-compose/docker-build/README.zh-CN.md new file mode 100644 index 000000000..70bdeec09 --- /dev/null +++ b/examples/agent-compose/docker-build/README.zh-CN.md @@ -0,0 +1,45 @@ +# 构建 Docker guest image + +语言:[English](README.md) | 中文 + +该示例构建一个基于 guest image 的本地镜像,并验证 build argument marker。 + +## 前置条件与配置 + +Docker 和 daemon 必须已启动,Docker 还需能获得发布版 guest 基础镜像。 +`build.context` 指向本目录,`dockerfile` 选择 Dockerfile,`args` 提供 marker, +`tags` 为主 `image` 引用增加第二个本地 tag。 + +## 运行示例 +在示例目录中执行: + +```bash +agent-compose build +agent-compose up +agent-compose run worker --command "cat /opt/agent-compose-example.txt" +agent-compose down +agent-compose rmi agent-compose-example-build:latest --force +agent-compose rmi agent-compose-example-build:local --force +``` + +预期 marker 为 `built-by-agent-compose`。构建需要 Docker 能访问发布版 guest base +image。示例使用固定 tag 以便阅读;自动化测试应复制示例并替换成唯一 tag。 + +## 预期结果 + +`build` 必须完成并创建两个本地 tag。worker 命令读取镜像构建阶段写入的文件,输出 +必须为 `built-by-agent-compose`。`down` 后两个 `rmi` 命令删除教程镜像。并发自动化 +不要复用这些固定 tag;每次 build 应使用唯一 tag。 + +## 输出示例 + +镜像构建和 guest run 成功后,输出示例如下: + +```console +status=succeeded +run=a023773553771e0be8d51fb1a983c37e66c2712697b37e9119be7ba4ccc04ef8 +sandbox=78459590803602e1945bdac9e3c74a1d9a656c29f57b6169bab757d1779b1d7e +built-by-agent-compose +``` + +动态 run 和 sandbox ID 会不同;并发自动化应替换为唯一 image tag。 diff --git a/examples/agent-compose/docker-build/agent-compose.yml b/examples/agent-compose/docker-build/agent-compose.yml new file mode 100644 index 000000000..4e5dcc75c --- /dev/null +++ b/examples/agent-compose/docker-build/agent-compose.yml @@ -0,0 +1,15 @@ +name: docker-build + +agents: + worker: + provider: codex + image: agent-compose-example-build:local + build: + context: . + dockerfile: Dockerfile + args: + EXAMPLE_MARKER: built-by-agent-compose + tags: + - agent-compose-example-build:latest + driver: + docker: {} diff --git a/examples/agent-compose/docker-env-secrets/README.md b/examples/agent-compose/docker-env-secrets/README.md new file mode 100644 index 000000000..748e8cfe0 --- /dev/null +++ b/examples/agent-compose/docker-env-secrets/README.md @@ -0,0 +1,53 @@ +# Project environment and secrets + +Languages: English | [中文](README.zh-CN.md) + +This example uses an explicit dotenv file, project variables, agent-specific +environment, and secret metadata. The committed value is intentionally fake. + +## Prerequisites and configuration + +Docker and the daemon must be running. `env_file: example.env` supplies +`EXAMPLE_SECRET`; project `variables` apply to every agent, while `agents.*.env` +is agent-scoped. `secret: true` marks values for redaction in rendered config. + +## Run the example +From this example directory: + +```bash +agent-compose config +agent-compose up +agent-compose run inspector --command ' + test "$PROJECT_VALUE" = project-level && + test "$AGENT_VALUE" = agent-level && + test "$PROJECT_SECRET" = safe-example-secret && + test "$AGENT_SECRET" = safe-example-secret && + echo "environment ok" +' +agent-compose down +``` + +`config` redacts values marked `secret: true`. Project variables are supplied to +runs, while agent env is scoped to that agent. Process environment values passed +to the CLI take precedence over `example.env`. + +## Expected result + +Before `up`, confirm `agent-compose config` contains `********` and never prints +`safe-example-secret`. The command run must print `environment ok`, proving the +real guest received both scopes. The value is a non-sensitive fixture; do not +commit production secrets or use this pattern as a secret manager. + +## Example output + +A successful environment check produces output like: + +```console +status=succeeded +run=f1d22000463b950c2251f72c77477d42fbe9a39b2663bbce39b0b20c04be05e8 +sandbox=9fffd2978773c87708dc46facb8bf5ba8b1edf275382afb56380b0294a497939 +environment ok +``` + +Rendered config must contain `********` and must not contain the fixture +secret. Generated IDs will differ. diff --git a/examples/agent-compose/docker-env-secrets/README.zh-CN.md b/examples/agent-compose/docker-env-secrets/README.zh-CN.md new file mode 100644 index 000000000..7752a96dc --- /dev/null +++ b/examples/agent-compose/docker-env-secrets/README.zh-CN.md @@ -0,0 +1,50 @@ +# Project 环境变量和 secret + +语言:[English](README.md) | 中文 + +该示例使用显式 dotenv 文件、project variables、agent 专属环境变量和 secret +元数据。仓库中提交的值是刻意设置的假值。 + +## 前置条件与配置 + +Docker 和 daemon 必须已启动。`env_file: example.env` 提供 `EXAMPLE_SECRET`; +project `variables` 作用于所有 agent,`agents.*.env` 只作用于对应 agent。 +`secret: true` 要求渲染配置时隐藏该值。 + +## 运行示例 +在示例目录中执行: + +```bash +agent-compose config +agent-compose up +agent-compose run inspector --command ' + test "$PROJECT_VALUE" = project-level && + test "$AGENT_VALUE" = agent-level && + test "$PROJECT_SECRET" = safe-example-secret && + test "$AGENT_SECRET" = safe-example-secret && + echo "environment ok" +' +agent-compose down +``` + +`config` 会隐藏标记为 `secret: true` 的值。Project variables 会传给 run,agent +env 只属于该 agent。启动 CLI 时的进程环境变量优先于 `example.env`。 + +## 预期结果 + +执行 `up` 前,确认 `agent-compose config` 包含 `********`,且不输出 +`safe-example-secret`。command run 必须输出 `environment ok`,证明真实 guest 收到 +两个作用域的变量。该值只是非敏感 fixture;不要提交生产 secret。 + +## 输出示例 + +环境检查成功后,输出示例如下: + +```console +status=succeeded +run=f1d22000463b950c2251f72c77477d42fbe9a39b2663bbce39b0b20c04be05e8 +sandbox=9fffd2978773c87708dc46facb8bf5ba8b1edf275382afb56380b0294a497939 +environment ok +``` + +渲染配置必须包含 `********` 且不能包含 fixture secret。动态 ID 会不同。 diff --git a/examples/agent-compose/docker-env-secrets/agent-compose.yml b/examples/agent-compose/docker-env-secrets/agent-compose.yml new file mode 100644 index 000000000..5e7df1a77 --- /dev/null +++ b/examples/agent-compose/docker-env-secrets/agent-compose.yml @@ -0,0 +1,21 @@ +name: docker-env-secrets + +env_file: example.env + +variables: + PROJECT_VALUE: project-level + PROJECT_SECRET: + value: ${EXAMPLE_SECRET} + secret: true + +agents: + inspector: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + AGENT_VALUE: agent-level + AGENT_SECRET: + value: ${EXAMPLE_SECRET} + secret: true diff --git a/examples/agent-compose/docker-env-secrets/example.env b/examples/agent-compose/docker-env-secrets/example.env new file mode 100644 index 000000000..9221d113d --- /dev/null +++ b/examples/agent-compose/docker-env-secrets/example.env @@ -0,0 +1 @@ +EXAMPLE_SECRET=safe-example-secret diff --git a/examples/agent-compose/docker-minimal/README.md b/examples/agent-compose/docker-minimal/README.md index 3b2e1e9df..aa7f25528 100644 --- a/examples/agent-compose/docker-minimal/README.md +++ b/examples/agent-compose/docker-minimal/README.md @@ -18,12 +18,12 @@ It is intentionally minimal: - Docker daemon is running. - The `agent-compose` daemon is already running. -- The `agent-compose-guest:latest` image exists locally. +- Docker can access `ghcr.io/chaitin/agent-compose-guest:latest`. -From the repository root, build the guest image if needed: +Pull the image referenced by the compose file if needed: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` If you have an installed `agent-compose` binary in `PATH`, use: @@ -48,7 +48,7 @@ name: docker-minimal agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} ``` @@ -85,9 +85,9 @@ Expected result: - `config` prints a normalized project with `driver.name: docker`. - `up` creates or updates the project and managed agent definition. -- `ps` shows the `reviewer` agent using Docker and `agent-compose-guest:latest`. +- `ps` shows the `reviewer` agent using Docker and the published guest image. -## Optional run test +## Run the agent To start a runtime session and keep it alive: @@ -95,15 +95,15 @@ To start a runtime session and keep it alive: agent-compose run reviewer --keep-running --prompt "hello from docker minimal example" ``` -A real agent run requires a working guest runtime and provider authentication. -For `provider: codex`, configure the required Codex credentials or API key in -the guest environment before expecting model execution to succeed. +A real agent run requires a working guest runtime and provider configuration in +the daemon. The guest uses the sandbox-scoped LLM facade; long-lived provider +credentials are not copied into it. If the runtime session is alive, you can run commands in it: ```bash -agent-compose exec --agent reviewer -- pwd -agent-compose exec --agent reviewer -- env +agent-compose exec -- pwd +agent-compose exec -- env ``` Clean up running project sessions: @@ -112,9 +112,10 @@ Clean up running project sessions: agent-compose down ``` -## Verification output +## Example output -Output from a local verification run. +A successful run produces output like the following. Generated IDs, hashes, +and timestamps will differ. ### 1. Config normalization @@ -124,7 +125,7 @@ name: docker-minimal agents: - name: reviewer provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: name: docker docker: {} @@ -156,7 +157,7 @@ created agent_definition reviewer ```console $ go run ./cmd/agent-compose --file examples/agent-compose/docker-minimal/agent-compose.yml ps AGENT SCHEDULER LATEST RUN RUN STATUS SESSION DRIVER IMAGE -reviewer disabled - - - docker agent-compose-guest:latest +reviewer disabled - - - docker ghcr.io/chaitin/agent-compose-guest:latest ``` ### 4. Docker runtime container @@ -164,5 +165,23 @@ reviewer disabled - - - docker agent-compose-gues ```console $ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' NAMES IMAGE STATUS -agent-compose-8aa2625d-db67-4428-82ae-8bef1a137a2f agent-compose-guest:latest Up 14 seconds +agent-compose-8aa2625d-db67-4428-82ae-8bef1a137a2f ghcr.io/chaitin/agent-compose-guest:latest Up 14 seconds ``` + +### 5. Successful provider run + +With a working daemon provider, a successful prompt run returns fields like: + +```json +{ + "id": "8363e8c144f6ab0124054c11a6ff06e67f74fe561c2af46e7b06dd2ffb420027", + "status": "succeeded", + "sandbox_id": "9f060d2ea52b1a4bedc740715ac8f745274820df03f5b551e01841315b006fb7", + "duration_ms": 15435, + "output": "agent-compose live provider ok", + "driver": "docker", + "image_ref": "ghcr.io/chaitin/agent-compose-guest:latest" +} +``` + +IDs and duration are generated values and will differ. diff --git a/examples/agent-compose/docker-minimal/README.zh-CN.md b/examples/agent-compose/docker-minimal/README.zh-CN.md index 91e4298e3..f88ce3f27 100644 --- a/examples/agent-compose/docker-minimal/README.zh-CN.md +++ b/examples/agent-compose/docker-minimal/README.zh-CN.md @@ -18,12 +18,12 @@ - Docker daemon 正在运行。 - `agent-compose` daemon 已经启动。 -- 本地存在 `agent-compose-guest:latest` 镜像。 +- Docker 能访问 `ghcr.io/chaitin/agent-compose-guest:latest`。 -如果还没有 guest image,可以在仓库根目录构建: +如有需要,拉取 compose 文件引用的镜像: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` 如果 `agent-compose` 二进制已经在 `PATH` 中,可以直接检查 daemon: @@ -48,7 +48,7 @@ name: docker-minimal agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} ``` @@ -85,9 +85,9 @@ go run ./cmd/agent-compose --file examples/agent-compose/docker-minimal/agent-co - `config` 输出标准化后的 project,并显示 `driver.name: docker`。 - `up` 创建或更新 project 和 managed agent definition。 -- `ps` 显示 `reviewer` agent 使用 Docker 和 `agent-compose-guest:latest`。 +- `ps` 显示 `reviewer` agent 使用 Docker 和发布版 guest 镜像。 -## 可选运行测试 +## 运行 agent 启动一次 runtime session,并在运行结束后保留 session: @@ -95,14 +95,14 @@ go run ./cmd/agent-compose --file examples/agent-compose/docker-minimal/agent-co agent-compose run reviewer --keep-running --prompt "hello from docker minimal example" ``` -真正执行 agent 需要 guest runtime 可用,并且 provider 已完成认证。对于 -`provider: codex`,需要先在 guest 环境中配置 Codex 凭据或 API key。 +真正执行 agent 需要 guest runtime 可用,并在 daemon 中配置 provider。guest 使用 +sandbox 范围的 LLM facade,不会获得长期 provider 凭据。 如果 runtime session 仍在运行,可以在其中执行命令: ```bash -agent-compose exec --agent reviewer -- pwd -agent-compose exec --agent reviewer -- env +agent-compose exec -- pwd +agent-compose exec -- env ``` 清理正在运行的 project sessions: @@ -111,9 +111,9 @@ agent-compose exec --agent reviewer -- env agent-compose down ``` -## 验证输出 +## 输出示例 -以下为一次本地验证运行的输出。 +成功运行后的输出示例如下。动态生成的 ID、hash 和时间戳会不同。 ### 1. 配置标准化 @@ -123,7 +123,7 @@ name: docker-minimal agents: - name: reviewer provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: name: docker docker: {} @@ -155,7 +155,7 @@ created agent_definition reviewer ```console $ go run ./cmd/agent-compose --file examples/agent-compose/docker-minimal/agent-compose.yml ps AGENT SCHEDULER LATEST RUN RUN STATUS SESSION DRIVER IMAGE -reviewer disabled - - - docker agent-compose-guest:latest +reviewer disabled - - - docker ghcr.io/chaitin/agent-compose-guest:latest ``` ### 4. Docker runtime 容器 @@ -163,5 +163,23 @@ reviewer disabled - - - docker agent-compose-gues ```console $ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' NAMES IMAGE STATUS -agent-compose-8aa2625d-db67-4428-82ae-8bef1a137a2f agent-compose-guest:latest Up 14 seconds +agent-compose-8aa2625d-db67-4428-82ae-8bef1a137a2f ghcr.io/chaitin/agent-compose-guest:latest Up 14 seconds ``` + +### 5. Provider 成功输出 + +daemon 配置可用 provider 后,prompt run 成功输出如下: + +```json +{ + "id": "8363e8c144f6ab0124054c11a6ff06e67f74fe561c2af46e7b06dd2ffb420027", + "status": "succeeded", + "sandbox_id": "9f060d2ea52b1a4bedc740715ac8f745274820df03f5b551e01841315b006fb7", + "duration_ms": 15435, + "output": "agent-compose live provider ok", + "driver": "docker", + "image_ref": "ghcr.io/chaitin/agent-compose-guest:latest" +} +``` + +ID 和耗时是动态值,本地运行会不同。 diff --git a/examples/agent-compose/docker-minimal/agent-compose.yml b/examples/agent-compose/docker-minimal/agent-compose.yml index 5fe0e4d43..64d927494 100644 --- a/examples/agent-compose/docker-minimal/agent-compose.yml +++ b/examples/agent-compose/docker-minimal/agent-compose.yml @@ -3,6 +3,6 @@ name: docker-minimal agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} diff --git a/examples/agent-compose/docker-multi-agent/README.md b/examples/agent-compose/docker-multi-agent/README.md new file mode 100644 index 000000000..e3f27f60e --- /dev/null +++ b/examples/agent-compose/docker-multi-agent/README.md @@ -0,0 +1,49 @@ +# Docker multi-agent project + +Languages: English | [中文](README.zh-CN.md) + +Two agents share one workspace declaration while receiving independent sandbox +copies and agent definitions. + +## Prerequisites and configuration + +Docker and the daemon must be running. Both agents refer to the same local +workspace source, but each run creates an independent sandbox copy. Their +different `system_prompt` values apply to model prompts, not shell commands. + +## Run the example +From this example directory: + +```bash +agent-compose up +agent-compose inspect agent reviewer +agent-compose inspect agent tester +agent-compose run reviewer --command "test -f project.txt && printf 'reviewer ok\\n'" +agent-compose run tester --command "test -f project.txt && printf 'tester ok\\n'" +agent-compose logs reviewer +agent-compose logs tester +agent-compose down +``` + +The command path does not invoke the configured provider. The distinct system +prompts apply when the agents are run with `--prompt`. + +## Expected result + +`inspect agent` should show two definitions. Both command runs should read +`workspace/project.txt`, return their respective marker, and have different +run/sandbox IDs. Use `--prompt` only when the daemon has a configured provider. +`down` cleans both agents' project sandboxes. + +## Example output + +Successful runs from both agents produce output like: + +```console +reviewer status=succeeded sandbox=56dc449f3f6c47169bda2ca943a7681b847e0005c5b24aca3294aa5a5cb1a78e +reviewer ok +tester status=succeeded sandbox=4151fb772c909e76d9b07a6d2d86045037eece1548ea6e83380609c73ce01d4b +tester ok +``` + +The distinct generated sandbox IDs confirm the runs were isolated. diff --git a/examples/agent-compose/docker-multi-agent/README.zh-CN.md b/examples/agent-compose/docker-multi-agent/README.zh-CN.md new file mode 100644 index 000000000..6b83718c7 --- /dev/null +++ b/examples/agent-compose/docker-multi-agent/README.zh-CN.md @@ -0,0 +1,48 @@ +# Docker 多 agent project + +语言:[English](README.md) | 中文 + +两个 agent 引用同一个 workspace 声明,但会获得独立的 sandbox workspace 副本 +和 agent definition。 + +## 前置条件与配置 + +Docker 和 daemon 必须已启动。两个 agent 引用相同本地 workspace 源,但每次 run +创建独立 sandbox 副本。不同的 `system_prompt` 只作用于模型 prompt,不作用于 +shell command。 + +## 运行示例 +在示例目录中执行: + +```bash +agent-compose up +agent-compose inspect agent reviewer +agent-compose inspect agent tester +agent-compose run reviewer --command "test -f project.txt && printf 'reviewer ok\\n'" +agent-compose run tester --command "test -f project.txt && printf 'tester ok\\n'" +agent-compose logs reviewer +agent-compose logs tester +agent-compose down +``` + +command 路径不会调用 provider;使用 `--prompt` 时才会应用两个 agent 各自的 +system prompt。 + +## 预期结果 + +`inspect agent` 应显示两个 definition。两个 command run 都应能读取 workspace +fixture、输出各自 marker,并具有不同 run/sandbox ID。只有 daemon 配置了 provider +后才运行 `--prompt`。`down` 清理两个 agent 的项目 sandbox。 + +## 输出示例 + +两个 agent 都成功运行后,输出示例如下: + +```console +reviewer status=succeeded sandbox=56dc449f3f6c47169bda2ca943a7681b847e0005c5b24aca3294aa5a5cb1a78e +reviewer ok +tester status=succeeded sandbox=4151fb772c909e76d9b07a6d2d86045037eece1548ea6e83380609c73ce01d4b +tester ok +``` + +两个动态 sandbox ID 不同,证明 run 相互隔离。 diff --git a/examples/agent-compose/docker-multi-agent/agent-compose.yml b/examples/agent-compose/docker-multi-agent/agent-compose.yml new file mode 100644 index 000000000..e31fc8f6f --- /dev/null +++ b/examples/agent-compose/docker-multi-agent/agent-compose.yml @@ -0,0 +1,24 @@ +name: docker-multi-agent + +workspaces: + source: + provider: local + path: ./workspace + +agents: + reviewer: + provider: codex + system_prompt: Review changes and report risks. + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + workspace: + name: source + tester: + provider: codex + system_prompt: Run focused checks and report failures. + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + workspace: + name: source diff --git a/examples/agent-compose/docker-multi-agent/workspace/project.txt b/examples/agent-compose/docker-multi-agent/workspace/project.txt new file mode 100644 index 000000000..f0d07b762 --- /dev/null +++ b/examples/agent-compose/docker-multi-agent/workspace/project.txt @@ -0,0 +1 @@ +shared source fixture diff --git a/examples/agent-compose/docker-scheduler-cron/README.md b/examples/agent-compose/docker-scheduler-cron/README.md index 15899efc5..440843fbc 100644 --- a/examples/agent-compose/docker-scheduler-cron/README.md +++ b/examples/agent-compose/docker-scheduler-cron/README.md @@ -21,12 +21,12 @@ authentication. - Docker daemon is running. - The `agent-compose` daemon is already running. -- The `agent-compose-guest:latest` image exists locally. +- Docker can access `ghcr.io/chaitin/agent-compose-guest:latest`. -From the repository root, build the guest image if needed: +Pull the image referenced by the compose file if needed: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` ## Compose file @@ -37,7 +37,7 @@ name: docker-scheduler-cron agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} scheduler: @@ -102,9 +102,10 @@ scheduler: Use cron when you want calendar-based scheduling. Use interval when you want short local feedback while testing. -## Verification output +## Example output -Output from a local verification run. +A successful run produces output like the following. Generated IDs, hashes, +and timestamps will differ. ### 1. Config normalization @@ -114,7 +115,7 @@ name: docker-scheduler-cron agents: - name: reviewer provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: name: docker docker: {} @@ -155,7 +156,7 @@ created loader docker-scheduler-cron/reviewer scheduler ```console $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-cron/agent-compose.yml ps AGENT SCHEDULER LATEST RUN RUN STATUS SESSION DRIVER IMAGE -reviewer enabled - - - docker agent-compose-guest:latest +reviewer enabled - - - docker ghcr.io/chaitin/agent-compose-guest:latest ``` ### 4. Inspect project @@ -174,7 +175,7 @@ $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-cron { "agent_name": "reviewer", "provider": "codex", - "image": "agent-compose-guest:latest", + "image": "ghcr.io/chaitin/agent-compose-guest:latest", "driver": "docker", "scheduler_enabled": true } @@ -198,3 +199,22 @@ ACTION TYPE NAME ID M updated project_scheduler reviewer scheduler-reviewer-default-ed0b5bed0daa disabled by project down updated loader reviewer loader-reviewer-default-ed0b5bed0daa disabled by project down ``` + +### 6. Successful manual trigger + +With a working provider, manually triggering the cron entry returns fields +like: + +```json +{ + "id": "b602c4c8479449081121ed0c9c8bfb9ded5c74d77027e6169098bc5f179e954c", + "status": "succeeded", + "sandbox_id": "2d60f25ed6752646d7468500d112dbf01c2b2f0f9c6c8aba8a965deab52c92e2", + "duration_ms": 10822, + "output": "cron scheduler ok", + "driver": "docker", + "image_ref": "ghcr.io/chaitin/agent-compose-guest:latest" +} +``` + +IDs and duration are generated values and will differ. diff --git a/examples/agent-compose/docker-scheduler-cron/README.zh-CN.md b/examples/agent-compose/docker-scheduler-cron/README.zh-CN.md index 4a698da91..37f54936f 100644 --- a/examples/agent-compose/docker-scheduler-cron/README.zh-CN.md +++ b/examples/agent-compose/docker-scheduler-cron/README.zh-CN.md @@ -20,12 +20,12 @@ managed cron scheduler。 - Docker daemon 正在运行。 - `agent-compose` daemon 已经启动。 -- 本地存在 `agent-compose-guest:latest` 镜像。 +- Docker 能访问 `ghcr.io/chaitin/agent-compose-guest:latest`。 -如果还没有 guest image,可以在仓库根目录构建: +如有需要,拉取 compose 文件引用的镜像: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` ## Compose 文件 @@ -36,7 +36,7 @@ name: docker-scheduler-cron agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} scheduler: @@ -98,9 +98,9 @@ scheduler: 需要基于日历时间调度时使用 cron;需要本地快速反馈时使用 interval。 -## 验证输出 +## 输出示例 -以下为一次本地验证运行的输出。 +成功运行后的输出示例如下。动态生成的 ID、hash 和时间戳会不同。 ### 1. 配置标准化 @@ -110,7 +110,7 @@ name: docker-scheduler-cron agents: - name: reviewer provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: name: docker docker: {} @@ -151,7 +151,7 @@ created loader docker-scheduler-cron/reviewer scheduler ```console $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-cron/agent-compose.yml ps AGENT SCHEDULER LATEST RUN RUN STATUS SESSION DRIVER IMAGE -reviewer enabled - - - docker agent-compose-guest:latest +reviewer enabled - - - docker ghcr.io/chaitin/agent-compose-guest:latest ``` ### 4. 查看 project @@ -170,7 +170,7 @@ $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-cron { "agent_name": "reviewer", "provider": "codex", - "image": "agent-compose-guest:latest", + "image": "ghcr.io/chaitin/agent-compose-guest:latest", "driver": "docker", "scheduler_enabled": true } @@ -194,3 +194,21 @@ ACTION TYPE NAME ID M updated project_scheduler reviewer scheduler-reviewer-default-ed0b5bed0daa disabled by project down updated loader reviewer loader-reviewer-default-ed0b5bed0daa disabled by project down ``` + +### 6. 手动 trigger 成功输出 + +配置可用 provider 后,手动触发 cron 条目的输出如下: + +```json +{ + "id": "b602c4c8479449081121ed0c9c8bfb9ded5c74d77027e6169098bc5f179e954c", + "status": "succeeded", + "sandbox_id": "2d60f25ed6752646d7468500d112dbf01c2b2f0f9c6c8aba8a965deab52c92e2", + "duration_ms": 10822, + "output": "cron scheduler ok", + "driver": "docker", + "image_ref": "ghcr.io/chaitin/agent-compose-guest:latest" +} +``` + +ID 和耗时是动态值,本地运行会不同。 diff --git a/examples/agent-compose/docker-scheduler-cron/agent-compose.yml b/examples/agent-compose/docker-scheduler-cron/agent-compose.yml index 079e9bc34..3cd265393 100644 --- a/examples/agent-compose/docker-scheduler-cron/agent-compose.yml +++ b/examples/agent-compose/docker-scheduler-cron/agent-compose.yml @@ -3,11 +3,12 @@ name: docker-scheduler-cron agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} scheduler: enabled: true + sandbox_policy: new triggers: - name: hourly-review cron: "0 * * * *" diff --git a/examples/agent-compose/docker-scheduler-script-runtime/README.md b/examples/agent-compose/docker-scheduler-script-runtime/README.md new file mode 100644 index 000000000..2e9794846 --- /dev/null +++ b/examples/agent-compose/docker-scheduler-script-runtime/README.md @@ -0,0 +1,48 @@ +# Scheduler script runtime + +Languages: English | [中文](README.zh-CN.md) + +This inline QJS script combines a stable interval trigger, persisted state, +loader logs, and a Docker-backed shell command. + +## Prerequisites and configuration + +Docker and the daemon must be running. The scheduler uses `sandbox_policy: new` +and inline QJS. `scheduler.state` belongs to the loader and persists across +callbacks; `scheduler.shell` runs in a Docker sandbox. No provider call occurs. + +## Run the example +From this example directory: + +```bash +agent-compose up +agent-compose scheduler ls heartbeat +agent-compose scheduler inspect heartbeat warmup +agent-compose scheduler inspect heartbeat follow-up +# Wait until both timeout triggers report that they have fired. +agent-compose down +``` + +The two automatic timeout runs produce `heartbeat 1` and `heartbeat 2`, +demonstrating that loader state persists between runs. The interval remains as +the long-running schedule. No model provider is called. + +## Expected result + +`scheduler ls heartbeat` should list `warmup`, `follow-up`, and `heartbeat`. +Inspect the two timeout triggers until their events contain `heartbeat 1` and +`heartbeat 2`. That ordered output proves state persisted between distinct +loader callbacks. `down` disables the interval and cleans project sandboxes. + +## Example output + +After the second callback succeeds, the scheduler event looks like: + +```console +type=loader.log +message="heartbeat completed" +payload={"count":2,"output":"heartbeat 2\n"} +``` + +The first callback outputs `heartbeat 1`, followed by the event above. This +proves the loader state survived between callbacks. diff --git a/examples/agent-compose/docker-scheduler-script-runtime/README.zh-CN.md b/examples/agent-compose/docker-scheduler-script-runtime/README.zh-CN.md new file mode 100644 index 000000000..441a149e8 --- /dev/null +++ b/examples/agent-compose/docker-scheduler-script-runtime/README.zh-CN.md @@ -0,0 +1,46 @@ +# Scheduler 脚本 runtime + +语言:[English](README.md) | 中文 + +该 inline QJS 脚本组合了稳定的 interval trigger、持久状态、Loader 日志和 +Docker-backed shell command。 + +## 前置条件与配置 + +Docker 和 daemon 必须已启动。scheduler 使用 `sandbox_policy: new` 和 inline QJS。 +`scheduler.state` 归 loader 所有并跨 callback 保留;`scheduler.shell` 在 Docker +sandbox 中执行,不调用模型 provider。 + +## 运行示例 +在示例目录中执行: + +```bash +agent-compose up +agent-compose scheduler ls heartbeat +agent-compose scheduler inspect heartbeat warmup +agent-compose scheduler inspect heartbeat follow-up +# 等待两个 timeout trigger 都显示已触发。 +agent-compose down +``` + +两个自动 timeout run 分别产生 `heartbeat 1` 和 `heartbeat 2`,证明 Loader +state 会在 run 之间持久化;interval 保留为长期调度。该流程不会调用模型 provider。 + +## 预期结果 + +`scheduler ls heartbeat` 应列出 `warmup`、`follow-up` 和 `heartbeat`。检查两个 +timeout trigger,直到 event 分别包含 `heartbeat 1` 和 `heartbeat 2`;有序输出证明 +state 在不同 loader callback 间持久化。`down` 禁用 interval 并清理 sandbox。 + +## 输出示例 + +第二个 callback 成功后,scheduler event 如下: + +```console +type=loader.log +message="heartbeat completed" +payload={"count":2,"output":"heartbeat 2\n"} +``` + +第一个 callback 输出 `heartbeat 1`,随后出现上面的 event,证明 loader state 跨 +callback 保留。 diff --git a/examples/agent-compose/docker-scheduler-script-runtime/agent-compose.yml b/examples/agent-compose/docker-scheduler-script-runtime/agent-compose.yml new file mode 100644 index 000000000..84a346c90 --- /dev/null +++ b/examples/agent-compose/docker-scheduler-script-runtime/agent-compose.yml @@ -0,0 +1,36 @@ +name: docker-scheduler-script-runtime + +agents: + heartbeat: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + scheduler: + enabled: true + sandbox_policy: new + script: | + function runHeartbeat() { + const previous = scheduler.state.get("heartbeat-count") ?? 0; + const count = Number(previous) + 1; + scheduler.state.set("heartbeat-count", count); + const command = scheduler.shell("printf 'heartbeat %s\\n' " + count); + scheduler.log("heartbeat completed", { count, output: command.output }); + return { count, output: command.output, success: command.success }; + } + + scheduler.timeout("warmup", function warmup() { + return runHeartbeat(); + }, 1000); + + scheduler.timeout("follow-up", function followUp() { + return runHeartbeat(); + }, 3000); + + scheduler.interval("heartbeat", function heartbeat() { + return runHeartbeat(); + }, 60000); + + function main() { + return runHeartbeat(); + } diff --git a/examples/agent-compose/docker-scheduler-script-url/README.md b/examples/agent-compose/docker-scheduler-script-url/README.md index faac9b615..d79e35071 100644 --- a/examples/agent-compose/docker-scheduler-script-url/README.md +++ b/examples/agent-compose/docker-scheduler-script-url/README.md @@ -5,10 +5,21 @@ Languages: English | [中文](README.zh-CN.md) This example keeps QJS in `scheduler.js` and references it from `agent-compose.yml` with `scheduler.script.url`. +The script keeps the original daily `scheduler.cron(...)` agent callback and +also defines a two-second `scheduler.timeout(...)` shell callback. The timeout +provides a quick way to verify that the external file was loaded; it does not +replace the cron tutorial. + +## Run the example +From this example directory: + ```bash agent-compose config agent-compose up agent-compose ps +agent-compose scheduler ls reviewer +agent-compose scheduler inspect reviewer daily-review +agent-compose scheduler inspect reviewer source-loaded agent-compose down ``` @@ -18,4 +29,28 @@ content snapshot, and sends only script text to the daemon. Editing the directory containing `agent-compose.yml`. The control-plane commands do not require provider authentication. A scheduled -run does require a working guest runtime and provider credentials. +agent callback requires a working guest runtime and daemon provider +configuration. The `source-loaded` shell callback does not call a model. + +Expected checks: + +1. `config` contains the resolved QJS content. +2. `scheduler ls reviewer` lists `daily-review` and `source-loaded`. +3. After about two seconds, `source-loaded` has fired and its event contains + `scheduler script URL ok`. +4. `daily-review` remains scheduled for `0 9 * * *` and calls the agent when its + calendar time arrives. +5. `down` disables the scheduler and cleans project sandboxes. + +## Example output + +When the `source-loaded` callback succeeds, its event looks like: + +```console +type=loader.command.completed +message="scheduler script URL ok" +payload={"exitCode":0,"mode":"shell","stderrTruncated":false,"stdoutTruncated":false,"success":true} +``` + +The full event also contains generated cell and sandbox IDs, omitted here for +readability. The message and success fields confirm the script ran. diff --git a/examples/agent-compose/docker-scheduler-script-url/README.zh-CN.md b/examples/agent-compose/docker-scheduler-script-url/README.zh-CN.md index 8cbaaeeae..44b4d3b92 100644 --- a/examples/agent-compose/docker-scheduler-script-url/README.zh-CN.md +++ b/examples/agent-compose/docker-scheduler-script-url/README.zh-CN.md @@ -5,10 +5,20 @@ 本示例把 QJS 保存在 `scheduler.js`,并在 `agent-compose.yml` 中通过 `scheduler.script.url` 引用。 +脚本保留原来的每日 `scheduler.cron(...)` agent callback,同时增加两秒后执行的 +`scheduler.timeout(...)` shell callback。timeout 用于快速验证外部文件已加载,不会 +替代 cron 教程。 + +## 运行示例 +在示例目录中执行: + ```bash agent-compose config agent-compose up agent-compose ps +agent-compose scheduler ls reviewer +agent-compose scheduler inspect reviewer daily-review +agent-compose scheduler inspect reviewer source-loaded agent-compose down ``` @@ -16,5 +26,26 @@ agent-compose down hash,并且只把脚本文本发送给 daemon。修改 `scheduler.js` 后需再次执行 `up` 才会生效。相对路径以 `agent-compose.yml` 所在目录为基准。 -控制面命令不要求 provider 凭证;实际定时运行仍需要可用的 guest runtime 和 -provider 凭证。 +控制面命令不要求 provider 凭证;scheduled agent callback 要求可用的 guest +runtime 和 daemon provider 配置。`source-loaded` shell callback 不调用模型。 + +预期检查: + +1. `config` 包含解析后的 QJS 内容。 +2. `scheduler ls reviewer` 同时列出 `daily-review` 和 `source-loaded`。 +3. 约两秒后,`source-loaded` 已触发,event 中包含 `scheduler script URL ok`。 +4. `daily-review` 仍按 `0 9 * * *` 调度,并在日历时间到达时调用 agent。 +5. `down` 禁用 scheduler 并清理项目 sandbox。 + +## 输出示例 + +`source-loaded` callback 成功后,event 如下: + +```console +type=loader.command.completed +message="scheduler script URL ok" +payload={"exitCode":0,"mode":"shell","stderrTruncated":false,"stdoutTruncated":false,"success":true} +``` + +完整 event 还包含动态 cell 和 sandbox ID,此处为可读性省略。message 和 success +字段可以确认脚本已运行。 diff --git a/examples/agent-compose/docker-scheduler-script-url/agent-compose.yml b/examples/agent-compose/docker-scheduler-script-url/agent-compose.yml index 28d4ec9c5..c882ca5cc 100644 --- a/examples/agent-compose/docker-scheduler-script-url/agent-compose.yml +++ b/examples/agent-compose/docker-scheduler-script-url/agent-compose.yml @@ -3,10 +3,11 @@ name: docker-scheduler-script-url agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} scheduler: enabled: true + sandbox_policy: new script: url: ./scheduler.js diff --git a/examples/agent-compose/docker-scheduler-script-url/scheduler.js b/examples/agent-compose/docker-scheduler-script-url/scheduler.js index f3572a2ee..e50fd3aa5 100644 --- a/examples/agent-compose/docker-scheduler-script-url/scheduler.js +++ b/examples/agent-compose/docker-scheduler-script-url/scheduler.js @@ -2,6 +2,10 @@ scheduler.cron("daily-review", "0 9 * * *", function dailyReview() { return scheduler.agent("Review the current project state."); }); +scheduler.timeout("source-loaded", function sourceLoaded() { + return scheduler.shell("printf 'scheduler script URL ok\\n'"); +}, 2000); + function main(payload) { return { ok: true, payload }; } diff --git a/examples/agent-compose/docker-scheduler-timeout/README.md b/examples/agent-compose/docker-scheduler-timeout/README.md index 08e97a278..f793097de 100644 --- a/examples/agent-compose/docker-scheduler-timeout/README.md +++ b/examples/agent-compose/docker-scheduler-timeout/README.md @@ -19,13 +19,14 @@ It verifies that agent-compose can: - Docker daemon is running. - The `agent-compose` daemon is already running. -- The `agent-compose-guest:latest` image exists locally. -- The guest image has working Codex credentials or API access. +- Docker can access `ghcr.io/chaitin/agent-compose-guest:latest`. +- The daemon has a working Codex/LLM provider configuration. The guest uses the + sandbox-scoped LLM facade rather than receiving the long-lived provider key. -From the repository root, build the guest image if needed: +Pull the image referenced by the compose file if needed: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` ## Compose file @@ -36,11 +37,12 @@ name: docker-scheduler-timeout agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} scheduler: enabled: true + sandbox_policy: new triggers: - name: run-once-after-15-seconds timeout: 15s @@ -87,10 +89,10 @@ Expected result: - `logs --run ` prints the agent output. - `down` disables the managed scheduler and loader. -## Verification output +## Example output -Output from a local verification run. The run id below is from that run; yours -will differ. +A successful scheduled run produces output like the following. Generated IDs, +hashes, timestamps, and durations will differ. ### 1. Config normalization @@ -100,7 +102,7 @@ name: docker-scheduler-timeout agents: - name: reviewer provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: name: docker docker: {} @@ -141,7 +143,7 @@ created loader docker-scheduler-timeout/reviewer scheduler ```console $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-timeout/agent-compose.yml ps AGENT SCHEDULER LATEST RUN RUN STATUS SESSION DRIVER IMAGE -reviewer enabled run-reviewer-28c0ef985c8d succeeded - docker agent-compose-guest:latest +reviewer enabled run-reviewer-28c0ef985c8d succeeded - docker ghcr.io/chaitin/agent-compose-guest:latest ``` ### 4. Inspect successful run @@ -161,7 +163,7 @@ $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-time "output": "timeout scheduler ok", "result_json": "{\"agent\":\"codex\",\"exitCode\":0,\"stopReason\":\"completed\",\"success\":true}", "driver": "docker", - "image_ref": "agent-compose-guest:latest" + "image_ref": "ghcr.io/chaitin/agent-compose-guest:latest" } ``` @@ -185,3 +187,15 @@ ACTION TYPE NAME ID M updated project_scheduler reviewer scheduler-reviewer-default-181247660dc1 disabled by project down updated loader reviewer loader-reviewer-default-181247660dc1 disabled by project down ``` + +### 7. Successful automatic timeout run + +After the automatic timeout trigger succeeds, the run reports: + +```console +status=RUN_STATUS_SUCCEEDED +run=236f8ab07988370527571cd40eaf38e9ced83afb22651e2c8b0f4b6e15d4b960 +sandbox=825642e2863644eac04f63e4bec17af025379ce8a050561abe58a178a3443c0e +``` + +Run and sandbox IDs are generated and will differ. diff --git a/examples/agent-compose/docker-scheduler-timeout/README.zh-CN.md b/examples/agent-compose/docker-scheduler-timeout/README.zh-CN.md index 7fdff74b8..45de28501 100644 --- a/examples/agent-compose/docker-scheduler-timeout/README.zh-CN.md +++ b/examples/agent-compose/docker-scheduler-timeout/README.zh-CN.md @@ -19,13 +19,14 @@ - Docker daemon 正在运行。 - `agent-compose` daemon 已经启动。 -- 本地存在 `agent-compose-guest:latest` 镜像。 -- guest image 中已经配置可用的 Codex 凭据或 API 访问能力。 +- Docker 能访问 `ghcr.io/chaitin/agent-compose-guest:latest`。 +- daemon 已配置可用的 Codex/LLM provider。guest 使用 sandbox 范围的 LLM facade, + 不会获得长期 provider key。 -如果还没有 guest image,可以在仓库根目录构建: +如有需要,拉取 compose 文件引用的镜像: ```bash -task image:agent-compose-guest +docker pull ghcr.io/chaitin/agent-compose-guest:latest ``` ## Compose 文件 @@ -36,11 +37,12 @@ name: docker-scheduler-timeout agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} scheduler: enabled: true + sandbox_policy: new triggers: - name: run-once-after-15-seconds timeout: 15s @@ -86,9 +88,9 @@ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-timeou - `logs --run ` 输出 agent 日志。 - `down` 禁用 managed scheduler 和 loader。 -## 验证输出 +## 输出示例 -以下为一次本地验证运行的输出。其中的 run id 来自该次运行,你本地的会不同。 +scheduled run 成功后的输出示例如下。动态生成的 ID、hash、时间戳和耗时会不同。 ### 1. 配置标准化 @@ -98,7 +100,7 @@ name: docker-scheduler-timeout agents: - name: reviewer provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: name: docker docker: {} @@ -139,7 +141,7 @@ created loader docker-scheduler-timeout/reviewer scheduler ```console $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-timeout/agent-compose.yml ps AGENT SCHEDULER LATEST RUN RUN STATUS SESSION DRIVER IMAGE -reviewer enabled run-reviewer-28c0ef985c8d succeeded - docker agent-compose-guest:latest +reviewer enabled run-reviewer-28c0ef985c8d succeeded - docker ghcr.io/chaitin/agent-compose-guest:latest ``` ### 4. 查看成功 run @@ -159,7 +161,7 @@ $ go run ./cmd/agent-compose --file examples/agent-compose/docker-scheduler-time "output": "timeout scheduler ok", "result_json": "{\"agent\":\"codex\",\"exitCode\":0,\"stopReason\":\"completed\",\"success\":true}", "driver": "docker", - "image_ref": "agent-compose-guest:latest" + "image_ref": "ghcr.io/chaitin/agent-compose-guest:latest" } ``` @@ -183,3 +185,15 @@ ACTION TYPE NAME ID M updated project_scheduler reviewer scheduler-reviewer-default-181247660dc1 disabled by project down updated loader reviewer loader-reviewer-default-181247660dc1 disabled by project down ``` + +### 7. 自动 timeout 成功输出 + +自动 timeout trigger 成功后,run 输出如下: + +```console +status=RUN_STATUS_SUCCEEDED +run=236f8ab07988370527571cd40eaf38e9ced83afb22651e2c8b0f4b6e15d4b960 +sandbox=825642e2863644eac04f63e4bec17af025379ce8a050561abe58a178a3443c0e +``` + +run 和 sandbox ID 每次动态生成,本地结果会不同。 diff --git a/examples/agent-compose/docker-scheduler-timeout/agent-compose.yml b/examples/agent-compose/docker-scheduler-timeout/agent-compose.yml index 808a1456c..be455e529 100644 --- a/examples/agent-compose/docker-scheduler-timeout/agent-compose.yml +++ b/examples/agent-compose/docker-scheduler-timeout/agent-compose.yml @@ -3,11 +3,12 @@ name: docker-scheduler-timeout agents: reviewer: provider: codex - image: agent-compose-guest:latest + image: ghcr.io/chaitin/agent-compose-guest:latest driver: docker: {} scheduler: enabled: true + sandbox_policy: new triggers: - name: run-once-after-15-seconds timeout: 15s diff --git a/examples/agent-compose/docker-volume-persistence/README.md b/examples/agent-compose/docker-volume-persistence/README.md new file mode 100644 index 000000000..70834d506 --- /dev/null +++ b/examples/agent-compose/docker-volume-persistence/README.md @@ -0,0 +1,49 @@ +# Docker volume persistence + +Languages: English | [中文](README.zh-CN.md) + +This project mounts a managed named volume at `/cache` and a read-only bind +fixture at `/fixtures`. + +## Prerequisites and configuration + +Docker and the daemon must be running. The top-level `cache` volume is managed +by the project and mounted read-write. `./fixtures:/fixtures:ro` resolves from +the compose directory and is mounted read-only. + +## Run the example +From this example directory: + +```bash +agent-compose up +agent-compose run worker --command "cat /fixtures/readonly.txt && printf 'persistent\\n' > /cache/value" --keep-running +agent-compose stop +agent-compose resume +agent-compose exec -- cat /cache/value +agent-compose exec -- sh -c 'if touch /fixtures/unexpected 2>/dev/null; then exit 1; fi' +agent-compose stop +agent-compose rm +agent-compose down +``` + +The cache value survives sandbox stop/resume. `down` removes project-managed +volume ownership, so do not use this example as a backup mechanism. + +## Expected result + +Use the sandbox id returned by the kept run. The first command must read the +fixture and write `/cache/value`; after stop/resume, `cat` must return +`persistent`. The `touch` check must fail inside the read-only mount. Stop and +remove the sandbox before `down` for an explicit lifecycle cleanup. + +## Example output + +After a successful sandbox stop/resume, the persisted value and read-only check +look like: + +```console +$ agent-compose exec -- cat /cache/value +persistent +$ agent-compose exec -- sh -c 'if touch /fixtures/unexpected 2>/dev/null; then exit 1; fi' +# exit status 0: writing to the read-only mount was rejected +``` diff --git a/examples/agent-compose/docker-volume-persistence/README.zh-CN.md b/examples/agent-compose/docker-volume-persistence/README.zh-CN.md new file mode 100644 index 000000000..4bb3f85e6 --- /dev/null +++ b/examples/agent-compose/docker-volume-persistence/README.zh-CN.md @@ -0,0 +1,46 @@ +# Docker volume 持久化 + +语言:[English](README.md) | 中文 + +该 project 把 managed named volume 挂载到 `/cache`,并把只读 bind fixture +挂载到 `/fixtures`。 + +## 前置条件与配置 + +Docker 和 daemon 必须已启动。顶层 `cache` volume 由项目管理并以读写方式挂载; +`./fixtures:/fixtures:ro` 相对 compose 目录解析并只读挂载。 + +## 运行示例 +在示例目录中执行: + +```bash +agent-compose up +agent-compose run worker --command "cat /fixtures/readonly.txt && printf 'persistent\\n' > /cache/value" --keep-running +agent-compose stop +agent-compose resume +agent-compose exec -- cat /cache/value +agent-compose exec -- sh -c 'if touch /fixtures/unexpected 2>/dev/null; then exit 1; fi' +agent-compose stop +agent-compose rm +agent-compose down +``` + +cache 值在 sandbox stop/resume 后仍存在。`down` 会移除 project-managed volume +的归属,不应把该示例当作备份机制。 + +## 预期结果 + +使用保留 run 返回的 sandbox ID。第一次命令必须读取 fixture 并写入 +`/cache/value`;stop/resume 后 `cat` 必须返回 `persistent`。`touch` 检查必须因只读 +挂载而失败。最后显式 stop、rm,再执行 `down`。 + +## 输出示例 + +sandbox 成功 stop/resume 后,持久化值和只读检查如下: + +```console +$ agent-compose exec -- cat /cache/value +persistent +$ agent-compose exec -- sh -c 'if touch /fixtures/unexpected 2>/dev/null; then exit 1; fi' +# exit status 0:只读挂载拒绝写入 +``` diff --git a/examples/agent-compose/docker-volume-persistence/agent-compose.yml b/examples/agent-compose/docker-volume-persistence/agent-compose.yml new file mode 100644 index 000000000..f6a02a129 --- /dev/null +++ b/examples/agent-compose/docker-volume-persistence/agent-compose.yml @@ -0,0 +1,17 @@ +name: docker-volume-persistence + +volumes: + cache: + driver: local + labels: + purpose: agent-compose-example + +agents: + worker: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + volumes: + - cache:/cache + - ./fixtures:/fixtures:ro diff --git a/examples/agent-compose/docker-volume-persistence/fixtures/readonly.txt b/examples/agent-compose/docker-volume-persistence/fixtures/readonly.txt new file mode 100644 index 000000000..8dd1fefe6 --- /dev/null +++ b/examples/agent-compose/docker-volume-persistence/fixtures/readonly.txt @@ -0,0 +1 @@ +read-only bind fixture diff --git a/examples/agent-compose/docker-workspace-lifecycle/README.md b/examples/agent-compose/docker-workspace-lifecycle/README.md new file mode 100644 index 000000000..85df433c6 --- /dev/null +++ b/examples/agent-compose/docker-workspace-lifecycle/README.md @@ -0,0 +1,56 @@ +# Docker workspace lifecycle + +Languages: English | [中文](README.zh-CN.md) + +This example copies `workspace/` into each new sandbox and demonstrates the +current sandbox lifecycle without model credentials. + +## Prerequisites + +- Docker and the `agent-compose` daemon are running. +- The published guest image is available to Docker. + +## Configuration + +The `source` workspace uses the local provider and `path: ./workspace`. The +`worker` agent refers to it by name. A new sandbox receives a copy; the host +source directory is not mounted as a writable working tree. + +## Run the example +From this example directory: + +```bash +agent-compose up +agent-compose run worker --command "printf 'sandbox-only\\n' > generated.txt" --keep-running +agent-compose ps +agent-compose exec -- cat generated.txt +agent-compose stop +agent-compose resume +agent-compose exec -- cat generated.txt +agent-compose stop +agent-compose rm +agent-compose down +``` + +`generated.txt` survives stop/resume but is not written into the committed +`workspace/` source. A second new sandbox receives a fresh source copy. + +## Check and clean up + +Use the sandbox id returned by `run`. Confirm the file before and after +stop/resume, then check that `workspace/generated.txt` does not exist on the +host. `rm` deletes the stopped sandbox; `down` cleans remaining project state. + +## Example output + +A successful run and stop/resume cycle produces output like: + +```console +status=succeeded +run=7f1f9aaf0cd1d1c125c51bac9f915cdcf57aa9d531353beb9e77faa4ed4109d7 +sandbox=8eac6735af343c91804590c5329d57e274a1e395c6a3ecb41f4c0c62c1ff4629 +$ agent-compose exec -- cat generated.txt +sandbox-only +``` + +Run and sandbox IDs are generated and will differ. diff --git a/examples/agent-compose/docker-workspace-lifecycle/README.zh-CN.md b/examples/agent-compose/docker-workspace-lifecycle/README.zh-CN.md new file mode 100644 index 000000000..0133afaf6 --- /dev/null +++ b/examples/agent-compose/docker-workspace-lifecycle/README.zh-CN.md @@ -0,0 +1,50 @@ +# Docker workspace 生命周期 + +语言:[English](README.md) | 中文 + +该示例把 `workspace/` 复制到每个新 sandbox,并在不需要模型凭证的情况下演示 +当前 sandbox 生命周期。 + +## 前置条件与配置 + +Docker 和 `agent-compose` daemon 必须已启动,并且 Docker 可获得发布版 guest +镜像。`source` 使用本地 workspace provider,`worker` 按名称引用它。新 sandbox +得到源目录副本,不会把 host 源目录作为可写工作区挂载。 + +## 运行示例 +在示例目录中执行: + +```bash +agent-compose up +agent-compose run worker --command "printf 'sandbox-only\\n' > generated.txt" --keep-running +agent-compose ps +agent-compose exec -- cat generated.txt +agent-compose stop +agent-compose resume +agent-compose exec -- cat generated.txt +agent-compose stop +agent-compose rm +agent-compose down +``` + +`generated.txt` 在 stop/resume 后仍存在,但不会写回仓库中的 `workspace/` source。 +新建第二个 sandbox 时会得到一份全新的 source 副本。 + +## 检查与清理 + +使用 `run` 返回的 sandbox ID,在 stop/resume 前后读取文件,并确认 host 上不存在 +`workspace/generated.txt`。`rm` 删除已停止 sandbox,`down` 清理剩余项目状态。 + +## 输出示例 + +成功完成 run 和 stop/resume 后,输出示例如下: + +```console +status=succeeded +run=7f1f9aaf0cd1d1c125c51bac9f915cdcf57aa9d531353beb9e77faa4ed4109d7 +sandbox=8eac6735af343c91804590c5329d57e274a1e395c6a3ecb41f4c0c62c1ff4629 +$ agent-compose exec -- cat generated.txt +sandbox-only +``` + +run 和 sandbox ID 每次动态生成,本地结果会不同。 diff --git a/examples/agent-compose/docker-workspace-lifecycle/agent-compose.yml b/examples/agent-compose/docker-workspace-lifecycle/agent-compose.yml new file mode 100644 index 000000000..3502c901e --- /dev/null +++ b/examples/agent-compose/docker-workspace-lifecycle/agent-compose.yml @@ -0,0 +1,15 @@ +name: docker-workspace-lifecycle + +workspaces: + source: + provider: local + path: ./workspace + +agents: + worker: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + workspace: + name: source diff --git a/examples/agent-compose/docker-workspace-lifecycle/workspace/README.md b/examples/agent-compose/docker-workspace-lifecycle/workspace/README.md new file mode 100644 index 000000000..e1f702492 --- /dev/null +++ b/examples/agent-compose/docker-workspace-lifecycle/workspace/README.md @@ -0,0 +1,4 @@ +# Workspace lifecycle fixture + +This file is copied into each new sandbox workspace. Changes made in a sandbox +must not modify this source directory. diff --git a/examples/agent-compose/microsandbox-minimal/README.md b/examples/agent-compose/microsandbox-minimal/README.md new file mode 100644 index 000000000..46e001145 --- /dev/null +++ b/examples/agent-compose/microsandbox-minimal/README.md @@ -0,0 +1,59 @@ +# Minimal Microsandbox project + +Languages: English | [中文](README.zh-CN.md) + +This example defines a single Codex agent backed by the Microsandbox driver. + +## Requirements + +Runtime use requires Linux, KVM access, Microsandbox artifacts, and a binary +whose `compiled_drivers` contains `microsandbox`. Check the host first: + +```bash +agent-compose --json version +test -r /dev/kvm && test -w /dev/kvm +``` + +## Inspect the configuration + +```bash +agent-compose config +``` + +The normalized output should contain `driver.name: microsandbox`. + +## Run on a Microsandbox host + +After the requirements above are satisfied: + +```bash +agent-compose up +agent-compose run reviewer --command "uname -a" +agent-compose ps --all +agent-compose down +``` + +`run` should return `status: succeeded`, a non-empty sandbox ID, and the guest +kernel information. If the binary does not include Microsandbox, the command +reports the driver as unsupported. Missing KVM access or runtime artifacts +cause Microsandbox initialization to fail. + +## Normalized config output + +`agent-compose config` produces: + +```yaml +name: microsandbox-minimal +agents: + - name: reviewer + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + name: microsandbox + microsandbox: {} +network: + mode: default +``` + +This output confirms that the project selects Microsandbox. Runtime output +depends on the Microsandbox artifacts and KVM environment on the host. diff --git a/examples/agent-compose/microsandbox-minimal/README.zh-CN.md b/examples/agent-compose/microsandbox-minimal/README.zh-CN.md new file mode 100644 index 000000000..7c2d6ce6c --- /dev/null +++ b/examples/agent-compose/microsandbox-minimal/README.zh-CN.md @@ -0,0 +1,58 @@ +# 最小 Microsandbox project + +语言:[English](README.md) | 中文 + +本示例定义一个使用 Microsandbox driver 的 Codex agent。 + +## 环境要求 + +实际运行要求 Linux、KVM 权限、Microsandbox artifacts,以及 `compiled_drivers` +包含 `microsandbox` 的二进制。先检查 host: + +```bash +agent-compose --json version +test -r /dev/kvm && test -w /dev/kvm +``` + +## 查看配置 + +```bash +agent-compose config +``` + +归一化输出应包含 `driver.name: microsandbox`。 + +## 在 Microsandbox host 上运行 + +满足上述环境要求后执行: + +```bash +agent-compose up +agent-compose run reviewer --command "uname -a" +agent-compose ps --all +agent-compose down +``` + +`run` 应返回 `status: succeeded`、非空 sandbox ID 和 guest kernel 信息。如果二进制 +未包含 Microsandbox,命令会报告 driver 不受支持;缺少 KVM 权限或 runtime +artifacts 会导致 Microsandbox 初始化失败。 + +## Config 归一化输出 + +`agent-compose config` 输出如下: + +```yaml +name: microsandbox-minimal +agents: + - name: reviewer + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + name: microsandbox + microsandbox: {} +network: + mode: default +``` + +该输出表明 project 已选择 Microsandbox;runtime 输出取决于 host 上的 +Microsandbox artifacts 和 KVM 环境。 diff --git a/examples/agent-compose/microsandbox-minimal/agent-compose.yml b/examples/agent-compose/microsandbox-minimal/agent-compose.yml new file mode 100644 index 000000000..ec564f316 --- /dev/null +++ b/examples/agent-compose/microsandbox-minimal/agent-compose.yml @@ -0,0 +1,8 @@ +name: microsandbox-minimal + +agents: + reviewer: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + microsandbox: {} diff --git a/test/e2e/examples_docker_live_provider_test.go b/test/e2e/examples_docker_live_provider_test.go new file mode 100644 index 000000000..645a1218b --- /dev/null +++ b/test/e2e/examples_docker_live_provider_test.go @@ -0,0 +1,86 @@ +package e2e + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestE2EExamplesDockerLiveProvider(t *testing.T) { + if strings.TrimSpace(os.Getenv("AGENT_COMPOSE_E2E_EXAMPLES_LIVE_PROVIDER")) != "1" { + t.Skip("set AGENT_COMPOSE_E2E_EXAMPLES_LIVE_PROVIDER=1 to use the configured live provider") + } + image := strings.TrimSpace(os.Getenv(examplesDockerImageEnv)) + if image == "" { + t.Fatalf("%s is required", examplesDockerImageEnv) + } + + providerEnv := liveProviderE2EEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + defer cancel() + root := e2eRepoRoot(t) + testRoot, err := os.MkdirTemp(root, ".docker-examples-live-provider-e2e-") + if err != nil { + t.Fatalf("create live-provider E2E root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(testRoot) }) + dockerClient := newE2EDockerClient(t, ctx, image) + t.Cleanup(func() { removeE2EDockerContainersUnderRoot(t, dockerClient, testRoot) }) + binary := e2eDaemonBinary(t, ctx, root, testRoot) + listen := unusedLoopbackAddress(t) + baseURL := "http://" + listen + _, port, err := net.SplitHostPort(listen) + if err != nil { + t.Fatalf("split daemon listen address: %v", err) + } + gateway := dockerBridgeGateway(t, ctx, dockerClient) + providerEnv["AGENT_COMPOSE_RUNTIME_BASE_URL"] = "http://" + net.JoinHostPort(gateway, port) + providerEnv["DOCKER_HOST_SANDBOX_ROOT"] = "" + providerEnv["HTTP_LISTEN"] = net.JoinHostPort("0.0.0.0", port) + + daemon := startE2EDaemonWithOverrides(t, binary, root, testRoot, listen, image, providerEnv) + waitForE2EDaemon(t, ctx, daemon, baseURL) + t.Cleanup(func() { + if t.Failed() { + t.Logf("live-provider examples daemon log:\n%s", daemon.logs.String()) + } + }) + + file := filepath.Join(root, "examples", "agent-compose", "docker-minimal", "agent-compose.yml") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + const marker = "agent-compose live provider ok" + out := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "--json", "run", "reviewer", "--prompt", "Reply with exactly: "+marker) + t.Logf("verified live-provider output: %s", strings.TrimSpace(out)) + var run exampleRunOutput + if err := json.Unmarshal([]byte(out), &run); err != nil { + t.Fatalf("decode live-provider run output %q: %v", out, err) + } + if run.Status != "succeeded" || run.SandboxID == "" || !strings.Contains(run.Output, marker) { + t.Fatalf("live-provider run = %#v", run) + } +} + +func liveProviderE2EEnv(t *testing.T) map[string]string { + t.Helper() + required := []string{"LLM_API_ENDPOINT", "LLM_API_KEY", "LLM_MODEL"} + env := make(map[string]string, len(required)+3) + for _, name := range required { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + t.Fatalf("%s is required for the live-provider E2E", name) + } + env[name] = value + } + for _, name := range []string{"LLM_API_PROTOCOL", "LLM_TIMEOUT", "AGENT_TIMEOUT"} { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + env[name] = value + } + } + return env +} diff --git a/test/e2e/examples_docker_provider_test.go b/test/e2e/examples_docker_provider_test.go new file mode 100644 index 000000000..8ffcd144a --- /dev/null +++ b/test/e2e/examples_docker_provider_test.go @@ -0,0 +1,173 @@ +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + networkapi "github.com/docker/docker/api/types/network" + + agentcomposev2 "agent-compose/proto/agentcompose/v2" + "agent-compose/proto/agentcompose/v2/agentcomposev2connect" +) + +func TestE2EExamplesDockerProvider(t *testing.T) { + if strings.TrimSpace(os.Getenv("AGENT_COMPOSE_E2E_EXAMPLES_PROVIDER")) != "1" { + t.Skip("set AGENT_COMPOSE_E2E_EXAMPLES_PROVIDER=1 to run provider-backed examples") + } + image := strings.TrimSpace(os.Getenv(examplesDockerImageEnv)) + if image == "" { + t.Fatalf("%s is required", examplesDockerImageEnv) + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + defer cancel() + root := e2eRepoRoot(t) + testRoot, err := os.MkdirTemp(root, ".docker-examples-provider-e2e-") + if err != nil { + t.Fatalf("create provider E2E root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(testRoot) }) + dockerClient := newE2EDockerClient(t, ctx, image) + t.Cleanup(func() { removeE2EDockerContainersUnderRoot(t, dockerClient, testRoot) }) + binary := e2eDaemonBinary(t, ctx, root, testRoot) + listen := unusedLoopbackAddress(t) + baseURL := "http://" + listen + provider := newExampleOpenAIProvider(t) + _, port, err := net.SplitHostPort(listen) + if err != nil { + t.Fatalf("split daemon listen address: %v", err) + } + gateway := dockerBridgeGateway(t, ctx, dockerClient) + overrides := providerE2EEnv(provider.URL, "http://"+net.JoinHostPort(gateway, port), port) + daemon := startE2EDaemonWithOverrides(t, binary, root, testRoot, listen, image, overrides) + waitForE2EDaemon(t, ctx, daemon, baseURL) + t.Cleanup(func() { + if t.Failed() { + t.Logf("provider examples daemon log:\n%s", daemon.logs.String()) + } + }) + + examplesRoot := filepath.Join(root, "examples", "agent-compose") + t.Run("manual cron trigger", func(t *testing.T) { + file := filepath.Join(examplesRoot, "docker-scheduler-cron", "agent-compose.yml") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + out := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "--json", "scheduler", "trigger", "reviewer", "hourly-review", "--prompt", "Reply with exactly: cron scheduler ok") + t.Logf("verified manual cron output: %s", strings.TrimSpace(out)) + if !strings.Contains(out, `"status": "succeeded"`) || !strings.Contains(out, "cron scheduler ok") { + t.Fatalf("cron trigger output = %q", out) + } + }) + + t.Run("automatic timeout trigger", func(t *testing.T) { + file := filepath.Join(examplesRoot, "docker-scheduler-timeout", "agent-compose.yml") + up := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "--json", "up") + defer downExampleProject(t, ctx, binary, root, baseURL, file) + var applied struct { + Project struct { + ID string `json:"id"` + } `json:"project"` + } + if err := json.Unmarshal([]byte(up), &applied); err != nil || applied.Project.ID == "" { + t.Fatalf("decode timeout up output %q: %v", up, err) + } + client := agentcomposev2connect.NewRunServiceClient(newE2EHTTPClient(), baseURL) + deadline := time.Now().Add(3 * time.Minute) + for time.Now().Before(deadline) { + resp, err := client.ListRuns(ctx, connect.NewRequest(&agentcomposev2.ListRunsRequest{ + ProjectId: applied.Project.ID, + AgentName: "reviewer", + Source: agentcomposev2.RunSource_RUN_SOURCE_SCHEDULER, + Limit: 20, + })) + if err == nil { + for _, run := range resp.Msg.GetRuns() { + if run.GetStatus() == agentcomposev2.RunStatus_RUN_STATUS_SUCCEEDED { + t.Logf("verified timeout run: id=%s sandbox=%s status=%s", run.GetRunId(), run.GetSandboxId(), run.GetStatus()) + return + } + if run.GetStatus() == agentcomposev2.RunStatus_RUN_STATUS_FAILED { + t.Fatalf("timeout run failed: %s", run.GetError()) + } + } + } + time.Sleep(500 * time.Millisecond) + } + t.Fatal("timeout scheduler did not produce a successful run") + }) +} + +func providerE2EEnv(endpoint, runtimeBaseURL, daemonPort string) map[string]string { + return map[string]string{ + "AGENT_COMPOSE_RUNTIME_BASE_URL": runtimeBaseURL, + "AGENT_TIMEOUT": "1m", + "DOCKER_HOST_SANDBOX_ROOT": "", + "HTTP_LISTEN": net.JoinHostPort("0.0.0.0", daemonPort), + "LLM_API_ENDPOINT": endpoint, + "LLM_API_KEY": "example-provider-key", + "LLM_API_PROTOCOL": "chat_completions", + "LLM_MODEL": "gpt-5.4", + "OPENAI_API_KEY": "", + "OPENAI_BASE_URL": "", + } +} + +func dockerBridgeGateway(t *testing.T, ctx context.Context, dockerClient interface { + NetworkInspect(context.Context, string, networkapi.InspectOptions) (networkapi.Inspect, error) +}) string { + t.Helper() + network, err := dockerClient.NetworkInspect(ctx, "bridge", networkapi.InspectOptions{}) + if err != nil { + t.Fatalf("inspect Docker bridge network: %v", err) + } + for _, config := range network.IPAM.Config { + if gateway := strings.TrimSpace(config.Gateway); gateway != "" { + return gateway + } + } + t.Fatal("Docker bridge network has no gateway") + return "" +} + +func newExampleOpenAIProvider(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + t.Logf("controlled provider request %s %s", request.Method, request.URL.Path) + body, err := io.ReadAll(request.Body) + if err != nil { + t.Errorf("read provider request: %v", err) + http.Error(w, "read request", http.StatusBadRequest) + return + } + if !strings.HasSuffix(request.URL.Path, "/chat/completions") { + t.Errorf("provider path = %q", request.URL.Path) + http.NotFound(w, request) + return + } + text := "cron scheduler ok" + if strings.Contains(string(body), "timeout scheduler ok") { + text = "timeout scheduler ok" + } + if strings.Contains(string(body), `"stream":true`) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: {\"id\":\"chatcmpl-example\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":%q},\"finish_reason\":null}]}\n\n", text) + _, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-example\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, "{\"id\":\"chatcmpl-example\",\"object\":\"chat.completion\",\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":%q},\"finish_reason\":\"stop\"}]}", text) + })) + t.Cleanup(server.Close) + return server +} diff --git a/test/e2e/examples_docker_test.go b/test/e2e/examples_docker_test.go new file mode 100644 index 000000000..95b60db37 --- /dev/null +++ b/test/e2e/examples_docker_test.go @@ -0,0 +1,330 @@ +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + + agentcomposev2 "agent-compose/proto/agentcompose/v2" + "agent-compose/proto/agentcompose/v2/agentcomposev2connect" +) + +const examplesDockerImageEnv = "AGENT_COMPOSE_E2E_EXAMPLES_IMAGE" + +type exampleRunOutput struct { + ID string `json:"id"` + AgentName string `json:"agent_name"` + Status string `json:"status"` + SandboxID string `json:"sandbox_id"` + Output string `json:"output"` + ResultJSON string `json:"result_json"` +} + +func TestE2EExamplesDocker(t *testing.T) { + image := strings.TrimSpace(os.Getenv(examplesDockerImageEnv)) + if image == "" { + t.Skipf("set %s to the locally available published guest image", examplesDockerImageEnv) + } + const manifestImage = "ghcr.io/chaitin/agent-compose-guest:latest" + if image != manifestImage { + t.Fatalf("%s = %q, examples use %q", examplesDockerImageEnv, image, manifestImage) + } + + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + root := e2eRepoRoot(t) + testRoot, err := os.MkdirTemp(root, ".docker-examples-e2e-") + if err != nil { + t.Fatalf("create Docker-visible E2E root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(testRoot) }) + dockerClient := newE2EDockerClient(t, ctx, image) + t.Cleanup(func() { removeE2EDockerContainersUnderRoot(t, dockerClient, testRoot) }) + binary := e2eDaemonBinary(t, ctx, root, testRoot) + listen := unusedLoopbackAddress(t) + baseURL := "http://" + listen + daemon := startE2EDaemonWithOverrides(t, binary, root, testRoot, listen, image, map[string]string{ + "DOCKER_HOST_SANDBOX_ROOT": "", + }) + waitForE2EDaemon(t, ctx, daemon, baseURL) + t.Cleanup(func() { + if t.Failed() { + t.Logf("examples daemon log:\n%s", daemon.logs.String()) + } + }) + + examplesRoot := filepath.Join(root, "examples", "agent-compose") + t.Run("minimal", func(t *testing.T) { + file := filepath.Join(examplesRoot, "docker-minimal", "agent-compose.yml") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + run := runExampleCommand(t, ctx, binary, root, baseURL, file, "reviewer", "printf 'docker minimal ok\\n'", true) + t.Logf("verified run: id=%s sandbox=%s status=%s output=%q", run.ID, run.SandboxID, run.Status, run.Output) + if run.Status != "succeeded" || !strings.Contains(run.Output, "docker minimal ok") || run.SandboxID == "" { + t.Fatalf("minimal run = %#v", run) + } + out := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "exec", run.SandboxID, "--", "pwd") + if strings.TrimSpace(out) == "" { + t.Fatal("minimal exec returned no working directory") + } + }) + + t.Run("workspace lifecycle", func(t *testing.T) { + dir := filepath.Join(examplesRoot, "docker-workspace-lifecycle") + file := filepath.Join(dir, "agent-compose.yml") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + run := runExampleCommand(t, ctx, binary, root, baseURL, file, "worker", "test -f README.md && printf 'sandbox-only\\n' > generated.txt", true) + t.Logf("verified run: id=%s sandbox=%s status=%s", run.ID, run.SandboxID, run.Status) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "stop", run.SandboxID) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "resume", run.SandboxID) + out := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "exec", run.SandboxID, "--", "cat", "generated.txt") + if strings.TrimSpace(out) != "sandbox-only" { + t.Fatalf("resumed workspace output = %q", out) + } + t.Logf("verified resumed output: %q", strings.TrimSpace(out)) + if _, err := os.Stat(filepath.Join(dir, "workspace", "generated.txt")); !os.IsNotExist(err) { + t.Fatalf("sandbox change leaked to source: %v", err) + } + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "stop", run.SandboxID) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "rm", run.SandboxID) + }) + + t.Run("multi agent", func(t *testing.T) { + file := filepath.Join(examplesRoot, "docker-multi-agent", "agent-compose.yml") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + for _, agent := range []string{"reviewer", "tester"} { + run := runExampleCommand(t, ctx, binary, root, baseURL, file, agent, "test -f project.txt && printf '"+agent+" ok\\n'", false) + t.Logf("verified %s run: id=%s sandbox=%s status=%s output=%q", agent, run.ID, run.SandboxID, run.Status, run.Output) + if run.AgentName != agent || !strings.Contains(run.Output, agent+" ok") { + t.Fatalf("%s run = %#v", agent, run) + } + } + }) + + t.Run("environment and secrets", func(t *testing.T) { + file := filepath.Join(examplesRoot, "docker-env-secrets", "agent-compose.yml") + config := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "config") + if strings.Contains(config, "safe-example-secret") || !strings.Contains(config, "********") { + t.Fatalf("redacted config = %q", config) + } + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + command := `test "$PROJECT_VALUE" = project-level && test "$AGENT_VALUE" = agent-level && test "$PROJECT_SECRET" = safe-example-secret && test "$AGENT_SECRET" = safe-example-secret && printf 'environment ok\n'` + run := runExampleCommand(t, ctx, binary, root, baseURL, file, "inspector", command, false) + t.Logf("verified run: id=%s sandbox=%s status=%s output=%q", run.ID, run.SandboxID, run.Status, run.Output) + if !strings.Contains(run.Output, "environment ok") { + t.Fatalf("environment run = %#v", run) + } + }) + + t.Run("volume persistence", func(t *testing.T) { + file := filepath.Join(examplesRoot, "docker-volume-persistence", "agent-compose.yml") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + command := `grep -q 'read-only bind fixture' /fixtures/readonly.txt && printf 'persistent\n' > /cache/value` + run := runExampleCommand(t, ctx, binary, root, baseURL, file, "worker", command, true) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "stop", run.SandboxID) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "resume", run.SandboxID) + out := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "exec", run.SandboxID, "--", "cat", "/cache/value") + if strings.TrimSpace(out) != "persistent" { + t.Fatalf("persistent volume output = %q", out) + } + t.Logf("verified resumed volume output: %q", strings.TrimSpace(out)) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "exec", run.SandboxID, "--", "sh", "-c", "if touch /fixtures/unexpected 2>/dev/null; then exit 1; fi") + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "stop", run.SandboxID) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "rm", run.SandboxID) + }) + + for _, tc := range []struct { + name string + dir string + agent string + trigger string + want string + }{ + {name: "script URL", dir: "docker-scheduler-script-url", agent: "reviewer", trigger: "source-loaded", want: "scheduler script URL ok"}, + {name: "script runtime", dir: "docker-scheduler-script-runtime", agent: "heartbeat", trigger: "follow-up", want: "heartbeat 2"}, + } { + t.Run(tc.name, func(t *testing.T) { + file := filepath.Join(examplesRoot, tc.dir, "agent-compose.yml") + projectID := applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "scheduler", "inspect", tc.agent, tc.trigger) + waitForExampleSchedulerEvent(t, ctx, baseURL, projectID, tc.agent, tc.want) + }) + } + + t.Run("cron control plane", func(t *testing.T) { + file := filepath.Join(examplesRoot, "docker-scheduler-cron", "agent-compose.yml") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + out := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "scheduler", "ls", "reviewer") + t.Logf("verified scheduler list:\n%s", strings.TrimSpace(out)) + if !strings.Contains(out, "hourly-review") || !strings.Contains(out, "cron") { + t.Fatalf("cron scheduler list = %q", out) + } + }) + + t.Run("build", func(t *testing.T) { + file, imageRef := copyBuildExampleWithUniqueTag(t, examplesRoot, testRoot) + defer func() { + _ = exampleCLIErr(ctx, binary, root, baseURL, "--file", file, "rmi", imageRef, "--force") + }() + exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "build") + applyExampleProject(t, ctx, binary, root, baseURL, file) + defer downExampleProject(t, ctx, binary, root, baseURL, file) + run := runExampleCommand(t, ctx, binary, root, baseURL, file, "worker", "cat /opt/agent-compose-example.txt", false) + t.Logf("verified run: id=%s sandbox=%s status=%s output=%q", run.ID, run.SandboxID, run.Status, run.Output) + if !strings.Contains(run.Output, "built-by-agent-compose") { + t.Fatalf("build run = %#v", run) + } + }) +} + +func applyExampleProject(t *testing.T, ctx context.Context, binary, root, baseURL, file string) string { + t.Helper() + out := exampleCLI(t, ctx, binary, root, baseURL, "--file", file, "--json", "up") + var applied struct { + Project struct { + ID string `json:"id"` + } `json:"project"` + } + if err := json.Unmarshal([]byte(out), &applied); err != nil || applied.Project.ID == "" { + t.Fatalf("decode up output %q: %v", out, err) + } + return applied.Project.ID +} + +func downExampleProject(t *testing.T, ctx context.Context, binary, root, baseURL, file string) { + t.Helper() + var ps struct { + Sandboxes []struct { + ID string `json:"sandbox_id"` + } `json:"sandboxes"` + } + if out, err := exampleCLIOutput(ctx, binary, root, baseURL, "--file", file, "--json", "ps", "--all"); err == nil { + if err := json.Unmarshal([]byte(out), &ps); err != nil { + t.Errorf("decode ps output for %s: %v", file, err) + } else { + for _, sandbox := range ps.Sandboxes { + if err := exampleCLIErr(ctx, binary, root, baseURL, "--file", file, "rm", sandbox.ID, "--force"); err != nil { + t.Errorf("remove example sandbox %s: %v", sandbox.ID, err) + } + } + } + } + if err := exampleCLIErr(ctx, binary, root, baseURL, "--file", file, "down"); err != nil { + t.Errorf("down example %s: %v", file, err) + } +} + +func runExampleCommand(t *testing.T, ctx context.Context, binary, root, baseURL, file, agent, command string, keep bool) exampleRunOutput { + t.Helper() + args := []string{"--file", file, "--json", "run", agent, "--command", command} + if keep { + args = append(args, "--keep-running") + } + out := exampleCLI(t, ctx, binary, root, baseURL, args...) + var run exampleRunOutput + if err := json.Unmarshal([]byte(out), &run); err != nil { + t.Fatalf("decode run output %q: %v", out, err) + } + if run.ID == "" || run.Status != "succeeded" { + t.Fatalf("run did not succeed: %#v", run) + } + return run +} + +func exampleCLI(t *testing.T, ctx context.Context, binary, root, baseURL string, args ...string) string { + t.Helper() + cmdArgs := append([]string{"--host", baseURL}, args...) + cmd := exec.CommandContext(ctx, binary, cmdArgs...) + cmd.Dir = root + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("agent-compose %s: %v\n%s", strings.Join(cmdArgs, " "), err, out) + } + return string(out) +} + +func exampleCLIErr(ctx context.Context, binary, root, baseURL string, args ...string) error { + _, err := exampleCLIOutput(ctx, binary, root, baseURL, args...) + return err +} + +func exampleCLIOutput(ctx context.Context, binary, root, baseURL string, args ...string) (string, error) { + cmdArgs := append([]string{"--host", baseURL}, args...) + cmd := exec.CommandContext(ctx, binary, cmdArgs...) + cmd.Dir = root + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("agent-compose %s: %w: %s", strings.Join(cmdArgs, " "), err, out) + } + return string(out), nil +} + +func waitForExampleSchedulerEvent(t *testing.T, ctx context.Context, baseURL, projectID, agentName, want string) { + t.Helper() + client := agentcomposev2connect.NewProjectServiceClient(newE2EHTTPClient(), baseURL) + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + resp, err := client.ListSchedulerEvents(ctx, connect.NewRequest(&agentcomposev2.ListSchedulerEventsRequest{ + Project: &agentcomposev2.ProjectRef{ProjectId: projectID}, + AgentName: agentName, + Limit: 100, + })) + if err == nil { + for _, event := range resp.Msg.GetEvents() { + if event.GetType() == "loader.run.failed" { + t.Fatalf("scheduler run failed: %s", event.GetMessage()) + } + if strings.Contains(event.GetMessage(), want) || strings.Contains(event.GetPayloadJson(), want) { + t.Logf("verified scheduler event: type=%s message=%q payload=%s", event.GetType(), event.GetMessage(), event.GetPayloadJson()) + return + } + } + } + time.Sleep(250 * time.Millisecond) + } + t.Fatalf("scheduler event did not contain %q", want) +} + +func copyBuildExampleWithUniqueTag(t *testing.T, examplesRoot, testRoot string) (string, string) { + t.Helper() + source := filepath.Join(examplesRoot, "docker-build") + destination := filepath.Join(testRoot, "docker-build") + if err := os.MkdirAll(destination, 0o755); err != nil { + t.Fatalf("create build example copy: %v", err) + } + dockerfile, err := os.ReadFile(filepath.Join(source, "Dockerfile")) + if err != nil { + t.Fatalf("read build Dockerfile: %v", err) + } + if err := os.WriteFile(filepath.Join(destination, "Dockerfile"), dockerfile, 0o600); err != nil { + t.Fatalf("write build Dockerfile: %v", err) + } + composeData, err := os.ReadFile(filepath.Join(source, "agent-compose.yml")) + if err != nil { + t.Fatalf("read build compose: %v", err) + } + imageRef := "agent-compose-example-e2e-" + fmt.Sprint(time.Now().UnixNano()) + ":local" + content := strings.ReplaceAll(string(composeData), "agent-compose-example-build:local", imageRef) + content = strings.ReplaceAll(content, "agent-compose-example-build:latest", imageRef) + file := filepath.Join(destination, "agent-compose.yml") + if err := os.WriteFile(file, []byte(content), 0o600); err != nil { + t.Fatalf("write build compose: %v", err) + } + return file, imageRef +} diff --git a/test/e2e/host_daemon_helpers_test.go b/test/e2e/host_daemon_helpers_test.go index 1db8108b2..0111c9567 100644 --- a/test/e2e/host_daemon_helpers_test.go +++ b/test/e2e/host_daemon_helpers_test.go @@ -44,11 +44,15 @@ type e2eDaemonProcess struct { } func startE2EDaemon(t *testing.T, binary, repoRoot, testRoot, listenAddress, image string) *e2eDaemonProcess { + return startE2EDaemonWithOverrides(t, binary, repoRoot, testRoot, listenAddress, image, nil) +} + +func startE2EDaemonWithOverrides(t *testing.T, binary, repoRoot, testRoot, listenAddress, image string, overrides map[string]string) *e2eDaemonProcess { t.Helper() process := &e2eDaemonProcess{done: make(chan error, 1)} process.cmd = exec.Command(binary, "daemon") process.cmd.Dir = repoRoot - process.cmd.Env = overrideE2EEnv(os.Environ(), map[string]string{ + env := map[string]string{ "AGENT_COMPOSE_SOCKET": filepath.Join(testRoot, "agent-compose.sock"), "AUTH_PASSWORD": "", "AUTH_USERNAME": "", @@ -67,7 +71,11 @@ func startE2EDaemon(t *testing.T, binary, repoRoot, testRoot, listenAddress, ima "RUNTIME_DRIVER": "docker", "SANDBOX_ROOT": filepath.Join(testRoot, "sandboxes"), "SANDBOX_START_TIMEOUT": "3m", - }) + } + for name, value := range overrides { + env[name] = value + } + process.cmd.Env = overrideE2EEnv(os.Environ(), env) process.cmd.Stdout = &process.logs process.cmd.Stderr = &process.logs if err := process.cmd.Start(); err != nil { @@ -210,6 +218,38 @@ func removeE2EDockerSandboxFallback(t *testing.T, ctx context.Context, dockerCli } } +func removeE2EDockerContainersUnderRoot(t *testing.T, dockerClient *client.Client, root string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + args := filters.NewArgs(filters.Arg("label", "agent-compose.driver=docker")) + containers, err := dockerClient.ContainerList(ctx, containerapi.ListOptions{All: true, Filters: args}) + if err != nil { + t.Errorf("list Docker containers for root cleanup: %v", err) + return + } + root = filepath.Clean(root) + string(filepath.Separator) + for _, item := range containers { + info, err := dockerClient.ContainerInspect(ctx, item.ID) + if err != nil { + t.Errorf("inspect Docker container %s for root cleanup: %v", item.ID, err) + continue + } + owned := false + for _, mount := range info.Mounts { + if strings.HasPrefix(filepath.Clean(mount.Source)+string(filepath.Separator), root) { + owned = true + break + } + } + if owned { + if err := dockerClient.ContainerRemove(ctx, item.ID, containerapi.RemoveOptions{Force: true}); err != nil { + t.Errorf("remove Docker container %s for root cleanup: %v", item.ID, err) + } + } + } +} + func unusedLoopbackAddress(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0")