diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index 53b5ebb1a56..fa30354d16a 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -11,13 +11,21 @@ name: Voltage images # buzz-relay the upstream root Dockerfile (target runtime) → buzz-relay # # The changes job selects only the images whose inputs changed. Manual runs -# (workflow_dispatch) build all three. +# (workflow_dispatch) build all three, or only buzz-agent with agent_only. # # Trust model: this repository and its logs are public. Pull requests run # without AWS access. Only the publish and merge jobs request an OIDC token, # and they run only on main, the only ref that the github-buzz-ecr-push role # trusts. The AWS_ACCOUNT_ID secret keeps the account ID out of the # repository and masks it in the logs. +# +# Engineering skills: the agent image ships voltagecloud/engineering-skills +# (internal) from that repository's master branch at build time. Only the +# publish job reads it, with a short-lived, read-only token from the +# voltage-agent-images GitHub App. The app's client ID and key are secrets of +# the `engineering-skills` environment, which only main may use, so pull +# requests never receive them; they build with an empty skills context. +# select-skills.sh prints counts only, never skill content. on: push: branches: [main] @@ -61,7 +69,12 @@ on: - admin-web/** - .github/workflows/voltage-agent-images.yml # Publishes only when run on main; on other refs it builds and tests. - workflow_dispatch: {} + workflow_dispatch: + inputs: + agent_only: + description: "Build only buzz-agent, for example to ship new engineering skills" + type: boolean + default: false # Pull requests share one group per ref and cancel superseded runs. Each main # commit gets its own group: a queued main run that a newer push replaced @@ -84,6 +97,7 @@ jobs: outputs: any: ${{ steps.select.outputs.any }} build: ${{ steps.select.outputs.build }} + agent: ${{ steps.select.outputs.agent }} merge: ${{ steps.select.outputs.merge }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -138,14 +152,15 @@ jobs: - name: Build the image matrices id: select env: - ALL: ${{ github.event_name == 'workflow_dispatch' }} + ALL: ${{ github.event_name == 'workflow_dispatch' && !inputs.agent_only }} + AGENT_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.agent_only }} AGENT: ${{ steps.filter.outputs.agent }} REAPER: ${{ steps.filter.outputs.reaper }} RELAY: ${{ steps.filter.outputs.relay }} run: | set -euo pipefail selected=() - if [ "$ALL" = true ] || [ "$AGENT" = true ]; then selected+=(buzz-agent); fi + if [ "$ALL" = true ] || [ "$AGENT_ONLY" = true ] || [ "$AGENT" = true ]; then selected+=(buzz-agent); fi if [ "$ALL" = true ] || [ "$REAPER" = true ]; then selected+=(buzz-sandbox-reaper); fi if [ "$ALL" = true ] || [ "$RELAY" = true ]; then selected+=(buzz-relay); fi images='{ @@ -162,6 +177,7 @@ jobs: --args "${selected[@]}") { echo "any=$([ "${#selected[@]}" -gt 0 ] && echo true || echo false)" + echo "agent=$([[ " ${selected[*]} " == *" buzz-agent "* ]] && echo true || echo false)" echo "build=$build" echo "merge=$merge" } >> "$GITHUB_OUTPUT" @@ -186,12 +202,15 @@ jobs: with: dockerfile: voltage/images/buzz-sandbox-reaper/Dockerfile failure-threshold: error + # --check resolves every COPY --from source, so the agent image needs its + # engineering-skills context; the other images ignore it. - name: BuildKit checks run: | set -euo pipefail + sh voltage/images/buzz-agent/select-skills.sh --none "$RUNNER_TEMP/engineering-skills" for file in voltage/images/buzz-agent/Dockerfile voltage/images/buzz-sandbox-reaper/Dockerfile Dockerfile; do echo "==> $file" - docker buildx build --check -f "$file" . + docker buildx build --check --build-context engineering-skills="$RUNNER_TEMP/engineering-skills" -f "$file" . done # Pull requests, and runs on refs other than main: build and test, no push. @@ -221,6 +240,9 @@ jobs: buildkitd-config-inline: | [worker.oci] max-parallelism = 2 + # Pull requests never read the internal skills repository. + - name: Empty engineering skills context + run: sh voltage/images/buzz-agent/select-skills.sh --none "$RUNNER_TEMP/engineering-skills" - name: Build uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: @@ -229,6 +251,7 @@ jobs: target: ${{ matrix.target }} platforms: linux/${{ matrix.arch }} build-args: ${{ matrix.image == 'buzz-relay' && env.RELAY_BUILD_ARGS || '' }} + build-contexts: engineering-skills=${{ runner.temp }}/engineering-skills load: true push: false tags: ${{ env.IMAGE }} @@ -261,14 +284,53 @@ jobs: python3 test-codex-login.py "$IMAGE" python3 -O test-profile.py "$IMAGE" python3 test-cluster.py "$IMAGE" + python3 test-skills.py "$IMAGE" + + # main: resolve engineering-skills master once, so every platform image of + # this run carries the same skills commit. + skills: + name: Resolve engineering skills + needs: changes + if: needs.changes.outputs.agent == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: engineering-skills + outputs: + revision: ${{ steps.resolve.outputs.revision }} + steps: + - name: Engineering skills token + id: token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.AGENT_IMAGE_APP_CLIENT_ID }} + private-key: ${{ secrets.AGENT_IMAGE_APP_PRIVATE_KEY }} + owner: voltagecloud + repositories: engineering-skills + permission-contents: read + - name: Resolve master + id: resolve + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail + revision=$(gh api repos/voltagecloud/engineering-skills/commits/master --jq .sha) + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + echo "engineering skills: master is $revision" # main: build each platform on its native runner and push it by digest. publish: name: Publish ${{ matrix.image }} (${{ matrix.arch }}) - needs: [changes, lint] - if: needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + # A skills failure fails only the agent image: the relay and reaper still + # publish. skills is skipped when this run does not build the agent. + needs: [changes, lint, skills] + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.lint.result == 'success' && needs.skills.result != 'cancelled' && needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 + # Only the agent image needs the engineering-skills app secrets, which this + # main-only environment holds. The github-buzz-ecr-push role trusts its + # OIDC subject; the other images keep the main branch subject. + environment: ${{ matrix.image == 'buzz-agent' && 'engineering-skills' || '' }} permissions: contents: read id-token: write # OIDC token for the github-buzz-ecr-push role @@ -290,6 +352,48 @@ jobs: buildkitd-config-inline: | [worker.oci] max-parallelism = 2 + - name: Require the resolved skills commit + if: matrix.image == 'buzz-agent' + env: + REVISION: ${{ needs.skills.outputs.revision }} + run: | + [[ "$REVISION" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::engineering skills were not resolved (skills job: ${{ needs.skills.result }})"; exit 1; } + # Read-only, for engineering-skills alone, and revoked when the job ends. + - name: Engineering skills token + id: skills-token + if: matrix.image == 'buzz-agent' + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.AGENT_IMAGE_APP_CLIENT_ID }} + private-key: ${{ secrets.AGENT_IMAGE_APP_PRIVATE_KEY }} + owner: voltagecloud + repositories: engineering-skills + permission-contents: read + - name: Check out engineering skills + if: matrix.image == 'buzz-agent' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: voltagecloud/engineering-skills + ref: ${{ needs.skills.outputs.revision }} + token: ${{ steps.skills-token.outputs.token }} + path: .engineering-skills + persist-credentials: false + - name: Select engineering skills + id: skills + env: + AGENT: ${{ matrix.image == 'buzz-agent' }} + EXPECTED: ${{ needs.skills.outputs.revision }} + run: | + set -euo pipefail + out="$RUNNER_TEMP/engineering-skills" + if [ "$AGENT" = true ]; then + sh voltage/images/buzz-agent/select-skills.sh .engineering-skills "$out" + rm -rf .engineering-skills + [ "$(cat "$out/REVISION")" = "$EXPECTED" ] + else + sh voltage/images/buzz-agent/select-skills.sh --none "$out" + fi + echo "revision=$(cat "$out/REVISION")" >> "$GITHUB_OUTPUT" # Build and smoke-test before credentials exist; the push build below # reuses this build's cache. - name: Build @@ -300,6 +404,7 @@ jobs: target: ${{ matrix.target }} platforms: linux/${{ matrix.arch }} build-args: ${{ matrix.image == 'buzz-relay' && env.RELAY_BUILD_ARGS || '' }} + build-contexts: engineering-skills=${{ runner.temp }}/engineering-skills load: true push: false tags: ${{ env.IMAGE }} @@ -313,7 +418,11 @@ jobs: run: bash voltage/images/test-relay-image.sh "$IMAGE" - name: Image contract test if: matrix.image == 'buzz-agent' - run: bash voltage/images/buzz-agent/test-image.sh "$IMAGE" + env: + REQUIRE_SKILLS: "1" + run: | + bash voltage/images/buzz-agent/test-image.sh "$IMAGE" + python3 voltage/images/buzz-agent/test-skills.py "$IMAGE" - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-buzz-ecr-push @@ -328,9 +437,11 @@ jobs: target: ${{ matrix.target }} platforms: linux/${{ matrix.arch }} build-args: ${{ matrix.image == 'buzz-relay' && env.RELAY_BUILD_ARGS || '' }} + build-contexts: engineering-skills=${{ runner.temp }}/engineering-skills labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} + cloud.voltage.engineering-skills.revision=${{ steps.skills.outputs.revision }} outputs: type=image,name=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-west-2.amazonaws.com/${{ matrix.repository }},push-by-digest=true,name-canonical=true,push=true - name: Export digest env: @@ -350,7 +461,7 @@ jobs: # A failed platform build fails only its own image's merge. merge: name: Merge ${{ matrix.image }} manifest - needs: [changes, publish] + needs: [changes, skills, publish] if: ${{ !cancelled() && needs.publish.result != 'skipped' && needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 15 @@ -380,7 +491,9 @@ jobs: IMAGE: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-west-2.amazonaws.com/${{ matrix.repository }} NAME: ${{ matrix.image }} REPOSITORY: ${{ matrix.repository }} - TAG: ${{ github.sha }} + # An agent_only run republishes a commit that already has an image, + # so it tags the result with the skills commit as well. + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.agent_only && format('{0}-skills-{1}', github.sha, needs.skills.outputs.revision) || github.sha }} run: | set -euo pipefail if digest=$(aws ecr describe-images --repository-name "$REPOSITORY" \ diff --git a/voltage/images/README.md b/voltage/images/README.md index 27b180b93f9..a996b3d29fa 100644 --- a/voltage/images/README.md +++ b/voltage/images/README.md @@ -53,13 +53,15 @@ The voltage-terraform admission policy admits only digest-pinned images from `vo Run from the repository root. The Rust build takes a long time; the Dockerfiles set `CARGO_BUILD_JOBS=2`. ```bash -docker buildx build --check -f voltage/images/buzz-agent/Dockerfile . +sh voltage/images/buzz-agent/select-skills.sh --none /tmp/engineering-skills +docker buildx build --check --build-context engineering-skills=/tmp/engineering-skills -f voltage/images/buzz-agent/Dockerfile . docker buildx build --check -f voltage/images/buzz-sandbox-reaper/Dockerfile . docker build --platform linux/amd64 -f voltage/images/buzz-sandbox-reaper/Dockerfile -t buzz-sandbox-reaper:local . docker run --rm buzz-sandbox-reaper:local --help -docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile -t buzz-agent:local . +docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile \ + --build-context engineering-skills=/tmp/engineering-skills -t buzz-agent:local . bash voltage/images/buzz-agent/test-image.sh buzz-agent:local ``` diff --git a/voltage/images/buzz-agent/Dockerfile b/voltage/images/buzz-agent/Dockerfile index def06e65203..267ef6429c1 100644 --- a/voltage/images/buzz-agent/Dockerfile +++ b/voltage/images/buzz-agent/Dockerfile @@ -92,6 +92,12 @@ COPY --chmod=0755 voltage/images/buzz-agent/subagent.py /usr/local/bin/buzz-suba RUN install -d -m 0755 /etc/buzz-agent COPY --chmod=0644 voltage/images/buzz-agent/subagents.md /etc/buzz-agent/subagents.md COPY --chmod=0644 voltage/images/buzz-agent/cluster.md /etc/buzz-agent/cluster.md +# Voltage engineering skills from the `engineering-skills` build context, made by +# select-skills.sh: master of voltagecloud/engineering-skills on main, empty for +# pull requests and local builds. Root owns the copy, so the agent can read but +# not change it; profile.py links each skill into the harnesses. +# hadolint ignore=DL3022 +COPY --from=engineering-skills . /opt/engineering-skills/ COPY --chmod=0755 voltage/images/buzz-agent/heavy-bin/ /opt/buzz-heavy/bin/ COPY --chmod=0755 voltage/images/buzz-agent/gh.sh /usr/local/bin/gh COPY --chmod=0755 voltage/images/buzz-agent/via.sh /usr/local/bin/via diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index 35c75870e09..e6ae73c858c 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -26,7 +26,9 @@ The build context is the repository root. `Dockerfile.dockerignore` next to the From the repository root: ```bash -docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile -t buzz-agent:local . +sh voltage/images/buzz-agent/select-skills.sh --none /tmp/engineering-skills +docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile \ + --build-context engineering-skills=/tmp/engineering-skills -t buzz-agent:local . cd voltage/images/buzz-agent bash test-image.sh buzz-agent:local bash test-harness.sh buzz-agent:local @@ -42,13 +44,37 @@ python3 test-worker.py buzz-agent:local python3 test-cache.py buzz-agent:local python3 -O test-profile.py buzz-agent:local python3 test-cluster.py buzz-agent:local +python3 test-skills.py buzz-agent:local ``` +To build with the engineering skills, replace `--none` with a local checkout: +`sh voltage/images/buzz-agent/select-skills.sh ../engineering-skills /tmp/engineering-skills`. + `test-image.sh` also runs `test-review.py`; run the Python script directly for focused review checks. These checks use synthetic tasks and accounts. Live GitHub, signing, `via`, cache, and staging behavior require end-to-end validation. Run the bundled Rust suites with `docker build --platform linux/amd64 --target package-checks -f voltage/images/buzz-agent/Dockerfile .`. Build and test `linux/arm64` on an arm64 host, or through `.github/workflows/voltage-agent-images.yml`, which builds each platform on a native runner. +## Engineering skills + +The image ships [voltagecloud/engineering-skills](https://github.com/voltagecloud/engineering-skills) (internal) in `/opt/engineering-skills`, owned by root. +That repository is internal and this one is public, so the skills reach the build only as the `engineering-skills` [build context](https://docs.docker.com/build/building/context/#named-contexts). +`select-skills.sh` makes that context. It leaves out `walkthrough-video` and `cli-demo-video`, which need a browser, narration, and VHS, and writes the source commit to `REVISION`. + +| Build | Skills | +| --- | --- | +| `main` (publish job) | `master` of engineering-skills at build time; the image label `cloud.voltage.engineering-skills.revision` records the commit | +| Pull requests, other refs, local builds | None (`select-skills.sh --none`) | + +The publish job reads the repository with a short-lived token from the `voltage-agent-images` GitHub App: read-only contents, limited to engineering-skills. +The app's client ID and private key are the `AGENT_IMAGE_APP_CLIENT_ID` and `AGENT_IMAGE_APP_PRIVATE_KEY` secrets of the `engineering-skills` environment, which only `main` may use. +A skills change reaches agents with the next published agent image. To publish one without a code change, run the `Voltage images` workflow on `main` with `agent_only`. That image is tagged `-skills-`, because the Buzz commit already has an image. +Each run resolves `master` once (the `skills` job), so both platform images carry the same skills commit. + +At each session and worker start, `buzz-agent-profile` links every skill into `~/.claude/skills`, `$CODEX_HOME/skills`, and `~/.agents/skills`. +It manages only its own links: a skill the agent wrote keeps its name, and links to skills a newer image dropped are removed. +A linking failure prints a warning; the session still starts. + ## Sandbox contract Use an immutable registry digest in `spec.podTemplate.spec.containers[].image`. diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index 7c4d634f611..e73709b948b 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -347,6 +347,48 @@ def install_subagent_guide(env): handle.write(guide) +SKILLS = Path('/opt/engineering-skills/skills') + + +def skill_directories(env): + """Where Claude Code, Codex, and Buzz's own agent discover user skills.""" + return (Path('/home/agent/.claude/skills'), + Path(env.get('CODEX_HOME', '/home/agent/.codex')) / 'skills', + Path('/home/agent/.agents/skills')) + + +def install_skills(env, source=SKILLS, targets=None): + """Link the image's engineering skills into each harness; never block the session. + + Only links into `source` are managed: a skill the agent wrote, or one a + checkpoint restored, keeps its name. Links left by an older image whose + skill is gone are removed. + """ + try: + shipped = {path.name: path for path in source.iterdir() + if (path / 'SKILL.md').is_file()} if source.is_dir() else {} + except OSError as error: + print('Engineering skills not installed: '+type(error).__name__, file=sys.stderr) + return + # Each harness directory on its own: one failure leaves the others linked. + for directory in targets or skill_directories(env): + try: + directory.mkdir(parents=True, exist_ok=True) + for link in directory.iterdir(): + if (link.is_symlink() and Path(os.readlink(link)).parent == source + and link.name not in shipped): + link.unlink() + for name, path in shipped.items(): + link = directory / name + if link.is_symlink() and Path(os.readlink(link)).parent == source: + link.unlink() + if not os.path.lexists(link): + link.symlink_to(path, target_is_directory=True) + except OSError as error: + print('Engineering skills not installed in '+directory.name+' of '+directory.parent.name + +': '+type(error).__name__, file=sys.stderr) + + def main(): """Load the owner profile and cluster access before starting a session or worker.""" parser = argparse.ArgumentParser(description=__doc__) @@ -359,6 +401,8 @@ def main(): env = bootstrap() step('configuring staging cluster diagnostics') install_cluster_access(env) + step('installing engineering skills') + install_skills(env) step('starting the worker') worker = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-agent-worker'] os.execvpe(worker[0], worker, env) @@ -370,6 +414,8 @@ def main(): install_cluster_access(env) step('installing the sub-agent guide') install_subagent_guide(env) + step('installing engineering skills') + install_skills(env) step('starting the harness') arguments = args.arguments[1:] if args.arguments[:1] == ['--'] else args.arguments harness = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-acp', *arguments] diff --git a/voltage/images/buzz-agent/select-skills.sh b/voltage/images/buzz-agent/select-skills.sh new file mode 100755 index 00000000000..0a85b66eacb --- /dev/null +++ b/voltage/images/buzz-agent/select-skills.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Prepare the `engineering-skills` build context for the agent image. +# +# select-skills.sh +# select-skills.sh --none +# +# voltagecloud/engineering-skills is internal and this repository is public, +# so the skills never enter this repository or the main build context. CI +# checks out the repository's master branch on main and passes the result to +# `docker build --build-context engineering-skills=`. Pull +# requests and local builds pass an empty context made with --none. +# +# The output holds skills// for each shipped skill and REVISION, the +# commit the skills come from ("none" for an empty context). This script +# prints counts only: the CI logs are public. +set -eu + +# Skills that need tools a Sandbox cannot run (a browser, narration, VHS). +EXCLUDE="walkthrough-video cli-demo-video" + +usage() { + echo "usage: $0 " >&2 + echo " $0 --none " >&2 + exit 2 +} + +[ "$#" -eq 2 ] || usage +out=$2 +rm -rf "$out" +mkdir -p "$out/skills" + +if [ "$1" = --none ]; then + echo none > "$out/REVISION" + echo "engineering skills: none (empty build context)" + exit 0 +fi + +source=$1 +[ -d "$source/skills" ] || { echo "no skills/ directory in the checkout" >&2; exit 1; } +shipped=0 +skipped=0 +for dir in "$source"/skills/*/; do + [ -f "$dir/SKILL.md" ] || continue + name=$(basename "$dir") + case " $EXCLUDE " in + *" $name "*) skipped=$((skipped + 1)); continue ;; + esac + cp -R "$dir" "$out/skills/$name" + shipped=$((shipped + 1)) +done +[ "$shipped" -gt 0 ] || { echo "the checkout has no shippable skills" >&2; exit 1; } +git -C "$source" rev-parse HEAD > "$out/REVISION" +echo "engineering skills: $shipped shipped, $skipped excluded, revision $(cat "$out/REVISION")" diff --git a/voltage/images/buzz-agent/test-image.sh b/voltage/images/buzz-agent/test-image.sh index 6febade987d..595e3cedb4b 100755 --- a/voltage/images/buzz-agent/test-image.sh +++ b/voltage/images/buzz-agent/test-image.sh @@ -22,7 +22,24 @@ docker run --rm --entrypoint /bin/bash "$IMAGE" -ceu ' test -r /etc/buzz-agent/cluster.md ! test -e /home/agent/.codex/auth.json ! test -e /home/agent/.claude/.credentials.json + # Engineering skills (select-skills.sh): read-only, each with SKILL.md, none + # of the excluded ones. Empty in pull request and local builds. + test -r /opt/engineering-skills/REVISION + ! test -w /opt/engineering-skills/skills + for skill in /opt/engineering-skills/skills/*/; do + [ -d "$skill" ] || continue + test -r "$skill/SKILL.md" + done + ! test -e /opt/engineering-skills/skills/walkthrough-video + ! test -e /opt/engineering-skills/skills/cli-demo-video ' +# Published images must carry skills (the publish job sets REQUIRE_SKILLS=1). +if [ "${REQUIRE_SKILLS:-0}" = 1 ]; then + docker run --rm --entrypoint /bin/sh "$IMAGE" -ceu ' + grep -qE "^[0-9a-f]{40}$" /opt/engineering-skills/REVISION + ls /opt/engineering-skills/skills/*/SKILL.md >/dev/null + ' +fi for adapter in codex-acp claude-agent-acp; do docker run --rm "$IMAGE" auth-methods --agent-command "$adapter" --agent-args '' --json done diff --git a/voltage/images/buzz-agent/test-skills.py b/voltage/images/buzz-agent/test-skills.py new file mode 100755 index 00000000000..c43a38673d2 --- /dev/null +++ b/voltage/images/buzz-agent/test-skills.py @@ -0,0 +1,78 @@ +"""Check that profile.py links engineering skills into each harness without blocking a session.""" +import subprocess +import sys + +SCRIPT = r''' +import io, os, sys, tempfile +from contextlib import redirect_stderr +from importlib.machinery import SourceFileLoader +from pathlib import Path + +profile = SourceFileLoader('profile', '/usr/local/bin/buzz-agent-profile').load_module() + +def require(condition, message): + if not condition: raise RuntimeError(message) + +root = Path(tempfile.mkdtemp()) +source = root/'opt'/'skills' +for name in ('commit-policy', 'pr-review'): + (source/name).mkdir(parents=True) + (source/name/'SKILL.md').write_text('---\nname: '+name+'\ndescription: fixture\n---\n') +(source/'not-a-skill').mkdir() +claude, codex = root/'claude'/'skills', root/'codex'/'skills' +# The agent wrote its own pr-review skill before this session started. +(claude/'pr-review').mkdir(parents=True) +(claude/'pr-review'/'SKILL.md').write_text('agent-owned\n') + +profile.install_skills({}, source=source, targets=(claude, codex)) +for directory in (claude, codex): + link = directory/'commit-policy' + require(link.is_symlink() and link.resolve() == (source/'commit-policy').resolve(), 'skill not linked') + require((link/'SKILL.md').is_file(), 'linked skill unreadable') + require(not os.path.lexists(directory/'not-a-skill'), 'a directory without SKILL.md was linked') +require(not (claude/'pr-review').is_symlink(), 'agent-owned skill replaced') +require((claude/'pr-review'/'SKILL.md').read_text() == 'agent-owned\n', 'agent-owned skill changed') +require((codex/'pr-review').is_symlink(), 'skill missing where the agent had none') + +# A rerun, as on every session start, changes nothing. +profile.install_skills({}, source=source, targets=(claude, codex)) +require((claude/'commit-policy').is_symlink() and not (claude/'pr-review').is_symlink(), 'rerun changed links') + +# A newer image drops commit-policy: its links go, the agent's skill stays. +(source/'commit-policy'/'SKILL.md').unlink() +(source/'commit-policy').rmdir() +profile.install_skills({}, source=source, targets=(claude, codex)) +for directory in (claude, codex): + require(not os.path.lexists(directory/'commit-policy'), 'stale skill link kept') +require((claude/'pr-review'/'SKILL.md').read_text() == 'agent-owned\n', 'agent-owned skill changed') + +# A failing directory warns and the next one is still linked: skills are optional. +blocked = root/'blocked' +blocked.write_text('a file, not a directory\n') +after = root/'after'/'skills' +stderr = io.StringIO() +with redirect_stderr(stderr): + profile.install_skills({}, source=source, targets=(blocked/'skills', after)) +require('Engineering skills not installed in skills of blocked' in stderr.getvalue(), 'failure not reported') +require((after/'pr-review').is_symlink(), 'one failing directory stopped the others') + +# Default locations: Claude Code, CODEX_HOME, and Buzz's own .agents directory. +require(profile.skill_directories({'CODEX_HOME': '/home/agent/.codex-x'}) == ( + Path('/home/agent/.claude/skills'), Path('/home/agent/.codex-x/skills'), + Path('/home/agent/.agents/skills')), 'unexpected skill directories') +print('ok') +''' + + +def main(): + image = sys.argv[1] + result = subprocess.run(['docker', 'run', '--rm', '--entrypoint', 'python3', image, '-c', SCRIPT], + capture_output=True, text=True, timeout=120) + if result.returncode or result.stdout.strip() != 'ok': + print(result.stdout[-2000:], result.stderr[-4000:], file=sys.stderr) + sys.exit(1) + print('engineering skills: linking checks passed') + + +if __name__ == '__main__': + main()