From 85228490cabad5ebcc45a5c7ceb16e5c99d09290 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Thu, 7 May 2026 20:41:14 +0000 Subject: [PATCH] Surface unsubstituted placeholders during init Issue #59: After `sandcat init`, generated files (`compose-proxy.yml`, `app-user-init.sh`) sometimes contained unreplaced `__AGENT_*__` / `__MITM_*__` placeholder tokens. Compose then mounted the literal `./scripts/__AGENT_MITM_ADDON__` path, creating empty stray directories and leaving the proxy with garbage flags. Make the substitution helpers fail when their target file is missing, and add a `verify_no_placeholders` tripwire that runs at the end of `devcontainer()` so any leftover token aborts init with a clear error instead of silently shipping a broken devcontainer. Also strip `__pycache__` / `*.pyc` artefacts after copying templates so stale bytecode can't shadow the addon sources on the bind mount. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/lib/devcontainer.bash | 41 +++++++++++++++++++++++++++++++++ cli/libexec/init/devcontainer | 8 +++++++ cli/test/init/devcontainer.bats | 28 ++++++++++++++++++++++ cli/test/init/regression.bats | 40 ++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/cli/lib/devcontainer.bash b/cli/lib/devcontainer.bash index 33a8cc9..05eb4ae 100644 --- a/cli/lib/devcontainer.bash +++ b/cli/lib/devcontainer.bash @@ -106,6 +106,11 @@ apply_template_placeholders() { local file=$1 shift + if [[ ! -f "$file" ]]; then + echo "apply_template_placeholders: file not found: $file" >&2 + return 1 + fi + local tokens=() replacements=() while [[ $# -ge 2 ]]; do tokens+=("$1") @@ -146,6 +151,11 @@ apply_inline_placeholders() { local file=$1 shift + if [[ ! -f "$file" ]]; then + echo "apply_inline_placeholders: file not found: $file" >&2 + return 1 + fi + local tokens=() replacements=() while [[ $# -ge 2 ]]; do tokens+=("$1") @@ -164,6 +174,37 @@ apply_inline_placeholders() { mv "$tmpfile" "$file" } +# Fail loudly if any unsubstituted __PLACEHOLDER__ tokens remain in the +# generated devcontainer files. Acts as a tripwire so the bug surfaces +# during init rather than at compose-up time, where stale placeholders +# silently mount empty directories or pass garbage flags to mitmproxy. +# +# Args: +# $1 - Path to the generated devcontainer directory +verify_no_placeholders() { + local devcontainer_dir=$1 + local hits rc=0 + # grep exit codes: 0 = match, 1 = no match, >1 = real error. We + # treat 1 as success (no leftover placeholders) and let >1 (e.g. + # the path doesn't exist) propagate as a hard failure. + # + # -I skips binary files (e.g. compiled .pyc bytecode that may + # travel alongside the addon sources). The pattern requires + # uppercase letters/digits so Python lowercase dunders like + # __name__ and __init__ in template scripts don't false-match. + hits=$(grep -IrnE '__[A-Z][A-Z0-9_]*__' "$devcontainer_dir") || rc=$? + if [[ "$rc" -gt 1 ]]; then + echo "verify_no_placeholders: grep failed (rc=$rc) for $devcontainer_dir" >&2 + return "$rc" + fi + if [[ -n "$hits" ]]; then + echo "Unsubstituted template placeholders remain in generated files:" >&2 + echo "$hits" >&2 + return 1 + fi + return 0 +} + # Replaces provider-specific placeholders in generated templates. # Args: # $1 - Path to devcontainer directory diff --git a/cli/libexec/init/devcontainer b/cli/libexec/init/devcontainer index 58c3a62..61ba397 100755 --- a/cli/libexec/init/devcontainer +++ b/cli/libexec/init/devcontainer @@ -85,6 +85,12 @@ devcontainer() { mkdir -p "$devcontainer_dir" # Use rsync-style copy to include dotfiles (glob * skips them) cp -R "$SCT_TEMPLATEDIR/devcontainer/." "$devcontainer_dir/" + # Strip any compiled-Python artefacts that may have been copied along + # with the addon sources. Stale .pyc files would otherwise be picked + # up by mitmproxy in preference to the .py source on the bind mount. + find "$devcontainer_dir" -depth \ + \( -name '__pycache__' -type d -o -name '*.pyc' \) \ + -exec rm -rf {} + local rel_settings_file="../$settings_file" @@ -116,6 +122,8 @@ devcontainer() { customize_devcontainer_json "$devcontainer_dir/devcontainer.json" "$project_name" + verify_no_placeholders "$devcontainer_dir" + echo "Devcontainer dir created at ${devcontainer_dir#"$project_path"/}" | info } diff --git a/cli/test/init/devcontainer.bats b/cli/test/init/devcontainer.bats index c276924..2b955a6 100644 --- a/cli/test/init/devcontainer.bats +++ b/cli/test/init/devcontainer.bats @@ -46,3 +46,31 @@ teardown() { run grep '__STACK_EXTENSIONS__' "$DEVCONTAINER_JSON" assert_success } + +@test "apply_template_placeholders fails when target file is missing" { + run apply_template_placeholders "$BATS_TEST_TMPDIR/missing.txt" "__X__" "y" + assert_failure + assert_output --partial "file not found" +} + +@test "apply_inline_placeholders fails when target file is missing" { + run apply_inline_placeholders "$BATS_TEST_TMPDIR/missing.txt" "__X__" "y" + assert_failure + assert_output --partial "file not found" +} + +@test "verify_no_placeholders passes when no placeholders remain" { + mkdir -p "$BATS_TEST_TMPDIR/dc" + echo "all good here" > "$BATS_TEST_TMPDIR/dc/file.txt" + run verify_no_placeholders "$BATS_TEST_TMPDIR/dc" + assert_success +} + +@test "verify_no_placeholders fails when a placeholder remains" { + mkdir -p "$BATS_TEST_TMPDIR/dc" + echo "still has __AGENT_MITM_ADDON__ here" > "$BATS_TEST_TMPDIR/dc/file.txt" + run verify_no_placeholders "$BATS_TEST_TMPDIR/dc" + assert_failure + assert_output --partial "Unsubstituted template placeholders" + assert_output --partial "__AGENT_MITM_ADDON__" +} diff --git a/cli/test/init/regression.bats b/cli/test/init/regression.bats index 7479f9f..8aa4ebc 100644 --- a/cli/test/init/regression.bats +++ b/cli/test/init/regression.bats @@ -257,6 +257,46 @@ cursor_agent_compose_file_has_expected_content() { assert_customization_volumes_core "$compose_file" } +# Issue #59: init silently produced a devcontainer with unreplaced +# __AGENT_MITM_ADDON__ / __AGENT_USER_INIT__ / __MITM_HTTP2__ placeholders. +# verify_no_placeholders now fails init outright; this test pins that +# behavior for the claude+jetbrains+node combination from the bug report. +@test "devcontainer leaves no placeholders for claude+jetbrains+node" { + run devcontainer \ + --settings-file "$SETTINGS_FILE" \ + --project-path "$PROJECT_DIR" \ + --agent "claude" \ + --ide "jetbrains" \ + --name "test-project" \ + --stacks "node" \ + --proxy "web" + assert_success + + local scripts_dir="$PROJECT_DIR/.devcontainer/sandcat/scripts" + local f + for f in \ + mitmproxy_addon_claude.py \ + mitmproxy_addon_common.py \ + wg-client-init.sh \ + app-init.sh \ + app-post-start.sh \ + app-user-init.sh + do + [[ -s "$scripts_dir/$f" ]] || \ + fail "expected non-empty $scripts_dir/$f" + done + + # Pin the specific symptoms from #59: the bug report named these + # files and these tokens. Asserting via grep rather than relying + # solely on verify_no_placeholders means this test still fails if + # someone later removes the verify call from `devcontainer`. + local proxy_yml="$PROJECT_DIR/.devcontainer/sandcat/compose-proxy.yml" + run grep -nE '__(AGENT_MITM_ADDON|MITM_HTTP2|AGENT_MITM_STREAMING_FLAGS)__' "$proxy_yml" + assert_failure + run grep -nE '__AGENT_USER_INIT__' "$scripts_dir/app-user-init.sh" + assert_failure +} + @test "devcontainer end-to-end: creates devcontainer config for claude agent" { export SANDCAT_MOUNT_CLAUDE_CONFIG="true" export SANDCAT_ENABLE_DOTFILES="true"