diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c471a9..706a534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ The version is carried in `pyproject.toml` and `src/assistant/__init__.py` ## [Unreleased] ### Added +- Add `bin/cmux-claude-spawn.sh` and `bin/cmux-claude-send.sh`, one-command + launchers that a narrow `permissions.allow` rule can approve in auto mode. + The spawn script starts the `claude` alias in a new unfocused cmux workspace + and has it read one prompt file; the send script has a running session read + one prompt file. Both accept only files under `~/.assistant/spawn-prompts/`, + send with `cmux paste --submit` (which refuses over a draft or open dialog), + and confirm delivery from the transcript, counting a message queued to a + busy session. The spawn script accepts only `FFP_CHANNEL` and `FFP_EXP_*` + environment switches, refuses a `claude` alias that isn't auto mode or + carries `--dangerously-skip-permissions`, and types nothing until the + session shows auto mode. The send script takes surface UUIDs only, refuses + its caller's own surface, and refuses a target that isn't showing auto mode. + - Add the `cmux-ws-numberer` LaunchAgent as an opt-in feature. It adds a `[N]` suffix to cmux workspace titles and gives each workspace an unused sidebar color. Enable it with `install.sh --apply --with-ws-numberer`, answer the @@ -56,6 +69,11 @@ The version is carried in `pyproject.toml` and `src/assistant/__init__.py` - Add an attention overview with compact task cards, expandable return notes, and reminders to finish older pending work before starting another task. +### Changed +- `spawn-claude-workspace` launches `engine=claude` with + `bin/cmux-claude-spawn.sh` and no longer adds + `--dangerously-skip-permissions`; child sessions run in auto mode. + ### Fixed - Bring the warm Slack session up again on Claude Code 2.1.303 and cmux 0.65. Claude Code now writes several hundred KB of records right after each prompt, diff --git a/bin/cmux-claude-send.sh b/bin/cmux-claude-send.sh new file mode 100755 index 0000000..9eb30df --- /dev/null +++ b/bin/cmux-claude-send.sh @@ -0,0 +1,31 @@ +#!/bin/zsh +# Have a running Claude Code session read one staged prompt file, and confirm it reached the transcript. +# usage: cmux-claude-send.sh +# must be a file under ~/.assistant/spawn-prompts/; the session receives "Read and follow it." +# stdout: "delivered" or "not-delivered" +# exit: 0 delivered; 1 refused by cmux (draft or dialog open) or not seen within 120s; 2 bad arguments; +# 3 the target isn't showing auto mode. +set -u +PROMPTS="$HOME/.assistant/spawn-prompts" +[[ $# -eq 3 ]] || { echo "usage: ${0:t} " >&2; exit 2; } +SF="$1"; T="$2"; PF="${3:A}" +[[ "$SF" =~ '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' ]] \ + || { echo "surface must be a UUID: $SF" >&2; exit 2; } +[[ "${SF:u}" != "${${CMUX_SURFACE_ID:-}:u}" ]] || { echo "refusing to type into this session's own surface" >&2; exit 2; } +[[ -f "$T" ]] || { echo "no such transcript: $T" >&2; exit 2; } +[[ "$PF" == "${PROMPTS:A}"/* && -f "$PF" && "$PF" =~ '^[A-Za-z0-9._/-]+$' ]] \ + || { echo "prompt file must be a plain-named file under $PROMPTS: $3" >&2; exit 2; } + +MSG="Read $PF and follow it." +count() { grep -F -- "$MSG" "$T" | grep -cE '"type":"user"|"operation":"enqueue"'; } +SCREEN=$(CMUX_QUIET=1 cmux read-screen --surface "$SF" --lines 60 2>/dev/null) +[[ "$SCREEN" == *"auto mode on"* && "$SCREEN" != *"bypass permissions on"* ]] \ + || { echo not-delivered; echo "refusing: the target session isn't showing auto mode" >&2; exit 3; } +BEFORE=$(count) +CMUX_QUIET=1 cmux paste --surface "$SF" --submit -- "$MSG" >/dev/null || { echo not-delivered; echo "cmux refused the paste (draft or dialog open?)" >&2; exit 1; } +for i in {1..60}; do + (( $(count) > BEFORE )) && { echo delivered; exit 0; } + sleep 2 +done +echo not-delivered +exit 1 diff --git a/bin/cmux-claude-spawn.sh b/bin/cmux-claude-spawn.sh new file mode 100755 index 0000000..62c07ef --- /dev/null +++ b/bin/cmux-claude-spawn.sh @@ -0,0 +1,53 @@ +#!/bin/zsh +# Start Claude Code (auto mode) in a new unfocused cmux workspace and have it read one staged prompt file. +# usage: cmux-claude-spawn.sh [--env FFP_CHANNEL=|FFP_EXP_=]... <prompt-file> +# <prompt-file> must be a file under ~/.assistant/spawn-prompts/; the session receives "Read <file> and follow it." +# stdout: "<workspace-ref> <surface-uuid> <transcript>" +# exit: 0 submitted and seen in a new transcript; 1 not ready, refused by cmux, or never seen; 2 bad arguments; +# 3 the claude alias isn't auto mode. Runs up to about 4 minutes; give the caller a 5-minute timeout. +set -u +PROMPTS="$HOME/.assistant/spawn-prompts" +ENVS=() +while [[ "${1:-}" == --env ]]; do + [[ "${2:-}" =~ '^(FFP_CHANNEL|FFP_EXP_[A-Z0-9_]+)=[a-z0-9]+$' ]] || { echo "bad --env value: ${2:-}" >&2; exit 2; } + ENVS+=(--env "$2"); shift 2 +done +[[ $# -eq 3 ]] || { echo "usage: ${0:t} [--env FFP_CHANNEL=<v>|FFP_EXP_<ID>=<v>]... <cwd> <title> <prompt-file>" >&2; exit 2; } +CWD="${1:A}"; TITLE="$2"; PF="${3:A}" +[[ -d "$CWD" ]] || { echo "no such directory: $CWD" >&2; exit 2; } +[[ "$PF" == "${PROMPTS:A}"/* && -f "$PF" && "$PF" =~ '^[A-Za-z0-9._/-]+$' ]] \ + || { echo "prompt file must be a plain-named file under $PROMPTS: $3" >&2; exit 2; } + +ALIAS=$(zsh -lic 'alias claude' 2>/dev/null) +[[ "$ALIAS" == *"--permission-mode auto"* && "$ALIAS" != *--dangerously-skip-permissions* ]] \ + || { echo "refusing: the claude alias isn't auto mode: $ALIAS" >&2; exit 3; } + +MSG="Read $PF and follow it." +SD="$HOME/.claude/projects/${CWD//[^A-Za-z0-9]/-}" +BEFORE=$(find "$SD" -maxdepth 1 -name '*.jsonl' 2>/dev/null | sort) + +OUT=$(CMUX_QUIET=1 cmux new-workspace --cwd "$CWD" --name "$TITLE" --focus false "${ENVS[@]}" --command "zsh -lic claude") +WS=$(printf '%s' "$OUT" | grep -oE 'workspace:[0-9]+' | head -1) +[[ -n "$WS" ]] && SF=$(CMUX_QUIET=1 cmux list-pane-surfaces --workspace "$WS" --id-format uuids | grep -oE '[0-9A-Fa-f]{8}-[0-9A-Fa-f-]{27}' | head -1) +[[ -n "$WS" && -n "${SF:-}" ]] || { echo "spawn failed: $OUT" >&2; exit 1; } +echo "spawned $WS $SF" >&2 + +READY=0 +for i in {1..90}; do + SCREEN=$(CMUX_QUIET=1 cmux read-screen --surface "$SF" --lines 60 2>/dev/null) + [[ "$SCREEN" == *"auto mode on"* && "$SCREEN" != *"bypass permissions on"* ]] && { READY=1; break; } + [[ -z "${SCREEN//[[:space:]]/}" ]] && (( i % 5 == 3 )) && CMUX_QUIET=1 cmux send-key --surface "$SF" enter >/dev/null 2>&1 + sleep 1 +done +(( READY )) || { echo "$WS $SF not-ready"; echo "session never showed auto mode; nothing was typed" >&2; exit 1; } + +CMUX_QUIET=1 cmux paste --surface "$SF" --submit -- "$MSG" >/dev/null || { echo "$WS $SF paste-refused"; exit 1; } + +for i in {1..60}; do + for f in ${(f)"$(find "$SD" -maxdepth 1 -name '*.jsonl' 2>/dev/null | sort | comm -13 <(print -r -- "$BEFORE") -)"}; do + grep -F -- "$MSG" "$f" 2>/dev/null | grep -qE '"type":"user"|"operation":"enqueue"' && { echo "$WS $SF $f"; exit 0; } + done + sleep 2 +done +echo "$WS $SF transcript-not-found" +exit 1 diff --git a/skills/spawn-claude-workspace/SKILL.md b/skills/spawn-claude-workspace/SKILL.md index 7550919..6f05cbc 100644 --- a/skills/spawn-claude-workspace/SKILL.md +++ b/skills/spawn-claude-workspace/SKILL.md @@ -16,7 +16,7 @@ Two engines are supported, selected by the `engine` input: - Always pass `--focus false` to `cmux new-workspace`. - Never stream a long prompt through cmux. Stage it under `~/.assistant/spawn-prompts/` and send a short `Read <path>...` instruction. -- Use `surface.send_text` followed by an explicit `surface.send_key` Enter. +- `engine=droid`: use `surface.send_text` followed by an explicit `surface.send_key` Enter. `engine=claude`: the scripts below use `cmux paste --submit`, which refuses over a draft or open dialog. - Never select the new workspace automatically. - The transcript is the authoritative submission signal — a cmux success response alone is NOT confirmation. @@ -40,10 +40,11 @@ bracket number. Leave naming and color to the daemon. ### `claude` -- Launch via the login-shell `claude` alias PLUS an explicit `--dangerously-skip-permissions`. The alias may drift and drop the flag; adding it explicitly is idempotent and prevents an unattended session stalling on the first permission gate. The alias still supplies `--model`, `--add-dir`, etc. -- The command must run in a login shell so the alias expands: `zsh -lic 'claude --dangerously-skip-permissions'`. +- Launch with `/Users/mukuls/.claude/bin/cmux-claude-spawn.sh`. It starts the login-shell `claude` alias, which runs in auto mode, so every child session is checked by the same auto-mode classifier. Never add `--dangerously-skip-permissions`; the script refuses to launch when the alias carries it or isn't auto mode. +- Run the script by that exact absolute path, as one command with nothing piped, chained, or captured around it, so its `permissions.allow` rule applies. Give it a 5-minute timeout. +- To message a running child session, stage a file under `~/.assistant/spawn-prompts/` and run `/Users/mukuls/.claude/bin/cmux-claude-send.sh <surface-uuid> <transcript> <prompt-file>`, the same way. - Do NOT hardcode `--model`/`--add-dir` — the alias owns them. Override `--model` only when the task genuinely needs a different tier. -- Claude transcripts: `~/.claude/projects/<project-slug>/<session-id>.jsonl`. `<project-slug>` is the cwd with every `/` replaced by `-` (leading `-` included). Sessions share one project dir; confirmation is a NEW jsonl appearing after spawn. +- Claude transcripts: `~/.claude/projects/<project-slug>/<session-id>.jsonl`. `<project-slug>` is the cwd with every non-alphanumeric character replaced by `-` (leading `-` included). Sessions share one project dir; confirmation is a NEW jsonl appearing after spawn. - Never fall back to Droid if Claude fails. ## Inputs @@ -56,14 +57,14 @@ bracket number. Leave naming and color to the daemon. ## Procedure +For `engine=claude`, do step 3, then run `/Users/mukuls/.claude/bin/cmux-claude-spawn.sh [--env FFP_CHANNEL=<v>|FFP_EXP_<ID>=<v>]... <cwd> <title> <prompt-file>` in place of steps 1, 2, and 4-7. It prints `<workspace-ref> <surface-uuid> <transcript>` and exits 0 only when the transcript shows the instruction. On a nonzero exit, leave the workspace and prompt file intact, report the refs it printed, and don't spawn a duplicate. The steps below are for `engine=droid`. + 1. Verify cmux and the engine's prerequisites: ```bash cmux ping >/dev/null # engine=droid: test -f "$HOME/.assistant/droid-glm-settings.json" -# engine=claude: -zsh -lic 'alias claude' | grep -q 'claude --model' ``` 2. Resolve the cwd physically and derive the engine's transcript directory: @@ -74,9 +75,6 @@ CWD=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$CWD") # engine=droid: SLUG=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]).replace("/", "-"))' "$CWD") SESSION_DIR="$HOME/.factory/sessions/$SLUG" -# engine=claude (project slug: leading dash + slashes to dashes): -CSLUG=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]).replace("/", "-"))' "$CWD") -SESSION_DIR="$HOME/.claude/projects/$CSLUG" mkdir -p "$SESSION_DIR" "$HOME/.assistant/spawn-prompts" ``` @@ -96,8 +94,6 @@ LAUNCH_CMD="droid --settings '$HOME/.assistant/droid-glm-settings.json' --auto h if test -f "$HOME/.claude/CLAUDE.md"; then LAUNCH_CMD="$LAUNCH_CMD --append-system-prompt-file '$HOME/.claude/CLAUDE.md'" fi -# engine=claude (login shell so the alias expands; explicit dsp for unattended runs): -LAUNCH_CMD="zsh -lic 'claude --dangerously-skip-permissions'" OUT=$(cmux new-workspace --cwd "$CWD" --name "$TITLE" --focus false --command "$LAUNCH_CMD") WS_REF=$(printf '%s' "$OUT" | grep -oE 'workspace:[0-9]+' | head -n1) SURFACE_REF=$(cmux list-pane-surfaces --workspace "$WS_REF" | grep -oE 'surface:[0-9]+' | head -n1) @@ -107,7 +103,6 @@ test -n "$WS_REF" && test -n "$SURFACE_REF" 5. Poll the whole screen until the engine is ready. Read 200 lines and accept an engine-appropriate marker: - **droid**: `GLM-5.2`, `allow all commands`, `Skills (<n>)`, or `? for help`. Do not accept a Claude banner. -- **claude**: the Claude Code prompt box / `? for shortcuts`, `bypass permissions` (the dsp banner), or the model id `claude-opus`. Do not accept a Droid banner. ```bash READY=0 @@ -115,8 +110,6 @@ for _ in $(seq 1 30); do SCREEN=$(cmux rpc surface.read_text "$(python3 -c 'import json,sys; print(json.dumps({"surface_id":sys.argv[1],"lines":200}))' "$SURFACE_REF")" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("text", ""))') # engine=droid: if printf '%s' "$SCREEN" | grep -Eq 'GLM-?5\.2|allow all commands|Skills \([0-9]+\)|\? for help'; then READY=1; break; fi - # engine=claude: - if printf '%s' "$SCREEN" | grep -Eiq 'bypass permissions|\? for shortcuts|claude-opus|Welcome to Claude'; then READY=1; break; fi sleep 1 done test "$READY" = 1 diff --git a/tests/check_change_coverage.py b/tests/check_change_coverage.py index 91161dc..b9fb6e0 100644 --- a/tests/check_change_coverage.py +++ b/tests/check_change_coverage.py @@ -20,7 +20,7 @@ # Shell files with no line-coverage tool; each changed code hunk needs a # mutation_smoke check that breaks only changed code lines in it. A check can # still be written to dodge this, so review new checks like any test. -MUTATION_PROVEN = {"install.sh"} +MUTATION_PROVEN = {"install.sh", "bin/cmux-claude-spawn.sh", "bin/cmux-claude-send.sh"} def changed_lines(repo: Path, base: str) -> dict[str, set[int]]: diff --git a/tests/mutation_smoke.py b/tests/mutation_smoke.py index 1712935..7c2b074 100644 --- a/tests/mutation_smoke.py +++ b/tests/mutation_smoke.py @@ -154,6 +154,48 @@ "", "tests/test_install_optin_prompt.py::test_apply_removes_the_legacy_ws_numberer_plist", ), + ( + "spawn accepts only FFP experiment switches", + "bin/cmux-claude-spawn.sh", + "\"${2:-}\" =~ '^(FFP_CHANNEL|FFP_EXP_[A-Z0-9_]+)=[a-z0-9]+$'", + "\"${2:-}\" =~ '^[A-Za-z_]+=.+$'", + "tests/test_cmux_claude_launcher.py::test_spawn_refuses_env_other_than_ffp_experiment_switches", + ), + ( + "spawn requires an auto-mode claude alias", + "bin/cmux-claude-spawn.sh", + '"$ALIAS" == *"--permission-mode auto"* && ', + "", + "tests/test_cmux_claude_launcher.py::test_spawn_refuses_an_alias_that_is_not_auto_mode", + ), + ( + "spawn reads prompts only from the staging folder", + "bin/cmux-claude-spawn.sh", + '"$PF" == "${PROMPTS:A}"/*', + '"$PF" == "${PROMPTS:A}"*', + "tests/test_cmux_claude_launcher.py::test_spawn_refuses_a_sibling_of_the_staging_folder", + ), + ( + "spawn keeps the new workspace unfocused", + "bin/cmux-claude-spawn.sh", + "--focus false", + "--focus true", + "tests/test_cmux_claude_launcher.py::test_spawn_launches_unfocused_auto_mode_claude_and_returns_the_new_transcript", + ), + ( + "send refuses the caller's own surface", + "bin/cmux-claude-send.sh", + '[[ "${SF:u}" != "${${CMUX_SURFACE_ID:-}:u}" ]] ||', + "true ||", + "tests/test_cmux_claude_launcher.py::test_send_refuses_its_own_surface_and_short_refs", + ), + ( + "send refuses a target in bypass mode", + "bin/cmux-claude-send.sh", + ' && "$SCREEN" != *"bypass permissions on"*', + "", + "tests/test_cmux_claude_launcher.py::test_send_refuses_a_target_that_is_not_in_auto_mode", + ), ) diff --git a/tests/test_cmux_claude_launcher.py b/tests/test_cmux_claude_launcher.py new file mode 100644 index 0000000..49a0328 --- /dev/null +++ b/tests/test_cmux_claude_launcher.py @@ -0,0 +1,275 @@ +"""cmux-claude-spawn.sh and cmux-claude-send.sh against a fake cmux that plays Claude Code.""" +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SPAWN = REPO / "bin" / "cmux-claude-spawn.sh" +SEND = REPO / "bin" / "cmux-claude-send.sh" +SURFACE = "11111111-2222-3333-4444-555555555555" +AUTO_ALIAS = "alias claude='claude --model \"opus[1m]\" --permission-mode auto --add-dir ~/dev'\n" + +FAKE_CMUX = r'''#!/usr/bin/env python3 +"""Fake cmux: logs calls; a submitted paste lands in the transcript the way Claude Code writes it.""" +import json, os, sys +from pathlib import Path + +state = Path(os.environ["FAKE_CMUX_STATE"]) +with (state / "calls.jsonl").open("a") as fh: + fh.write(json.dumps(sys.argv[1:]) + "\n") +args = sys.argv[1:] +if args[0] == "new-workspace": + print("OK workspace:7") +elif args[0] == "list-pane-surfaces": + print("* 11111111-2222-3333-4444-555555555555 Claude Code [selected]") +elif args[0] == "read-screen": + reads = state / "reads" + n = int(reads.read_text()) + 1 if reads.exists() else 1 + reads.write_text(str(n)) + blank = n <= int(os.environ.get("FAKE_BLANK_READS", "0")) + print("" if blank else os.environ.get("FAKE_SCREEN", "⏵⏵ auto mode on (shift+tab to cycle)")) +elif args[0] == "paste": + if os.environ.get("FAKE_PASTE_REFUSE") == "1": + print("Error: draft_present", file=sys.stderr) + sys.exit(1) + text = args[args.index("--") + 1] + target = Path(os.environ["FAKE_CMUX_TRANSCRIPT"]) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a") as fh: + mode = os.environ.get("FAKE_MODE", "idle") + if mode == "idle": + fh.write(json.dumps({"type": "user", "message": {"role": "user", "content": text}}, separators=(",", ":")) + "\n") + elif mode == "busy": + fh.write(json.dumps({"type": "queue-operation", "operation": "enqueue", "content": text}, separators=(",", ":")) + "\n") + fh.write(json.dumps({"type": "attachment", "attachment": {"type": "queued_command", "prompt": text}}, separators=(",", ":")) + "\n") + fh.write(json.dumps({"type": "last-prompt", "lastPrompt": text}, separators=(",", ":")) + "\n") +''' + + +@pytest.fixture +def env(tmp_path): + home = tmp_path / "home" + (home / ".assistant" / "spawn-prompts").mkdir(parents=True) + (home / ".zprofile").write_text(AUTO_ALIAS) + fake_bin = tmp_path / "fakebin" + fake_bin.mkdir() + cmux = fake_bin / "cmux" + cmux.write_text(FAKE_CMUX) + cmux.chmod(0o755) + state = tmp_path / "state" + state.mkdir() + e = {k: v for k, v in os.environ.items() if not k.startswith(("CMUX_", "ZDOTDIR"))} + e.update(HOME=str(home), PATH=f"{fake_bin}:{os.environ['PATH']}", FAKE_CMUX_STATE=str(state)) + return e + + +def prompt(env, name="prompt-x.md") -> Path: + p = Path(env["HOME"]) / ".assistant" / "spawn-prompts" / name + p.write_text("Do the thing.\n") + return p + + +def calls(env): + log = Path(env["FAKE_CMUX_STATE"]) / "calls.jsonl" + return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + + +def project_dir(env, cwd: Path) -> Path: + slug = "".join(c if c.isalnum() else "-" for c in str(cwd.resolve())) + return Path(env["HOME"]) / ".claude" / "projects" / slug + + +def run(env, script, *args, timeout=240): + return subprocess.run(["zsh", str(script), *map(str, args)], capture_output=True, text=True, env=env, timeout=timeout) + + +def test_spawn_launches_unfocused_auto_mode_claude_and_returns_the_new_transcript(tmp_path, env): + work = tmp_path / "my.repo" + work.mkdir() + transcript = project_dir(env, work) / "abc.jsonl" + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + pf = prompt(env) + r = run(env, SPAWN, "--env", "FFP_CHANNEL=beta", "--env", "FFP_EXP_LIGHT_LANE=on", work, "FFP-1 beta", pf) + assert r.returncode == 0, r.stderr + assert r.stdout.split() == ["workspace:7", SURFACE, str(transcript)] + assert [c for c in calls(env) if c[0] == "new-workspace"] == [[ + "new-workspace", "--cwd", str(work.resolve()), "--name", "FFP-1 beta", "--focus", "false", + "--env", "FFP_CHANNEL=beta", "--env", "FFP_EXP_LIGHT_LANE=on", "--command", "zsh -lic claude"]] + assert [c for c in calls(env) if c[0] == "paste"] == [ + ["paste", "--surface", SURFACE, "--submit", "--", f"Read {pf} and follow it."]] + assert not [c for c in calls(env) if c[0] == "send-key"] + + +def test_spawn_ignores_transcripts_that_existed_before_launch(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + pf = prompt(env) + old = project_dir(env, work) / "0-old.jsonl" + old.parent.mkdir(parents=True) + old.write_text(json.dumps({"type": "user", "message": {"content": f"Read {pf} and follow it."}}, + separators=(",", ":")) + "\n") + new = project_dir(env, work) / "new.jsonl" + env["FAKE_CMUX_TRANSCRIPT"] = str(new) + r = run(env, SPAWN, work, "t", pf) + assert r.returncode == 0, r.stderr + assert r.stdout.split()[2] == str(new) + + +def test_spawn_wakes_a_blank_surface_then_waits_for_auto_mode(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_CMUX_TRANSCRIPT"] = str(project_dir(env, work) / "s.jsonl") + env["FAKE_BLANK_READS"] = "4" + r = run(env, SPAWN, work, "t", prompt(env)) + assert r.returncode == 0, r.stderr + kinds = [c[0] for c in calls(env)] + assert ["send-key", "--surface", SURFACE, "enter"] in calls(env) + assert kinds.index("send-key") < kinds.index("paste") + + +def test_spawn_types_nothing_when_a_dialog_is_showing(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_SCREEN"] = "Do you trust the files in this folder?\n> 1. Yes, proceed" + r = run(env, SPAWN, work, "t", prompt(env)) + assert (r.returncode, r.stdout.split()) == (1, ["workspace:7", SURFACE, "not-ready"]) + assert not [c for c in calls(env) if c[0] in ("paste", "send-key")] + + +def test_spawn_reports_a_refused_paste(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_PASTE_REFUSE"] = "1" + r = run(env, SPAWN, work, "t", prompt(env)) + assert (r.returncode, r.stdout.split()) == (1, ["workspace:7", SURFACE, "paste-refused"]) + + +@pytest.mark.parametrize("bad", [ + "PATH=/tmp/evil:/usr/bin", "ANTHROPIC_BASE_URL=https://example.com", "NODE_OPTIONS=-r/tmp/x.js", + "FFP_CHANNEL=beta;id", "FFP_CHANNEL=Beta", "FFP_EXP_X=", "FFP_CHANNEL", +]) +def test_spawn_refuses_env_other_than_ffp_experiment_switches(tmp_path, env, bad): + r = run(env, SPAWN, "--env", bad, tmp_path, "t", prompt(env)) + assert r.returncode == 2 + assert "bad --env value" in r.stderr + assert calls(env) == [] + + +def test_spawn_refuses_prompt_files_outside_the_staging_folder(tmp_path, env): + outside = tmp_path / "prompt.md" + outside.write_text("x") + staged = prompt(env) + escape = staged.parent / ".." / ".." / ".." / "prompt.md" + for pf in (outside, escape, prompt(env, "has space.md"), staged.parent / "missing.md"): + r = run(env, SPAWN, tmp_path, "t", pf) + assert r.returncode == 2, pf + assert "prompt file must be" in r.stderr + assert calls(env) == [] + + +def test_spawn_refuses_when_the_claude_alias_bypasses_permissions(tmp_path, env): + (Path(env["HOME"]) / ".zprofile").write_text( + "alias claude='claude --model x --dangerously-skip-permissions --permission-mode auto'\n") + r = run(env, SPAWN, tmp_path, "t", prompt(env)) + assert r.returncode == 3 + assert "isn't auto mode" in r.stderr + assert calls(env) == [] + + +def test_send_confirms_delivery_and_ignores_last_prompt_copies(tmp_path, env): + pf = prompt(env) + msg = f"Read {pf} and follow it." + transcript = tmp_path / "t.jsonl" + transcript.write_text("".join(json.dumps({"type": "last-prompt", "lastPrompt": msg}) + "\n" for _ in range(3))) + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + r = run(env, SEND, SURFACE, transcript, pf) + assert (r.returncode, r.stdout.strip()) == (0, "delivered"), r.stderr + assert [c for c in calls(env) if c[0] == "paste"] == [["paste", "--surface", SURFACE, "--submit", "--", msg]] + + +def test_send_counts_a_message_queued_to_a_busy_session(tmp_path, env): + pf = prompt(env) + transcript = tmp_path / "t.jsonl" + transcript.write_text(json.dumps({"type": "queue-operation", "operation": "remove", + "content": f"Read {pf} and follow it."}, separators=(",", ":")) + "\n") + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + env["FAKE_MODE"] = "busy" + r = run(env, SEND, SURFACE, transcript, pf) + assert (r.returncode, r.stdout.strip()) == (0, "delivered"), r.stderr + + +def test_send_does_not_count_a_last_prompt_record_as_delivery(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + env["FAKE_CMUX_TRANSCRIPT"] = str(transcript) + env["FAKE_MODE"] = "lost" + r = run(env, SEND, SURFACE, transcript, prompt(env)) + assert (r.returncode, r.stdout.strip()) == (1, "not-delivered") + assert len([c for c in calls(env) if c[0] == "paste"]) == 1 + + +def test_send_reports_a_refused_paste(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + env["FAKE_PASTE_REFUSE"] = "1" + r = run(env, SEND, SURFACE, transcript, prompt(env)) + assert (r.returncode, r.stdout.strip()) == (1, "not-delivered") + + +def test_send_refuses_its_own_surface_and_short_refs(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + pf = prompt(env) + own = {**env, "CMUX_SURFACE_ID": SURFACE.upper()} + r = run(own, SEND, SURFACE, transcript, pf) + assert r.returncode == 2 and "own surface" in r.stderr + r = run(env, SEND, "surface:9", transcript, pf) + assert r.returncode == 2 and "must be a UUID" in r.stderr + assert calls(env) == [] + + +def test_send_refuses_a_target_that_is_not_in_auto_mode(tmp_path, env): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + env["FAKE_SCREEN"] = "said: auto mode on\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle)" + r = run(env, SEND, SURFACE, transcript, prompt(env)) + assert (r.returncode, r.stdout.strip()) == (3, "not-delivered") + assert not [c for c in calls(env) if c[0] == "paste"] + + +def test_spawn_refuses_an_alias_that_is_not_auto_mode(tmp_path, env): + (Path(env["HOME"]) / ".zprofile").write_text("alias claude='claude --model x --permission-mode bypassPermissions'\n") + r = run(env, SPAWN, tmp_path, "t", prompt(env)) + assert r.returncode == 3 + assert "isn't auto mode" in r.stderr + assert calls(env) == [] + + +def test_spawn_refuses_a_sibling_of_the_staging_folder(tmp_path, env): + sibling = Path(env["HOME"]) / ".assistant" / "spawn-prompts-x" + sibling.mkdir() + pf = sibling / "p.md" + pf.write_text("x") + r = run(env, SPAWN, tmp_path, "t", pf) + assert r.returncode == 2 + assert calls(env) == [] + + +def test_send_refuses_a_missing_transcript(tmp_path, env): + r = run(env, SEND, SURFACE, tmp_path / "missing.jsonl", prompt(env)) + assert r.returncode == 2 and "no such transcript" in r.stderr + assert calls(env) == [] + + +def test_spawn_types_nothing_into_a_bypass_mode_session(tmp_path, env): + work = tmp_path / "w" + work.mkdir() + env["FAKE_SCREEN"] = "earlier output: auto mode on\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle)" + r = run(env, SPAWN, work, "t", prompt(env)) + assert (r.returncode, r.stdout.split()[2]) == (1, "not-ready") + assert not [c for c in calls(env) if c[0] in ("paste", "send-key")]