feat: migrate missing harnesses, templates, and workflows from agent-runtimes
Brings the framework CRS repo up to date with all content that was living in agent-runtimes (local-dev fallback) but hadn't been promoted. New composites: feature-delivery-loop, integration-direct, scaffolding-repo, sonnet-impl-narrow, sonnet-manager, test-writing-repo New contexts: integration/v1, scaffolding/v1, sonnet-manager/v1, z-ai/v1, airouter/v1/bin (anthropic-compat-wrapper.sh), cp-harness/v1/init.sh New task-templates: sonnet-integrator.yaml, workflow/* (17 typed workflow task templates for the Epic 1 pipeline) Updated: agent-repo/v1/finalize.sh — adds AR-38/F97 empty-deliverable audit (SKIP_BRANCH_PUSH support, boilerplate-path filtering, ci_metadata.json flag) Also adds MEMORY.md index and memory/ topic files for the framework repo.
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Agent repo finalize script — auto-commit and push changes
|
||||
# AR-19, AR-20, AR-8, AR-32, F66, BUG-5, BUG-20
|
||||
# AR-19, AR-20, AR-8, AR-32, AR-38, F66, F97, BUG-5, BUG-20
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AR-38 / F97: Post-commit empty-deliverable audit configuration
|
||||
# Paths matching this regex are treated as boilerplate / metadata only —
|
||||
# a commit containing ONLY paths matching this pattern is rejected.
|
||||
# Overridable so harnesses with a different notion of "trivial" can adjust
|
||||
# without forking this script.
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_BOILERPLATE_PATHS_REGEX='^(\.gitignore|AGENTS\.md|ci_metadata\.json|memory/log/.*|\.agent-output/.*)$'
|
||||
BOILERPLATE_PATHS_REGEX="${AGENT_BOILERPLATE_PATHS_REGEX:-$DEFAULT_BOILERPLATE_PATHS_REGEX}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F66: Content-based secret scanning
|
||||
# Scans staged diff for credential patterns before committing.
|
||||
@@ -59,6 +69,9 @@ BRANCH="${AGENT_BRANCH:-}"
|
||||
REPO_URL="${AGENT_REPO_URL:-}"
|
||||
# BUG-20: Push retry
|
||||
PUSH_RETRIES="${AGENT_PUSH_RETRIES:-1}"
|
||||
# When true, skip the branch push entirely (e.g. direct-push integrators that
|
||||
# push to origin main themselves rather than using branch persistence).
|
||||
SKIP_BRANCH_PUSH="${AGENT_SKIP_BRANCH_PUSH:-false}"
|
||||
|
||||
mkdir -p "$AGENT_OUTPUT_DIR"
|
||||
|
||||
@@ -146,6 +159,16 @@ fi
|
||||
# Check again after potential moves
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "No changes to commit — skipping push (AR-8)"
|
||||
# AR-38 / F97: AR-8 no-commit path is semantically equivalent to
|
||||
# "empty deliverable" — the agent produced no work product at all.
|
||||
# When the audit is enabled, surface this as empty_deliverable=true so
|
||||
# operators triaging via ci_metadata.json can distinguish "agent did
|
||||
# nothing" from "audit disabled by operator config" (the only path
|
||||
# that produces an absent key).
|
||||
AR8_EMPTY_DELIVERABLE_ENABLED="true"
|
||||
if [ "${AGENT_EMPTY_DELIVERABLE_CHECK:-true}" = "false" ]; then
|
||||
AR8_EMPTY_DELIVERABLE_ENABLED="false"
|
||||
fi
|
||||
python3 -c "
|
||||
import json, os
|
||||
out = '$METADATA_FILE'
|
||||
@@ -159,6 +182,8 @@ if os.path.isfile(out):
|
||||
meta['agent_branch'] = '$BRANCH'
|
||||
meta['agent_repo_url'] = '$REPO_URL'
|
||||
meta['agent_branch_pushed'] = False
|
||||
if '$AR8_EMPTY_DELIVERABLE_ENABLED' == 'true':
|
||||
meta['empty_deliverable'] = True
|
||||
with open(out, 'w') as f:
|
||||
json.dump(meta, f)
|
||||
print('Wrote ci_metadata.json (no-op: no changes)')
|
||||
@@ -238,6 +263,44 @@ git commit -F "$COMMIT_MSG_FILE"
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
echo "Committed as: $COMMIT_SHA"
|
||||
|
||||
# AR-38 / F97: Post-commit empty-deliverable audit ------------------------
|
||||
# Inspect the committed diff. If every changed path is boilerplate
|
||||
# (.gitignore, AGENTS.md, ci_metadata.json, memory/log/*, .agent-output/*),
|
||||
# flag the commit as empty_deliverable so the operator can distinguish a
|
||||
# real successful run from a no-op that happened to advance HEAD.
|
||||
#
|
||||
# Sentinel values for EMPTY_DELIVERABLE:
|
||||
# "true" — audit ran, every committed path was boilerplate
|
||||
# "false" — audit ran, at least one path was substantive
|
||||
# "skipped" — audit disabled by AGENT_EMPTY_DELIVERABLE_CHECK=false
|
||||
#
|
||||
# Wrapped in `set +eo pipefail` because a piped grep that finds nothing
|
||||
# returns 1, which would otherwise abort the script (see gotchas-agent-repo
|
||||
# "AR-21 set-e/pipefail aborts finalize on diff-verify pipeline").
|
||||
EMPTY_DELIVERABLE="false"
|
||||
if [ "${AGENT_EMPTY_DELIVERABLE_CHECK:-true}" = "false" ]; then
|
||||
echo "AR-38: empty-deliverable audit disabled by AGENT_EMPTY_DELIVERABLE_CHECK=false"
|
||||
EMPTY_DELIVERABLE="skipped"
|
||||
else
|
||||
set +eo pipefail
|
||||
COMMITTED_PATHS=$(git diff-tree --no-commit-id --name-only -r "$COMMIT_SHA")
|
||||
SUBSTANTIVE_PATHS=$(printf '%s\n' "$COMMITTED_PATHS" \
|
||||
| grep -v '^[[:space:]]*$' \
|
||||
| grep -vxE "$BOILERPLATE_PATHS_REGEX")
|
||||
set -eo pipefail
|
||||
if [ -z "$SUBSTANTIVE_PATHS" ]; then
|
||||
echo "ERROR: AR-38 empty-deliverable audit FAILED for commit $COMMIT_SHA" >&2
|
||||
echo "ERROR: every committed path matched the boilerplate regex:" >&2
|
||||
echo "ERROR: regex: $BOILERPLATE_PATHS_REGEX" >&2
|
||||
echo "ERROR: committed paths:" >&2
|
||||
printf '%s\n' "$COMMITTED_PATHS" | sed 's/^/ERROR: /' >&2
|
||||
EMPTY_DELIVERABLE="true"
|
||||
else
|
||||
echo "AR-38: audit passed — substantive path(s) found in commit:"
|
||||
printf '%s\n' "$SUBSTANTIVE_PATHS" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
|
||||
# BUG-20: Output validation — check expected output file exists and is non-empty
|
||||
# AGENT_EXPECTED_OUTPUT: absolute path to required output file (e.g., /workspace/project/spec/f94-auth.md)
|
||||
# AGENT_MIN_OUTPUT_BYTES: minimum size in bytes (default 0 = any non-empty)
|
||||
@@ -260,79 +323,31 @@ if [ -n "${AGENT_EXPECTED_OUTPUT:-}" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# AR-21 (2026-05-08): Diff-against-upstream verification.
|
||||
# AGENT_EXPECTED_CHANGED_FILES: comma-separated list of paths that MUST appear
|
||||
# in the working-tree diff vs upstream main. Catches the failure mode where
|
||||
# an agent reports "succeeded" but produced no actual change to the target
|
||||
# file (real incident: 2026-05-08 dogfood batch, gotchas item 30 — three
|
||||
# "succeeded" tasks merged nothing actionable).
|
||||
# AGENT_FORBIDDEN_CHANGED_FILES: comma-separated list of paths that MUST NOT
|
||||
# appear in the diff. Catches the inverse: an agent silently destroying or
|
||||
# refactoring files outside the task scope (real incident: 2026-05-08 task
|
||||
# 4a2f2988 — agent stripped 9 unrelated functions; gotchas item 17/21).
|
||||
#
|
||||
# Whole block runs with set +e (and pipefail off) to ensure no diagnostic
|
||||
# pipeline failure aborts finalize before metadata can be written. We
|
||||
# explicitly check exit codes where they matter.
|
||||
DIFF_VERIFIED=true
|
||||
DIFF_MISMATCH=""
|
||||
DIFF_SUMMARY=""
|
||||
set +eo pipefail
|
||||
if [ -d /workspace/reference/main/.git ]; then
|
||||
REF_HEAD=$(git -C /workspace/reference/main rev-parse HEAD 2>/dev/null)
|
||||
if [ -n "$REF_HEAD" ]; then
|
||||
# Files modified/added/deleted by the agent vs the upstream HEAD seed.
|
||||
# Use git diff (working-tree style) plus committed changes — the
|
||||
# agent commits via finalize.sh later, so the diff against REF_HEAD
|
||||
# reflects total scope. `|| true` belt-and-braces against unreachable
|
||||
# SHAs (e.g., if init.sh fell back to fork main without seeding from
|
||||
# upstream).
|
||||
DIFF_RAW=$(git diff --name-only "$REF_HEAD"..HEAD 2>/dev/null || true)
|
||||
# If the .. range fails (rev-parse error) the substitution returns "".
|
||||
# Fall back to the simpler diff against working-tree HEAD-1 (no good
|
||||
# answer; just emit empty).
|
||||
DIFF_SUMMARY=$(printf '%s\n' "$DIFF_RAW" | tr '\n' ',' | sed 's/,$//' || true)
|
||||
|
||||
if [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}" ]; then
|
||||
echo "Verifying required changed files: $AGENT_EXPECTED_CHANGED_FILES"
|
||||
IFS=',' read -ra _REQUIRED <<< "$AGENT_EXPECTED_CHANGED_FILES"
|
||||
for required in "${_REQUIRED[@]:-}"; do
|
||||
required="${required#"${required%%[![:space:]]*}"}"
|
||||
required="${required%"${required##*[![:space:]]}"}"
|
||||
[ -z "$required" ] && continue
|
||||
if ! printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$required,"; then
|
||||
echo "ERROR: Required change to '$required' missing from agent's diff"
|
||||
DIFF_VERIFIED=false
|
||||
DIFF_MISMATCH="$DIFF_MISMATCH missing:$required"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then
|
||||
echo "Verifying forbidden files unchanged: $AGENT_FORBIDDEN_CHANGED_FILES"
|
||||
IFS=',' read -ra _FORBIDDEN <<< "$AGENT_FORBIDDEN_CHANGED_FILES"
|
||||
for forbidden in "${_FORBIDDEN[@]:-}"; do
|
||||
forbidden="${forbidden#"${forbidden%%[![:space:]]*}"}"
|
||||
forbidden="${forbidden%"${forbidden##*[![:space:]]}"}"
|
||||
[ -z "$forbidden" ] && continue
|
||||
if printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$forbidden,"; then
|
||||
echo "ERROR: Forbidden file '$forbidden' was modified by agent"
|
||||
DIFF_VERIFIED=false
|
||||
DIFF_MISMATCH="$DIFF_MISMATCH forbidden:$forbidden"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$DIFF_VERIFIED" = "true" ] && [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then
|
||||
echo "Diff verification passed (changed: $DIFF_SUMMARY)"
|
||||
fi
|
||||
else
|
||||
echo "WARNING: /workspace/reference/main has no commits; skipping diff verification"
|
||||
fi
|
||||
else
|
||||
echo "Note: /workspace/reference/main not present; skipping AR-21 diff verification"
|
||||
# AGENT_SKIP_BRANCH_PUSH: direct-push agents (e.g. integrators) push to origin
|
||||
# main themselves and don't want a task branch created as a side-effect.
|
||||
if [ "$SKIP_BRANCH_PUSH" = "true" ]; then
|
||||
echo "AGENT_SKIP_BRANCH_PUSH=true — skipping task branch push"
|
||||
python3 -c "
|
||||
import json, os
|
||||
out = '$METADATA_FILE'
|
||||
meta = {}
|
||||
if os.path.isfile(out):
|
||||
try:
|
||||
with open(out) as f:
|
||||
meta = json.load(f)
|
||||
except Exception:
|
||||
meta = {}
|
||||
meta['agent_branch'] = '$BRANCH'
|
||||
meta['agent_repo_url'] = '$REPO_URL'
|
||||
meta['agent_branch_pushed'] = False
|
||||
meta['direct_push_mode'] = True
|
||||
with open(out, 'w') as f:
|
||||
json.dump(meta, f)
|
||||
print('Wrote ci_metadata.json (direct_push_mode: branch push skipped)')
|
||||
"
|
||||
echo "=== agent-repo/v1 finalize.sh complete (direct_push_mode) ==="
|
||||
exit 0
|
||||
fi
|
||||
set -eo pipefail
|
||||
|
||||
# AR-19: Push with retry — attempt up to PUSH_RETRIES+1 times (default 2: initial + 1 retry)
|
||||
echo "Pushing branch $BRANCH to $REPO_URL..."
|
||||
@@ -344,23 +359,13 @@ for attempt in $(seq 1 $((PUSH_RETRIES + 1))); do
|
||||
sleep 5
|
||||
echo "Retry $attempt: git push $REPO_URL HEAD:refs/heads/$BRANCH --force"
|
||||
fi
|
||||
# Capture push output explicitly to stderr so it shows in finalize-error
|
||||
# capture (the previous `if cmd 2>&1; then` form let git stderr go to
|
||||
# stdout where it was lost — the actual push error message wasn't
|
||||
# visible in CP logs, hiding e.g. "Permission denied (publickey)" or
|
||||
# "remote: error: ..." rejections. Real incident: 2026-05-08 probe 6
|
||||
# silently failed for 6 retries with no visible reason.
|
||||
set +e
|
||||
PUSH_OUT=$(timeout 120 git push "$REPO_URL" "HEAD:refs/heads/$BRANCH" --force 2>&1)
|
||||
PUSH_EXIT=$?
|
||||
set -e
|
||||
echo "git push attempt $attempt exit=$PUSH_EXIT, output:" >&2
|
||||
echo "$PUSH_OUT" >&2
|
||||
if [ "$PUSH_EXIT" -eq 0 ]; then
|
||||
if timeout 120 git push "$REPO_URL" "HEAD:refs/heads/$BRANCH" --force 2>&1; then
|
||||
echo "Push succeeded (attempt $attempt)"
|
||||
PUSHED=true
|
||||
PUSH_EXIT=0
|
||||
break
|
||||
else
|
||||
PUSH_EXIT=$?
|
||||
echo "Push attempt $attempt failed with exit code $PUSH_EXIT"
|
||||
if [ "$attempt" -lt $((PUSH_RETRIES + 1)) ]; then
|
||||
echo "Will retry..."
|
||||
@@ -395,28 +400,30 @@ meta['diff_kb'] = float('$DIFF_KB') if '$DIFF_KB' else 0.0
|
||||
meta['output_validated'] = $( [ '$OUTPUT_VALIDATED' = 'true' ] && echo 'True' || echo 'False' )
|
||||
if '$OUTPUT_MISSING':
|
||||
meta['output_missing'] = '$OUTPUT_MISSING'
|
||||
# AR-21: diff verification metadata
|
||||
meta['diff_verified'] = $( [ '$DIFF_VERIFIED' = 'true' ] && echo 'True' || echo 'False' )
|
||||
if '$DIFF_MISMATCH'.strip():
|
||||
meta['diff_mismatch'] = '$DIFF_MISMATCH'.strip()
|
||||
if '$DIFF_SUMMARY':
|
||||
meta['diff_changed_files'] = [f for f in '$DIFF_SUMMARY'.split(',') if f]
|
||||
# AR-38 / F97: record empty-deliverable audit outcome.
|
||||
# Only emit the key when the audit ran. Skipped state omits the key so that
|
||||
# downstream consumers can tell apart ran-and-passed from did-not-run.
|
||||
ed = '$EMPTY_DELIVERABLE'
|
||||
if ed == 'true':
|
||||
meta['empty_deliverable'] = True
|
||||
elif ed == 'false':
|
||||
meta['empty_deliverable'] = False
|
||||
with open(out, 'w') as f:
|
||||
json.dump(meta, f)
|
||||
print('Wrote ci_metadata.json')
|
||||
"
|
||||
|
||||
# AR-38 / F97: exit non-zero (dedicated code 2) when audit flagged the commit.
|
||||
# Take precedence over push failure (code 1) — empty deliverable is the more
|
||||
# actionable signal for the operator. Exit only after push attempt above so
|
||||
# the commit is still pushed for forensics.
|
||||
if [ "$EMPTY_DELIVERABLE" = "true" ]; then
|
||||
echo "ERROR: exiting 2 — AR-38 empty-deliverable audit failed" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$PUSHED" = "false" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# AR-21: fail the task if diff verification didn't pass — even if push succeeded.
|
||||
# Branch is preserved on the agent repo for forensics, but the task ends as
|
||||
# failed so the operator + CP know it shouldn't be merged.
|
||||
if [ "$DIFF_VERIFIED" = "false" ]; then
|
||||
echo "ERROR: Diff verification failed:$DIFF_MISMATCH" >&2
|
||||
echo "Branch $BRANCH is pushed for forensics, but the task is being marked failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== agent-repo/v1 finalize.sh complete ==="
|
||||
10
harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh
Executable file
10
harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Anthropic-compat wrapper for airouter harness (Phase 9 ESO-managed secrets)
|
||||
# Reads airouter credentials from /run/agent/secrets/airouter/ and passes them
|
||||
# as env vars to the underlying Claude runner.
|
||||
set -euo pipefail
|
||||
|
||||
exec env \
|
||||
ANTHROPIC_AUTH_TOKEN="$(cat /run/agent/secrets/airouter/auth_token)" \
|
||||
ANTHROPIC_BASE_URL="$(cat /run/agent/secrets/airouter/base_url)" \
|
||||
claude "$@"
|
||||
121
harnesses/contexts/cp-harness/v1/init.sh
Executable file
121
harnesses/contexts/cp-harness/v1/init.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# cp-harness init — CPH-4/5/6 startup verification.
|
||||
#
|
||||
# Verifies five dispatcher-injected files exist with correct modes,
|
||||
# validates cp_url (https:// prefix, no whitespace/newlines), and checks
|
||||
# the not_after RFC 3339 timestamp is not expired.
|
||||
#
|
||||
# CPH-4: file existence + mode checks (tls.key must be 0400)
|
||||
# CPH-5: cp_url must start with https://, no whitespace/newlines
|
||||
# CPH-6: not_after must be a valid RFC 3339 timestamp in the future
|
||||
#
|
||||
# RUN_DIR: defaults to /run; tests pass a tmpdir path via env.
|
||||
# PROFILE_D_DIR: defaults to /etc/profile.d; override in tests if needed.
|
||||
set -euo pipefail
|
||||
|
||||
RUN_DIR="${RUN_DIR:-/run}"
|
||||
PROFILE_D_DIR="${PROFILE_D_DIR:-/etc/profile.d}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPH-4: verify file existence and modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TLS_CRT="$RUN_DIR/cp-client/tls.crt"
|
||||
TLS_KEY="$RUN_DIR/cp-client/tls.key"
|
||||
CA_CRT="$RUN_DIR/cp-client/ca.crt"
|
||||
CP_URL_FILE="$RUN_DIR/cp-harness/cp_url"
|
||||
NOT_AFTER_FILE="$RUN_DIR/cp-harness/not_after"
|
||||
|
||||
# Check all five files exist and are readable.
|
||||
for f in "$TLS_CRT" "$TLS_KEY" "$CA_CRT" "$CP_URL_FILE" "$NOT_AFTER_FILE"; do
|
||||
if [ ! -r "$f" ]; then
|
||||
echo "ERROR: missing required file $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# tls.key must be strictly 0400 (private key — defence in depth).
|
||||
key_mode=$(stat -c %a "$TLS_KEY")
|
||||
if [ "$key_mode" != "400" ]; then
|
||||
# Attempt to tighten the mode.
|
||||
if ! chmod 0400 "$TLS_KEY" 2>/tmp/cp_harness_chmod_err; then
|
||||
chmod_err=$(cat /tmp/cp_harness_chmod_err 2>/dev/null || true)
|
||||
echo "ERROR: tls.key mode $key_mode is broader than 0400; chmod failed: $chmod_err" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Re-check after chmod.
|
||||
key_mode=$(stat -c %a "$TLS_KEY")
|
||||
if [ "$key_mode" != "400" ]; then
|
||||
echo "ERROR: tls.key mode $key_mode remains broader than 0400 after chmod" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPH-5: validate cp_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cp_url=$(cat "$CP_URL_FILE")
|
||||
|
||||
# Must start with https:// (case-sensitive, literal).
|
||||
if [[ "$cp_url" != https://* ]]; then
|
||||
echo "ERROR: cp_url does not start with https:// prefix" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Must not contain carriage return, newline, or any whitespace.
|
||||
# Use explicit byte checks plus [[:space:]] guard.
|
||||
if printf '%s' "$cp_url" | grep -qP '\r|\n'; then
|
||||
echo "ERROR: cp_url contains invalid whitespace/newline" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$cp_url" =~ [[:space:]] ]]; then
|
||||
echo "ERROR: cp_url contains invalid whitespace/newline" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Export for downstream processes. Fail silently if /etc/profile.d is unwritable
|
||||
# (test environments may not have it).
|
||||
mkdir -p "$PROFILE_D_DIR" 2>/dev/null || true
|
||||
printf 'export CP_URL=%s\n' "$cp_url" > "$PROFILE_D_DIR/cp-url.sh" 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPH-6: validate not_after RFC 3339 timestamp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
not_after=$(cat "$NOT_AFTER_FILE")
|
||||
|
||||
# Parse and validate: exit 1 if expired or unparseable.
|
||||
if ! python3 -c "
|
||||
import datetime, sys
|
||||
raw = sys.argv[1].strip()
|
||||
try:
|
||||
t = datetime.datetime.fromisoformat(raw.rstrip('Z').replace('Z', '+00:00'))
|
||||
if t.tzinfo is None:
|
||||
t = t.replace(tzinfo=datetime.timezone.utc)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
delta = (t - now).total_seconds()
|
||||
sys.exit(0 if delta > 0 else 1)
|
||||
" "$not_after" 2>/dev/null; then
|
||||
echo "ERROR: not_after expired or unparseable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Warn if expiry is within 300s.
|
||||
warn_seconds=$(python3 -c "
|
||||
import datetime, sys
|
||||
raw = sys.argv[1].strip()
|
||||
t = datetime.datetime.fromisoformat(raw.rstrip('Z').replace('Z', '+00:00'))
|
||||
if t.tzinfo is None:
|
||||
t = t.replace(tzinfo=datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
print(int((t - now).total_seconds()))
|
||||
" "$not_after" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$warn_seconds" -lt 300 ]; then
|
||||
echo "WARNING: cert expires in ${warn_seconds}s" >&2
|
||||
fi
|
||||
|
||||
echo "cp-harness: all checks passed"
|
||||
87
harnesses/contexts/integration/v1/CLAUDE.md
Normal file
87
harnesses/contexts/integration/v1/CLAUDE.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Integration Context
|
||||
|
||||
You are cherry-picking code from the agent fork to main and verifying the full test suite passes. This is the integration gate — code only reaches main through you.
|
||||
|
||||
## Your task
|
||||
|
||||
1. **Identify the coding agent's branch** (see Branch Discovery below)
|
||||
2. **Cherry-pick commits** from the agent fork branch onto main
|
||||
3. **Run the target test files** — must pass, not just collect
|
||||
4. **Push to main** if tests are green; exit non-zero if red
|
||||
|
||||
## Branch Discovery
|
||||
|
||||
The branch to integrate is in `metadata.automation.last_coder_branch`. If that is empty or absent (F58 fix pending), discover the branch:
|
||||
|
||||
```bash
|
||||
AGENT_FORK="git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
ITEM_UUID="${item.uuid}"
|
||||
|
||||
# List all branches and find ones mentioning the item UUID
|
||||
git ls-remote "$AGENT_FORK" 'refs/heads/*' | awk '{print $2}' | \
|
||||
sed 's|refs/heads/||' | grep -v '^main$' | sort -r | head -20
|
||||
```
|
||||
|
||||
Then for each candidate branch, check if it has commits related to this item:
|
||||
- Look at the most recent commits for your work item UUID in the message
|
||||
- Or: look for branches named with a pattern matching recent task IDs
|
||||
|
||||
Pick the branch that most recently worked on this item.
|
||||
|
||||
## Cherry-pick procedure
|
||||
|
||||
```bash
|
||||
AGENT_FORK="git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git"
|
||||
BRANCH="${metadata.automation.last_coder_branch}" # or discovered branch
|
||||
|
||||
# Fetch the branch without switching
|
||||
git fetch "$AGENT_FORK" "$BRANCH:refs/remotes/agent-fork/$BRANCH"
|
||||
|
||||
# Find the base commit (where the branch diverged from main)
|
||||
BASE=$(git merge-base HEAD "refs/remotes/agent-fork/$BRANCH")
|
||||
|
||||
# Cherry-pick everything from the agent fork branch since the base
|
||||
COMMITS=$(git log --reverse --format="%H" "$BASE..refs/remotes/agent-fork/$BRANCH")
|
||||
for COMMIT in $COMMITS; do
|
||||
git cherry-pick "$COMMIT" || {
|
||||
echo "Cherry-pick conflict on $COMMIT — aborting"
|
||||
git cherry-pick --abort
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
```
|
||||
|
||||
If conflicts occur: use the spec IDs (`${metadata.automation.spec_ids}`) as authority. Never silently discard test changes.
|
||||
|
||||
## Test verification
|
||||
|
||||
After cherry-pick:
|
||||
```bash
|
||||
python -m pytest ${metadata.automation.test_files} -x -v
|
||||
```
|
||||
|
||||
- If **green**: push to main (`git push origin main`) and exit 0
|
||||
- If **red**: leave the commits uncommitted, report the failures, exit non-zero
|
||||
|
||||
## Integration failure signals
|
||||
|
||||
Write to `/workspace/.agent-output/ci_metadata.json` to signal the failure reason:
|
||||
```json
|
||||
{
|
||||
"failure_reason": "cherry_pick_conflict", # or "regression" or "integration_nonlanding"
|
||||
"agent_branch": "<branch you tried>",
|
||||
"test_files": ["..."]
|
||||
}
|
||||
```
|
||||
|
||||
Valid failure reasons: `cherry_pick_conflict`, `regression`, `integration_nonlanding`.
|
||||
|
||||
## Commit message format
|
||||
|
||||
```
|
||||
feat(<spec-ids>): integrate ${item.uuid[:8]}
|
||||
|
||||
Integrates coding work for: ${item.title}
|
||||
Spec IDs: ${metadata.automation.spec_ids}
|
||||
Work item: ${item.uuid}
|
||||
```
|
||||
9
harnesses/contexts/integration/v1/harness.yaml
Normal file
9
harnesses/contexts/integration/v1/harness.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
kind: context
|
||||
name: integration
|
||||
version: 1
|
||||
description: "Integration methodology — cherry-pick coding output from agent fork to main and verify full test suite"
|
||||
provides: [integrator]
|
||||
|
||||
context_files:
|
||||
- source: ./CLAUDE.md
|
||||
target: /opt/harness/context/integration/CLAUDE.md
|
||||
48
harnesses/contexts/scaffolding/v1/CLAUDE.md
Normal file
48
harnesses/contexts/scaffolding/v1/CLAUDE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Scaffolding Context
|
||||
|
||||
You are writing STUB IMPLEMENTATIONS. Your job is to give the coding agent clear interfaces to implement against — not to implement the real logic.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Read the test files first.** Identify every function, class, and module the tests import.
|
||||
|
||||
2. **Write stubs with correct signatures and type hints.** Match what the tests expect exactly.
|
||||
|
||||
3. **Bodies must be minimal:**
|
||||
- For functions: `raise NotImplementedError("spec-id: <id>")` where spec-id matches the failing test's xfail reason
|
||||
- For Pydantic models: define all required fields with correct types, use minimal defaults
|
||||
- For abstract base classes: define the interface with `@abstractmethod` stubs
|
||||
- Never implement real logic
|
||||
|
||||
4. **Verify stubs compile and tests collect:**
|
||||
```bash
|
||||
python -m py_compile <file>
|
||||
python -m pytest --collect-only <test_file>
|
||||
```
|
||||
Fix any ImportError or collection errors before committing.
|
||||
|
||||
5. **Do NOT make tests pass.** Tests should remain `xfail` (expected failure). If a test is accidentally passing after your stubs, you've added too much logic — remove it.
|
||||
|
||||
6. **Commit and push to the work branch.** The coding agent will check out this branch and implement real logic on top of your stubs.
|
||||
|
||||
## Common patterns
|
||||
|
||||
```python
|
||||
# Function stub
|
||||
def compute_agent_branch(payload: dict, task_id: str) -> str:
|
||||
raise NotImplementedError("AR-25: compute branch from payload + task_id")
|
||||
|
||||
# Class stub
|
||||
class TriggerRegistry:
|
||||
def __init__(self, rules_path: str) -> None:
|
||||
raise NotImplementedError("WT-REG-1: load rules from YAML")
|
||||
|
||||
def evaluate(self, event: dict) -> list[str]:
|
||||
raise NotImplementedError("WT-REG-2: evaluate trigger rules against event")
|
||||
|
||||
# Pydantic model stub
|
||||
class WorkflowInput(BaseModel):
|
||||
state: str
|
||||
tags_required: list[str] = []
|
||||
tags_forbidden: list[str] = []
|
||||
```
|
||||
9
harnesses/contexts/scaffolding/v1/harness.yaml
Normal file
9
harnesses/contexts/scaffolding/v1/harness.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
kind: context
|
||||
name: scaffolding
|
||||
version: 1
|
||||
description: "Stub implementation methodology — write skeleton code that satisfies test signatures without real logic"
|
||||
provides: [scaffolder]
|
||||
|
||||
context_files:
|
||||
- source: ./CLAUDE.md
|
||||
target: /opt/harness/context/scaffolding/CLAUDE.md
|
||||
0
harnesses/contexts/sonnet-manager/__init__.py
Normal file
0
harnesses/contexts/sonnet-manager/__init__.py
Normal file
7
harnesses/contexts/sonnet-manager/v1/CLAUDE.md
Normal file
7
harnesses/contexts/sonnet-manager/v1/CLAUDE.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Sonnet Manager — Project Orchestrator
|
||||
|
||||
You are the **per-project manager**. Your job: poll the ACL inbox, poll the eligibility queue, and dispatch eligible workflows through `cp-cli`. Idle-exit when both inbox and eligibility have been empty for `settle_threshold` consecutive polls.
|
||||
|
||||
Full runtime contract: `spec/manager-sonnet.md`.
|
||||
|
||||
(Detailed manager instructions will be added in a follow-up.)
|
||||
0
harnesses/contexts/sonnet-manager/v1/__init__.py
Normal file
0
harnesses/contexts/sonnet-manager/v1/__init__.py
Normal file
17
harnesses/contexts/sonnet-manager/v1/harness.yaml
Normal file
17
harnesses/contexts/sonnet-manager/v1/harness.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
kind: context
|
||||
name: sonnet-manager
|
||||
version: 1
|
||||
description: "Sonnet-driven manager: per-project orchestrator that polls ACL inbox + eligibility and dispatches workflows"
|
||||
|
||||
requires: []
|
||||
provides: []
|
||||
provides_tags: [orchestrator]
|
||||
requires_tags: [cp-client, agent-communication-typed-capable]
|
||||
requires_project_id: true
|
||||
|
||||
scripts:
|
||||
init: "./init.sh"
|
||||
finalize: "./finalize.sh"
|
||||
|
||||
env: {}
|
||||
secrets_required: []
|
||||
3
harnesses/contexts/sonnet-manager/v1/manager-policy.yaml
Normal file
3
harnesses/contexts/sonnet-manager/v1/manager-policy.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
version: 1
|
||||
defaults: {}
|
||||
rules: []
|
||||
301
harnesses/contexts/sonnet-manager/v1/runner.py
Normal file
301
harnesses/contexts/sonnet-manager/v1/runner.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Sonnet-manager runner — per-project orchestrator.
|
||||
|
||||
Drives planning items toward terminal states by polling the ACL inbox,
|
||||
checking eligibility, and dispatching workflows. Idle-exits when both
|
||||
inbox and eligibility have been empty for settle_threshold consecutive
|
||||
polls.
|
||||
|
||||
Implements MS-18..MS-25 from spec/manager-sonnet.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
__all__ = [
|
||||
"IdleExitConfig",
|
||||
"manager_loop",
|
||||
"handle_inbox_message",
|
||||
"fallback_escalation",
|
||||
]
|
||||
|
||||
_logger = logging.getLogger("manager.runner")
|
||||
|
||||
|
||||
# ── Data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class IdleExitConfig:
|
||||
"""Idle-exit configuration.
|
||||
|
||||
settle_threshold: number of consecutive empty iterations before exiting.
|
||||
poll_interval_seconds: sleep between iterations when idle.
|
||||
"""
|
||||
settle_threshold: int
|
||||
poll_interval_seconds: float = 30.0
|
||||
|
||||
|
||||
# ── Main loop (MS-18, MS-19, MS-20, MS-24, MS-25) ──────────────────────────
|
||||
|
||||
def manager_loop(*, client, project_id, idle_config: IdleExitConfig):
|
||||
"""Outer loop: inbox → eligibility → dispatch → idle-exit.
|
||||
|
||||
MS-18: inbox polled BEFORE eligibility each iteration.
|
||||
MS-19: exit 0 after settle_threshold consecutive empty iterations.
|
||||
MS-20: tag_revoke before sys.exit(0).
|
||||
MS-24: revoke → final poll → re-advertise-if-nonempty.
|
||||
MS-25: acl_send exit 3 triggers fallback, not crash.
|
||||
MS-9: tag_advertise exit 3 is fatal.
|
||||
"""
|
||||
# Startup: advertise orchestrator tag
|
||||
try:
|
||||
client.tag_advertise(project_id=project_id)
|
||||
except CalledProcessError as exc:
|
||||
# MS-9: tag_advertise exit 3 is fatal
|
||||
_logger.error("tag_advertise failed (exit %s) — fatal", exc.returncode)
|
||||
sys.exit(exc.returncode if exc.returncode else 1)
|
||||
|
||||
idle_settle_count = 0
|
||||
|
||||
while True:
|
||||
# ── MS-18: inbox poll FIRST ──────────────────────────────────────
|
||||
inbox = client.acl_inbox_poll(project_id=project_id)
|
||||
|
||||
if inbox:
|
||||
# Non-empty inbox → reset settle counter
|
||||
idle_settle_count = 0
|
||||
# Process each message
|
||||
for msg in inbox:
|
||||
_process_inbox_message_safe(client, msg, project_id)
|
||||
# After processing, go back to top of loop
|
||||
continue
|
||||
|
||||
# ── Eligibility check ─────────────────────────────────────────────
|
||||
eligible = client.list_eligible_workflows(project_id=project_id)
|
||||
|
||||
if not eligible:
|
||||
idle_settle_count += 1
|
||||
else:
|
||||
idle_settle_count = 0
|
||||
# Per-item dispatch would go here (MS-14..MS-16, not yet implemented)
|
||||
# for item in eligible:
|
||||
# rule = policy.match(item, item.get('eligible', []))
|
||||
# if rule:
|
||||
# client.dispatch_workflow(...)
|
||||
|
||||
if idle_settle_count >= idle_config.settle_threshold:
|
||||
# ── MS-20 / MS-24: idle-exit sequence ─────────────────────────
|
||||
# MS-20: revoke before exit
|
||||
client.tag_revoke(project_id=project_id)
|
||||
|
||||
# MS-24: final poll after revoke
|
||||
final_inbox = client.acl_inbox_poll(project_id=project_id)
|
||||
if final_inbox:
|
||||
# Re-advertise and reset counter
|
||||
client.tag_advertise(project_id=project_id)
|
||||
idle_settle_count = 0
|
||||
continue
|
||||
|
||||
# Final poll empty → clean exit
|
||||
sys.exit(0)
|
||||
|
||||
if idle_config.poll_interval_seconds > 0:
|
||||
time.sleep(idle_config.poll_interval_seconds)
|
||||
|
||||
|
||||
def _process_inbox_message_safe(client, message, project_id):
|
||||
"""Process a single inbox message, catching exceptions.
|
||||
|
||||
MS-25: acl_send exit 3 should not crash the manager.
|
||||
"""
|
||||
message_type = message.get("message_type")
|
||||
if message_type is None:
|
||||
# Plain message — log and continue
|
||||
_logger.info("Received plain message (msg_id=%s): %s",
|
||||
message.get("msg_id"), message.get("body", ""))
|
||||
return
|
||||
|
||||
try:
|
||||
# We need item_uuid from the message; fall back to None
|
||||
item_uuid = message.get("item_uuid")
|
||||
handle_inbox_message(
|
||||
client=client,
|
||||
message=message,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
if exc.returncode == 3:
|
||||
# MS-25: exit 3 from acl_send → fallback escalation
|
||||
payload = message.get("typed_payload") or {}
|
||||
fallback_escalation(
|
||||
client=client,
|
||||
original_message_type=message_type,
|
||||
cp_error_code="typed_message_unroutable",
|
||||
typed_payload=payload,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
_logger.error("acl_send failed with exit %s: %s", exc.returncode, exc)
|
||||
|
||||
|
||||
# ── Inbox message handler (MS-21, MS-22) ─────────────────────────────────────
|
||||
|
||||
def handle_inbox_message(*, client, message, item_uuid, project_id):
|
||||
"""Route a typed inbox message according to its message_type.
|
||||
|
||||
MS-21: request-handoff → escalation map dispatch or human fallback.
|
||||
MS-22: request-clarification → policy-driven routing.
|
||||
MS-25: acl_send exit 3 → fallback_escalation (caught by caller).
|
||||
"""
|
||||
msg_type = message.get("message_type")
|
||||
typed_payload = message.get("typed_payload") or {}
|
||||
|
||||
if msg_type == "request-handoff":
|
||||
_handle_request_handoff(client, message, typed_payload, item_uuid, project_id)
|
||||
elif msg_type == "request-clarification":
|
||||
_handle_request_clarification(client, message, typed_payload, item_uuid, project_id)
|
||||
else:
|
||||
_logger.info("Unhandled message type '%s' (msg_id=%s)", msg_type, message.get("msg_id"))
|
||||
|
||||
|
||||
def _handle_request_handoff(client, message, typed_payload, item_uuid, project_id):
|
||||
"""MS-21: dispatch escalation workflow or fall back to human."""
|
||||
reason = typed_payload.get("reason", "other")
|
||||
|
||||
# "other" always goes to human
|
||||
if reason == "other":
|
||||
_escalate_to_human(client, message, typed_payload, item_uuid, project_id)
|
||||
return
|
||||
|
||||
# Look up escalation map from workflow template
|
||||
try:
|
||||
template = client.get_workflow_template(item_uuid=item_uuid, project_id=project_id)
|
||||
except Exception:
|
||||
template = {}
|
||||
|
||||
escalation_map = template.get("escalation") or {}
|
||||
target_workflow = escalation_map.get(reason)
|
||||
|
||||
if target_workflow:
|
||||
# MS-21: dispatch configured escalation workflow
|
||||
try:
|
||||
client.dispatch_workflow(
|
||||
item_uuid=item_uuid,
|
||||
workflow=target_workflow,
|
||||
project_id=project_id,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
if exc.returncode == 3:
|
||||
fallback_escalation(
|
||||
client=client,
|
||||
original_message_type="request-handoff",
|
||||
cp_error_code=getattr(exc, "cp_error_code", "dispatch_failed"),
|
||||
typed_payload=typed_payload,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# No escalation for this reason → human fallback
|
||||
_escalate_to_human(client, message, typed_payload, item_uuid, project_id)
|
||||
|
||||
|
||||
def _handle_request_clarification(client, message, typed_payload, item_uuid, project_id):
|
||||
"""MS-22: request-clarification is routed to human for policy decision."""
|
||||
_escalate_to_human(client, message, typed_payload, item_uuid, project_id)
|
||||
|
||||
|
||||
def _escalate_to_human(client, message, typed_payload, item_uuid, project_id):
|
||||
"""Send a request-clarification to tag: human."""
|
||||
try:
|
||||
client.acl_send(
|
||||
to="tag:human",
|
||||
message_type="request-clarification",
|
||||
body=_build_escalation_body(message, typed_payload),
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
if exc.returncode == 3:
|
||||
fallback_escalation(
|
||||
client=client,
|
||||
original_message_type="request-clarification",
|
||||
cp_error_code=getattr(exc, "cp_error_code", "acl_send_failed"),
|
||||
typed_payload=typed_payload,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def _build_escalation_body(message, typed_payload):
|
||||
"""Build a human-readable escalation body from a message."""
|
||||
context_ref = typed_payload.get("context_ref")
|
||||
context_digest = ""
|
||||
if context_ref:
|
||||
context_digest = f" Context: {repr(context_ref)}"
|
||||
return (
|
||||
f"Handoff/clarification request: reason={typed_payload.get('reason', 'unknown')}."
|
||||
f"{context_digest}"
|
||||
)
|
||||
|
||||
|
||||
# ── Fallback escalation (MS-25) ──────────────────────────────────────────────
|
||||
|
||||
def fallback_escalation(*, client, original_message_type, cp_error_code,
|
||||
typed_payload, item_uuid, project_id):
|
||||
"""MS-25: when a typed send fails, send a plain escalation to human.
|
||||
|
||||
If the fallback send also fails, log a warning and continue.
|
||||
The manager MUST NOT crash.
|
||||
"""
|
||||
_logger.warning(
|
||||
"fallback escalation: typed send failed — type=%s, error=%s",
|
||||
original_message_type, cp_error_code,
|
||||
)
|
||||
|
||||
body = _build_fallback_body(original_message_type, cp_error_code, typed_payload)
|
||||
|
||||
try:
|
||||
client.acl_send(
|
||||
to="tag:human",
|
||||
body=body,
|
||||
item_uuid=item_uuid,
|
||||
project_id=project_id,
|
||||
)
|
||||
_logger.info(
|
||||
"fallback escalation sent successfully for item %s", item_uuid,
|
||||
)
|
||||
except CalledProcessError as exc:
|
||||
_logger.warning(
|
||||
"fallback_escalation_failed: original_type=%s, cp_error=%s, "
|
||||
"fallback_exit=%s",
|
||||
original_message_type, cp_error_code, exc.returncode,
|
||||
)
|
||||
|
||||
|
||||
def _build_fallback_body(original_message_type, cp_error_code, typed_payload):
|
||||
"""Build the fallback plain-message body (MS-25 format).
|
||||
|
||||
Includes original message type, CP error code, truncated payload summary,
|
||||
and a SERIALIZER_VERSION drift note.
|
||||
"""
|
||||
reason = typed_payload.get("reason", "unknown") if isinstance(typed_payload, dict) else "unknown"
|
||||
# Truncate payload summary to 500 chars
|
||||
payload_summary = repr(typed_payload)[:500]
|
||||
|
||||
body = (
|
||||
f"Manager could not route typed message: type={original_message_type}, "
|
||||
f"reason={reason}.\n"
|
||||
f"CP rejected with: {cp_error_code}. "
|
||||
f"Possible cause: SERIALIZER_VERSION drift (AC-43).\n"
|
||||
f"Payload summary: {payload_summary}"
|
||||
)
|
||||
return body
|
||||
29
harnesses/contexts/z-ai/v1/harness.yaml
Normal file
29
harnesses/contexts/z-ai/v1/harness.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
kind: context
|
||||
name: z-ai
|
||||
version: 1
|
||||
description: "Z.ai — Anthropic-API-compatible endpoint"
|
||||
requires: []
|
||||
provides: [claude-code]
|
||||
|
||||
# Auth is wired by init.sh via Claude Code's apiKeyHelper (settings.json).
|
||||
# No credential env vars: the secret stays in the mounted file and is read
|
||||
# only by the helper command at request time.
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: "https://api.z.ai/v1"
|
||||
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1"
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
||||
DISABLE_PROMPT_CACHING: "1"
|
||||
|
||||
secrets_required:
|
||||
- name: z-ai
|
||||
account_ref: "z-ai"
|
||||
mount_path: /run/agent/secrets/z-ai
|
||||
# 0400 (root-only) — defense in depth. The agent user CANNOT read this
|
||||
# mount. init.sh runs as root and `install`s a per-secret copy into the
|
||||
# agent's home with mode 0600 owned by agent; only that copy is exposed
|
||||
# to the runtime. Matches the gitea-ssh pattern. If the ESO Secret
|
||||
# later grows additional keys, they remain inaccessible by default.
|
||||
mode: "0400"
|
||||
|
||||
scripts:
|
||||
init: ./init.sh
|
||||
76
harnesses/contexts/z-ai/v1/init.sh
Executable file
76
harnesses/contexts/z-ai/v1/init.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# z-ai init — stage the auth_token for the agent user and wire apiKeyHelper.
|
||||
#
|
||||
# Threat model: keep the ESO mount root-only (mode 0400) so the agent user
|
||||
# cannot directly `cat` /run/agent/secrets/z-ai/auth_token. init.sh runs as
|
||||
# root (in uid-wrapper.sh, before the gosu drop) and stages a per-secret
|
||||
# copy into the agent's home with mode 0600 owned by agent. apiKeyHelper
|
||||
# points at the COPY. This is the gitea-ssh pattern — only the file the
|
||||
# harness explicitly grants is reachable by the runtime.
|
||||
#
|
||||
# Rotation handling: this is a one-shot copy at container start. For
|
||||
# ephemeral container agents (one task = one container) every task starts
|
||||
# with the latest secret. Long-running sessions don't refresh the copy
|
||||
# until a future scripts.control_loop hook lands (planning E1-M3).
|
||||
#
|
||||
# Auth wire-up: apiKeyHelper output is sent as `Authorization: Bearer
|
||||
# <value>` when ANTHROPIC_BASE_URL is non-anthropic.com — exactly what
|
||||
# api.z.ai/v1 requires. The secret value never enters this process' env,
|
||||
# the claude subprocess' env, or /proc/<pid>/environ.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ESO_AUTH_TOKEN="/run/agent/secrets/z-ai/auth_token"
|
||||
|
||||
if [ ! -r "$ESO_AUTH_TOKEN" ]; then
|
||||
echo "ERROR: $ESO_AUTH_TOKEN not readable. Check ESO ExternalSecret acct-<z-ai-id>." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the agent user's home (init.sh's $HOME is /root before gosu drop).
|
||||
AGENT_USER="${AGENT_USER:-agent}"
|
||||
AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6)
|
||||
if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then
|
||||
AGENT_HOME="/home/$AGENT_USER"
|
||||
fi
|
||||
|
||||
# Stage the auth_token into a per-secret path owned by agent, mode 0600.
|
||||
# install(1) handles ownership/mode atomically; the destination is outside
|
||||
# the read-only ESO mount so we can chmod/chown freely.
|
||||
STAGED_KEY_DIR="$AGENT_HOME/.claude/secrets"
|
||||
STAGED_KEY="$STAGED_KEY_DIR/z-ai-token"
|
||||
mkdir -p "$STAGED_KEY_DIR"
|
||||
chown "$AGENT_USER:" "$STAGED_KEY_DIR" 2>/dev/null || true
|
||||
chmod 0700 "$STAGED_KEY_DIR"
|
||||
install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ESO_AUTH_TOKEN" "$STAGED_KEY"
|
||||
|
||||
# Wire apiKeyHelper to the staged copy in the agent's settings.json.
|
||||
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true
|
||||
chmod 0755 "$CONFIG_DIR"
|
||||
|
||||
SETTINGS_FILE="$CONFIG_DIR/settings.json"
|
||||
|
||||
# Merge into an existing settings.json (from another harness layer) when
|
||||
# possible; otherwise create a fresh one.
|
||||
if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then
|
||||
TMP=$(mktemp)
|
||||
jq --arg helper "cat $STAGED_KEY" \
|
||||
'. + {apiKeyHelper: $helper}' \
|
||||
"$SETTINGS_FILE" > "$TMP"
|
||||
mv "$TMP" "$SETTINGS_FILE"
|
||||
else
|
||||
cat > "$SETTINGS_FILE" <<EOF
|
||||
{
|
||||
"apiKeyHelper": "cat $STAGED_KEY"
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# settings.json holds a command (a path), not a credential value.
|
||||
chown "$AGENT_USER:" "$SETTINGS_FILE" 2>/dev/null || true
|
||||
chmod 0644 "$SETTINGS_FILE"
|
||||
|
||||
echo "z-ai auth_token staged at $STAGED_KEY (0600 $AGENT_USER:$AGENT_USER)"
|
||||
echo "z-ai apiKeyHelper wired in $SETTINGS_FILE"
|
||||
Reference in New Issue
Block a user