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..a22f9fa 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,18 @@ 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 + +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 `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 +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 ### 1. GitHub Codespaces @@ -34,8 +46,9 @@ isolated environments below instead — see GitHub's own take on why: `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. +[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 [`devcontainer.features.json`](src/container/templates/power-platform/.devcontainer/devcontainer.features.json). @@ -45,6 +58,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) @@ -63,8 +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 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, 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 new file mode 100644 index 0000000..09fb09b --- /dev/null +++ b/src/agent/README.md @@ -0,0 +1,94 @@ +# Agent configuration + +Two manifests configure the agents in an AgentBox, split along the line between the box and the agent: + +- **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 `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. + +[`../container/scripts/configure-agent-harness.sh`](../container/scripts/configure-agent-harness.sh) +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 piece lands + +Nothing is project-scoped, so the config applies whichever repository is cloned into the box. + +| 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 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 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. + +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 +`/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` → `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 +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 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 + +- `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 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, which apply a fixture through every path above without touching the network: + +```bash +HOME="$(mktemp -d)" bash test/agent/assert.sh +``` diff --git a/src/agent/agent.json b/src/agent/agent.json new file mode 100644 index 0000000..7167577 --- /dev/null +++ b/src/agent/agent.json @@ -0,0 +1,13 @@ +{ + "//": "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" + }, + + "plugins": [ + "implement@talxis" + ], + + "instructions": "https://talxis.com/agentbox-instructions" +} diff --git a/src/container/scripts/configure-agent-harness.sh b/src/container/scripts/configure-agent-harness.sh index 1004546..821a058 100755 --- a/src/container/scripts/configure-agent-harness.sh +++ b/src/container/scripts/configure-agent-harness.sh @@ -1,9 +1,25 @@ #!/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 — 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: +# +# 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 +# +# 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 +# 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 +# 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 +30,334 @@ # 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. 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}" + +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 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}" +} + +# --- 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 ------------------------------------------------------------------------ + +# 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="$(download_manifest "${url}" "${dir}/instructions.json")" || return 1 + base="${effective%/*}" + while IFS= read -r file; do + [ -z "${file}" ] && continue + mkdir -p "$(dirname "${dir}/${file}")" + # 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: instructions.json declares ${file}, but ${base}/${file} could not be downloaded." >&2 + return 1 + } + done < <(jq -r '[.systemPrompt, .initialMessage] | map(select(type == "string"))[]' "${dir}/instructions.json") } -if [ "${AGENTBOX_HARNESS:-}" != "copilot" ] && [ "${AGENTBOX_HARNESS:-}" != "none" ] \ - && command -v claude >/dev/null 2>&1; then - echo "--- Registering the talxis plugin marketplace with Claude Code ---" +# 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. + 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)" +elif download_manifest "${AGENT_CONFIG_URL}" "${WORKDIR}/agent.json" >/dev/null; then + CONFIG_DIR="${WORKDIR}" +fi + +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 + +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")' \ + "${INSTRUCTIONS_DIR}/instructions.json" 2>/dev/null)" + [ -z "${name}" ] && return 0 + 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 "${INSTRUCTIONS_DIR}/${name}" ] && printf '%s' "${INSTRUCTIONS_DIR}/${name}" + return 0 +} + +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 +# 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 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 + claude) jq -Rs '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: .}}' < "${message}" ;; + *) jq -Rs '{additionalContext: .}' < "${message}" ;; +esac +HOOK + # 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 +install_initial_message && INITIAL_MESSAGE_READY=0 + +# --- Claude Code ------------------------------------------------------------------------------- + +if configures_claude; then + 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 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 + claude_hook_filter=' + .hooks.SessionStart = ( + ((.hooks.SessionStart // []) + | map(select(((.hooks // []) | map(.command // "") | any(contains("agentbox"))) | not))) + + [{matcher: "startup|resume", hooks: [{type: "command", command: $cmd}]}] + )' + 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 -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 +# --- GitHub Copilot CLI ------------------------------------------------------------------------ + +if configures_copilot; then + 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 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}" \ + --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 + + [ -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 745d7d1..01ea146 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,14 +153,22 @@ 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. +# +# 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 ===" @@ -175,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 new file mode 100755 index 0000000..b98045e --- /dev/null +++ b/test/agent/assert.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# 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: +# +# 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}/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"; } + +bash "${CONFIGURE}" + +echo "--- plugins registered on both harnesses ---" +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")" +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-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" +[ -f /etc/claude-code/CLAUDE.md ] \ + && fail "/etc/claude-code/CLAUDE.md should be gone once the block it only held is removed" + +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["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" ] \ + || 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 + +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 both DIR +# overrides unset and URLs on loopback port 1 that fail immediately (no network needed). +STANDALONE="${WORK}/configure-standalone.sh" +cp "${CONFIGURE}" "${STANDALONE}" +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 -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" + +echo "=== agent config assertions PASSED ===" 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. diff --git a/test/agent/test.sh b/test/agent/test.sh new file mode 100755 index 0000000..53be5e5 --- /dev/null +++ b/test/agent/test.sh @@ -0,0 +1,22 @@ +#!/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 + + # 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 +' + +echo "=== agent config smoke test PASSED ==="