From 519f2b3d6458c2f5d245cb4ed6503d4191ca6023 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:28:21 +0000 Subject: [PATCH 1/4] Configure Skills, system prompt, and initial message in one place (src/agent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/agent/ as the single source of truth for agent behaviour across every AgentBox surface (Claude Code cloud, Copilot cloud sandbox, Codespaces, Docker) and both harnesses: agent.json plugin marketplaces + plugins (the Skills list) system-prompt.md instructions loaded into every session initial-message.md context injected once per session, at session start configure-agent-harness.sh — already run by every surface — applies all three instead of hardcoding the plugin registration, so an edit needs no per-surface change. Per harness: plugins plugin CLI calls, plus the declarative form in ~/.copilot/settings.json (and /etc/skel) system prompt /etc/claude-code/CLAUDE.md (managed policy; ~/.claude/CLAUDE.md when not root) and ~/.copilot/copilot-instructions.md initial message SessionStart hook in ~/.claude/settings.json and a sessionStart hook in ~/.copilot/hooks/agentbox.json, both fed by one generated script that prints the message in the JSON shape the calling harness expects Writes are idempotent so re-running picks up an edit: JSON is merged, and the markdown files carry a marked block that is replaced rather than appended, so content a developer added around it survives. Emptying a payload file removes the block again. If the config can't be fetched, the script falls back to the plugin registration it did before, so an offline run never leaves a box with no Skills. test/agent/ asserts every path above, plus idempotency, block removal, and the AGENTBOX_HARNESS=none no-op, using stub harness binaries so no auth is needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KaKGKCwRPFoHcZkRJZxbn2 --- .github/workflows/test-pr.yaml | 4 + README.md | 26 +- src/agent/README.md | 69 +++++ src/agent/agent.json | 15 ++ src/agent/initial-message.md | 24 ++ src/agent/system-prompt.md | 24 ++ .../scripts/configure-agent-harness.sh | 250 ++++++++++++++++-- src/container/scripts/install-features.sh | 21 +- test/agent/assert.sh | 112 ++++++++ test/agent/test.sh | 16 ++ 10 files changed, 526 insertions(+), 35 deletions(-) create mode 100644 src/agent/README.md create mode 100644 src/agent/agent.json create mode 100644 src/agent/initial-message.md create mode 100644 src/agent/system-prompt.md create mode 100755 test/agent/assert.sh create mode 100755 test/agent/test.sh diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index bfde238..0f1dff8 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -16,6 +16,10 @@ jobs: pac-cli: ./**/pac-cli/** txc-cli: ./**/txc-cli/** power-platform: ./**/power-platform/** + agent: + - src/agent/** + - src/container/scripts/configure-agent-harness.sh + - test/agent/** test-features: needs: [detect-changes] diff --git a/README.md b/README.md index 2eabd96..5ce6ae5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,20 @@ real security exposure, and heavy agent workloads wear on a machine over time. R isolated environments below instead — see GitHub's own take on why: [About cloud and local sandboxes](https://docs.github.com/en/copilot/concepts/about-cloud-and-local-sandboxes). +## Configuring the agents + +Everything about how the agents behave in an AgentBox is configured in one place — [`src/agent/`](src/agent) — +and applies to every environment below and to both harnesses (Claude Code, GitHub Copilot CLI): + +| File | What it controls | +|------|------------------| +| [`src/agent/agent.json`](src/agent/agent.json) | The Skills list — which plugin marketplaces and plugins get registered | +| [`src/agent/system-prompt.md`](src/agent/system-prompt.md) | Instructions loaded into every session | +| [`src/agent/initial-message.md`](src/agent/initial-message.md) | Context injected once per session, at session start | + +Each environment below already runs the script that applies them, so an edit needs no per-environment +change — see [`src/agent/README.md`](src/agent/README.md) for where each knob lands per harness. + ## Choose your environment ### 1. GitHub Codespaces @@ -33,9 +47,10 @@ isolated environments below instead — see GitHub's own take on why: **Already have a `devcontainer.json`?** Point `image` at `ghcr.io/talxis/tools-agentbox/image:latest` and add that same `postCreateCommand` line — it's easy to end up with a working toolchain but no Skills/MCP registration if this step gets skipped, -since nothing it does is baked into the image itself. It registers the -[TALXIS/skills](https://github.com/TALXIS/skills) plugin (Skills + the `txc` MCP server) with -whichever of Claude Code / GitHub Copilot is present — omit it if you don't want that. +since nothing it does is baked into the image itself. It applies [`src/agent/`](src/agent) — the +[TALXIS/skills](https://github.com/TALXIS/skills) plugin (Skills + the `txc` MCP server), the system +prompt, and the initial message — to whichever of Claude Code / GitHub Copilot is present. Omit it if +you don't want that. Or build your own `devcontainer.json` from individual features listed in [`devcontainer.features.json`](src/container/templates/power-platform/.devcontainer/devcontainer.features.json). @@ -63,8 +78,9 @@ with: This installs the Feature list from [`devcontainer.features.json`](src/container/templates/power-platform/.devcontainer/devcontainer.features.json) -directly on the VM, and registers the [TALXIS/skills](https://github.com/TALXIS/skills) plugin -(Skills + the `txc` MCP server) with Claude Code. No changes needed in individual repos. +directly on the VM, and applies [`src/agent/`](src/agent) — the +[TALXIS/skills](https://github.com/TALXIS/skills) plugin (Skills + the `txc` MCP server), the system +prompt, and the initial message — to Claude Code. No changes needed in individual repos. To keep `txc` and the Dataverse templates current between setup script runs, merge the `hooks` from [`src/container/claude-code/session-start-hook.json`](src/container/claude-code/session-start-hook.json) into diff --git a/src/agent/README.md b/src/agent/README.md new file mode 100644 index 0000000..3f1a451 --- /dev/null +++ b/src/agent/README.md @@ -0,0 +1,69 @@ +# Agent configuration + +The one place agent behaviour is configured for **every** AgentBox surface (Claude Code cloud +environment, Copilot cloud sandbox, Codespaces, Docker) and **every** harness (Claude Code, GitHub +Copilot CLI). Edit these files and nothing else: + +| File | What it controls | +|------|------------------| +| [`agent.json`](agent.json) | The Skills list — which plugin marketplaces and plugins get registered | +| [`system-prompt.md`](system-prompt.md) | Instructions loaded into **every** session | +| [`initial-message.md`](initial-message.md) | Context injected **once per session**, at session start | + +[`../container/scripts/configure-agent-harness.sh`](../container/scripts/configure-agent-harness.sh) +applies all three. Every surface already runs it — the Claude Code cloud setup script and the Copilot +cloud sandbox via `install-features.sh`, Codespaces via its own `postCreateCommand` — so an edit here +reaches all of them with no per-surface change. Re-running is safe and is how an edit is picked up: +JSON is merged, and the markdown files carry a marked `AGENTBOX` block that is replaced rather than +appended, so anything a developer wrote around it survives. + +## Where each knob lands + +| Knob | Claude Code | GitHub Copilot CLI | +|------|-------------|--------------------| +| Skills / plugins | `claude plugin marketplace add` + `claude plugin install` | `copilot plugin ...`, plus `extraKnownMarketplaces`/`enabledPlugins` in `~/.copilot/settings.json` (and `/etc/skel` for users created later) | +| System prompt | `/etc/claude-code/CLAUDE.md` — managed-policy memory: machine-wide, user-independent, and not excludable via a user's `claudeMdExcludes`. Falls back to `~/.claude/CLAUDE.md` when not running as root | `~/.copilot/copilot-instructions.md` — user-level custom instructions, loaded in every repository (and `/etc/skel`) | +| Initial message | `SessionStart` hook merged into `~/.claude/settings.json`, returning `hookSpecificOutput.additionalContext` | `sessionStart` hook in `~/.copilot/hooks/agentbox.json`, returning `additionalContext` | + +Both harnesses get the initial message from one generated script, +`/usr/local/share/agentbox/session-start.sh`, which prints `initial-message.md` in whichever JSON +shape the calling harness expects. Don't edit that generated copy. + +## Two things to know before writing instructions + +- **This is context, not enforcement.** Both harnesses load these files as instructions the model is + asked to follow, not as constraints on what it can do — and a developer can opt out + (`copilot --no-custom-instructions`). Anything that must hold regardless of what the model decides + belongs in a permission rule or a `PreToolUse`/`preToolUse` hook instead. +- **Length costs adherence.** Everything in `system-prompt.md` is in the context window of every + session, every time. Keep it to rules that apply to all work in an AgentBox; anything task-specific + belongs in a Skill in [TALXIS/skills](https://github.com/TALXIS/skills), which loads on demand. + +## Verifying + +In a session on a freshly provisioned environment: + +- Claude Code: `/context` lists `/etc/claude-code/CLAUDE.md` (or `~/.claude/CLAUDE.md`) under + **Memory files**; `/hooks` shows the `SessionStart` entry. +- Copilot CLI: `/instructions` lists `copilot-instructions.md` as an active source. + +Or run the assertions against a live box, which apply the config and check every path above: + +```bash +HOME="$(mktemp -d)" bash test/agent/assert.sh +``` + +## How the config is found + +`configure-agent-harness.sh` resolves this directory in order: + +1. `AGENTBOX_CONFIG_DIR`, if set (used by the tests). +2. A `src/agent/` sibling, when the script runs from a repository checkout. +3. Over the network: `AGENTBOX_CONFIG_URL` (default `https://talxis.com/agentbox-agent`), then the + `raw.githubusercontent.com` URL for `master` as a fallback. `system-prompt.md` and + `initial-message.md` are fetched relative to wherever the manifest resolved from, so pointing the + short link at `src/agent/agent.json` on a branch picks up that branch's payload files too. + +If the config can't be reached at all, the script falls back to registering +`implement@talxis` from `TALXIS/skills` — the behaviour it had before it was config-driven — so an +offline run never silently leaves a box with no Skills. diff --git a/src/agent/agent.json b/src/agent/agent.json new file mode 100644 index 0000000..0ee5138 --- /dev/null +++ b/src/agent/agent.json @@ -0,0 +1,15 @@ +{ + "//": "The one place agent behaviour is configured for every AgentBox surface (Claude Code cloud, Copilot cloud sandbox, Codespaces, Docker) and every harness (Claude Code, GitHub Copilot CLI). Applied by src/container/scripts/configure-agent-harness.sh — see src/agent/README.md.", + + "marketplaces": { + "talxis": "TALXIS/skills" + }, + + "plugins": [ + "implement@talxis" + ], + + "systemPrompt": "system-prompt.md", + + "initialMessage": "initial-message.md" +} diff --git a/src/agent/initial-message.md b/src/agent/initial-message.md new file mode 100644 index 0000000..807157b --- /dev/null +++ b/src/agent/initial-message.md @@ -0,0 +1,24 @@ +# AgentBox session briefing + +This is injected once, at the start of each session, before the developer's first message. It is +context for you, not a message from the developer — do not answer it. + +## Where you are + +An AgentBox container/VM provisioned by [TALXIS/tools-agentbox](https://github.com/TALXIS/tools-agentbox). +The toolchain (`txc`, `pac`, `dotnet`, `az`, `func`, `terraform`, `pwsh`, `gh`) and the +`implement@talxis` plugin (TALXIS Skills + the `txc` MCP server) are installed and current — a +background job refreshes `txc` and the Dataverse templates at session start, so a version check may +be a few seconds stale. + +## First turn + +If the developer's opening message is a greeting, is empty, or asks what they can do here, reply with +a short orientation instead of starting work: + +- what this environment is provisioned for (Power Platform / Dataverse development), +- the TALXIS Skills available in this session and what each is for, +- one concrete suggested next step based on the repository that is checked out. + +Otherwise, skip the orientation and start on what they asked. Keep it to a few lines either way — the +developer can ask for more. diff --git a/src/agent/system-prompt.md b/src/agent/system-prompt.md new file mode 100644 index 0000000..e9350fb --- /dev/null +++ b/src/agent/system-prompt.md @@ -0,0 +1,24 @@ +# TALXIS AgentBox + +You are running inside a TALXIS AgentBox environment: an isolated, pre-provisioned Power Platform / +Dataverse development machine. The toolchain below is already installed and on `PATH` — never +install, upgrade, or `sudo apt-get` a tool listed here, and never suggest the developer run agents on +their own workstation. + +## Available tooling + +- `txc` — TALXIS CLI. The primary tool for TALXIS Dataverse work. Prefer it over hand-written + scripts, and prefer its MCP tools (from the `implement@talxis` plugin) over shelling out. +- `pac` — Microsoft Power Platform CLI: solutions, environments, plugin registration. +- `dotnet` — with the `TALXIS.DevKit.Templates.Dataverse` templates installed (`dotnet new list`). +- `az`, `func`, `terraform`, `pwsh`, `gh` — Azure, Azure Functions, infrastructure, PowerShell, GitHub. + +## Working agreements + +- Authentication is resolved by TALXIS Valet; never prompt for or hard-code credentials, connection + strings, or client secrets. If auth fails, report the failure — do not work around it. +- Solution and plugin changes belong in source control: edit the files in the repository, then + deploy with `txc`/`pac`. Do not make one-off changes in a Dataverse environment that the repository + cannot reproduce. +- Never point tooling at a production environment unless the developer names it explicitly in the + current conversation. diff --git a/src/container/scripts/configure-agent-harness.sh b/src/container/scripts/configure-agent-harness.sh index 1004546..ae255be 100755 --- a/src/container/scripts/configure-agent-harness.sh +++ b/src/container/scripts/configure-agent-harness.sh @@ -1,9 +1,23 @@ #!/bin/bash -# Configures whichever agent harness is present — Claude Code and/or GitHub Copilot CLI — beyond -# just installing their binaries. Currently: registers the TALXIS/skills plugin (Skills + the -# `txc` MCP server). Named generically, not after that one thing, since this is the intended home -# for any other per-harness setup agentbox ends up needing (e.g. other marketplaces, settings.json -# tweaks) — one script per harness-configuration concern, not one script per plugin. +# Configures whichever agent harness is present — Claude Code and/or GitHub Copilot CLI — from the +# single source of truth in src/agent/, so all three knobs are configured in one place for every +# surface (Claude Code cloud, Copilot cloud sandbox, Codespaces, Docker) and every harness: +# +# agent.json which plugin marketplaces/plugins to register (the Skills list) +# system-prompt.md instructions loaded into every session, on every harness +# initial-message.md context injected once per session, at session start +# +# What each knob maps to per harness (see src/agent/README.md for the full matrix and why): +# +# knob Claude Code GitHub Copilot CLI +# plugins claude plugin marketplace add / install copilot plugin ... + ~/.copilot/settings.json +# system prompt /etc/claude-code/CLAUDE.md (managed policy) ~/.copilot/copilot-instructions.md +# ~/.claude/CLAUDE.md when not root +# initial message SessionStart hook in ~/.claude/settings.json ~/.copilot/hooks/agentbox.json (sessionStart) +# +# Both harnesses treat these as context, not enforcement: they steer the model, they do not constrain +# it. Anything that must hold regardless of what the model decides belongs in a permission rule or a +# PreToolUse/preToolUse hook instead. # # No-ops for a harness whose binary isn't installed, or one explicitly excluded via # AGENTBOX_HARNESS ("claude", "copilot", or "none" to skip both — see src/container/scripts/install-features.sh @@ -14,40 +28,236 @@ # Copilot is ever relevant there, but this script is reused rather than maintaining a Copilot-only # copy). Per VS Code's own docs, VS Code's Copilot Chat automatically discovers plugins installed # this way, so one install covers both the `copilot` CLI and VS Code. +# +# Re-running is safe and is the intended way to pick up an edit to src/agent/: every write below is +# idempotent (JSON merges, and a marked block in the markdown files that is replaced, not appended). set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "${WORKDIR}"' EXIT + +# Where the agent config comes from, in order: an explicit AGENTBOX_CONFIG_DIR, a src/agent/ sibling +# when this script runs from a checkout, then the network. The short link is preferred over the raw +# URL for the same reason install-features.sh uses one (no branch/path hardcoded in a published +# command), but the raw URL is tried too so this works before the redirect exists. +AGENT_CONFIG_URL="${AGENTBOX_CONFIG_URL:-https://talxis.com/agentbox-agent}" +AGENT_CONFIG_URL_RAW="https://raw.githubusercontent.com/TALXIS/tools-agentbox/master/src/agent/agent.json" + +# Used when src/agent/agent.json can't be reached at all, so an offline run still registers the +# plugin exactly as this script did before it was config-driven, instead of silently doing nothing. +DEFAULT_MANIFEST='{"marketplaces":{"talxis":"TALXIS/skills"},"plugins":["implement@talxis"]}' + +BLOCK_BEGIN="" +BLOCK_END="" + +is_root() { [ "$(id -u)" -eq 0 ]; } + merge_json_file() { local file="$1" filter="$2" + shift 2 mkdir -p "$(dirname "${file}")" local current="{}" [ -s "${file}" ] && current="$(cat "${file}")" - jq "${filter}" <<<"${current}" > "${file}.tmp" && mv "${file}.tmp" "${file}" + jq "$@" "${filter}" <<<"${current}" > "${file}.tmp" && mv "${file}.tmp" "${file}" +} + +# Replaces (or, when the payload is empty/missing, removes) the AGENTBOX block in a markdown file, +# leaving anything a developer wrote around it untouched — these files are shared with the user +# (~/.copilot/copilot-instructions.md especially), so this must never be a blind overwrite. +apply_marked_block() { + local file="$1" payload="${2:-}" tmp block + tmp="${WORKDIR}/block-target.$$" + block="${WORKDIR}/block-body.$$" + : > "${block}" + if [ -n "${payload}" ] && [ -s "${payload}" ]; then + { printf '%s\n' "${BLOCK_BEGIN}"; cat "${payload}"; printf '%s\n' "${BLOCK_END}"; } > "${block}" + fi + + mkdir -p "$(dirname "${file}")" 2>/dev/null || return 0 + + if [ -f "${file}" ] && grep -qF "${BLOCK_BEGIN}" "${file}"; then + awk -v b="${BLOCK_BEGIN}" -v e="${BLOCK_END}" -v bf="${block}" ' + index($0, b) { while ((getline line < bf) > 0) print line; close(bf); skip = 1; next } + index($0, e) { skip = 0; next } + !skip + ' "${file}" > "${tmp}" || return 0 + elif [ -s "${block}" ]; then + { [ -s "${file}" ] && { cat "${file}"; printf '\n'; }; cat "${block}"; } > "${tmp}" || return 0 + else + return 0 + fi + + mv "${tmp}" "${file}" || return 0 + # A file left with nothing but whitespace (block removed, nothing else in it) is noise. + grep -q '[^[:space:]]' "${file}" 2>/dev/null || rm -f "${file}" +} + +# --- Resolve the config ------------------------------------------------------------------------ + +download_config() { + local url="$1" dir="$2" effective base file + mkdir -p "${dir}" + effective="$(curl -fsSL --max-time 20 -w '%{url_effective}' -o "${dir}/agent.json" "${url}")" || return 1 + jq -e 'type == "object"' "${dir}/agent.json" >/dev/null 2>&1 || return 1 + # Payload file names in agent.json are relative to the manifest, so resolve them against the URL + # curl actually ended up on (the short link redirects to raw.githubusercontent.com). + base="${effective%/*}" + while IFS= read -r file; do + [ -z "${file}" ] && continue + mkdir -p "$(dirname "${dir}/${file}")" + curl -fsSL --max-time 20 -o "${dir}/${file}" "${base}/${file}" \ + || echo "WARNING: could not download ${base}/${file}, continuing without it" >&2 + done < <(jq -r '[.systemPrompt, .initialMessage] | map(select(type == "string"))[]' "${dir}/agent.json") +} + +CONFIG_DIR="" +if [ -n "${AGENTBOX_CONFIG_DIR:-}" ] && [ -f "${AGENTBOX_CONFIG_DIR}/agent.json" ]; then + CONFIG_DIR="${AGENTBOX_CONFIG_DIR}" +elif [ -f "${SCRIPT_DIR}/../../agent/agent.json" ]; then + CONFIG_DIR="$(cd "${SCRIPT_DIR}/../../agent" && pwd)" +else + for url in "${AGENT_CONFIG_URL}" "${AGENT_CONFIG_URL_RAW}"; do + if download_config "${url}" "${WORKDIR}/agent"; then + CONFIG_DIR="${WORKDIR}/agent" + break + fi + echo "WARNING: could not fetch the agent config from ${url}" >&2 + done +fi + +if [ -n "${CONFIG_DIR}" ]; then + echo "--- Agent config: ${CONFIG_DIR} ---" + MANIFEST="${CONFIG_DIR}/agent.json" +else + echo "WARNING: no agent config available, falling back to the built-in plugin defaults" >&2 + MANIFEST="${WORKDIR}/agent-default.json" + printf '%s\n' "${DEFAULT_MANIFEST}" > "${MANIFEST}" +fi + +resolve_payload() { + local key="$1" name + name="$(jq -r --arg k "${key}" '.[$k] // "" | select(type == "string")' "${MANIFEST}" 2>/dev/null)" + [ -z "${name}" ] && return 0 + [ -s "${CONFIG_DIR}/${name}" ] && printf '%s' "${CONFIG_DIR}/${name}" } +SYSTEM_PROMPT_FILE="$(resolve_payload systemPrompt)" +INITIAL_MESSAGE_FILE="$(resolve_payload initialMessage)" + +# One shared hook script, installed next to the message it prints, emitting whichever JSON shape the +# calling harness expects. Claude Code reads hookSpecificOutput.additionalContext from a SessionStart +# hook; Copilot CLI reads a bare additionalContext from a sessionStart hook. +SHARE_DIR="/usr/local/share/agentbox" +mkdir -p "${SHARE_DIR}" 2>/dev/null || true +[ -w "${SHARE_DIR}" ] 2>/dev/null || SHARE_DIR="${HOME:-/root}/.agentbox" +SESSION_START_SCRIPT="${SHARE_DIR}/session-start.sh" + +install_initial_message() { + [ -n "${INITIAL_MESSAGE_FILE}" ] || return 1 + mkdir -p "${SHARE_DIR}" || return 1 + cp "${INITIAL_MESSAGE_FILE}" "${SHARE_DIR}/initial-message.md" || return 1 + cat > "${SESSION_START_SCRIPT}" <<'HOOK' +#!/bin/bash +# Installed by configure-agent-harness.sh — prints the AgentBox session briefing as session-start +# context. Edit the message in src/agent/initial-message.md, never this generated copy. +message="$(dirname "$(readlink -f "$0")")/initial-message.md" +[ -s "${message}" ] || exit 0 +case "${1:-}" in + claude) jq -Rs '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: .}}' < "${message}" ;; + *) jq -Rs '{additionalContext: .}' < "${message}" ;; +esac +HOOK + chmod +x "${SESSION_START_SCRIPT}" || return 1 +} + +INITIAL_MESSAGE_READY=1 +install_initial_message && INITIAL_MESSAGE_READY=0 + +# --- Claude Code ------------------------------------------------------------------------------- + if [ "${AGENTBOX_HARNESS:-}" != "copilot" ] && [ "${AGENTBOX_HARNESS:-}" != "none" ] \ && command -v claude >/dev/null 2>&1; then - echo "--- Registering the talxis plugin marketplace with Claude Code ---" + echo "--- Configuring Claude Code ---" export CLAUDE_CODE_PLUGIN_CACHE_DIR="/usr/local/claude-plugin-seed" - timeout 30 claude plugin marketplace add TALXIS/skills \ - || echo "WARNING: could not add the talxis plugin marketplace, continuing" >&2 - timeout 30 claude plugin install implement@talxis --yes \ - || echo "WARNING: could not install the implement@talxis plugin, continuing" >&2 + + while IFS= read -r repo; do + [ -z "${repo}" ] && continue + timeout 30 claude plugin marketplace add "${repo}" \ + || echo "WARNING: could not add the ${repo} plugin marketplace, continuing" >&2 + done < <(jq -r '.marketplaces // {} | to_entries[] | .value' "${MANIFEST}") + + while IFS= read -r plugin; do + [ -z "${plugin}" ] && continue + timeout 30 claude plugin install "${plugin}" --yes \ + || echo "WARNING: could not install the ${plugin} plugin, continuing" >&2 + done < <(jq -r '.plugins // [] | .[]' "${MANIFEST}") + + # System prompt: the managed-policy CLAUDE.md is machine-wide, user-independent, and cannot be + # excluded by a user's claudeMdExcludes — the right home for it when this runs as root. Falling + # back to the user memory file keeps a non-root Codespaces run working. + if is_root; then + apply_marked_block "/etc/claude-code/CLAUDE.md" "${SYSTEM_PROMPT_FILE}" + else + apply_marked_block "${HOME:-/root}/.claude/CLAUDE.md" "${SYSTEM_PROMPT_FILE}" + fi + + # Initial message: a SessionStart hook, matched on startup|resume so it also lands after a + # session is resumed. Merged into user settings rather than replacing them, and any earlier + # agentbox entry is dropped so re-runs don't stack up duplicates. + if [ "${INITIAL_MESSAGE_READY}" -eq 0 ]; then + merge_json_file "${HOME:-/root}/.claude/settings.json" ' + .hooks.SessionStart = ( + ((.hooks.SessionStart // []) + | map(select(((.hooks // []) | map(.command // "") | any(contains("agentbox"))) | not))) + + [{matcher: "startup|resume", hooks: [{type: "command", command: $cmd}]}] + )' --arg cmd "${SESSION_START_SCRIPT} claude" + fi fi +# --- GitHub Copilot CLI ------------------------------------------------------------------------ + if [ "${AGENTBOX_HARNESS:-}" != "claude" ] && [ "${AGENTBOX_HARNESS:-}" != "none" ] \ && command -v copilot >/dev/null 2>&1; then - echo "--- Registering the talxis plugin marketplace with GitHub Copilot ---" - timeout 30 copilot plugin marketplace add TALXIS/skills >/dev/null 2>&1 \ - || echo "WARNING: could not add the talxis plugin marketplace, continuing" >&2 - timeout 30 copilot plugin install implement@talxis >/dev/null 2>&1 \ - || echo "WARNING: could not install the implement@talxis plugin, continuing" >&2 + echo "--- Configuring GitHub Copilot CLI ---" + + while IFS= read -r repo; do + [ -z "${repo}" ] && continue + timeout 30 copilot plugin marketplace add "${repo}" >/dev/null 2>&1 \ + || echo "WARNING: could not add the ${repo} plugin marketplace, continuing" >&2 + done < <(jq -r '.marketplaces // {} | to_entries[] | .value' "${MANIFEST}") + + while IFS= read -r plugin; do + [ -z "${plugin}" ] && continue + timeout 30 copilot plugin install "${plugin}" >/dev/null 2>&1 \ + || echo "WARNING: could not install the ${plugin} plugin, continuing" >&2 + done < <(jq -r '.plugins // [] | .[]' "${MANIFEST}") # Declarative form too, so `copilot plugin update` finds it without re-adding the marketplace, # and so a fresh user created later from /etc/skel starts with it already declared. - marketplace_filter='.extraKnownMarketplaces.talxis = {"source": {"source": "github", "repo": "TALXIS/skills"}} | - .enabledPlugins["implement@talxis"] = true' - merge_json_file "${HOME:-/root}/.copilot/settings.json" "${marketplace_filter}" - merge_json_file "/etc/skel/.copilot/settings.json" "${marketplace_filter}" + marketplace_filter='.extraKnownMarketplaces = ((.extraKnownMarketplaces // {}) + ($manifest.marketplaces // {} + | with_entries({key: .key, value: {source: {source: "github", repo: .value}}}))) + | .enabledPlugins = ((.enabledPlugins // {}) + (($manifest.plugins // []) | map({key: ., value: true}) | from_entries))' + + copilot_dirs=("${HOME:-/root}/.copilot") + is_root && copilot_dirs+=("/etc/skel/.copilot") + + for dir in "${copilot_dirs[@]}"; do + merge_json_file "${dir}/settings.json" "${marketplace_filter}" \ + --argjson manifest "$(cat "${MANIFEST}")" + + # System prompt: user-level custom instructions, loaded in every repository. + apply_marked_block "${dir}/copilot-instructions.md" "${SYSTEM_PROMPT_FILE}" + + # Initial message: a user-level sessionStart hook. Its own file, so this is a plain write — + # Copilot merges every hook file it finds in the directory. + if [ "${INITIAL_MESSAGE_READY}" -eq 0 ]; then + mkdir -p "${dir}/hooks" \ + && jq -n --arg cmd "${SESSION_START_SCRIPT} copilot" \ + '{version: 1, hooks: {sessionStart: [{type: "command", bash: $cmd, timeoutSec: 15}]}}' \ + > "${dir}/hooks/agentbox.json" + fi + done fi exit 0 diff --git a/src/container/scripts/install-features.sh b/src/container/scripts/install-features.sh index 745d7d1..8ae7d0e 100755 --- a/src/container/scripts/install-features.sh +++ b/src/container/scripts/install-features.sh @@ -2,8 +2,8 @@ # Installs the devcontainer Features (https://containers.dev) listed in # src/container/templates/power-platform/.devcontainer/devcontainer.features.json directly onto # the host, for environments with no Docker daemon (Claude Code cloud) or no devcontainer.json -# support (GitHub Copilot cloud sandbox). Also registers the TALXIS/skills plugin (Skills + the -# `txc` MCP server) with whichever agent harness is calling this script. +# support (GitHub Copilot cloud sandbox). Also applies the agent config in src/agent/ (Skills list, +# system prompt, initial message) to whichever agent harness is calling this script. # # Claude Code cloud environment: paste the bootstrap command from the README's "Claude Code cloud # environment" section into the environment's "Setup script" field at @@ -15,12 +15,12 @@ # GitHub Copilot cloud sandbox: run via .github/workflows/copilot-setup-steps.yml. # # Also used by src/container/image/power-platform/Dockerfile to build the pre-built image, with -# AGENTBOX_HARNESS=none — the image build shouldn't register the plugin for either harness itself: -# Codespaces' own postCreateCommand does that once, at container creation, as the container's real -# user, by fetching and running src/container/scripts/configure-agent-harness.sh directly — the -# same script this file delegates to below. +# AGENTBOX_HARNESS=none — the image build shouldn't configure either harness itself: Codespaces' own +# postCreateCommand does that once, at container creation, as the container's real user, by fetching +# and running src/container/scripts/configure-agent-harness.sh directly — the same script this file +# delegates to below. # -# AGENTBOX_HARNESS ("claude", "copilot", or "none"), set by the callers above, skips the plugin +# AGENTBOX_HARNESS ("claude", "copilot", or "none"), set by the callers above, skips the harness # setup for whichever harness isn't relevant to that caller, so nobody pays for another harness's # network call or risks seeing its warnings. Left unset (manual/local runs), both are attempted if # installed. @@ -153,9 +153,10 @@ fi dotnet new install TALXIS.DevKit.Templates.Dataverse || true -# Configure whichever harness this surface actually uses (currently: register the TALXIS/skills -# plugin) — same script a repo's own devcontainer.json postCreateCommand fetches and runs -# directly, so there's one place this logic lives regardless of caller. AGENTBOX_HARNESS +# Configure whichever harness this surface actually uses, from the agent config in src/agent/ +# (Skills list, system prompt, initial message) — same script a repo's own devcontainer.json +# postCreateCommand fetches and runs directly, so there's one place this logic lives regardless of +# caller, and one place the config it reads lives. AGENTBOX_HARNESS # (already exported by this script's caller) narrows it to the relevant harness; unset (manual/ # local runs) tries both, skipping whichever binary isn't installed. curl -fsSL --max-time 20 "https://talxis.com/agentbox-harness" \ diff --git a/test/agent/assert.sh b/test/agent/assert.sh new file mode 100755 index 0000000..366e9af --- /dev/null +++ b/test/agent/assert.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Asserts that src/agent/ actually reaches every place a harness reads from. Applies the config with +# stub `claude`/`copilot` binaries on PATH, so neither real harness (nor its auth) is needed: +# configure-agent-harness.sh only cares that the binary exists, and every plugin call it makes is +# allowed to fail. +# +# Called by test/agent/test.sh inside a throwaway container. Can also be run directly to verify a +# live AgentBox — it writes to $HOME, /etc/claude-code, /etc/skel and /usr/local/share/agentbox, so +# point HOME at a scratch directory first and expect those system paths to be (re)written: +# +# HOME="$(mktemp -d)" bash test/agent/assert.sh +set -e + +REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +CONFIGURE="${REPO}/src/container/scripts/configure-agent-harness.sh" + +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT + +# Stubs go on PATH rather than into /usr/local/bin, so a run on a live AgentBox doesn't shadow the +# harness binaries it actually has installed. +PLUGIN_LOG="${WORK}/plugin-calls.log" +: > "${PLUGIN_LOG}" +mkdir -p "${WORK}/bin" +for bin in claude copilot; do + cat > "${WORK}/bin/${bin}" <> "${PLUGIN_LOG}" +exit 0 +STUB + chmod +x "${WORK}/bin/${bin}" +done +export PATH="${WORK}/bin:${PATH}" + +export AGENTBOX_CONFIG_DIR="${REPO}/src/agent" +CONFIG="${AGENTBOX_CONFIG_DIR}" + +fail() { echo "FAIL: $1"; exit 1; } +assert_contains() { grep -qF "$2" "$1" || fail "$1 does not contain: $2"; } + +bash "${CONFIGURE}" + +echo "--- plugins registered on both harnesses ---" +assert_contains "${PLUGIN_LOG}" "claude plugin marketplace add TALXIS/skills" +assert_contains "${PLUGIN_LOG}" "claude plugin install implement@talxis --yes" +assert_contains "${PLUGIN_LOG}" "copilot plugin marketplace add TALXIS/skills" +assert_contains "${PLUGIN_LOG}" "copilot plugin install implement@talxis" +[ "$(jq -r '.enabledPlugins["implement@talxis"]' "${HOME}/.copilot/settings.json")" = "true" ] \ + || fail "implement@talxis not enabled in ~/.copilot/settings.json" +[ "$(jq -r '.extraKnownMarketplaces.talxis.source.repo' "${HOME}/.copilot/settings.json")" = "TALXIS/skills" ] \ + || fail "the talxis marketplace is not declared in ~/.copilot/settings.json" +[ "$(jq -r '.enabledPlugins["implement@talxis"]' /etc/skel/.copilot/settings.json)" = "true" ] \ + || fail "implement@talxis not enabled in /etc/skel/.copilot/settings.json" + +echo "--- system prompt reaches both harnesses ---" +FIRST_LINE="$(head -1 "${CONFIG}/system-prompt.md")" +assert_contains /etc/claude-code/CLAUDE.md "${FIRST_LINE}" +assert_contains /etc/claude-code/CLAUDE.md "BEGIN AGENTBOX" +assert_contains "${HOME}/.copilot/copilot-instructions.md" "${FIRST_LINE}" +assert_contains /etc/skel/.copilot/copilot-instructions.md "${FIRST_LINE}" + +echo "--- initial message is wired as a session-start hook on both harnesses ---" +assert_contains /usr/local/share/agentbox/initial-message.md "$(head -1 "${CONFIG}/initial-message.md")" +CLAUDE_HOOK="$(jq -r '.hooks.SessionStart[0].hooks[0].command' "${HOME}/.claude/settings.json")" +case "${CLAUDE_HOOK}" in + */agentbox/session-start.sh\ claude) ;; + *) fail "unexpected Claude SessionStart hook command: ${CLAUDE_HOOK}" ;; +esac +[ "$(jq -r '.version' "${HOME}/.copilot/hooks/agentbox.json")" = "1" ] \ + || fail "~/.copilot/hooks/agentbox.json is not a version 1 hook file" +COPILOT_HOOK="$(jq -r '.hooks.sessionStart[0].bash' "${HOME}/.copilot/hooks/agentbox.json")" +case "${COPILOT_HOOK}" in + */agentbox/session-start.sh\ copilot) ;; + *) fail "unexpected Copilot sessionStart hook command: ${COPILOT_HOOK}" ;; +esac + +echo "--- the hook emits the JSON shape each harness expects ---" +/usr/local/share/agentbox/session-start.sh claude \ + | jq -e '.hookSpecificOutput.hookEventName == "SessionStart" and (.hookSpecificOutput.additionalContext | length) > 0' >/dev/null \ + || fail "the Claude hook did not emit hookSpecificOutput.additionalContext" +/usr/local/share/agentbox/session-start.sh copilot \ + | jq -e '(.additionalContext | length) > 0' >/dev/null \ + || fail "the Copilot hook did not emit additionalContext" + +echo "--- re-running is idempotent and keeps content the developer added ---" +printf '\n# My own notes\n' >> "${HOME}/.copilot/copilot-instructions.md" +bash "${CONFIGURE}" +[ "$(grep -cF "BEGIN AGENTBOX" "${HOME}/.copilot/copilot-instructions.md")" = "1" ] \ + || fail "the AGENTBOX block was duplicated in ~/.copilot/copilot-instructions.md" +assert_contains "${HOME}/.copilot/copilot-instructions.md" "# My own notes" +[ "$(jq -r '.hooks.SessionStart | length' "${HOME}/.claude/settings.json")" = "1" ] \ + || fail "the Claude SessionStart hook was duplicated on re-run" + +echo "--- emptying system-prompt.md removes the block, not the developer's file ---" +mkdir -p "${WORK}/empty-config" +cp "${CONFIG}/agent.json" "${CONFIG}/initial-message.md" "${WORK}/empty-config/" +: > "${WORK}/empty-config/system-prompt.md" +AGENTBOX_CONFIG_DIR="${WORK}/empty-config" bash "${CONFIGURE}" +grep -qF "BEGIN AGENTBOX" "${HOME}/.copilot/copilot-instructions.md" \ + && fail "the AGENTBOX block survived an emptied system-prompt.md" +assert_contains "${HOME}/.copilot/copilot-instructions.md" "# My own notes" +[ -f /etc/claude-code/CLAUDE.md ] \ + && fail "/etc/claude-code/CLAUDE.md should be gone once the block it only held is removed" + +echo "--- AGENTBOX_HARNESS=none touches nothing ---" +rm -rf "${HOME}/.claude" "${HOME}/.copilot" /etc/claude-code +AGENTBOX_HARNESS=none bash "${CONFIGURE}" +[ -e "${HOME}/.claude/settings.json" ] && fail "AGENTBOX_HARNESS=none wrote Claude settings" +[ -e /etc/claude-code/CLAUDE.md ] && fail "AGENTBOX_HARNESS=none wrote the managed CLAUDE.md" +[ -e "${HOME}/.copilot/copilot-instructions.md" ] && fail "AGENTBOX_HARNESS=none wrote Copilot instructions" + +echo "=== agent config assertions PASSED ===" diff --git a/test/agent/test.sh b/test/agent/test.sh new file mode 100755 index 0000000..374f017 --- /dev/null +++ b/test/agent/test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Smoke test for src/agent/ (the one place agent behaviour is configured) and the script that applies +# it. Runs test/agent/assert.sh in a throwaway container, since the assertions write to system paths +# (/etc/claude-code, /etc/skel, /usr/local/share/agentbox). +set -e + +echo "=== Smoke test: agent config (src/agent) ===" + +docker run --rm -v "$(pwd):/repo:ro" ubuntu:24.04 bash -c ' + set -e + apt-get update -qq >/dev/null && apt-get install -y -qq jq >/dev/null + export HOME=/root REPO=/repo + bash /repo/test/agent/assert.sh +' + +echo "=== agent config smoke test PASSED ===" From 91f427f73baae15e3a167c2650351a26c85c70cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:44:31 +0000 Subject: [PATCH 2/4] Configure the agent config in one place only, and fail loudly when it can't be read Review fix-ups to the src/agent change: - Drop DEFAULT_MANIFEST. It was a second copy of the marketplace and plugin list inside the script, which broke the "configured in one place" property and quietly produced a half-configured box. A run that cannot read the config now reports the URL that failed and exits non-zero, and install-features.sh re-raises that status instead of swallowing it, so a cloud setup script fails rather than reporting success. A payload the manifest declares but that can't be downloaded, and an AGENTBOX_CONFIG_DIR without an agent.json, are errors for the same reason; a declared payload that exists and is empty still means "knob off" and removes the marked block. - Drop the hardcoded raw.githubusercontent.com URL. The talxis.com/agentbox-agent short link is the only source now, matching how the other payloads are fetched; the prompt and message files still resolve relative to wherever the manifest redirected, so no branch, path or filename is pinned anywhere. - Decide which harnesses a run configures before reading the config, so a run with nothing to configure (the image build, or a box without either CLI) never needs it and cannot fail on it. - Write the user-level files to the home of the user behind sudo as well. The Copilot cloud sandbox provisions with `sudo -E bash install-features.sh`, where HOME is /root, then runs the agent as the unprivileged user, so nothing written under ${HOME} was ever read there and /etc/skel does not help for a user that already exists. Also fix the shared hook's permissions, which were left at the provisioning umask and so could be unreadable to the user running the harness. - Docs: stop enumerating files, which goes stale; describe the knobs and where each lands per harness instead. Same for the two generated-artifact comments that pointed at specific repo paths. Tests cover the new failure paths (unreadable config writes nothing and exits non-zero; AGENTBOX_HARNESS=none succeeds with no config at all), the sudo-user mirroring, and the hook's file modes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KaKGKCwRPFoHcZkRJZxbn2 --- README.md | 20 +-- src/agent/README.md | 83 +++++---- .../scripts/configure-agent-harness.sh | 161 ++++++++++++------ src/container/scripts/install-features.sh | 14 +- test/agent/assert.sh | 43 ++++- test/agent/test.sh | 6 + 6 files changed, 237 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 5ce6ae5..a1b74a4 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,13 @@ isolated environments below instead — see GitHub's own take on why: ## Configuring the agents -Everything about how the agents behave in an AgentBox is configured in one place — [`src/agent/`](src/agent) — -and applies to every environment below and to both harnesses (Claude Code, GitHub Copilot CLI): +Everything about how the agents behave in an AgentBox — the Skills they get, the instructions loaded +into every session, and the briefing injected at session start — is configured in one place, +[`src/agent/`](src/agent), and applies to both harnesses (Claude Code, GitHub Copilot CLI). -| File | What it controls | -|------|------------------| -| [`src/agent/agent.json`](src/agent/agent.json) | The Skills list — which plugin marketplaces and plugins get registered | -| [`src/agent/system-prompt.md`](src/agent/system-prompt.md) | Instructions loaded into every session | -| [`src/agent/initial-message.md`](src/agent/initial-message.md) | Context injected once per session, at session start | - -Each environment below already runs the script that applies them, so an edit needs no per-environment -change — see [`src/agent/README.md`](src/agent/README.md) for where each knob lands per harness. +Every environment below already runs the script that applies it, so an edit needs no per-environment +change, and nothing is written into a checkout: the config applies whichever repository is cloned into +the box. See [`src/agent/README.md`](src/agent/README.md) for where each knob lands per harness. ## Choose your environment @@ -60,6 +56,10 @@ Or build your own `devcontainer.json` from individual features listed in Copilot's cloud agent environment doesn't use `devcontainer.json` — customize it with [`.github/workflows/copilot-setup-steps.yml`](.github/workflows/copilot-setup-steps.yml) instead. +That step runs as root via `sudo`, while the agent itself runs as the unprivileged runner user, so the +agent config is written to both homes — see +[`src/agent/README.md`](src/agent/README.md#which-home-directories-get-the-user-level-files). + ### 3. Claude Code cloud environment Create an org-shared [Claude Code cloud environment](https://code.claude.com/docs/en/cloud-environments) diff --git a/src/agent/README.md b/src/agent/README.md index 3f1a451..21d8685 100644 --- a/src/agent/README.md +++ b/src/agent/README.md @@ -2,13 +2,11 @@ The one place agent behaviour is configured for **every** AgentBox surface (Claude Code cloud environment, Copilot cloud sandbox, Codespaces, Docker) and **every** harness (Claude Code, GitHub -Copilot CLI). Edit these files and nothing else: +Copilot CLI). Three knobs live in this directory: -| File | What it controls | -|------|------------------| -| [`agent.json`](agent.json) | The Skills list — which plugin marketplaces and plugins get registered | -| [`system-prompt.md`](system-prompt.md) | Instructions loaded into **every** session | -| [`initial-message.md`](initial-message.md) | Context injected **once per session**, at session start | +- the **Skills list** — which plugin marketplaces and plugins get registered, +- the **system prompt** — instructions loaded into every session, +- the **initial message** — context injected once per session, at session start. [`../container/scripts/configure-agent-harness.sh`](../container/scripts/configure-agent-harness.sh) applies all three. Every surface already runs it — the Claude Code cloud setup script and the Copilot @@ -17,37 +15,68 @@ reaches all of them with no per-surface change. Re-running is safe and is how an JSON is merged, and the markdown files carry a marked `AGENTBOX` block that is replaced rather than appended, so anything a developer wrote around it survives. +The config exists here and nowhere else. There is no built-in copy in the script to fall back on: a +run that cannot read this directory (or the short link below) fails instead of leaving a +half-configured box. + ## Where each knob lands +Nothing is project-scoped, so the config applies whichever repository is cloned into the box. + | Knob | Claude Code | GitHub Copilot CLI | |------|-------------|--------------------| -| Skills / plugins | `claude plugin marketplace add` + `claude plugin install` | `copilot plugin ...`, plus `extraKnownMarketplaces`/`enabledPlugins` in `~/.copilot/settings.json` (and `/etc/skel` for users created later) | -| System prompt | `/etc/claude-code/CLAUDE.md` — managed-policy memory: machine-wide, user-independent, and not excludable via a user's `claudeMdExcludes`. Falls back to `~/.claude/CLAUDE.md` when not running as root | `~/.copilot/copilot-instructions.md` — user-level custom instructions, loaded in every repository (and `/etc/skel`) | -| Initial message | `SessionStart` hook merged into `~/.claude/settings.json`, returning `hookSpecificOutput.additionalContext` | `sessionStart` hook in `~/.copilot/hooks/agentbox.json`, returning `additionalContext` | +| Skills / plugins | `claude plugin marketplace add` + `claude plugin install`, with the plugin cache seeded machine-wide via `CLAUDE_CODE_PLUGIN_CACHE_DIR` | `copilot plugin …`, plus `extraKnownMarketplaces`/`enabledPlugins` in `settings.json` | +| System prompt | `/etc/claude-code/CLAUDE.md` — managed-policy memory: machine-wide, user-independent, and not excludable via a user's `claudeMdExcludes`. Falls back to `~/.claude/CLAUDE.md` when not running as root | `copilot-instructions.md` — user-level custom instructions, loaded in every repository | +| Initial message | `SessionStart` hook in `settings.json`, returning `hookSpecificOutput.additionalContext` | `sessionStart` hook in `hooks/agentbox.json`, returning `additionalContext` | Both harnesses get the initial message from one generated script, -`/usr/local/share/agentbox/session-start.sh`, which prints `initial-message.md` in whichever JSON -shape the calling harness expects. Don't edit that generated copy. +`/usr/local/share/agentbox/session-start.sh`, which prints the message in whichever JSON shape the +calling harness expects. Don't edit that generated copy. + +### Which home directories get the user-level files + +Claude's system prompt has a machine-level path; the rest are user-level, and the user who runs the +harness is not always the user that provisioned the box. So each user-level file is written to: + +1. the invoking user's home, +2. `/etc/skel`, when running as root, so a user created later starts configured, +3. the home of the user behind `sudo`, when there is one. + +That third case is not hypothetical: the Copilot cloud sandbox provisions with +`sudo -E bash install-features.sh`, where `HOME` resolves to `/root`, and then runs the agent as the +unprivileged user. Without the mirror, nothing written under `${HOME}` would ever be read there, and +`/etc/skel` doesn't help because that user already exists. ## Two things to know before writing instructions - **This is context, not enforcement.** Both harnesses load these files as instructions the model is asked to follow, not as constraints on what it can do — and a developer can opt out (`copilot --no-custom-instructions`). Anything that must hold regardless of what the model decides - belongs in a permission rule or a `PreToolUse`/`preToolUse` hook instead. -- **Length costs adherence.** Everything in `system-prompt.md` is in the context window of every - session, every time. Keep it to rules that apply to all work in an AgentBox; anything task-specific - belongs in a Skill in [TALXIS/skills](https://github.com/TALXIS/skills), which loads on demand. + belongs in a permission rule or a `PreToolUse`/`preToolUse` hook instead. Each harness does have a + machine-level policy channel that a developer cannot remove (Claude's + `/etc/claude-code/managed-settings.json`, Copilot's `/etc/github-copilot/policy.d/`); AgentBox + deliberately doesn't use them, because it configures boxes rather than policing them. +- **Length costs adherence.** The system prompt is in the context window of every session, every + time. Keep it to rules that apply to all work in an AgentBox; anything task-specific belongs in a + Skill in [TALXIS/skills](https://github.com/TALXIS/skills), which loads on demand. + +## Mechanisms deliberately not used + +- `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` pointing at a machine-wide directory: it only works where the + environment is under our control, and as `install-features.sh` notes, neither Claude Code nor a + GitHub Actions step sources `/etc/profile.d`. +- An `AGENTS.md` in a directory above the clone: Copilot discovers instruction files in the git root + and the working directory, not above them. ## Verifying In a session on a freshly provisioned environment: -- Claude Code: `/context` lists `/etc/claude-code/CLAUDE.md` (or `~/.claude/CLAUDE.md`) under - **Memory files**; `/hooks` shows the `SessionStart` entry. +- Claude Code: `/context` lists the managed `CLAUDE.md` under **Memory files**; `/hooks` shows the + `SessionStart` entry. - Copilot CLI: `/instructions` lists `copilot-instructions.md` as an active source. -Or run the assertions against a live box, which apply the config and check every path above: +Or run the assertions, which apply the config and check every path above: ```bash HOME="$(mktemp -d)" bash test/agent/assert.sh @@ -55,15 +84,11 @@ HOME="$(mktemp -d)" bash test/agent/assert.sh ## How the config is found -`configure-agent-harness.sh` resolves this directory in order: - -1. `AGENTBOX_CONFIG_DIR`, if set (used by the tests). -2. A `src/agent/` sibling, when the script runs from a repository checkout. -3. Over the network: `AGENTBOX_CONFIG_URL` (default `https://talxis.com/agentbox-agent`), then the - `raw.githubusercontent.com` URL for `master` as a fallback. `system-prompt.md` and - `initial-message.md` are fetched relative to wherever the manifest resolved from, so pointing the - short link at `src/agent/agent.json` on a branch picks up that branch's payload files too. +`configure-agent-harness.sh` resolves this directory from `AGENTBOX_CONFIG_DIR` if set (used by the +tests), else a sibling of the script when it runs from a checkout, else over the network from +`AGENTBOX_CONFIG_URL` — default `https://talxis.com/agentbox-agent`, which redirects to this +directory's manifest. -If the config can't be reached at all, the script falls back to registering -`implement@talxis` from `TALXIS/skills` — the behaviour it had before it was config-driven — so an -offline run never silently leaves a box with no Skills. +One short link covers it: the payload files are fetched relative to the URL the manifest actually +resolved to, so they follow its branch and path automatically. Point the short link at a branch and +that branch's prompt and message are what a box gets. diff --git a/src/container/scripts/configure-agent-harness.sh b/src/container/scripts/configure-agent-harness.sh index ae255be..c3010a7 100755 --- a/src/container/scripts/configure-agent-harness.sh +++ b/src/container/scripts/configure-agent-harness.sh @@ -3,17 +3,18 @@ # single source of truth in src/agent/, so all three knobs are configured in one place for every # surface (Claude Code cloud, Copilot cloud sandbox, Codespaces, Docker) and every harness: # -# agent.json which plugin marketplaces/plugins to register (the Skills list) -# system-prompt.md instructions loaded into every session, on every harness -# initial-message.md context injected once per session, at session start +# agent.json the manifest: which plugin marketplaces/plugins to register (the Skills list), +# plus the systemPrompt and initialMessage files it points at +# systemPrompt instructions loaded into every session, on every harness +# initialMessage context injected once per session, at session start # -# What each knob maps to per harness (see src/agent/README.md for the full matrix and why): +# See src/agent/README.md for which path each knob lands in per harness, and why. # -# knob Claude Code GitHub Copilot CLI -# plugins claude plugin marketplace add / install copilot plugin ... + ~/.copilot/settings.json -# system prompt /etc/claude-code/CLAUDE.md (managed policy) ~/.copilot/copilot-instructions.md -# ~/.claude/CLAUDE.md when not root -# initial message SessionStart hook in ~/.claude/settings.json ~/.copilot/hooks/agentbox.json (sessionStart) +# Nothing written here is project-scoped: every target is a machine or user path the harness reads +# whatever repository is cloned into the sandbox. Where a harness offers a machine-level path the +# config goes there (Claude's managed-policy CLAUDE.md); otherwise it goes to every home directory +# that could belong to the user who ends up running the harness — the invoking user, /etc/skel for +# users created later, and the user behind sudo, which is the one the Copilot cloud sandbox runs as. # # Both harnesses treat these as context, not enforcement: they steer the model, they do not constrain # it. Anything that must hold regardless of what the model decides belongs in a permission rule or a @@ -38,21 +39,36 @@ WORKDIR="$(mktemp -d)" trap 'rm -rf "${WORKDIR}"' EXIT # Where the agent config comes from, in order: an explicit AGENTBOX_CONFIG_DIR, a src/agent/ sibling -# when this script runs from a checkout, then the network. The short link is preferred over the raw -# URL for the same reason install-features.sh uses one (no branch/path hardcoded in a published -# command), but the raw URL is tried too so this works before the redirect exists. +# when this script runs from a checkout, then the network. A short link rather than a raw URL for the +# same reason install-features.sh uses one — no branch, path or filename hardcoded anywhere. The +# config exists in exactly one place, so there is no built-in copy to fall back on: a run that cannot +# read it fails instead of half-configuring a box. AGENT_CONFIG_URL="${AGENTBOX_CONFIG_URL:-https://talxis.com/agentbox-agent}" -AGENT_CONFIG_URL_RAW="https://raw.githubusercontent.com/TALXIS/tools-agentbox/master/src/agent/agent.json" -# Used when src/agent/agent.json can't be reached at all, so an offline run still registers the -# plugin exactly as this script did before it was config-driven, instead of silently doing nothing. -DEFAULT_MANIFEST='{"marketplaces":{"talxis":"TALXIS/skills"},"plugins":["implement@talxis"]}' - -BLOCK_BEGIN="" +BLOCK_BEGIN="" BLOCK_END="" is_root() { [ "$(id -u)" -eq 0 ]; } +# The home of the user behind sudo, when that isn't the invoking user's own. The Copilot cloud +# sandbox provisions with `sudo -E bash install-features.sh`, where HOME resolves to /root, and then +# runs the agent as the unprivileged user — so a config written only to ${HOME} is never read there. +# /etc/skel doesn't cover it either: that user already exists by the time this runs. +sudo_user_home() { + local home + is_root || return 1 + [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ] || return 1 + home="$(getent passwd "${SUDO_USER}" 2>/dev/null | cut -d: -f6)" + [ -n "${home}" ] && [ -d "${home}" ] && [ "${home}" != "${HOME:-/root}" ] || return 1 + printf '%s' "${home}" +} + +# Hand back anything written into that user's home, so they can edit their own config afterwards. +restore_sudo_user_ownership() { + [ -n "${SUDO_USER:-}" ] && [ -e "$1" ] || return 0 + chown -R "${SUDO_USER}" "$1" 2>/dev/null || true +} + merge_json_file() { local file="$1" filter="$2" shift 2 @@ -93,6 +109,26 @@ apply_marked_block() { grep -q '[^[:space:]]' "${file}" 2>/dev/null || rm -f "${file}" } +# --- Which harnesses this run configures ------------------------------------------------------- + +configures_claude() { + [ "${AGENTBOX_HARNESS:-}" != "copilot" ] && [ "${AGENTBOX_HARNESS:-}" != "none" ] \ + && command -v claude >/dev/null 2>&1 +} + +configures_copilot() { + [ "${AGENTBOX_HARNESS:-}" != "claude" ] && [ "${AGENTBOX_HARNESS:-}" != "none" ] \ + && command -v copilot >/dev/null 2>&1 +} + +# Decided before the config is read, so a run with nothing to configure — the image build passes +# AGENTBOX_HARNESS=none, and a box may simply not have either CLI — never needs the config at all, +# and so can't fail on it. +if ! configures_claude && ! configures_copilot; then + echo "No agent harness to configure (AGENTBOX_HARNESS=${AGENTBOX_HARNESS:-unset}); nothing to do." + exit 0 +fi + # --- Resolve the config ------------------------------------------------------------------------ download_config() { @@ -106,44 +142,56 @@ download_config() { while IFS= read -r file; do [ -z "${file}" ] && continue mkdir -p "$(dirname "${dir}/${file}")" - curl -fsSL --max-time 20 -o "${dir}/${file}" "${base}/${file}" \ - || echo "WARNING: could not download ${base}/${file}, continuing without it" >&2 + # A file the manifest declares but that can't be downloaded is a broken config, not a reason + # to apply the rest: fail the whole resolution so the caller reports it. + curl -fsSL --max-time 20 -o "${dir}/${file}" "${base}/${file}" || { + echo "ERROR: agent.json declares ${file}, but ${base}/${file} could not be downloaded." >&2 + return 1 + } done < <(jq -r '[.systemPrompt, .initialMessage] | map(select(type == "string"))[]' "${dir}/agent.json") } CONFIG_DIR="" -if [ -n "${AGENTBOX_CONFIG_DIR:-}" ] && [ -f "${AGENTBOX_CONFIG_DIR}/agent.json" ]; then +if [ -n "${AGENTBOX_CONFIG_DIR:-}" ]; then + # Explicitly pointed somewhere: never quietly fall back to the network from there. + if [ ! -f "${AGENTBOX_CONFIG_DIR}/agent.json" ]; then + echo "ERROR: AGENTBOX_CONFIG_DIR=${AGENTBOX_CONFIG_DIR} has no agent.json." >&2 + exit 1 + fi CONFIG_DIR="${AGENTBOX_CONFIG_DIR}" elif [ -f "${SCRIPT_DIR}/../../agent/agent.json" ]; then CONFIG_DIR="$(cd "${SCRIPT_DIR}/../../agent" && pwd)" -else - for url in "${AGENT_CONFIG_URL}" "${AGENT_CONFIG_URL_RAW}"; do - if download_config "${url}" "${WORKDIR}/agent"; then - CONFIG_DIR="${WORKDIR}/agent" - break - fi - echo "WARNING: could not fetch the agent config from ${url}" >&2 - done +elif download_config "${AGENT_CONFIG_URL}" "${WORKDIR}/agent"; then + CONFIG_DIR="${WORKDIR}/agent" fi -if [ -n "${CONFIG_DIR}" ]; then - echo "--- Agent config: ${CONFIG_DIR} ---" - MANIFEST="${CONFIG_DIR}/agent.json" -else - echo "WARNING: no agent config available, falling back to the built-in plugin defaults" >&2 - MANIFEST="${WORKDIR}/agent-default.json" - printf '%s\n' "${DEFAULT_MANIFEST}" > "${MANIFEST}" +if [ -z "${CONFIG_DIR}" ]; then + echo "ERROR: could not read the agent config from ${AGENT_CONFIG_URL} — no harness was configured." >&2 + echo " Set AGENTBOX_CONFIG_DIR to a local src/agent directory, or AGENTBOX_CONFIG_URL to a" >&2 + echo " reachable agent.json, and re-run." >&2 + exit 1 fi +echo "--- Agent config: ${CONFIG_DIR} ---" +MANIFEST="${CONFIG_DIR}/agent.json" + +# A declared-but-absent payload is a broken config and stops the run. A declared payload that exists +# and is empty is how a knob is turned off deliberately (the marked block is then removed), so that +# stays allowed and resolves to nothing. resolve_payload() { local key="$1" name name="$(jq -r --arg k "${key}" '.[$k] // "" | select(type == "string")' "${MANIFEST}" 2>/dev/null)" [ -z "${name}" ] && return 0 + if [ ! -f "${CONFIG_DIR}/${name}" ]; then + echo "ERROR: agent.json declares ${key} as ${name}, which is missing from ${CONFIG_DIR}." >&2 + return 1 + fi [ -s "${CONFIG_DIR}/${name}" ] && printf '%s' "${CONFIG_DIR}/${name}" + return 0 } -SYSTEM_PROMPT_FILE="$(resolve_payload systemPrompt)" -INITIAL_MESSAGE_FILE="$(resolve_payload initialMessage)" +SYSTEM_PROMPT_FILE="$(resolve_payload systemPrompt)" || exit 1 +INITIAL_MESSAGE_FILE="$(resolve_payload initialMessage)" || exit 1 # One shared hook script, installed next to the message it prints, emitting whichever JSON shape the # calling harness expects. Claude Code reads hookSpecificOutput.additionalContext from a SessionStart @@ -160,7 +208,7 @@ install_initial_message() { cat > "${SESSION_START_SCRIPT}" <<'HOOK' #!/bin/bash # Installed by configure-agent-harness.sh — prints the AgentBox session briefing as session-start -# context. Edit the message in src/agent/initial-message.md, never this generated copy. +# context. Edit the agent config in TALXIS/tools-agentbox, never this generated copy. message="$(dirname "$(readlink -f "$0")")/initial-message.md" [ -s "${message}" ] || exit 0 case "${1:-}" in @@ -168,7 +216,10 @@ case "${1:-}" in *) jq -Rs '{additionalContext: .}' < "${message}" ;; esac HOOK - chmod +x "${SESSION_START_SCRIPT}" || return 1 + # The hook is registered for whichever user runs the harness, which needn't be the user that + # provisioned the box — so don't leave these at the provisioning umask. + chmod 0755 "${SHARE_DIR}" "${SESSION_START_SCRIPT}" || return 1 + chmod 0644 "${SHARE_DIR}/initial-message.md" || return 1 } INITIAL_MESSAGE_READY=1 @@ -176,8 +227,7 @@ install_initial_message && INITIAL_MESSAGE_READY=0 # --- Claude Code ------------------------------------------------------------------------------- -if [ "${AGENTBOX_HARNESS:-}" != "copilot" ] && [ "${AGENTBOX_HARNESS:-}" != "none" ] \ - && command -v claude >/dev/null 2>&1; then +if configures_claude; then echo "--- Configuring Claude Code ---" export CLAUDE_CODE_PLUGIN_CACHE_DIR="/usr/local/claude-plugin-seed" @@ -203,22 +253,29 @@ if [ "${AGENTBOX_HARNESS:-}" != "copilot" ] && [ "${AGENTBOX_HARNESS:-}" != "non fi # Initial message: a SessionStart hook, matched on startup|resume so it also lands after a - # session is resumed. Merged into user settings rather than replacing them, and any earlier - # agentbox entry is dropped so re-runs don't stack up duplicates. + # session is resumed. Merged into settings rather than replacing them, and any earlier agentbox + # entry is dropped so re-runs don't stack up duplicates. if [ "${INITIAL_MESSAGE_READY}" -eq 0 ]; then - merge_json_file "${HOME:-/root}/.claude/settings.json" ' + claude_hook_filter=' .hooks.SessionStart = ( ((.hooks.SessionStart // []) | map(select(((.hooks // []) | map(.command // "") | any(contains("agentbox"))) | not))) + [{matcher: "startup|resume", hooks: [{type: "command", command: $cmd}]}] - )' --arg cmd "${SESSION_START_SCRIPT} claude" + )' + merge_json_file "${HOME:-/root}/.claude/settings.json" "${claude_hook_filter}" \ + --arg cmd "${SESSION_START_SCRIPT} claude" + + if claude_sudo_home="$(sudo_user_home)"; then + merge_json_file "${claude_sudo_home}/.claude/settings.json" "${claude_hook_filter}" \ + --arg cmd "${SESSION_START_SCRIPT} claude" + restore_sudo_user_ownership "${claude_sudo_home}/.claude" + fi fi fi # --- GitHub Copilot CLI ------------------------------------------------------------------------ -if [ "${AGENTBOX_HARNESS:-}" != "claude" ] && [ "${AGENTBOX_HARNESS:-}" != "none" ] \ - && command -v copilot >/dev/null 2>&1; then +if configures_copilot; then echo "--- Configuring GitHub Copilot CLI ---" while IFS= read -r repo; do @@ -239,8 +296,14 @@ if [ "${AGENTBOX_HARNESS:-}" != "claude" ] && [ "${AGENTBOX_HARNESS:-}" != "none | with_entries({key: .key, value: {source: {source: "github", repo: .value}}}))) | .enabledPlugins = ((.enabledPlugins // {}) + (($manifest.plugins // []) | map({key: ., value: true}) | from_entries))' + # Copilot has no machine-level path for any of this, so write every home that could belong to the + # user who ends up running it: the invoking user, /etc/skel for users created later, and the user + # behind sudo (the Copilot cloud sandbox provisions as root and runs the agent as that user). copilot_dirs=("${HOME:-/root}/.copilot") is_root && copilot_dirs+=("/etc/skel/.copilot") + if copilot_sudo_home="$(sudo_user_home)"; then + copilot_dirs+=("${copilot_sudo_home}/.copilot") + fi for dir in "${copilot_dirs[@]}"; do merge_json_file "${dir}/settings.json" "${marketplace_filter}" \ @@ -258,6 +321,8 @@ if [ "${AGENTBOX_HARNESS:-}" != "claude" ] && [ "${AGENTBOX_HARNESS:-}" != "none > "${dir}/hooks/agentbox.json" fi done + + [ -n "${copilot_sudo_home:-}" ] && restore_sudo_user_ownership "${copilot_sudo_home}/.copilot" fi exit 0 diff --git a/src/container/scripts/install-features.sh b/src/container/scripts/install-features.sh index 8ae7d0e..01ea146 100755 --- a/src/container/scripts/install-features.sh +++ b/src/container/scripts/install-features.sh @@ -159,9 +159,16 @@ dotnet new install TALXIS.DevKit.Templates.Dataverse || true # caller, and one place the config it reads lives. AGENTBOX_HARNESS # (already exported by this script's caller) narrows it to the relevant harness; unset (manual/ # local runs) tries both, skipping whichever binary isn't installed. +# +# Its exit status is kept and re-raised at the end of this script: a box whose toolchain installed +# but whose agent config didn't reach the harness is broken in a way that's easy to miss, so the +# setup script has to fail rather than report success. The tool check below still runs first, so the +# diagnostics are in the log either way. +harness_status=0 curl -fsSL --max-time 20 "https://talxis.com/agentbox-harness" \ -o "${WORKDIR}/configure-agent-harness.sh" \ - && bash "${WORKDIR}/configure-agent-harness.sh" + && bash "${WORKDIR}/configure-agent-harness.sh" \ + || harness_status=$? # install_feature() logs failures but does not stop on them; report final status per tool. echo "=== Tool check ===" @@ -176,4 +183,9 @@ for tool in dotnet az pwsh terraform gh copilot claude func pac txc; do done [ "${missing}" -eq 1 ] && echo "One or more tools are missing — see the install output above for the matching WARNING line." +if [ "${harness_status}" -ne 0 ]; then + echo "ERROR: agent harness configuration failed (exit ${harness_status}) — see the output above." >&2 + exit "${harness_status}" +fi + exit 0 diff --git a/test/agent/assert.sh b/test/agent/assert.sh index 366e9af..d817a0b 100755 --- a/test/agent/assert.sh +++ b/test/agent/assert.sh @@ -102,9 +102,48 @@ assert_contains "${HOME}/.copilot/copilot-instructions.md" "# My own notes" [ -f /etc/claude-code/CLAUDE.md ] \ && fail "/etc/claude-code/CLAUDE.md should be gone once the block it only held is removed" -echo "--- AGENTBOX_HARNESS=none touches nothing ---" +echo "--- the shared hook is readable by users other than the one that provisioned ---" +SHARE_DIR="$(dirname "$(jq -r '.hooks.SessionStart[0].hooks[0].command' "${HOME}/.claude/settings.json" | cut -d' ' -f1)")" +[ "$(stat -c '%a' "${SHARE_DIR}")" = "755" ] || fail "${SHARE_DIR} is not mode 755" +[ "$(stat -c '%a' "${SHARE_DIR}/session-start.sh")" = "755" ] || fail "session-start.sh is not mode 755" +[ "$(stat -c '%a' "${SHARE_DIR}/initial-message.md")" = "644" ] || fail "initial-message.md is not mode 644" + +# The Copilot cloud sandbox provisions as root under sudo and runs the agent as SUDO_USER, so the +# user-level files have to reach that home too. Env-gated: test.sh creates the user, and a direct run +# against a live box skips it rather than writing into someone's home. +if [ -n "${AGENTBOX_TEST_SUDO_USER:-}" ]; then + echo "--- user-level config is mirrored to the user behind sudo ---" + SUDO_HOME="$(getent passwd "${AGENTBOX_TEST_SUDO_USER}" | cut -d: -f6)" + [ -n "${SUDO_HOME}" ] || fail "no home directory for ${AGENTBOX_TEST_SUDO_USER}" + rm -rf "${SUDO_HOME}/.copilot" "${SUDO_HOME}/.claude" + SUDO_USER="${AGENTBOX_TEST_SUDO_USER}" bash "${CONFIGURE}" + assert_contains "${SUDO_HOME}/.copilot/copilot-instructions.md" "${FIRST_LINE}" + [ "$(jq -r '.enabledPlugins["implement@talxis"]' "${SUDO_HOME}/.copilot/settings.json")" = "true" ] \ + || fail "implement@talxis not enabled in the sudo user's ~/.copilot/settings.json" + [ -f "${SUDO_HOME}/.copilot/hooks/agentbox.json" ] \ + || fail "no sessionStart hook in the sudo user's ~/.copilot/hooks/" + [ "$(jq -r '.hooks.SessionStart | length' "${SUDO_HOME}/.claude/settings.json")" = "1" ] \ + || fail "no SessionStart hook in the sudo user's ~/.claude/settings.json" + [ "$(stat -c '%U' "${SUDO_HOME}/.copilot/copilot-instructions.md")" = "${AGENTBOX_TEST_SUDO_USER}" ] \ + || fail "the sudo user's copilot-instructions.md is not owned by them" +fi + +echo "--- an unreadable config fails the run instead of half-configuring ---" +rm -rf "${HOME}/.claude" "${HOME}/.copilot" /etc/claude-code +# A copy outside the repo, so the script can't find src/agent/ as a sibling of itself, with +# AGENTBOX_CONFIG_DIR unset and a URL on loopback port 1 that fails immediately (no network needed). +STANDALONE="${WORK}/configure-standalone.sh" +cp "${CONFIGURE}" "${STANDALONE}" +env -u AGENTBOX_CONFIG_DIR AGENTBOX_CONFIG_URL="http://127.0.0.1:1/agent.json" bash "${STANDALONE}" \ + && fail "the script exited 0 with no readable config" +[ -e "${HOME}/.claude/settings.json" ] && fail "a failed run still wrote Claude settings" +[ -e "${HOME}/.copilot/copilot-instructions.md" ] && fail "a failed run still wrote Copilot instructions" +[ -e /etc/claude-code/CLAUDE.md ] && fail "a failed run still wrote the managed CLAUDE.md" + +echo "--- AGENTBOX_HARNESS=none touches nothing, and needs no config ---" rm -rf "${HOME}/.claude" "${HOME}/.copilot" /etc/claude-code -AGENTBOX_HARNESS=none bash "${CONFIGURE}" +env -u AGENTBOX_CONFIG_DIR AGENTBOX_HARNESS=none AGENTBOX_CONFIG_URL="http://127.0.0.1:1/agent.json" \ + bash "${STANDALONE}" || fail "AGENTBOX_HARNESS=none should succeed without reading the config" [ -e "${HOME}/.claude/settings.json" ] && fail "AGENTBOX_HARNESS=none wrote Claude settings" [ -e /etc/claude-code/CLAUDE.md ] && fail "AGENTBOX_HARNESS=none wrote the managed CLAUDE.md" [ -e "${HOME}/.copilot/copilot-instructions.md" ] && fail "AGENTBOX_HARNESS=none wrote Copilot instructions" diff --git a/test/agent/test.sh b/test/agent/test.sh index 374f017..53be5e5 100755 --- a/test/agent/test.sh +++ b/test/agent/test.sh @@ -10,6 +10,12 @@ docker run --rm -v "$(pwd):/repo:ro" ubuntu:24.04 bash -c ' set -e apt-get update -qq >/dev/null && apt-get install -y -qq jq >/dev/null export HOME=/root REPO=/repo + + # A second, unprivileged user, so the run covers the Copilot cloud sandbox shape: provisioning as + # root under sudo while the agent runs as someone else. + useradd -m agentboxtest + export AGENTBOX_TEST_SUDO_USER=agentboxtest + bash /repo/test/agent/assert.sh ' From 57fa7af2b9657b36b9289f91f346466b8e261c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:23:54 +0000 Subject: [PATCH 3/4] Fetch the agent's behaviour from TALXIS/skills, where it belongs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the config along the line between the box and the agent: this repo owns the environment, TALXIS/skills owns process and know-how. src/agent/agent.json keeps what provisioning a box actually means — which plugin marketplaces and plugins it gets — and gains an "instructions" pointer at the behaviour config, which now lives in TALXIS/skills alongside the Skills it belongs to (TALXIS/skills#8). The system prompt and initial message leave this repo. configure-agent-harness.sh resolves in two stages. download_config already fetched a manifest and resolved payload filenames against the URL curl landed on, which is exactly what the second stage needs, so it splits into download_manifest (the bootstrap) and download_instructions (the behaviour manifest plus its payloads). Both stages fail loudly with the URL named: a box with a plugin list but no house rules is half-configured, so an unreachable instructions manifest is as fatal as an unreachable bootstrap one. AGENTBOX_INSTRUCTIONS_URL / AGENTBOX_INSTRUCTIONS_DIR mirror the existing AGENTBOX_CONFIG_* pair. Tests now drive a fixture in test/agent/fixture/ rather than the real config, so CI covers the mechanism without reaching the network or depending on another repo — which it otherwise would have to, now that half the config lives elsewhere. Adds a case for unreadable instructions alongside the existing one for an unreadable bootstrap manifest. Unchanged: src/container/claude-code/session-start-hook.json and the README step for it. Keeping txc and the Dataverse templates current is tool updating, so it stays an agentbox concern rather than moving into the plugin. Requires the talxis.com/agentbox-instructions short link, and TALXIS/skills#8 merged first. Verified end-to-end against that PR's branch: behaviour fetched over the network, applied, and reported present in a live Claude session's context. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KaKGKCwRPFoHcZkRJZxbn2 --- README.md | 26 ++--- src/agent/README.md | 102 +++++++++--------- src/agent/agent.json | 6 +- src/agent/initial-message.md | 24 ----- src/agent/system-prompt.md | 24 ----- .../scripts/configure-agent-harness.sh | 79 ++++++++++---- test/agent/assert.sh | 77 ++++++++----- test/agent/fixture/agent.json | 13 +++ test/agent/fixture/initial-message.md | 3 + test/agent/fixture/instructions.json | 7 ++ test/agent/fixture/system-prompt.md | 3 + 11 files changed, 198 insertions(+), 166 deletions(-) delete mode 100644 src/agent/initial-message.md delete mode 100644 src/agent/system-prompt.md create mode 100644 test/agent/fixture/agent.json create mode 100644 test/agent/fixture/initial-message.md create mode 100644 test/agent/fixture/instructions.json create mode 100644 test/agent/fixture/system-prompt.md diff --git a/README.md b/README.md index a1b74a4..0393d35 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,15 @@ isolated environments below instead — see GitHub's own take on why: ## Configuring the agents -Everything about how the agents behave in an AgentBox — the Skills they get, the instructions loaded -into every session, and the briefing injected at session start — is configured in one place, -[`src/agent/`](src/agent), and applies to both harnesses (Claude Code, GitHub Copilot CLI). +Configuration is split along the line between the box and the agent. This repo owns the environment — +[`src/agent/`](src/agent) declares which Skills plugins every box gets, and installing and updating +tooling is its business. [TALXIS/skills](https://github.com/TALXIS/skills) owns process and know-how: +its `agent/` directory says how a harness should behave, colocated with the Skills it belongs to. -Every environment below already runs the script that applies it, so an edit needs no per-environment -change, and nothing is written into a checkout: the config applies whichever repository is cloned into -the box. See [`src/agent/README.md`](src/agent/README.md) for where each knob lands per harness. +Every environment below already runs the script that applies both, so an edit on either side needs no +per-environment change, and nothing is written into a checkout: the config applies whichever +repository is cloned into the box. See [`src/agent/README.md`](src/agent/README.md) for where each +piece lands per harness. ## Choose your environment @@ -43,9 +45,9 @@ the box. See [`src/agent/README.md`](src/agent/README.md) for where each knob la **Already have a `devcontainer.json`?** Point `image` at `ghcr.io/talxis/tools-agentbox/image:latest` and add that same `postCreateCommand` line — it's easy to end up with a working toolchain but no Skills/MCP registration if this step gets skipped, -since nothing it does is baked into the image itself. It applies [`src/agent/`](src/agent) — the -[TALXIS/skills](https://github.com/TALXIS/skills) plugin (Skills + the `txc` MCP server), the system -prompt, and the initial message — to whichever of Claude Code / GitHub Copilot is present. Omit it if +since nothing it does is baked into the image itself. It registers the +[TALXIS/skills](https://github.com/TALXIS/skills) plugin (Skills + the `txc` MCP server) and applies +that repo's agent behaviour config to whichever of Claude Code / GitHub Copilot is present. Omit it if you don't want that. Or build your own `devcontainer.json` from individual features listed in @@ -78,9 +80,9 @@ with: This installs the Feature list from [`devcontainer.features.json`](src/container/templates/power-platform/.devcontainer/devcontainer.features.json) -directly on the VM, and applies [`src/agent/`](src/agent) — the -[TALXIS/skills](https://github.com/TALXIS/skills) plugin (Skills + the `txc` MCP server), the system -prompt, and the initial message — to Claude Code. No changes needed in individual repos. +directly on the VM, registers the [TALXIS/skills](https://github.com/TALXIS/skills) plugin (Skills + +the `txc` MCP server), and applies that repo's agent behaviour config to Claude Code. No changes +needed in individual repos. To keep `txc` and the Dataverse templates current between setup script runs, merge the `hooks` from [`src/container/claude-code/session-start-hook.json`](src/container/claude-code/session-start-hook.json) into diff --git a/src/agent/README.md b/src/agent/README.md index 21d8685..2d1c617 100644 --- a/src/agent/README.md +++ b/src/agent/README.md @@ -1,64 +1,75 @@ # Agent configuration -The one place agent behaviour is configured for **every** AgentBox surface (Claude Code cloud -environment, Copilot cloud sandbox, Codespaces, Docker) and **every** harness (Claude Code, GitHub -Copilot CLI). Three knobs live in this directory: +Two manifests configure the agents in an AgentBox, split along the line between the box and the agent: -- the **Skills list** — which plugin marketplaces and plugins get registered, -- the **system prompt** — instructions loaded into every session, -- the **initial message** — context injected once per session, at session start. +- **This repo owns the environment.** `agent.json` here declares which plugin marketplaces and + plugins every box gets, and where the behaviour config lives. Installing and updating tooling is + also this repo's business. +- **[TALXIS/skills](https://github.com/TALXIS/skills) owns process and know-how.** Its `agent/` + directory holds how a harness should behave: the instructions loaded into every session, and the + briefing injected once at session start — colocated with the Skills they belong to. [`../container/scripts/configure-agent-harness.sh`](../container/scripts/configure-agent-harness.sh) -applies all three. Every surface already runs it — the Claude Code cloud setup script and the Copilot -cloud sandbox via `install-features.sh`, Codespaces via its own `postCreateCommand` — so an edit here -reaches all of them with no per-surface change. Re-running is safe and is how an edit is picked up: -JSON is merged, and the markdown files carry a marked `AGENTBOX` block that is replaced rather than -appended, so anything a developer wrote around it survives. - -The config exists here and nowhere else. There is no built-in copy in the script to fall back on: a -run that cannot read this directory (or the short link below) fails instead of leaving a +reads both and applies them. Every surface already runs it — the Claude Code cloud setup script and +the Copilot cloud sandbox via `install-features.sh`, Codespaces via its own `postCreateCommand` — so an +edit on either side reaches all of them with no per-surface change. Re-running is safe and is how an +edit is picked up: JSON is merged, and the markdown files carry a marked `AGENTBOX` block that is +replaced rather than appended, so anything a developer wrote around it survives. + +Each manifest exists in exactly one place, and there is no built-in copy to fall back on: a run that +cannot read either one names the URL that failed and exits non-zero rather than leaving a half-configured box. -## Where each knob lands +## Where each piece lands Nothing is project-scoped, so the config applies whichever repository is cloned into the box. -| Knob | Claude Code | GitHub Copilot CLI | -|------|-------------|--------------------| -| Skills / plugins | `claude plugin marketplace add` + `claude plugin install`, with the plugin cache seeded machine-wide via `CLAUDE_CODE_PLUGIN_CACHE_DIR` | `copilot plugin …`, plus `extraKnownMarketplaces`/`enabledPlugins` in `settings.json` | -| System prompt | `/etc/claude-code/CLAUDE.md` — managed-policy memory: machine-wide, user-independent, and not excludable via a user's `claudeMdExcludes`. Falls back to `~/.claude/CLAUDE.md` when not running as root | `copilot-instructions.md` — user-level custom instructions, loaded in every repository | -| Initial message | `SessionStart` hook in `settings.json`, returning `hookSpecificOutput.additionalContext` | `sessionStart` hook in `hooks/agentbox.json`, returning `additionalContext` | +| Piece | Claude Code | GitHub Copilot CLI | +|-------|-------------|--------------------| +| Marketplaces / plugins | `claude plugin marketplace add` + `claude plugin install`, with the plugin cache seeded machine-wide via `CLAUDE_CODE_PLUGIN_CACHE_DIR` | `copilot plugin …`, plus `extraKnownMarketplaces`/`enabledPlugins` in `settings.json` | +| Session instructions | `/etc/claude-code/CLAUDE.md` — managed-policy memory: machine-wide, user-independent, and not excludable via a user's `claudeMdExcludes`. Falls back to `~/.claude/CLAUDE.md` when not running as root | `copilot-instructions.md` — user-level custom instructions, loaded in every repository | +| Session briefing | `SessionStart` hook in `settings.json`, returning `hookSpecificOutput.additionalContext` | `sessionStart` hook in `hooks/agentbox.json`, returning `additionalContext` | -Both harnesses get the initial message from one generated script, -`/usr/local/share/agentbox/session-start.sh`, which prints the message in whichever JSON shape the -calling harness expects. Don't edit that generated copy. +Both harnesses get the briefing from one generated script, +`/usr/local/share/agentbox/session-start.sh`, which prints it in whichever JSON shape the calling +harness expects. Don't edit that generated copy. ### Which home directories get the user-level files -Claude's system prompt has a machine-level path; the rest are user-level, and the user who runs the -harness is not always the user that provisioned the box. So each user-level file is written to: +Claude's instructions have a machine-level path; the rest are user-level, and the user who runs the +harness is not always the user that provisioned the box. So each user-level file is written to the +invoking user's home, to `/etc/skel` when running as root so a user created later starts configured, +and to the home of the user behind `sudo` when there is one. -1. the invoking user's home, -2. `/etc/skel`, when running as root, so a user created later starts configured, -3. the home of the user behind `sudo`, when there is one. - -That third case is not hypothetical: the Copilot cloud sandbox provisions with +That last case is not hypothetical: the Copilot cloud sandbox provisions with `sudo -E bash install-features.sh`, where `HOME` resolves to `/root`, and then runs the agent as the -unprivileged user. Without the mirror, nothing written under `${HOME}` would ever be read there, and +unprivileged user. Without the mirror nothing written under `${HOME}` would ever be read there, and `/etc/skel` doesn't help because that user already exists. +## Short links + +| Short link | Resolves to | +|------------|-------------| +| `https://talxis.com/agentbox-agent` | this repo's `src/agent/agent.json` on `master` | +| `https://talxis.com/agentbox-instructions` | `TALXIS/skills` → `agent/instructions.json` on `master` | + +Payload files are fetched relative to the URL the instructions manifest actually resolved to, so no +branch, path or filename is pinned here. Point a short link at a branch and that branch's config is +what a box gets. `AGENTBOX_CONFIG_URL` / `AGENTBOX_INSTRUCTIONS_URL` override either at run time, and +`AGENTBOX_CONFIG_DIR` / `AGENTBOX_INSTRUCTIONS_DIR` point at local directories instead (which is how +the tests stay offline). + ## Two things to know before writing instructions - **This is context, not enforcement.** Both harnesses load these files as instructions the model is asked to follow, not as constraints on what it can do — and a developer can opt out - (`copilot --no-custom-instructions`). Anything that must hold regardless of what the model decides - belongs in a permission rule or a `PreToolUse`/`preToolUse` hook instead. Each harness does have a - machine-level policy channel that a developer cannot remove (Claude's - `/etc/claude-code/managed-settings.json`, Copilot's `/etc/github-copilot/policy.d/`); AgentBox - deliberately doesn't use them, because it configures boxes rather than policing them. -- **Length costs adherence.** The system prompt is in the context window of every session, every - time. Keep it to rules that apply to all work in an AgentBox; anything task-specific belongs in a - Skill in [TALXIS/skills](https://github.com/TALXIS/skills), which loads on demand. + (`copilot --no-custom-instructions`). Anything that must hold regardless belongs in a permission + rule or a `PreToolUse`/`preToolUse` hook. Each harness does have a machine-level policy channel a + developer cannot remove (Claude's `/etc/claude-code/managed-settings.json`, Copilot's + `/etc/github-copilot/policy.d/`); AgentBox deliberately doesn't use them, because it configures + boxes rather than policing them. +- **Length costs adherence**, and the instructions live in every session's context window. Keep them + to rules that apply to all work; task-specific guidance belongs in a Skill, which loads on demand. ## Mechanisms deliberately not used @@ -76,19 +87,8 @@ In a session on a freshly provisioned environment: `SessionStart` entry. - Copilot CLI: `/instructions` lists `copilot-instructions.md` as an active source. -Or run the assertions, which apply the config and check every path above: +Or run the assertions, which apply a fixture through every path above without touching the network: ```bash HOME="$(mktemp -d)" bash test/agent/assert.sh ``` - -## How the config is found - -`configure-agent-harness.sh` resolves this directory from `AGENTBOX_CONFIG_DIR` if set (used by the -tests), else a sibling of the script when it runs from a checkout, else over the network from -`AGENTBOX_CONFIG_URL` — default `https://talxis.com/agentbox-agent`, which redirects to this -directory's manifest. - -One short link covers it: the payload files are fetched relative to the URL the manifest actually -resolved to, so they follow its branch and path automatically. Point the short link at a branch and -that branch's prompt and message are what a box gets. diff --git a/src/agent/agent.json b/src/agent/agent.json index 0ee5138..7167577 100644 --- a/src/agent/agent.json +++ b/src/agent/agent.json @@ -1,5 +1,5 @@ { - "//": "The one place agent behaviour is configured for every AgentBox surface (Claude Code cloud, Copilot cloud sandbox, Codespaces, Docker) and every harness (Claude Code, GitHub Copilot CLI). Applied by src/container/scripts/configure-agent-harness.sh — see src/agent/README.md.", + "//": "What an AgentBox provisions into a box: the plugin marketplaces and plugins every harness gets, and where the harness-behaviour config lives. Applied by src/container/scripts/configure-agent-harness.sh — see src/agent/README.md.", "marketplaces": { "talxis": "TALXIS/skills" @@ -9,7 +9,5 @@ "implement@talxis" ], - "systemPrompt": "system-prompt.md", - - "initialMessage": "initial-message.md" + "instructions": "https://talxis.com/agentbox-instructions" } diff --git a/src/agent/initial-message.md b/src/agent/initial-message.md deleted file mode 100644 index 807157b..0000000 --- a/src/agent/initial-message.md +++ /dev/null @@ -1,24 +0,0 @@ -# AgentBox session briefing - -This is injected once, at the start of each session, before the developer's first message. It is -context for you, not a message from the developer — do not answer it. - -## Where you are - -An AgentBox container/VM provisioned by [TALXIS/tools-agentbox](https://github.com/TALXIS/tools-agentbox). -The toolchain (`txc`, `pac`, `dotnet`, `az`, `func`, `terraform`, `pwsh`, `gh`) and the -`implement@talxis` plugin (TALXIS Skills + the `txc` MCP server) are installed and current — a -background job refreshes `txc` and the Dataverse templates at session start, so a version check may -be a few seconds stale. - -## First turn - -If the developer's opening message is a greeting, is empty, or asks what they can do here, reply with -a short orientation instead of starting work: - -- what this environment is provisioned for (Power Platform / Dataverse development), -- the TALXIS Skills available in this session and what each is for, -- one concrete suggested next step based on the repository that is checked out. - -Otherwise, skip the orientation and start on what they asked. Keep it to a few lines either way — the -developer can ask for more. diff --git a/src/agent/system-prompt.md b/src/agent/system-prompt.md deleted file mode 100644 index e9350fb..0000000 --- a/src/agent/system-prompt.md +++ /dev/null @@ -1,24 +0,0 @@ -# TALXIS AgentBox - -You are running inside a TALXIS AgentBox environment: an isolated, pre-provisioned Power Platform / -Dataverse development machine. The toolchain below is already installed and on `PATH` — never -install, upgrade, or `sudo apt-get` a tool listed here, and never suggest the developer run agents on -their own workstation. - -## Available tooling - -- `txc` — TALXIS CLI. The primary tool for TALXIS Dataverse work. Prefer it over hand-written - scripts, and prefer its MCP tools (from the `implement@talxis` plugin) over shelling out. -- `pac` — Microsoft Power Platform CLI: solutions, environments, plugin registration. -- `dotnet` — with the `TALXIS.DevKit.Templates.Dataverse` templates installed (`dotnet new list`). -- `az`, `func`, `terraform`, `pwsh`, `gh` — Azure, Azure Functions, infrastructure, PowerShell, GitHub. - -## Working agreements - -- Authentication is resolved by TALXIS Valet; never prompt for or hard-code credentials, connection - strings, or client secrets. If auth fails, report the failure — do not work around it. -- Solution and plugin changes belong in source control: edit the files in the repository, then - deploy with `txc`/`pac`. Do not make one-off changes in a Dataverse environment that the repository - cannot reproduce. -- Never point tooling at a production environment unless the developer names it explicitly in the - current conversation. diff --git a/src/container/scripts/configure-agent-harness.sh b/src/container/scripts/configure-agent-harness.sh index c3010a7..821a058 100755 --- a/src/container/scripts/configure-agent-harness.sh +++ b/src/container/scripts/configure-agent-harness.sh @@ -1,14 +1,15 @@ #!/bin/bash -# Configures whichever agent harness is present — Claude Code and/or GitHub Copilot CLI — from the -# single source of truth in src/agent/, so all three knobs are configured in one place for every -# surface (Claude Code cloud, Copilot cloud sandbox, Codespaces, Docker) and every harness: +# Configures whichever agent harness is present — Claude Code and/or GitHub Copilot CLI — for every +# surface (Claude Code cloud, Copilot cloud sandbox, Codespaces, Docker), from two manifests that +# split along the line between the box and the agent: # -# agent.json the manifest: which plugin marketplaces/plugins to register (the Skills list), -# plus the systemPrompt and initialMessage files it points at -# systemPrompt instructions loaded into every session, on every harness -# initialMessage context injected once per session, at session start +# src/agent/agent.json this repo: which plugin marketplaces/plugins a box gets, and where the +# behaviour config lives (the "instructions" URL below) +# TALXIS/skills agent/ that repo: how the harness should behave — the instructions loaded into +# every session, and the briefing injected once at session start # -# See src/agent/README.md for which path each knob lands in per harness, and why. +# AgentBox owns the environment; the Skills repo owns process and know-how. See src/agent/README.md +# for which path each piece lands in per harness, and why. # # Nothing written here is project-scoped: every target is a machine or user path the harness reads # whatever repository is cloned into the sandbox. Where a harness offers a machine-level path the @@ -131,13 +132,20 @@ fi # --- Resolve the config ------------------------------------------------------------------------ -download_config() { +# Fetches a JSON manifest and prints the URL curl actually landed on, so a caller can resolve files +# that sit next to it (the short links redirect to raw.githubusercontent.com). +download_manifest() { + local url="$1" out="$2" effective + effective="$(curl -fsSL --max-time 20 -w '%{url_effective}' -o "${out}" "${url}")" || return 1 + jq -e 'type == "object"' "${out}" >/dev/null 2>&1 || return 1 + printf '%s' "${effective}" +} + +# The instructions manifest plus the payload files it declares, which name paths relative to it. +download_instructions() { local url="$1" dir="$2" effective base file mkdir -p "${dir}" - effective="$(curl -fsSL --max-time 20 -w '%{url_effective}' -o "${dir}/agent.json" "${url}")" || return 1 - jq -e 'type == "object"' "${dir}/agent.json" >/dev/null 2>&1 || return 1 - # Payload file names in agent.json are relative to the manifest, so resolve them against the URL - # curl actually ended up on (the short link redirects to raw.githubusercontent.com). + effective="$(download_manifest "${url}" "${dir}/instructions.json")" || return 1 base="${effective%/*}" while IFS= read -r file; do [ -z "${file}" ] && continue @@ -145,12 +153,14 @@ download_config() { # A file the manifest declares but that can't be downloaded is a broken config, not a reason # to apply the rest: fail the whole resolution so the caller reports it. curl -fsSL --max-time 20 -o "${dir}/${file}" "${base}/${file}" || { - echo "ERROR: agent.json declares ${file}, but ${base}/${file} could not be downloaded." >&2 + echo "ERROR: instructions.json declares ${file}, but ${base}/${file} could not be downloaded." >&2 return 1 } - done < <(jq -r '[.systemPrompt, .initialMessage] | map(select(type == "string"))[]' "${dir}/agent.json") + done < <(jq -r '[.systemPrompt, .initialMessage] | map(select(type == "string"))[]' "${dir}/instructions.json") } +# Stage 1: this repo's own manifest — which marketplaces and plugins a box gets, and where the +# behaviour config lives. CONFIG_DIR="" if [ -n "${AGENTBOX_CONFIG_DIR:-}" ]; then # Explicitly pointed somewhere: never quietly fall back to the network from there. @@ -161,8 +171,8 @@ if [ -n "${AGENTBOX_CONFIG_DIR:-}" ]; then CONFIG_DIR="${AGENTBOX_CONFIG_DIR}" elif [ -f "${SCRIPT_DIR}/../../agent/agent.json" ]; then CONFIG_DIR="$(cd "${SCRIPT_DIR}/../../agent" && pwd)" -elif download_config "${AGENT_CONFIG_URL}" "${WORKDIR}/agent"; then - CONFIG_DIR="${WORKDIR}/agent" +elif download_manifest "${AGENT_CONFIG_URL}" "${WORKDIR}/agent.json" >/dev/null; then + CONFIG_DIR="${WORKDIR}" fi if [ -z "${CONFIG_DIR}" ]; then @@ -172,21 +182,46 @@ if [ -z "${CONFIG_DIR}" ]; then exit 1 fi -echo "--- Agent config: ${CONFIG_DIR} ---" MANIFEST="${CONFIG_DIR}/agent.json" +echo "--- Agent config: ${MANIFEST} ---" + +# Stage 2: the behaviour config — how the harness should act — which lives with the Skills it belongs +# to, in TALXIS/skills, and is named by the manifest above rather than hardcoded here. +INSTRUCTIONS_DIR="" +if [ -n "${AGENTBOX_INSTRUCTIONS_DIR:-}" ]; then + if [ ! -f "${AGENTBOX_INSTRUCTIONS_DIR}/instructions.json" ]; then + echo "ERROR: AGENTBOX_INSTRUCTIONS_DIR=${AGENTBOX_INSTRUCTIONS_DIR} has no instructions.json." >&2 + exit 1 + fi + INSTRUCTIONS_DIR="${AGENTBOX_INSTRUCTIONS_DIR}" +else + INSTRUCTIONS_URL="${AGENTBOX_INSTRUCTIONS_URL:-$(jq -r '.instructions // ""' "${MANIFEST}")}" + if [ -z "${INSTRUCTIONS_URL}" ]; then + echo "ERROR: ${MANIFEST} declares no \"instructions\" URL, and AGENTBOX_INSTRUCTIONS_URL is unset." >&2 + exit 1 + fi + if ! download_instructions "${INSTRUCTIONS_URL}" "${WORKDIR}/instructions"; then + echo "ERROR: could not read the behaviour config from ${INSTRUCTIONS_URL} — no harness was configured." >&2 + exit 1 + fi + INSTRUCTIONS_DIR="${WORKDIR}/instructions" +fi + +echo "--- Behaviour config: ${INSTRUCTIONS_DIR}/instructions.json ---" # A declared-but-absent payload is a broken config and stops the run. A declared payload that exists # and is empty is how a knob is turned off deliberately (the marked block is then removed), so that # stays allowed and resolves to nothing. resolve_payload() { local key="$1" name - name="$(jq -r --arg k "${key}" '.[$k] // "" | select(type == "string")' "${MANIFEST}" 2>/dev/null)" + name="$(jq -r --arg k "${key}" '.[$k] // "" | select(type == "string")' \ + "${INSTRUCTIONS_DIR}/instructions.json" 2>/dev/null)" [ -z "${name}" ] && return 0 - if [ ! -f "${CONFIG_DIR}/${name}" ]; then - echo "ERROR: agent.json declares ${key} as ${name}, which is missing from ${CONFIG_DIR}." >&2 + if [ ! -f "${INSTRUCTIONS_DIR}/${name}" ]; then + echo "ERROR: instructions.json declares ${key} as ${name}, which is missing from ${INSTRUCTIONS_DIR}." >&2 return 1 fi - [ -s "${CONFIG_DIR}/${name}" ] && printf '%s' "${CONFIG_DIR}/${name}" + [ -s "${INSTRUCTIONS_DIR}/${name}" ] && printf '%s' "${INSTRUCTIONS_DIR}/${name}" return 0 } diff --git a/test/agent/assert.sh b/test/agent/assert.sh index d817a0b..b98045e 100755 --- a/test/agent/assert.sh +++ b/test/agent/assert.sh @@ -1,9 +1,13 @@ #!/usr/bin/env bash -# Asserts that src/agent/ actually reaches every place a harness reads from. Applies the config with +# Asserts that the agent config actually reaches every place a harness reads from. Applies it with # stub `claude`/`copilot` binaries on PATH, so neither real harness (nor its auth) is needed: # configure-agent-harness.sh only cares that the binary exists, and every plugin call it makes is # allowed to fail. # +# Both manifests come from test/agent/fixture/ rather than from the real config, so these assertions +# cover the mechanism without depending on the network or on TALXIS/skills, which owns the behaviour +# half. That is also why the expected strings below are fixture values, not TALXIS ones. +# # Called by test/agent/test.sh inside a throwaway container. Can also be run directly to verify a # live AgentBox — it writes to $HOME, /etc/claude-code, /etc/skel and /usr/local/share/agentbox, so # point HOME at a scratch directory first and expect those system paths to be (re)written: @@ -32,8 +36,9 @@ STUB done export PATH="${WORK}/bin:${PATH}" -export AGENTBOX_CONFIG_DIR="${REPO}/src/agent" -CONFIG="${AGENTBOX_CONFIG_DIR}" +export AGENTBOX_CONFIG_DIR="${REPO}/test/agent/fixture" +export AGENTBOX_INSTRUCTIONS_DIR="${REPO}/test/agent/fixture" +CONFIG="${AGENTBOX_INSTRUCTIONS_DIR}" fail() { echo "FAIL: $1"; exit 1; } assert_contains() { grep -qF "$2" "$1" || fail "$1 does not contain: $2"; } @@ -41,16 +46,16 @@ assert_contains() { grep -qF "$2" "$1" || fail "$1 does not contain: $2"; } bash "${CONFIGURE}" echo "--- plugins registered on both harnesses ---" -assert_contains "${PLUGIN_LOG}" "claude plugin marketplace add TALXIS/skills" -assert_contains "${PLUGIN_LOG}" "claude plugin install implement@talxis --yes" -assert_contains "${PLUGIN_LOG}" "copilot plugin marketplace add TALXIS/skills" -assert_contains "${PLUGIN_LOG}" "copilot plugin install implement@talxis" -[ "$(jq -r '.enabledPlugins["implement@talxis"]' "${HOME}/.copilot/settings.json")" = "true" ] \ - || fail "implement@talxis not enabled in ~/.copilot/settings.json" -[ "$(jq -r '.extraKnownMarketplaces.talxis.source.repo' "${HOME}/.copilot/settings.json")" = "TALXIS/skills" ] \ - || fail "the talxis marketplace is not declared in ~/.copilot/settings.json" -[ "$(jq -r '.enabledPlugins["implement@talxis"]' /etc/skel/.copilot/settings.json)" = "true" ] \ - || fail "implement@talxis not enabled in /etc/skel/.copilot/settings.json" +assert_contains "${PLUGIN_LOG}" "claude plugin marketplace add AGENTBOX/fixture-skills" +assert_contains "${PLUGIN_LOG}" "claude plugin install fixture-plugin@fixture --yes" +assert_contains "${PLUGIN_LOG}" "copilot plugin marketplace add AGENTBOX/fixture-skills" +assert_contains "${PLUGIN_LOG}" "copilot plugin install fixture-plugin@fixture" +[ "$(jq -r '.enabledPlugins["fixture-plugin@fixture"]' "${HOME}/.copilot/settings.json")" = "true" ] \ + || fail "the fixture plugin is not enabled in ~/.copilot/settings.json" +[ "$(jq -r '.extraKnownMarketplaces.fixture.source.repo' "${HOME}/.copilot/settings.json")" = "AGENTBOX/fixture-skills" ] \ + || fail "the fixture marketplace is not declared in ~/.copilot/settings.json" +[ "$(jq -r '.enabledPlugins["fixture-plugin@fixture"]' /etc/skel/.copilot/settings.json)" = "true" ] \ + || fail "the fixture plugin is not enabled in /etc/skel/.copilot/settings.json" echo "--- system prompt reaches both harnesses ---" FIRST_LINE="$(head -1 "${CONFIG}/system-prompt.md")" @@ -92,10 +97,10 @@ assert_contains "${HOME}/.copilot/copilot-instructions.md" "# My own notes" || fail "the Claude SessionStart hook was duplicated on re-run" echo "--- emptying system-prompt.md removes the block, not the developer's file ---" -mkdir -p "${WORK}/empty-config" -cp "${CONFIG}/agent.json" "${CONFIG}/initial-message.md" "${WORK}/empty-config/" -: > "${WORK}/empty-config/system-prompt.md" -AGENTBOX_CONFIG_DIR="${WORK}/empty-config" bash "${CONFIGURE}" +mkdir -p "${WORK}/empty-instructions" +cp "${CONFIG}/instructions.json" "${CONFIG}/initial-message.md" "${WORK}/empty-instructions/" +: > "${WORK}/empty-instructions/system-prompt.md" +AGENTBOX_INSTRUCTIONS_DIR="${WORK}/empty-instructions" bash "${CONFIGURE}" grep -qF "BEGIN AGENTBOX" "${HOME}/.copilot/copilot-instructions.md" \ && fail "the AGENTBOX block survived an emptied system-prompt.md" assert_contains "${HOME}/.copilot/copilot-instructions.md" "# My own notes" @@ -118,8 +123,8 @@ if [ -n "${AGENTBOX_TEST_SUDO_USER:-}" ]; then rm -rf "${SUDO_HOME}/.copilot" "${SUDO_HOME}/.claude" SUDO_USER="${AGENTBOX_TEST_SUDO_USER}" bash "${CONFIGURE}" assert_contains "${SUDO_HOME}/.copilot/copilot-instructions.md" "${FIRST_LINE}" - [ "$(jq -r '.enabledPlugins["implement@talxis"]' "${SUDO_HOME}/.copilot/settings.json")" = "true" ] \ - || fail "implement@talxis not enabled in the sudo user's ~/.copilot/settings.json" + [ "$(jq -r '.enabledPlugins["fixture-plugin@fixture"]' "${SUDO_HOME}/.copilot/settings.json")" = "true" ] \ + || fail "the fixture plugin is not enabled in the sudo user's ~/.copilot/settings.json" [ -f "${SUDO_HOME}/.copilot/hooks/agentbox.json" ] \ || fail "no sessionStart hook in the sudo user's ~/.copilot/hooks/" [ "$(jq -r '.hooks.SessionStart | length' "${SUDO_HOME}/.claude/settings.json")" = "1" ] \ @@ -128,22 +133,36 @@ if [ -n "${AGENTBOX_TEST_SUDO_USER:-}" ]; then || fail "the sudo user's copilot-instructions.md is not owned by them" fi -echo "--- an unreadable config fails the run instead of half-configuring ---" +assert_wrote_nothing() { + [ -e "${HOME}/.claude/settings.json" ] && fail "$1 still wrote Claude settings" + [ -e "${HOME}/.copilot/copilot-instructions.md" ] && fail "$1 still wrote Copilot instructions" + [ -e /etc/claude-code/CLAUDE.md ] && fail "$1 still wrote the managed CLAUDE.md" + return 0 +} + +echo "--- an unreadable bootstrap manifest fails the run instead of half-configuring ---" rm -rf "${HOME}/.claude" "${HOME}/.copilot" /etc/claude-code -# A copy outside the repo, so the script can't find src/agent/ as a sibling of itself, with -# AGENTBOX_CONFIG_DIR unset and a URL on loopback port 1 that fails immediately (no network needed). +# A copy outside the repo, so the script can't find src/agent/ as a sibling of itself, with both DIR +# overrides unset and URLs on loopback port 1 that fail immediately (no network needed). STANDALONE="${WORK}/configure-standalone.sh" cp "${CONFIGURE}" "${STANDALONE}" -env -u AGENTBOX_CONFIG_DIR AGENTBOX_CONFIG_URL="http://127.0.0.1:1/agent.json" bash "${STANDALONE}" \ - && fail "the script exited 0 with no readable config" -[ -e "${HOME}/.claude/settings.json" ] && fail "a failed run still wrote Claude settings" -[ -e "${HOME}/.copilot/copilot-instructions.md" ] && fail "a failed run still wrote Copilot instructions" -[ -e /etc/claude-code/CLAUDE.md ] && fail "a failed run still wrote the managed CLAUDE.md" +DEAD_URL="http://127.0.0.1:1/manifest.json" +env -u AGENTBOX_CONFIG_DIR -u AGENTBOX_INSTRUCTIONS_DIR AGENTBOX_CONFIG_URL="${DEAD_URL}" \ + bash "${STANDALONE}" && fail "the script exited 0 with no readable bootstrap manifest" +assert_wrote_nothing "a failed run" + +echo "--- unreadable behaviour config fails the run too ---" +# Bootstrap resolves fine; only the instructions are unreachable. A box with the plugin list but no +# house rules is half-configured, so this must fail exactly as loudly. +env -u AGENTBOX_INSTRUCTIONS_DIR AGENTBOX_INSTRUCTIONS_URL="${DEAD_URL}" bash "${CONFIGURE}" \ + && fail "the script exited 0 with unreadable instructions" +assert_wrote_nothing "a run with unreadable instructions" echo "--- AGENTBOX_HARNESS=none touches nothing, and needs no config ---" rm -rf "${HOME}/.claude" "${HOME}/.copilot" /etc/claude-code -env -u AGENTBOX_CONFIG_DIR AGENTBOX_HARNESS=none AGENTBOX_CONFIG_URL="http://127.0.0.1:1/agent.json" \ - bash "${STANDALONE}" || fail "AGENTBOX_HARNESS=none should succeed without reading the config" +env -u AGENTBOX_CONFIG_DIR -u AGENTBOX_INSTRUCTIONS_DIR AGENTBOX_HARNESS=none \ + AGENTBOX_CONFIG_URL="${DEAD_URL}" bash "${STANDALONE}" \ + || fail "AGENTBOX_HARNESS=none should succeed without reading any config" [ -e "${HOME}/.claude/settings.json" ] && fail "AGENTBOX_HARNESS=none wrote Claude settings" [ -e /etc/claude-code/CLAUDE.md ] && fail "AGENTBOX_HARNESS=none wrote the managed CLAUDE.md" [ -e "${HOME}/.copilot/copilot-instructions.md" ] && fail "AGENTBOX_HARNESS=none wrote Copilot instructions" diff --git a/test/agent/fixture/agent.json b/test/agent/fixture/agent.json new file mode 100644 index 0000000..806dd59 --- /dev/null +++ b/test/agent/fixture/agent.json @@ -0,0 +1,13 @@ +{ + "//": "Synthetic bootstrap manifest for test/agent/assert.sh. Deliberately not the real config: these assertions cover the mechanism, so they must not depend on TALXIS/skills or on the network.", + + "marketplaces": { + "fixture": "AGENTBOX/fixture-skills" + }, + + "plugins": [ + "fixture-plugin@fixture" + ], + + "instructions": "file-not-used-the-tests-pass-AGENTBOX_INSTRUCTIONS_DIR" +} diff --git a/test/agent/fixture/initial-message.md b/test/agent/fixture/initial-message.md new file mode 100644 index 0000000..10b34df --- /dev/null +++ b/test/agent/fixture/initial-message.md @@ -0,0 +1,3 @@ +# AGENTBOX-FIXTURE-BRIEFING + +Session-start context that must reach every harness. diff --git a/test/agent/fixture/instructions.json b/test/agent/fixture/instructions.json new file mode 100644 index 0000000..c441fc0 --- /dev/null +++ b/test/agent/fixture/instructions.json @@ -0,0 +1,7 @@ +{ + "//": "Synthetic behaviour config for test/agent/assert.sh, standing in for TALXIS/skills agent/.", + + "systemPrompt": "system-prompt.md", + + "initialMessage": "initial-message.md" +} diff --git a/test/agent/fixture/system-prompt.md b/test/agent/fixture/system-prompt.md new file mode 100644 index 0000000..0e32b5d --- /dev/null +++ b/test/agent/fixture/system-prompt.md @@ -0,0 +1,3 @@ +# AGENTBOX-FIXTURE-PROMPT + +House rules that must reach every harness. From 051ed9511dab4c67bb099156d97b4fb980fda051 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:28:45 +0000 Subject: [PATCH 4/4] Follow the harness/ rename in TALXIS/skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behaviour config directory there is harness/, not agent/ — "agent" is overloaded, and agents/ is a plugin component directory in both harnesses. Only doc references change here; the short link means no path is pinned in code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KaKGKCwRPFoHcZkRJZxbn2 --- README.md | 2 +- src/agent/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0393d35..a22f9fa 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ isolated environments below instead — see GitHub's own take on why: Configuration is split along the line between the box and the agent. This repo owns the environment — [`src/agent/`](src/agent) declares which Skills plugins every box gets, and installing and updating tooling is its business. [TALXIS/skills](https://github.com/TALXIS/skills) owns process and know-how: -its `agent/` directory says how a harness should behave, colocated with the Skills it belongs to. +its `harness/` directory says how a harness should behave, colocated with the Skills it belongs to. Every environment below already runs the script that applies both, so an edit on either side needs no per-environment change, and nothing is written into a checkout: the config applies whichever diff --git a/src/agent/README.md b/src/agent/README.md index 2d1c617..09fb09b 100644 --- a/src/agent/README.md +++ b/src/agent/README.md @@ -5,7 +5,7 @@ Two manifests configure the agents in an AgentBox, split along the line between - **This repo owns the environment.** `agent.json` here declares which plugin marketplaces and plugins every box gets, and where the behaviour config lives. Installing and updating tooling is also this repo's business. -- **[TALXIS/skills](https://github.com/TALXIS/skills) owns process and know-how.** Its `agent/` +- **[TALXIS/skills](https://github.com/TALXIS/skills) owns process and know-how.** Its `harness/` directory holds how a harness should behave: the instructions loaded into every session, and the briefing injected once at session start — colocated with the Skills they belong to. @@ -51,7 +51,7 @@ unprivileged user. Without the mirror nothing written under `${HOME}` would ever | Short link | Resolves to | |------------|-------------| | `https://talxis.com/agentbox-agent` | this repo's `src/agent/agent.json` on `master` | -| `https://talxis.com/agentbox-instructions` | `TALXIS/skills` → `agent/instructions.json` on `master` | +| `https://talxis.com/agentbox-instructions` | `TALXIS/skills` → `harness/instructions.json` on `master` | Payload files are fetched relative to the URL the instructions manifest actually resolved to, so no branch, path or filename is pinned here. Point a short link at a branch and that branch's config is