Skip to content
60 changes: 49 additions & 11 deletions .github/workflows/harness-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,13 @@ permissions:
contents: read

env:
# Scheduled set. prime-agent, grok and muse are dispatch-only until their
# harness install path is verifiable (run 37692378153: `prime-agent`,
# `@xai-org/grok-build` and `muse-code` are all 404 on npm; Prime Agent is
# now a Rust binary (gsc#1378), Muse Code ships as a binary, Grok Build's
# package is unnamed in the plugin repo). sdk-proxy is out until
# Scheduled set. prime-agent, grok and muse are dispatch-only. Prime Agent
# now installs from its GitHub release tarballs (npm `prime-agent` is 404),
# but the Rust beta has no extension loader, so the ACP extension cannot
# load and the leg stays red until the plugin is ported (gsc#1378, #1406);
# it joins the scheduled set only once it can go green. `@xai-org/grok-build`
# and `muse-code` are 404 on npm (run 37692378153); Muse Code ships as a
# binary, Grok Build's package is unnamed in the plugin repo. sdk-proxy is out until
# @agenticcontrolplane/proxy is actually published (404 on npm; not in the
# SDK monorepo tree either). Details: docs/harness-canary.md.
ALL_HARNESSES: '["claude-code","codex","opencode","qwen-code","pi","dsh","hermes","openclaw"]'
Expand Down Expand Up @@ -144,9 +146,12 @@ jobs:
# 2026-10-07). pi and prime-agent: the installer detects them by
# ~/.pi/agent and ~/.prime/agent, which a fresh npm install does not
# create, so the leg creates the dir (same as muse's ~/.config/muse).
# prime-agent, grok, muse names are still unverified (see env above).
# prime-agent installs from GitHub release tarballs (see its case arm);
# grok and muse names are still unverified (see env above).
- name: Install current released harness
id: harness
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eo pipefail
case "$HARNESS" in
Expand All @@ -155,7 +160,24 @@ jobs:
opencode) npm i -g opencode-ai@latest; v="$(opencode --version | head -1)" ;;
qwen-code) npm i -g @qwen-code/qwen-code@latest; v="$(qwen --version | head -1)" ;;
pi) npm i -g @earendil-works/pi-coding-agent@latest; mkdir -p "$HOME/.pi/agent"; v="$(pi --version | head -1)" ;;
prime-agent) npm i -g prime-agent@latest; mkdir -p "$HOME/.prime/agent"; v="$(prime-agent --version 2>/dev/null | head -1 || prime --version | head -1)" ;;
prime-agent)
# Not on npm (gsc#1390). Real install path: versioned GitHub
# release tarballs (PrimeIntellect-ai/prime-agent), verified
# against the release SHA256SUMS. "Latest" = newest non-draft
# release INCLUDING prereleases, because the Rust beta is what
# `prime-agent update` moves people onto; /releases/latest
# would pin the old TypeScript stable (v0.9.8).
tag="$(gh api repos/PrimeIntellect-ai/prime-agent/releases --jq '[.[] | select(.draft == false)][0].tag_name')"
test -n "$tag" || { echo "no prime-agent release resolved"; exit 1; }
ver="${tag#v}"; asset="prime-agent-${ver}-linux-x64.tar.gz"
pdir="$(mktemp -d)"
gh release download "$tag" -R PrimeIntellect-ai/prime-agent -p "$asset" -p SHA256SUMS -D "$pdir"
(cd "$pdir" && grep -F " $asset" SHA256SUMS | sha256sum -c -)
mkdir -p "$HOME/.local/share/prime-agent" "$HOME/.local/bin" "$HOME/.prime/agent"
tar -xzf "$pdir/$asset" -C "$HOME/.local/share/prime-agent"
ln -sf "$HOME/.local/share/prime-agent/prime-agent" "$HOME/.local/bin/prime-agent"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"; export PATH="$HOME/.local/bin:$PATH"
v="$(prime-agent --version | head -1)" ;;
grok) npm i -g @xai-org/grok-build@latest; v="$(grok --version | head -1)" ;;
dsh) npm i -g @deepseek-ai/dsh@latest; v="$(dsh --version | head -1)" ;;
hermes) pipx install hermes-agent; v="$(hermes --version | head -1)" ;;
Expand Down Expand Up @@ -216,8 +238,24 @@ jobs:
claude-code)
test -n "$CLAUDE_CODE_OAUTH_TOKEN" || { echo "CLAUDE_CODE_OAUTH_TOKEN secret is not set"; exit 2; }
timeout 300 "$HOME/.acp/bin/claude-acp" -p "$prompt" --allowedTools "Bash(echo:*)" 2>&1 | tee canary-run.log ;;
opencode) timeout 300 "$HOME/.acp/bin/opencode-acp" run "$prompt" 2>&1 | tee canary-run.log ;;
qwen-code) timeout 300 "$HOME/.acp/bin/qwen-acp" -p "$prompt" 2>&1 | tee canary-run.log ;;
# Headless `opencode run` has no one to answer a permission "ask"
# and auto-rejects it (opencode 1.18.35: "permission requested:
# bash (...); auto-rejecting"), because the installer writes
# permission.bash = "ask" for real, attended users. Pre-approve
# ONLY `echo *` for this one process via OPENCODE_CONFIG_CONTENT
# (highest-precedence config layer); ~/.config/opencode/opencode.json
# stays exactly as the installer wrote it, and the acp-opencode
# plugin's tool.execute.before still governs + audits the call.
opencode) OPENCODE_CONFIG_CONTENT='{"permission":{"bash":{"echo *":"allow"}}}' \
timeout 300 "$HOME/.acp/bin/opencode-acp" run "$prompt" 2>&1 | tee canary-run.log ;;
qwen-code)
# Headless qwen cannot prompt, so without --yolo it declines
# run_shell_command ("permission ... was declined (non-interactive
# mode cannot prompt)"), the model hunts with tool_search/Read/glob
# and never emits the echo. --yolo skips only qwen's own prompt;
# the ACP PreToolUse hook still gates every call. </dev/null so
# a stray stdin read cannot hang the leg.
QWEN_CODE_SUPPRESS_YOLO_WARNING=1 timeout 300 "$HOME/.acp/bin/qwen-acp" --yolo -p "$prompt" </dev/null 2>&1 | tee canary-run.log ;;
pi) timeout 300 "$HOME/.acp/bin/pi-acp" -p "$prompt" 2>&1 | tee canary-run.log ;;
prime-agent) timeout 300 "$HOME/.acp/bin/prime-acp" -p "$prompt" 2>&1 | tee canary-run.log ;;
*) echo "no live call defined for $HARNESS"; exit 2 ;;
Expand All @@ -228,9 +266,9 @@ jobs:
# in the window with its decision so the failure can be classified
# (decision=allow -> plugin/harness permission contract drift;
# decision=ask -> the gateway is asking in audit mode).
if grep -Eiq 'auto-rejecting|rejected permission|Denied at approval|permission denied|requires approval' canary-run.log; then
if grep -Eiq 'auto-rejecting|rejected permission|Denied at approval|permission denied|requires approval|permission was declined' canary-run.log; then
echo "FAIL $HARNESS rejected the governed call (gsc#1380):"
grep -Ei 'auto-rejecting|rejected permission|Denied at approval|permission denied|requires approval' canary-run.log
grep -Ei 'auto-rejecting|rejected permission|Denied at approval|permission denied|requires approval|permission was declined' canary-run.log
echo "audit rows seen since $CANARY_SINCE (decision per row):"
node scripts/canary-assert.mjs rows --harness "$HARNESS" --since "$CANARY_SINCE" || true
exit 1
Expand Down
13 changes: 8 additions & 5 deletions docs/harness-canary.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Each matrix leg, on a fresh `ubuntu-latest` runner with Node 22 (and Python
`acp-canary-<run id>-<harness>` through the launcher (commands in the
table). The step **fails** if the transcript contains a rejection
(`auto-rejecting`, `rejected permission`, `Denied at approval`,
`permission denied`, `requires approval`): that is gsc#1380 (a headless
`permission denied`, `requires approval`, `permission was declined`): that is gsc#1380 (a headless
harness rejecting the governed call), and the leg goes red with the
transcript attached rather than passing silently. On that failure the step
prints every audit row in the window with its `decision`
Expand Down Expand Up @@ -216,8 +216,11 @@ tenant doc before the first scheduled run.
installer only checks `opencode --help` for `--model`. If the released
opencode rejects a global `--model`, that leg fails at `governed-call` and
the issue says so. The installer also sets `permission.bash = "ask"` in
`opencode.json`; headless `opencode run` relies on the `acp-opencode`
plugin to answer that ask.
`opencode.json`; headless `opencode run` auto-rejects an unanswered ask, so
the canary pre-approves `echo *` via `OPENCODE_CONFIG_CONTENT` for its own
process (see the opencode row under the failure table). That means the leg
proves the plugin's audit row, not the `permission.ask` allow path.
- qwen-code headless needs `--yolo` (shell calls are declined, not prompted, in `-p` mode); the leg passes it and closes stdin.
- Headless `-p` on qwen-code, pi and prime-agent runs the shell call only if
the hook's `allow` is honoured as the permission decision; if the harness
still asks, the transcript shows the rejection and the leg goes red with
Expand Down Expand Up @@ -261,8 +264,8 @@ sdk-pydantic-ai) were green.
| `openclaw` | `openclaw@2026.9.8` refuses Node 22 (engines `>=24.16.0 <25 \|\| >=26.1.0`). | canary env | Node 24 for this leg only. |
| `hermes` | Installer exit 23. `install.sh` greps `pipx list` for `package hermes`, which matches `hermes-agent`, then runs `pipx inject hermes ...` against a venv that is named `hermes-agent`; the shebang fallback has no pip (pipx venvs ship without it). | **installer bug** (`install.sh`, the hermes block around `pipx inject hermes acp-hermes`) | None: the leg is right to be red until the installer injects into `hermes-agent`. |
| `claude-code` | Anthropic answered `401 authentication_error: Invalid bearer token` through the BYO path. The proxy forwards `Authorization: Bearer` and `anthropic-beta` verbatim (`apps/tenant-gateway/src/proxy/anthropicNative.ts`), so this is the `CLAUDE_CODE_OAUTH_TOKEN` secret itself. | secret | None: re-mint with `claude setup-token` and update the secret. |
| `qwen-code` | `qwen-acp -p` ran for the full 300 s (exit 124) printing `ACP write error: write EPIPE` from qwen-code's own `packages/cli/src/acp-integration` (Zed Agent Client Protocol transport); audit showed Read/Grep/agent/tool_search rows but never the `echo`. | upstream / product (reproduce in a sandbox) | None yet; gsc#1399 holds the log. |
| `opencode` | "The user rejected permission to use this specific tool call" in headless `run`. | product bug gsc#1380 | None. |
| `qwen-code` | `qwen-acp -p` ran for the full 300 s (exit 124) printing `ACP write error: write EPIPE` from qwen-code's own `packages/cli/src/acp-integration` (Zed Agent Client Protocol transport); audit showed Read/Grep/agent/tool_search rows but never the `echo`. | canary invocation (run 37784245015 later showed qwen finishing in ~60 s with 33 tool rows and no shell row: headless qwen declines `run_shell_command` without YOLO) | `qwen-acp --yolo -p ... </dev/null`; the ACP hook still gates the call. The EPIPE hang was not reproduced. |
| `opencode` | "permission requested: bash (echo ...); auto-rejecting" / "The user rejected permission to use this specific tool call" in headless `run` (opencode 1.18.35). The installer writes `permission.bash = "ask"` for attended users; `run` has no one to answer it and auto-rejects, even though the gateway row for the call was `allow`. | canary env (gsc#1380) | The leg sets `OPENCODE_CONFIG_CONTENT='{"permission":{"bash":{"echo *":"allow"}}}'` for that one process only; the installer's `opencode.json` is untouched. |
| `sdk-langchain` | `ACPMiddleware` imports `langchain.agents.middleware`; the leg installed only `acp-langchain`. | canary env | `pip install acp-langchain 'langchain>=1.3.3'`. |
| `sdk-governance-anthropic` | `getConfig` is not exported by `@agenticcontrolplane/governance-anthropic@0.2.1` (it re-exports `governed`, `withContext`, `configure`, `getContext`). | canary script | Version from the package manifest, base URL from env. |
| `sdk-proxy` | `@agenticcontrolplane/proxy` is 404 on npm and not in the SDK monorepo tree. | unpublished | Out of the scheduled set until it is published. |
Expand Down
22 changes: 19 additions & 3 deletions scripts/canary-sdk-governance-anthropic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,30 @@
// governance-anthropic re-exports governed/withContext/configure/getContext
// from @agenticcontrolplane/governance but NOT getConfig (0.2.1), so the
// version comes from the package manifest and the base URL from env.
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { configure, governHandlers, withContext } from "@agenticcontrolplane/governance-anthropic";

const key = process.env.ACP_CANARY_KEY;
if (!key) { console.error("ACP_CANARY_KEY is not set"); process.exit(2); }
const marker = process.env.MARKER || `acp-canary-local-${Date.now()}`;
const require = createRequire(import.meta.url);
const version = require("@agenticcontrolplane/governance-anthropic/package.json").version || "unknown";
// The package "exports" map has no "./package.json" subpath (0.2.x), so
// require(".../package.json") throws ERR_PACKAGE_PATH_NOT_EXPORTED. Resolve
// the allowed main entry, then walk up to the manifest on disk.
function pkgVersion() {
try {
let dir = dirname(fileURLToPath(import.meta.resolve("@agenticcontrolplane/governance-anthropic")));
for (let i = 0; i < 6; i++, dir = dirname(dir)) {
try {
const m = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
if (m.name === "@agenticcontrolplane/governance-anthropic") return m.version || "unknown";
} catch {}
}
} catch {}
return "unknown";
}
const version = pkgVersion();
configure({ baseUrl: process.env.ACP_BASE_URL || "https://api.agenticcontrolplane.com", clientHeader: `acp-canary-sdk-governance-anthropic/${version}` });

let lapse = "";
Expand Down
Loading