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
7 changes: 7 additions & 0 deletions .changes/harness-ops/headless-d2-permission-pins-fdba65a7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- The `consume-restarts print-schedule` command prints `--permission-mode auto`, plus `--permission-prompts none` when the CLI is 2.1.259 or later, on every scheduled `claude -p` line, so the reader no longer inherits the user's `defaultMode`. Regenerate an existing schedule to pick this up.
24 changes: 13 additions & 11 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1602,19 +1602,21 @@ const PERMISSION_PROMPTS_NONE_LAUNCHERS = new Set([
"d02b8697e092705153f0996549b05f95a446bb52",
]);

test("pr-run-activity-read.yml: every run-claude-cli pin is a launcher checked to pass --permission-prompts none", () => {
const pins = [];
for (const [id, job] of Object.entries(workflow("pr-run-activity-read.yml").jobs ?? {})) {
for (const step of job.steps ?? []) {
const uses = String(step.uses ?? "");
if (!uses.includes("/run-claude-cli@")) continue;
const sha = uses.slice(uses.indexOf("@") + 1);
pins.push(sha);
assert.match(sha, /^[0-9a-f]{40}$/, `${id} ${step.id} pins a full SHA`);
assert.ok(PERMISSION_PROMPTS_NONE_LAUNCHERS.has(sha), `${id} ${step.id}: ${sha} is not checked to pass --permission-prompts none`);
test("every run-claude-cli step pin in this repo's workflows is a launcher checked to pass --permission-prompts none", () => {
const files = new Set();
for (const file of workflowFiles()) {
for (const [id, job] of Object.entries(workflow(file).jobs ?? {})) {
for (const step of job.steps ?? []) {
const uses = String(step.uses ?? "");
if (!uses.includes("/run-claude-cli@")) continue;
const sha = uses.slice(uses.indexOf("@") + 1);
files.add(file);
assert.match(sha, /^[0-9a-f]{40}$/, `${file} ${id} ${step.id} pins a full SHA`);
assert.ok(PERMISSION_PROMPTS_NONE_LAUNCHERS.has(sha), `${file} ${id} ${step.id}: ${sha} is not checked to pass --permission-prompts none`);
}
}
}
assert.ok(pins.length >= 2, pins.join(", "));
for (const file of ["pr-run-activity-read.yml", "pr-verify.yml", "pr-unblock.yml"]) assert.ok(files.has(file), [...files].join(", "));
});

// Each launcher leg is followed by a check that the CLI's init message
Expand Down
6 changes: 5 additions & 1 deletion plugins/harness-ops/skills/lanes/context/restart-consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ from cmd.exe.

Two scheduled forms, different trade-offs:

- **Headless skill form**: `claude -p "/harness-ops:lanes consume-restarts run"`.
- **Headless skill form**: `claude -p "/harness-ops:lanes consume-restarts run"
--permission-mode auto --permission-prompts none`. The mode is stated, never
inherited from user settings; `--permission-prompts none` is printed only when
the CLI that runs `print-schedule` accepts it, so regenerate the schedule after
upgrading from an older CLI.
Survives plugin updates (the skill resolves `${CLAUDE_PLUGIN_ROOT}` freshly at
each invocation, so the task never embeds a plugin cache path that rots), but
each tick is a paid model turn. Bound the spend: `claude` supports `--model`
Expand Down
26 changes: 21 additions & 5 deletions plugins/harness-ops/skills/lanes/scripts/restart-consumer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ CONSUMER_MARKER="harness-ops:restart-consumer"
CONSUMER_LANE="restart-consumer"
SENTINEL_PREFIX="<!-- harness-ops:lane-telemetry marker="
TASK_NAME="HarnessOps Lane Restart Consumer"
# Below this Claude Code version --permission-prompts is an unknown option;
# the same floor as lane-launcher.sh. https://code.claude.com/docs/en/headless
PERMISSION_PROMPTS_MIN_VERSION="2.1.259"

err() { printf 'ERROR: %s\n' "$*" >&2; }
warn() { printf 'WARN: %s\n' "$*" >&2; }
Expand Down Expand Up @@ -1286,7 +1289,7 @@ print_report_header() {

# --- print-schedule -----------------------------------------------------------
action_print_schedule() {
local self claude_bin run_cmd win_repo win_claude data_dir opts opts_sq
local self claude_bin run_cmd win_repo win_claude data_dir opts opts_sq perm_flags cli_ver
self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
# Behavior-affecting options must survive into every emitted command. This
# action runs BEFORE resolve_config/resolve_target_repo, so these hold exactly
Expand Down Expand Up @@ -1318,7 +1321,20 @@ action_print_schedule() {
# `run` is load-bearing: the script's default action is the read-only
# `check`, so a schedule registered without `run` would report forever and
# never relaunch anything.
run_cmd="claude -p \"/harness-ops:lanes consume-restarts run${opts}\" --output-format text"
# The permission posture is stated in the command, as lane-launcher.sh's
# build_lane_flags states it, not inherited from whatever defaultMode the
# user's settings carry: explicit auto, never bypassPermissions, plus
# --permission-prompts none from the CLI version that accepts it (older
# CLIs reject it; an unreadable version omits it, as the launcher does).
perm_flags="--permission-mode auto"
cli_ver="$("$claude_bin" --version 2>/dev/null | tr -d '\r' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" || cli_ver=""
if [[ -n "$cli_ver" ]] && awk -v a="$cli_ver" -v b="$PERMISSION_PROMPTS_MIN_VERSION" 'BEGIN {
split(a, x, "."); split(b, y, ".")
for (i = 1; i <= 3; i++) { if (x[i] + 0 != y[i] + 0) exit !(x[i] + 0 > y[i] + 0) }
}'; then
perm_flags+=" --permission-prompts none"
fi
run_cmd="claude -p \"/harness-ops:lanes consume-restarts run${opts}\" ${perm_flags} --output-format text"

cat <<EOF
Registering the schedule is an OPERATOR action — this script never performs it.
Expand All @@ -1339,15 +1355,15 @@ Reader: $run_cmd
cmd.exe syntax). NOT from Git Bash: MSYS path conversion rewrites /-style
options — 'schtasks /Query' becomes an invalid 'C:/Program Files/Git/Query'.

schtasks /Create /TN "$TASK_NAME" /SC MINUTE /MO ${INTERVAL_MINUTES} /F /RU "%USERNAME%" /IT /RL LIMITED /TR "cmd /c cd /d \"$win_repo\" && \"$win_claude\" -p \"/harness-ops:lanes consume-restarts run${opts}\" --output-format text"
schtasks /Create /TN "$TASK_NAME" /SC MINUTE /MO ${INTERVAL_MINUTES} /F /RU "%USERNAME%" /IT /RL LIMITED /TR "cmd /c cd /d \"$win_repo\" && \"$win_claude\" -p \"/harness-ops:lanes consume-restarts run${opts}\" ${perm_flags} --output-format text"

/IT runs the task only while that user is logged on, so the CLI reads the
credentials already in the user profile: no stored password, no elevation.
schtasks has no working-directory flag, hence the 'cmd /c cd /d' wrapper.

Cold start after a reboot — add a logon trigger alongside the poll:

schtasks /Create /TN "$TASK_NAME (logon)" /SC ONLOGON /F /RU "%USERNAME%" /IT /RL LIMITED /TR "cmd /c cd /d \"$win_repo\" && \"$win_claude\" -p \"/harness-ops:lanes consume-restarts run${opts}\" --output-format text"
schtasks /Create /TN "$TASK_NAME (logon)" /SC ONLOGON /F /RU "%USERNAME%" /IT /RL LIMITED /TR "cmd /c cd /d \"$win_repo\" && \"$win_claude\" -p \"/harness-ops:lanes consume-restarts run${opts}\" ${perm_flags} --output-format text"

Remove both (also from cmd.exe):

Expand All @@ -1358,7 +1374,7 @@ schtasks /Delete /TN "$TASK_NAME (logon)" /F

Same command on the same interval. cron form:

*/${INTERVAL_MINUTES} * * * * cd '$REPO' && '$claude_bin' -p '/harness-ops:lanes consume-restarts run${opts}' --output-format text
*/${INTERVAL_MINUTES} * * * * cd '$REPO' && '$claude_bin' -p '/harness-ops:lanes consume-restarts run${opts}' ${perm_flags} --output-format text

== Offline variant (no model turn) ==

Expand Down
26 changes: 26 additions & 0 deletions plugins/harness-ops/skills/lanes/scripts/restart-consumer.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
# - the morning-brief issue-discovery literals are byte-identical in both files
# - print-schedule emits schtasks create + delete lines and mutates nothing,
# and substitutes a passed --data-dir into the offline form
# - every scheduled `claude -p` passes --permission-mode auto, and
# --permission-prompts none only from the CLI version that accepts it
# - argument validation exit codes
#
# PATH-stubs `claude` and `gh`, and injects the session list, the telemetry, and
Expand Down Expand Up @@ -65,6 +67,10 @@ STUBS="$TMP/stubs"
mkdir -p "$STUBS"
cat >"$STUBS/claude" <<'SH'
#!/usr/bin/env bash
if [[ "${1:-}" == --version ]]; then
[[ -n "${CLAUDE_STUB_VERSION:-}" ]] && echo "$CLAUDE_STUB_VERSION (Claude Code)"
exit 0
fi
echo '[]'
SH
# The gh stub is loud-by-default: reaching it means a test forgot an injection
Expand Down Expand Up @@ -403,6 +409,26 @@ assert_not_contains "the scheduled command never defaults to read-only check" "$
assert_contains "print-schedule offers the non-Windows form" "$OUT" "cron form"
assert_contains "print-schedule states that registering is the operator's" "$OUT" "OPERATOR action"

# The unattended reader states its permission posture rather than inheriting
# the user's defaultMode; --permission-prompts none only where the CLI takes it.
OUT="$(CLAUDE_STUB_VERSION=2.1.259 bash "$SCRIPT" print-schedule --repo "$REPO" 2>&1)"
assert_eq "every scheduled claude -p passes explicit auto and no prompts" "4" \
"$(grep -c -- 'run.\{0,2\} --permission-mode auto --permission-prompts none --output-format text' <<<"$OUT")"
for v in 2.2.0 10.0.0; do
OUT="$(CLAUDE_STUB_VERSION=$v bash "$SCRIPT" print-schedule --repo "$REPO" 2>&1)"
assert_contains "CLI $v gets --permission-prompts none" "$OUT" "--permission-prompts none"
done
OUT="$(CLAUDE_STUB_VERSION=2.0.999 bash "$SCRIPT" print-schedule --repo "$REPO" 2>&1)"
assert_not_contains "CLI 2.0.999 never gets --permission-prompts" "$OUT" "--permission-prompts"
OUT="$(CLAUDE_STUB_VERSION=2.1.258 bash "$SCRIPT" print-schedule --repo "$REPO" 2>&1)"
assert_contains "a CLI below the floor still gets explicit auto" "$OUT" 'run" --permission-mode auto --output-format text'
assert_not_contains "a CLI below the floor never gets --permission-prompts" "$OUT" "--permission-prompts"
OUT="$(bash "$SCRIPT" print-schedule --repo "$REPO" 2>&1)"
assert_not_contains "an unreadable CLI version omits --permission-prompts" "$OUT" "--permission-prompts"
assert_eq "the --permission-prompts floor matches lane-launcher.sh" \
"$(grep '^PERMISSION_PROMPTS_MIN_VERSION=' "$(dirname "$SCRIPT")/lane-launcher.sh")" \
"$(grep '^PERMISSION_PROMPTS_MIN_VERSION=' "$SCRIPT")"

# --- 14. Argument validation --------------------------------------------------
bash "$SCRIPT" bogus --repo "$REPO" >/dev/null 2>&1
assert_eq "an unknown action exits 3" "3" "$?"
Expand Down
Loading