Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions Containerfile.agent
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,15 @@ RUN mkdir -p /opt/ms-playwright \
&& chown -R ${AGENT_UID}:${AGENT_UID} "${MAIN_CHECKOUT}" /opt/ms-playwright

# --- Agent configuration -----------------------------------------------------
# The permissive mode the whole phase is for. This is the container's OWN
# ~/.claude/settings.json, not the repo's: the clone brings its own tracked
# .claude/settings.json along, and that one still carries the host's macOS
# sandbox block, which is meaningless in here. Project settings win over user
# settings in Claude Code, so run-agent.sh also passes
# --dangerously-skip-permissions; this file is the belt to that's braces, and
# documents the intent where someone will actually look for it.
# The permissive mode the whole phase is for. This user-level
# ~/.claude/settings.json documents intent, but it is NOT what makes the
# container permissive: the clone brings the repo's tracked .claude/settings.json
# along, project settings win over user settings in Claude Code, and
# --dangerously-skip-permissions does NOT override a project's `ask` rules (a
# headless ask is a hard denial -- implement-issue died on `gh api` on the first
# live dispatch). The real override is a read-only bind mount of
# container/agent-settings.json over the clone's .claude/settings.json, applied
# by scripts/lib/agent-dispatch.sh.
RUN mkdir -p /home/agent/.claude \
&& printf '%s\n' '{"permissions": {"defaultMode": "bypassPermissions"}}' \
> /home/agent/.claude/settings.json \
Expand Down
10 changes: 10 additions & 0 deletions backend/tests/test_agent_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@

import fnmatch
import json
import os
import re
from pathlib import Path

import pytest

# A dispatched container runs bypassPermissions by design: the clone's
# .claude/settings.json is shadowed by container/agent-settings.json, so the
# host permission profile is not what executes there. These pins validate that
# host profile -- meaningless (and failing) inside a container.
pytestmark = pytest.mark.skipif(
os.environ.get("BESS_HEADLESS_MODE") == "1",
reason="host permission policy is replaced by container/agent-settings.json in headless mode",
)

REPO_ROOT = Path(__file__).resolve().parents[2]
SETTINGS = REPO_ROOT / ".claude" / "settings.json"

Expand Down
5 changes: 5 additions & 0 deletions container/agent-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "bypassPermissions"
}
}
13 changes: 13 additions & 0 deletions scripts/lib/agent-dispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,19 @@ dispatch_run_args() {
"${DISPATCH_AGENT_AUTH[@]}"
)

# The clone carries the repo's tracked .claude/settings.json -- host-interactive
# policy (ask rules for `gh api`, git-stash denials, the macOS sandbox block) that
# has no meaning for a headless agent and stalls it: a headless ask is a hard
# denial, and --dangerously-skip-permissions does NOT override project ask rules
# (verified live on the first dispatch: implement-issue died on `gh api`). Shadow
# the clone's file with a container-scoped, read-only bypass so the container sees
# permissive policy while the host clone and interactive sessions keep the real one.
if [ -f "$DISPATCH_REPO_ROOT/container/agent-settings.json" ]; then
DISPATCH_RUN_ARGS+=(
-v "$DISPATCH_REPO_ROOT/container/agent-settings.json:$clone_dir/.claude/settings.json:ro"
)
fi

if [ "$with_compose" = true ]; then
DISPATCH_RUN_ARGS+=(
-v "$DISPATCH_PODMAN_SOCK:/run/podman/podman.sock"
Expand Down
18 changes: 16 additions & 2 deletions scripts/quality-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ echo "---------------------------"

if PYTEST=$(py_tool pytest); then
echo "🔸 Running fast tests (use '$PYTEST' directly to include slow algorithm tests)..."
if ! "$PYTEST" -m "not slow" --tb=short -q; then
if [ "${BESS_HEADLESS_MODE:-0}" = "1" ]; then
# test_agent_permissions.py pins the HOST .claude/settings.json profile,
# which a dispatched container replaces with container/agent-settings.json
# (bypassPermissions by design -- the container is the boundary). The pins
# do not apply there, so exclude the file.
PYTEST_IGNORE_PERM="--ignore=backend/tests/test_agent_permissions.py"
fi
if ! "$PYTEST" -m "not slow" --tb=short -q ${PYTEST_IGNORE_PERM:-}; then
echo "❌ Tests failed"
ERRORS=$((ERRORS + 1))
else
Expand Down Expand Up @@ -206,7 +213,14 @@ echo "-------------------------------------------"
# bare failing statement, so a plain heredoc here would skip the ERRORS
# increment, the checks below it, AND the final summary -- a missing rule would
# stop the run mid-file with no verdict, which is the opposite of a gate.
if ! python3 - <<'PY'
#
# These assertions validate the repo's committed .claude/settings.json. A
# dispatched container shadows that file with container/agent-settings.json --
# bypassPermissions by design, since the container is the boundary -- so the
# assertions do not apply there. They still run on the host, where they matter.
if [ "${BESS_HEADLESS_MODE:-0}" = "1" ]; then
echo "⏭️ Skipping permission-surface check (headless container mode)"
elif ! python3 - <<'PY'
import json, re, sys

# Patterns match the command AS WRITTEN -- prefix globbing, no normalisation.
Expand Down
12 changes: 11 additions & 1 deletion scripts/run-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,18 @@ mkdir -p "$FLEET_DIR"
dispatch_run_args "$CONTAINER" "$CLONE_DIR" dev "$EGRESS" "$WITH_COMPOSE"

# `--dangerously-skip-permissions` is the phase's whole point -- see the header.
#
# A dispatched run is a one-shot `-p` turn, and the first live dispatch (#666)
# showed that the env var alone is not enough: the agent read the skill's
# Headless local mode table, then still followed the INTERACTIVE Step-3 confirm
# gate -- it asked "should I proceed?" in its final message and ended its turn,
# posting nothing to the issue and leaving fleet status "working". A headless
# agent has no interactive channel, so a gate is not a question; state the mode
# in the invocation itself rather than trusting self-identification.
headless_directive="(HEADLESS DISPATCH: BESS_HEADLESS_MODE=1 -- one-shot turn, no re-invocation; ending your turn ends the container, so the skill's Headless local mode table governs you, not the interactive text. NEVER end your turn at the Step 3/7/11 gates NOR while background work is pending. Gates: post to the issue via scripts/gh-agent.sh --as dev, report via scripts/fleet-manifest.sh update-status, block IN PROCESS on scripts/wait-for-reply.sh <number> <now-iso8601>. Step 6 runs quality-check.sh and the slow suite in the FOREGROUND: no background agents, no ScheduleWakeup, no 'waiting for a background task'.)"

agent_cmd=(
claude -p "/implement-issue $NUMBER"
claude -p "/implement-issue $NUMBER $headless_directive"
--dangerously-skip-permissions
--output-format stream-json --verbose
)
Expand Down
Loading