From 676fbd03f6effd8cff217a64ac23d1b089cee487 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 12:45:43 +0530 Subject: [PATCH 1/9] fix(amika): harden credential transport --- docs/cli.md | 42 ++++-- scripts/init-amika-locality-snapshot.sh | 190 ++++++++++++++++++------ tests/init_amika_locality_snapshot.sh | 116 +++++++++++++-- 3 files changed, 276 insertions(+), 72 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 69942d14..e6409e8e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -73,11 +73,12 @@ Library callers cannot opt out accidentally: convenience sandbox, profile-key, session-credential, and generation-2 materialization APIs inspect the default state root, while `*_at_state_root` variants accept an explicit state root. -For a fresh remote Amika environment, the repository helper creates a sandbox, -checks out the current repository revision for scenario evidence, downloads the -versioned Locality `v0.3.7` Linux package, verifies its pinned SHA-256, extracts -the released `loc` binary without installing desktop dependencies, and streams -a one-time bootstrap token to `loc sandbox init` over standard input. After +For a remote Amika environment, the repository helper creates a sandbox or +reuses an explicitly named started sandbox, checks out the current repository +revision for scenario evidence, downloads the versioned Locality `v0.3.7` Linux +package, verifies its pinned SHA-256, extracts the released `loc` binary without +installing desktop dependencies, and streams a reusable Workspace Profile key +from the Admin UI to `loc sandbox init` over standard input. After materialization, the helper runs its inline Notion-only launch-gate scenario, prints the prompt, and prints `/home/amika/final_report.md`. @@ -89,22 +90,37 @@ in command arguments or writing it to a sandbox file: export AZURE_OPENAI_API_KEY= ``` -Then read a reusable Workspace Profile key from the administration portal without -adding it to shell history -and run the helper from this repository: +In `https://api.dev.locality.dev/admin/access`, select a ready Workspace Profile +and create a key. The plaintext is shown once. Read it without adding it to +shell history and run the helper from this repository: ```bash -read -rs LOCALITY_BOOTSTRAP_TOKEN +read -rs LOCALITY_PROFILE_KEY printf '%s\n' "$LOCALITY_PROFILE_KEY" | \ scripts/init-amika-locality-snapshot.sh \ --api-url https://api.dev.locality.dev unset LOCALITY_PROFILE_KEY ``` -The key can launch multiple short-lived sandboxes until revoked. The helper creates a uniquely named sandbox, publishes -the workspace at `/home/amika/locality-snapshot`, uses only `/home/amika` paths -in the prompt, and leaves the sandbox running for inspection. Use `--name` when -a stable sandbox name is needed. +The key remains reusable until it expires or is revoked. The helper creates a +uniquely named sandbox, publishes the workspace at +`/home/amika/locality-snapshot`, uses only `/home/amika` paths in the prompt, +and leaves the sandbox running for inspection. Reuse an existing started +sandbox only with an explicit name: + +```bash +printf '%s\n' "$LOCALITY_PROFILE_KEY" | \ + scripts/init-amika-locality-snapshot.sh \ + --api-url https://api.dev.locality.dev \ + --name saga-locality-snapshot \ + --reuse +``` + +Revoke the temporary Workspace Profile key in Admin after testing. +An existing generation-2 root is owned by the exact Profile key bytes used to +create it. Reuse therefore requires that same still-active key; creating a new +key for the same Workspace Profile does not authorize refresh of the old root. +After revocation, remove the old root and materialize a new one with the new key. ## Provider Connections diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index 25994c0e..b6d1bd0f 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -5,15 +5,16 @@ usage() { cat <<'EOF' Usage: init-amika-locality-snapshot.sh --api-url [options] -Creates a fresh remote Amika sandbox, installs the verified Locality v0.3.7 CLI, -and materializes a scoped workspace snapshot. It then runs one inline Notion-only -scenario and prints both the prompt and generated report. The reusable Workspace -Profile key is read from standard input and is never passed in a command-line -argument. +Creates or reuses a remote Amika sandbox, installs the verified Locality v0.3.7 +CLI, and materializes a scoped workspace snapshot. It then runs one inline +Notion-only scenario and prints both the prompt and generated report. The +Workspace Profile key created in Admin is read from standard input and is never +passed in a command-line argument. Options: --api-url Locality backend API origin (required). --name Amika sandbox name. Default: locality-snapshot-. + --reuse Reuse the explicitly named, already-started sandbox. --model Model passed to codex exec. Default: CODEX_MODEL or gpt-5.6-sol. --reasoning Reasoning effort passed to codex exec. @@ -45,49 +46,108 @@ amika_ssh() { if [ "${1:-}" = "--" ]; then shift fi - if [ -t 2 ] && [ -r /dev/tty ]; then - remote_command="$(encode_remote_argv "$@")" - amika sandbox ssh -t "$sandbox" -- "$remote_command" < /dev/tty - else - amika sandbox ssh "$sandbox" "$@" - fi + remote_command="$(encode_remote_argv "$@")" + command -v expect >/dev/null 2>&1 || fail "expect is required for Amika PTY transport" + AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ + expect -c ' + set timeout 1800 + spawn -noecho amika sandbox ssh -t $env(AMIKA_SANDBOX_NAME) -- $env(AMIKA_REMOTE_COMMAND) + expect { + eof { + set result [wait] + exit [lindex $result 3] + } + timeout { + catch {close} + catch {wait} + puts stderr "Amika operation did not finish within 30 minutes" + exit 124 + } + } + ' } amika_ssh_secret_line() { local sandbox="$1" local secret="$2" + local attempt=1 + local status local remote_command shift 2 if [ "${1:-}" = "--" ]; then shift fi - if [ -t 2 ] && [ -r /dev/tty ]; then - command -v expect >/dev/null 2>&1 || fail "expect is required for Amika TTY credential transfer" - remote_command="$(encode_remote_argv "$@")" - AMIKA_SECRET_LINE="$secret" AMIKA_SANDBOX_NAME="$sandbox" \ - AMIKA_REMOTE_COMMAND="$remote_command" \ - expect -f /dev/stdin <<'EXPECT' -set timeout -1 -spawn -noecho amika sandbox ssh -t $env(AMIKA_SANDBOX_NAME) -- $env(AMIKA_REMOTE_COMMAND) -expect "__LOCALITY_STDIN_READY__" -send -- "$env(AMIKA_SECRET_LINE)\n" -expect eof -set result [wait] -exit [lindex $result 3] -EXPECT - else - printf '%s\n' "$secret" | amika sandbox ssh "$sandbox" "$@" - fi + command -v expect >/dev/null 2>&1 || fail "expect is required for Amika credential transfer" + remote_command="$(encode_remote_argv "$@")" + + while [ "$attempt" -le 3 ]; do + if printf '%s\n' "$secret" | \ + AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ + expect -c ' + set timeout 30 + if {[gets stdin secret] < 0} { + puts stderr "credential input closed before a secret was read" + exit 65 + } + spawn -noecho amika sandbox ssh -t $env(AMIKA_SANDBOX_NAME) -- $env(AMIKA_REMOTE_COMMAND) + expect { + "__LOCALITY_STDIN_READY__" {} + eof { + catch {wait} + puts stderr "Amika SSH closed before requesting credential input" + exit 75 + } + timeout { + catch {close} + catch {wait} + puts stderr "Amika SSH did not request credential input within 30 seconds" + exit 75 + } + } + send -- "$secret\n" + set secret "" + set timeout 1800 + expect { + eof { + set result [wait] + exit [lindex $result 3] + } + timeout { + catch {close} + catch {wait} + puts stderr "Amika credential operation did not finish within 30 minutes; it was not retried" + exit 124 + } + } + '; then + secret="" + return 0 + else + status=$? + fi + + if [ "$status" -ne 75 ] || [ "$attempt" -eq 3 ]; then + secret="" + return "$status" + fi + printf 'Amika credential transport closed before secret delivery; retrying (%s/3)...\n' "$attempt" >&2 + attempt=$((attempt + 1)) + sleep 1 + done } API_URL="" SANDBOX_NAME="locality-snapshot-$(date -u +%Y%m%d-%H%M%S)" +SANDBOX_NAME_EXPLICIT=false +REUSE_SANDBOX=false REMOTE_ROOT="/home/amika/locality-snapshot" LOC_RELEASE_VERSION="0.3.7" LOC_RELEASE_DEB_SHA256="692b05460839ba44b85cd1e6b3b6969ad4a3f62f3e81f420c4651159ad7ef195" CODEX_MODEL="${CODEX_MODEL:-gpt-5.6-sol}" CODEX_REASONING_EFFORT="${CODEX_REASONING_EFFORT:-low}" AZURE_OPENAI_BASE_URL="${AZURE_OPENAI_BASE_URL:-https://aseem-mp32maxp-eastus2.openai.azure.com/openai/v1}" +AZURE_OPENAI_API_KEY_VALUE="${AZURE_OPENAI_API_KEY:-}" +unset AZURE_OPENAI_API_KEY SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -101,8 +161,13 @@ while [ "$#" -gt 0 ]; do --name) [ "$#" -ge 2 ] || fail "--name requires a value" SANDBOX_NAME="$2" + SANDBOX_NAME_EXPLICIT=true shift 2 ;; + --reuse) + REUSE_SANDBOX=true + shift + ;; --model) [ "$#" -ge 2 ] || fail "--model requires a value" CODEX_MODEL="$2" @@ -131,8 +196,11 @@ esac case "$SANDBOX_NAME" in *[!a-zA-Z0-9._-]*|'') fail "--name contains unsupported characters" ;; esac +if [ "$REUSE_SANDBOX" = true ] && [ "$SANDBOX_NAME_EXPLICIT" != true ]; then + fail "--reuse requires an explicit --name" +fi [ -n "$CODEX_MODEL" ] || fail "--model must not be empty" -[ -n "${AZURE_OPENAI_API_KEY:-}" ] || fail "AZURE_OPENAI_API_KEY is required" +[ -n "$AZURE_OPENAI_API_KEY_VALUE" ] || fail "AZURE_OPENAI_API_KEY is required" case "$AZURE_OPENAI_BASE_URL" in https://*) ;; *) fail "AZURE_OPENAI_BASE_URL must use https" ;; @@ -196,29 +264,26 @@ if [ -t 0 ]; then else IFS= read -r PROFILE_KEY || fail "read the Workspace Profile key from standard input" fi -[ -n "$PROFILE_KEY" ] || fail "Workspace Profile key must not be empty" +[ "${#PROFILE_KEY}" -eq 64 ] || fail "Workspace Profile key must be 64 lowercase hexadecimal characters" +case "$PROFILE_KEY" in + *[!0-9a-f]*) fail "Workspace Profile key must be 64 lowercase hexadecimal characters" ;; +esac -printf 'Creating Amika sandbox %s...\n' "$SANDBOX_NAME" -(cd "$REPO_ROOT" && amika sandbox create \ - --remote \ - --name "$SANDBOX_NAME" \ - --yes >/dev/null) +if [ "$REUSE_SANDBOX" = true ]; then + printf 'Reusing Amika sandbox %s...\n' "$SANDBOX_NAME" +else + printf 'Creating Amika sandbox %s...\n' "$SANDBOX_NAME" + (cd "$REPO_ROOT" && amika sandbox create \ + --remote \ + --name "$SANDBOX_NAME" \ + --yes >/dev/null) +fi printf 'Installing released loc CLI v%s in %s...\n' "$LOC_RELEASE_VERSION" "$SANDBOX_NAME" amika_ssh "$SANDBOX_NAME" -- sh -c ' set -eu - revision=$1 - loc_version=$2 - expected_sha256=$3 - manifest=$(find "$HOME/workspace" -mindepth 2 -maxdepth 2 -type f -name Cargo.toml -print -quit) - test -n "$manifest" - repo_dir=${manifest%/Cargo.toml} - git -C "$repo_dir" cat-file -e "$revision^{commit}" - git -C "$repo_dir" checkout --detach "$revision" - if [ "$repo_dir" != /home/amika/workspace/locality ]; then - test ! -e /home/amika/workspace/locality - ln -s "$repo_dir" /home/amika/workspace/locality - fi + loc_version=$1 + expected_sha256=$2 work_dir=$(mktemp -d) trap '\''rm -rf "$work_dir"'\'' EXIT package="$work_dir/Locality_Linux_v${loc_version}.deb" @@ -231,7 +296,7 @@ amika_ssh "$SANDBOX_NAME" -- sh -c ' mkdir -p "$HOME/.local/bin" install -m 0755 "$work_dir/package/usr/bin/loc" "$HOME/.local/bin/loc" "$HOME/.local/bin/loc" sandbox init --help >/dev/null -' sh "$SOURCE_REVISION" "$LOC_RELEASE_VERSION" "$LOC_RELEASE_DEB_SHA256" +' sh "$LOC_RELEASE_VERSION" "$LOC_RELEASE_DEB_SHA256" printf 'Materializing scoped workspace at %s:%s...\n' "$SANDBOX_NAME" "$REMOTE_ROOT" amika_ssh_secret_line "$SANDBOX_NAME" "$PROFILE_KEY" -- sh -c ' @@ -249,11 +314,36 @@ amika_ssh_secret_line "$SANDBOX_NAME" "$PROFILE_KEY" -- sh -c ' --api-url "$API_URL" \ --root "$REMOTE_ROOT" \ --profile-key-stdin \ + --profile \ --json unset PROFILE_KEY printf 'Snapshot ready in Amika sandbox %s at %s\n' "$SANDBOX_NAME" "$REMOTE_ROOT" +printf 'Preparing clean Locality evidence checkout at revision %s...\n' "$SOURCE_REVISION" +amika_ssh "$SANDBOX_NAME" -- sh -c ' + set -eu + revision=$1 + repo_dir=/home/amika/workspace/locality + mkdir -p /home/amika/workspace + if [ ! -e "$repo_dir" ]; then + git clone https://github.com/codeflash-ai/locality.git "$repo_dir" + fi + test -d "$repo_dir/.git" + origin_url=$(git -C "$repo_dir" remote get-url origin) + case "$origin_url" in + https://github.com/codeflash-ai/locality|https://github.com/codeflash-ai/locality.git|git@github.com:codeflash-ai/locality.git) ;; + *) printf "unexpected Locality repository origin: %s\n" "$origin_url" >&2; exit 65 ;; + esac + test -z "$(git -C "$repo_dir" status --porcelain --untracked-files=all)" || { + printf "Locality evidence checkout is dirty; use a clean sandbox or remove the changes explicitly\n" >&2 + exit 65 + } + git -C "$repo_dir" fetch origin --prune + git -C "$repo_dir" cat-file -e "$revision^{commit}" + git -C "$repo_dir" checkout --detach "$revision" +' sh "$SOURCE_REVISION" + amika_ssh "$SANDBOX_NAME" -- sh -c ' set -eu azure_base_url=$1 @@ -274,7 +364,7 @@ unset PROMPT_BASE64 printf '\n===== Inline scenario prompt =====\n%s\n' "$EFFECTIVE_PROMPT" printf '\n===== Running scenario in %s =====\n' "$SANDBOX_NAME" -amika_ssh_secret_line "$SANDBOX_NAME" "$AZURE_OPENAI_API_KEY" -- sh -c ' +amika_ssh_secret_line "$SANDBOX_NAME" "$AZURE_OPENAI_API_KEY_VALUE" -- sh -c ' set -eu model=$1 reasoning=$2 @@ -309,6 +399,8 @@ amika_ssh_secret_line "$SANDBOX_NAME" "$AZURE_OPENAI_API_KEY" -- sh -c ' exit 1 } ' sh "$CODEX_MODEL" "$CODEX_REASONING_EFFORT" "$REMOTE_ROOT" +AZURE_OPENAI_API_KEY_VALUE="" +unset AZURE_OPENAI_API_KEY_VALUE printf '\n===== /home/amika/final_report.md =====\n' amika_ssh "$SANDBOX_NAME" -- cat /home/amika/final_report.md diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index 9eedd4e0..b6df5d12 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -41,20 +41,47 @@ if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "create" ]; then fi if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "ssh" ]; then - case " $* " in - *" --profile-key-stdin "*) + [ -z "${AMIKA_SECRET_LINE:-}" ] || { + printf 'secret environment leak\n' >&2 + exit 90 + } + [ -z "${AZURE_OPENAI_API_KEY:-}" ] || { + printf 'Azure key environment leak\n' >&2 + exit 91 + } + last_arg="" + for arg in "$@"; do + last_arg="$arg" + done + encoded="${last_arg##* }" + decoded="$(printf '%s' "$encoded" | base64 -d | tr '\0' '\n')" + decoded_log="$(printf '%s' "$encoded" | base64 -d | tr '\0' ' ')" + printf 'remote %s\n' "$decoded_log" >> "$FAKE_AMIKA_LOG" + case "$decoded_log" in + *"--profile-key-stdin"*) + failures="${FAKE_PRE_SENTINEL_FAILURES:-0}" + count=0 + if [ -n "${FAKE_PRE_SENTINEL_COUNT:-}" ] && [ -f "$FAKE_PRE_SENTINEL_COUNT" ]; then + count="$(cat "$FAKE_PRE_SENTINEL_COUNT")" + fi + count=$((count + 1)) + if [ -n "${FAKE_PRE_SENTINEL_COUNT:-}" ]; then + printf '%s\n' "$count" > "$FAKE_PRE_SENTINEL_COUNT" + fi + if [ "$count" -le "$failures" ]; then + exit 255 + fi + printf '__LOCALITY_STDIN_READY__\n' IFS= read -r token printf '%s\n' "$token" > "${FAKE_PROFILE_KEY_INPUT:?}" printf '{"ok":true,"command":"sandbox_init","root":"/workspace/scoped"}\n' ;; *"base64 -d > /home/amika/scenario-prompt.md"*) - last_arg="" - for arg in "$@"; do - last_arg="$arg" - done - printf '%s' "$last_arg" | base64 -d > "${FAKE_PROMPT_INPUT:?}" + prompt_base64="$(printf '%s\n' "$decoded" | tail -n 1)" + printf '%s' "$prompt_base64" | base64 -d > "${FAKE_PROMPT_INPUT:?}" ;; *"codex exec"*) + printf '__LOCALITY_STDIN_READY__\n' IFS= read -r azure_key printf '%s\n' "$azure_key" > "${FAKE_AZURE_INPUT:?}" printf '# Fake Launch Gate Memo\n\nVerified report body.\n' > "${FAKE_REPORT:?}" @@ -74,7 +101,7 @@ exit 2 SH chmod +x "${fake_bin}/amika" -profile_key="$(printf 'a%.0s' {1..64})" +profile_key="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" azure_key="test-azure-key" output="$( printf '%s\n' "$profile_key" | \ @@ -93,7 +120,7 @@ output="$( )" assert_contains "$fake_log" "sandbox create --remote --name test-snapshot --yes" -assert_contains "$fake_log" "sandbox ssh test-snapshot" +assert_contains "$fake_log" "sandbox ssh -t test-snapshot" assert_contains "$fake_log" "Locality_Linux_v" assert_contains "$fake_log" "0.3.7" assert_contains "$fake_log" "692b05460839ba44b85cd1e6b3b6969ad4a3f62f3e81f420c4651159ad7ef195" @@ -107,12 +134,17 @@ assert_contains "$fake_log" "sandbox init" assert_contains "$fake_log" "--api-url https://api.dev.locality.dev" assert_contains "$fake_log" "--root /home/amika/locality-snapshot" assert_contains "$fake_log" "--profile-key-stdin" +assert_contains "$fake_log" "--profile" assert_contains "$fake_log" "/home/amika/scenario-prompt.md" assert_contains "$fake_log" "setup-codex-azure.sh" assert_contains "$fake_log" "codex exec" assert_contains "$fake_log" '< /dev/null' assert_contains "$fake_log" "test-model medium /home/amika/locality-snapshot" assert_contains "$fake_log" "cat /home/amika/final_report.md" +profile_exchange_line="$(grep -n -m1 -- '--profile-key-stdin' "$fake_log" | cut -d: -f1)" +repo_prepare_line="$(grep -n -m1 -- 'git clone https://github.com/codeflash-ai/locality.git' "$fake_log" | cut -d: -f1)" +[ "$profile_exchange_line" -lt "$repo_prepare_line" ] || \ + fail "scenario repository must be prepared only after profile authorization succeeds" if grep -F -q -- "$profile_key" "$fake_log"; then fail "Workspace Profile key leaked into Amika arguments" fi @@ -136,6 +168,54 @@ grep -F -q -- '===== /home/amika/final_report.md =====' <<<"$output" || \ grep -F -q -- 'Verified report body.' <<<"$output" || \ fail "terminal output did not include final_report.md" +: > "$fake_log" +reuse_output="$( + printf '%s\n' "$profile_key" | \ + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ + FAKE_AZURE_INPUT="$azure_input" \ + FAKE_PROMPT_INPUT="$prompt_input" \ + FAKE_REPORT="$fake_report" \ + "$SCRIPT" \ + --api-url https://api.dev.locality.dev \ + --name existing-snapshot \ + --reuse \ + --model test-model \ + --reasoning medium +)" +assert_contains "$fake_log" "sandbox ssh -t existing-snapshot" +if grep -F -q -- 'sandbox create' "$fake_log"; then + fail "--reuse must not create another sandbox" +fi +grep -F -q -- 'Reusing Amika sandbox existing-snapshot' <<<"$reuse_output" || \ + fail "reuse output did not identify the existing sandbox" + +: > "$fake_log" +pre_sentinel_count="${tmp_root}/pre-sentinel.count" +retry_output="$( + printf '%s\n' "$profile_key" | \ + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ + FAKE_AZURE_INPUT="$azure_input" \ + FAKE_PROMPT_INPUT="$prompt_input" \ + FAKE_REPORT="$fake_report" \ + FAKE_PRE_SENTINEL_FAILURES=1 \ + FAKE_PRE_SENTINEL_COUNT="$pre_sentinel_count" \ + "$SCRIPT" \ + --api-url https://api.dev.locality.dev \ + --name retry-snapshot \ + --reuse \ + --model test-model \ + --reasoning medium 2>&1 +)" +[ "$(cat "$pre_sentinel_count")" -eq 2 ] || fail "pre-sentinel transport failure was not retried exactly once" +grep -F -q -- 'retrying (1/3)' <<<"$retry_output" || fail "pre-sentinel retry was not explained" +[ "$(cat "$profile_key_input")" = "$profile_key" ] || fail "retry did not stream the Workspace Profile key" + : > "$fake_log" set +e missing_azure_output="$( @@ -173,7 +253,23 @@ invalid_status=$? set -e [ "$invalid_status" -eq 2 ] || fail "empty Workspace Profile key should return usage status 2" [ ! -s "$fake_log" ] || fail "empty Workspace Profile key should fail before creating a sandbox" -grep -F -q -- 'Workspace Profile key must not be empty' <<<"$invalid_output" || \ +grep -F -q -- 'Workspace Profile key must be 64 lowercase hexadecimal characters' <<<"$invalid_output" || \ fail "empty Workspace Profile key error was not actionable" +: > "$fake_log" +set +e +implicit_reuse_output="$( + printf '%s\n' "$profile_key" | \ + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + "$SCRIPT" --api-url https://api.dev.locality.dev --reuse 2>&1 +)" +implicit_reuse_status=$? +set -e +[ "$implicit_reuse_status" -eq 2 ] || fail "--reuse without --name should return usage status 2" +[ ! -s "$fake_log" ] || fail "invalid --reuse should fail before contacting Amika" +grep -F -q -- '--reuse requires an explicit --name' <<<"$implicit_reuse_output" || \ + fail "invalid --reuse error was not actionable" + printf 'init Amika Locality snapshot tests passed\n' From 4f7ac587105179678390ad681eecab4cf756b418 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 13:07:49 +0530 Subject: [PATCH 2/9] fix(amika): verify reusable sandbox boundaries --- scripts/init-amika-locality-snapshot.sh | 71 +++++++++++++++++++++++-- tests/init_amika_locality_snapshot.sh | 52 +++++++++++++++++- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index b6d1bd0f..6268b1c7 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -50,12 +50,28 @@ amika_ssh() { command -v expect >/dev/null 2>&1 || fail "expect is required for Amika PTY transport" AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ expect -c ' + proc child_status {result} { + if {[llength $result] >= 6 && [lindex $result 4] eq "CHILDKILLED"} { + array set signal_number { + SIGHUP 1 SIGINT 2 SIGQUIT 3 SIGKILL 9 SIGPIPE 13 SIGTERM 15 + } + set signal [lindex $result 5] + if {[info exists signal_number($signal)]} { + return [expr {128 + $signal_number($signal)}] + } + return 125 + } + if {[lindex $result 2] != 0} { + return 125 + } + return [lindex $result 3] + } set timeout 1800 spawn -noecho amika sandbox ssh -t $env(AMIKA_SANDBOX_NAME) -- $env(AMIKA_REMOTE_COMMAND) expect { eof { set result [wait] - exit [lindex $result 3] + exit [child_status $result] } timeout { catch {close} @@ -84,6 +100,22 @@ amika_ssh_secret_line() { if printf '%s\n' "$secret" | \ AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ expect -c ' + proc child_status {result} { + if {[llength $result] >= 6 && [lindex $result 4] eq "CHILDKILLED"} { + array set signal_number { + SIGHUP 1 SIGINT 2 SIGQUIT 3 SIGKILL 9 SIGPIPE 13 SIGTERM 15 + } + set signal [lindex $result 5] + if {[info exists signal_number($signal)]} { + return [expr {128 + $signal_number($signal)}] + } + return 125 + } + if {[lindex $result 2] != 0} { + return 125 + } + return [lindex $result 3] + } set timeout 30 if {[gets stdin secret] < 0} { puts stderr "credential input closed before a secret was read" @@ -110,7 +142,7 @@ amika_ssh_secret_line() { expect { eof { set result [wait] - exit [lindex $result 3] + exit [child_status $result] } timeout { catch {close} @@ -276,6 +308,7 @@ else (cd "$REPO_ROOT" && amika sandbox create \ --remote \ --name "$SANDBOX_NAME" \ + --no-git \ --yes >/dev/null) fi @@ -298,6 +331,26 @@ amika_ssh "$SANDBOX_NAME" -- sh -c ' "$HOME/.local/bin/loc" sandbox init --help >/dev/null ' sh "$LOC_RELEASE_VERSION" "$LOC_RELEASE_DEB_SHA256" +if [ "$REUSE_SANDBOX" = true ]; then + printf 'Checking reused sandbox evidence boundary before authorization...\n' + amika_ssh "$SANDBOX_NAME" -- sh -c ' + set -eu + repo_dir=/home/amika/workspace/locality + if [ -e "$repo_dir" ]; then + test "$(git -C "$repo_dir" rev-parse --is-inside-work-tree)" = true + origin_url=$(git -C "$repo_dir" remote get-url origin) + case "$origin_url" in + https://github.com/codeflash-ai/locality|https://github.com/codeflash-ai/locality.git|git@github.com:codeflash-ai/locality.git) ;; + *) printf "unexpected Locality repository origin: %s\n" "$origin_url" >&2; exit 65 ;; + esac + test -z "$(git -C "$repo_dir" status --porcelain --untracked-files=all)" || { + printf "Locality evidence checkout is dirty; use a clean sandbox or remove the changes explicitly\n" >&2 + exit 65 + } + fi + ' sh +fi + printf 'Materializing scoped workspace at %s:%s...\n' "$SANDBOX_NAME" "$REMOTE_ROOT" amika_ssh_secret_line "$SANDBOX_NAME" "$PROFILE_KEY" -- sh -c ' set -eu @@ -329,7 +382,7 @@ amika_ssh "$SANDBOX_NAME" -- sh -c ' if [ ! -e "$repo_dir" ]; then git clone https://github.com/codeflash-ai/locality.git "$repo_dir" fi - test -d "$repo_dir/.git" + test "$(git -C "$repo_dir" rev-parse --is-inside-work-tree)" = true origin_url=$(git -C "$repo_dir" remote get-url origin) case "$origin_url" in https://github.com/codeflash-ai/locality|https://github.com/codeflash-ai/locality.git|git@github.com:codeflash-ai/locality.git) ;; @@ -352,7 +405,12 @@ amika_ssh "$SANDBOX_NAME" -- sh -c ' AZURE_OPENAI_BASE_URL="$azure_base_url" \ CODEX_MODEL="$model" \ CODEX_REASONING_EFFORT="$reasoning" \ + AMIKA_AGENT_CWD="$HOME" \ bash /home/amika/workspace/locality/experiment/locality-mcp-comparison/setup-codex-azure.sh + test -z "$(git -C /home/amika/workspace/locality status --porcelain --untracked-files=all)" || { + printf "Codex setup dirtied the Locality evidence checkout\n" >&2 + exit 65 + } ' sh "$AZURE_OPENAI_BASE_URL" "$CODEX_MODEL" "$CODEX_REASONING_EFFORT" PROMPT_BASE64="$(printf '%s\n' "$EFFECTIVE_PROMPT" | base64 | tr -d '\n')" @@ -402,5 +460,12 @@ amika_ssh_secret_line "$SANDBOX_NAME" "$AZURE_OPENAI_API_KEY_VALUE" -- sh -c ' AZURE_OPENAI_API_KEY_VALUE="" unset AZURE_OPENAI_API_KEY_VALUE +amika_ssh "$SANDBOX_NAME" -- sh -c ' + test -z "$(git -C /home/amika/workspace/locality status --porcelain --untracked-files=all)" || { + printf "scenario modified the Locality evidence checkout\n" >&2 + exit 65 + } +' sh + printf '\n===== /home/amika/final_report.md =====\n' amika_ssh "$SANDBOX_NAME" -- cat /home/amika/final_report.md diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index b6df5d12..d79e1271 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -57,6 +57,9 @@ if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "ssh" ]; then decoded="$(printf '%s' "$encoded" | base64 -d | tr '\0' '\n')" decoded_log="$(printf '%s' "$encoded" | base64 -d | tr '\0' ' ')" printf 'remote %s\n' "$decoded_log" >> "$FAKE_AMIKA_LOG" + if [ -n "${FAKE_SIGNAL_MATCH:-}" ] && grep -F -q -- "$FAKE_SIGNAL_MATCH" <<<"$decoded_log"; then + kill -TERM "$$" + fi case "$decoded_log" in *"--profile-key-stdin"*) failures="${FAKE_PRE_SENTINEL_FAILURES:-0}" @@ -74,6 +77,9 @@ if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "ssh" ]; then printf '__LOCALITY_STDIN_READY__\n' IFS= read -r token printf '%s\n' "$token" > "${FAKE_PROFILE_KEY_INPUT:?}" + if [ -n "${FAKE_POST_SENTINEL_STATUS:-}" ]; then + exit "$FAKE_POST_SENTINEL_STATUS" + fi printf '{"ok":true,"command":"sandbox_init","root":"/workspace/scoped"}\n' ;; *"base64 -d > /home/amika/scenario-prompt.md"*) @@ -119,7 +125,7 @@ output="$( --reasoning medium )" -assert_contains "$fake_log" "sandbox create --remote --name test-snapshot --yes" +assert_contains "$fake_log" "sandbox create --remote --name test-snapshot --no-git --yes" assert_contains "$fake_log" "sandbox ssh -t test-snapshot" assert_contains "$fake_log" "Locality_Linux_v" assert_contains "$fake_log" "0.3.7" @@ -137,10 +143,12 @@ assert_contains "$fake_log" "--profile-key-stdin" assert_contains "$fake_log" "--profile" assert_contains "$fake_log" "/home/amika/scenario-prompt.md" assert_contains "$fake_log" "setup-codex-azure.sh" +assert_contains "$fake_log" 'AMIKA_AGENT_CWD="$HOME"' assert_contains "$fake_log" "codex exec" assert_contains "$fake_log" '< /dev/null' assert_contains "$fake_log" "test-model medium /home/amika/locality-snapshot" assert_contains "$fake_log" "cat /home/amika/final_report.md" +assert_contains "$fake_log" "status --porcelain --untracked-files=all" profile_exchange_line="$(grep -n -m1 -- '--profile-key-stdin' "$fake_log" | cut -d: -f1)" repo_prepare_line="$(grep -n -m1 -- 'git clone https://github.com/codeflash-ai/locality.git' "$fake_log" | cut -d: -f1)" [ "$profile_exchange_line" -lt "$repo_prepare_line" ] || \ @@ -216,6 +224,48 @@ retry_output="$( grep -F -q -- 'retrying (1/3)' <<<"$retry_output" || fail "pre-sentinel retry was not explained" [ "$(cat "$profile_key_input")" = "$profile_key" ] || fail "retry did not stream the Workspace Profile key" +: > "$fake_log" +set +e +signal_output="$( + printf '%s\n' "$profile_key" | \ + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ + FAKE_AZURE_INPUT="$azure_input" \ + FAKE_PROMPT_INPUT="$prompt_input" \ + FAKE_REPORT="$fake_report" \ + FAKE_SIGNAL_MATCH='Locality_Linux_v' \ + "$SCRIPT" --api-url https://api.dev.locality.dev --name signaled --reuse 2>&1 +)" +signal_status=$? +set -e +[ "$signal_status" -eq 143 ] || fail "signaled Amika child should return 143, got ${signal_status}: ${signal_output}" + +: > "$fake_log" +post_sentinel_count="${tmp_root}/post-sentinel.count" +set +e +post_sentinel_output="$( + printf '%s\n' "$profile_key" | \ + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ + FAKE_AZURE_INPUT="$azure_input" \ + FAKE_PROMPT_INPUT="$prompt_input" \ + FAKE_REPORT="$fake_report" \ + FAKE_PRE_SENTINEL_COUNT="$post_sentinel_count" \ + FAKE_POST_SENTINEL_STATUS=23 \ + "$SCRIPT" --api-url https://api.dev.locality.dev --name post-sentinel --reuse 2>&1 +)" +post_sentinel_status=$? +set -e +[ "$post_sentinel_status" -eq 23 ] || fail "post-sentinel failure should preserve status 23" +[ "$(cat "$post_sentinel_count")" -eq 1 ] || fail "post-sentinel failure must not retry" +if grep -F -q -- 'credential transport closed' <<<"$post_sentinel_output"; then + fail "post-sentinel failure was incorrectly classified as retryable transport" +fi + : > "$fake_log" set +e missing_azure_output="$( From f412e8678e10fb64f9d0445fffefa90e71156ee9 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 13:31:41 +0530 Subject: [PATCH 3/9] fix(amika): enforce fresh credential sandbox --- .github/workflows/ci.yml | 3 + Makefile | 6 +- docs/cli.md | 35 ++-- .../setup-codex-azure.sh | 162 +++++++++++++-- scripts/init-amika-locality-snapshot.sh | 182 +++++++++-------- tests/init_amika_locality_snapshot.sh | 186 ++++++++++++++---- 6 files changed, 434 insertions(+), 140 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b8ff06e..3391a7bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ + expect \ patchelf \ sqlite3 - name: Report Linux FUSE coverage @@ -75,6 +76,8 @@ jobs: run: cargo test --workspace --all-targets - name: Run real Linux FUSE smoke test run: LOCALITY_FUSE_SMOKE=1 LOCALITY_FUSE_SMOKE_REQUIRED=1 tests/linux_fuse_smoke.sh + - name: Run Amika snapshot shell test + run: make test-init-amika-locality-snapshot - name: Install desktop dependencies run: npm ci working-directory: apps/desktop diff --git a/Makefile b/Makefile index 0fee28d2..10c26768 100644 --- a/Makefile +++ b/Makefile @@ -189,7 +189,7 @@ audit-oauth-service: $(OAUTH_SERVICE_NODE_MODULES_STAMP) ## Audit OAuth service $(OAUTH_SERVICE_NPM) audit .PHONY: test -test: test-rust ## Run the default test suite. +test: test-rust test-init-amika-locality-snapshot ## Run the default test suite. .PHONY: test-rust test-rust: ## Run all Rust workspace tests. @@ -239,6 +239,10 @@ test-launch-readiness-wrappers: ## Validate launch-readiness wrapper defaults. tests/launch_readiness_aws_wrapper.sh tests/init_amika_locality_snapshot.sh +.PHONY: test-init-amika-locality-snapshot +test-init-amika-locality-snapshot: ## Validate secure Amika snapshot initialization and interruption cleanup. + tests/init_amika_locality_snapshot.sh + .PHONY: fmt fmt: ## Format Rust code. $(CARGO) fmt --all diff --git a/docs/cli.md b/docs/cli.md index e6409e8e..fb899f0b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -73,15 +73,20 @@ Library callers cannot opt out accidentally: convenience sandbox, profile-key, session-credential, and generation-2 materialization APIs inspect the default state root, while `*_at_state_root` variants accept an explicit state root. -For a remote Amika environment, the repository helper creates a sandbox or -reuses an explicitly named started sandbox, checks out the current repository -revision for scenario evidence, downloads the versioned Locality `v0.3.7` Linux -package, verifies its pinned SHA-256, extracts the released `loc` binary without -installing desktop dependencies, and streams a reusable Workspace Profile key -from the Admin UI to `loc sandbox init` over standard input. After +For a remote Amika environment, the repository helper creates a fresh sandbox, +checks out the current repository revision for scenario evidence, downloads the +versioned Locality `v0.3.7` Linux package, verifies its pinned SHA-256, extracts +the released `loc` binary without installing desktop dependencies, and streams +a reusable Workspace Profile key from the Admin UI to `loc sandbox init` over +standard input. After materialization, the helper runs its inline Notion-only launch-gate scenario, prints the prompt, and prints `/home/amika/final_report.md`. +The local machine needs `amika`, `git`, Python 3.11 or newer, and `expect` on `PATH`. +Install Expect with `brew install expect` on macOS or `sudo apt-get install +expect` on Debian/Ubuntu. Expect provides the PTY transport used to stream the +credentials without putting them in arguments or logs. + The helper uses the existing Azure Codex setup. Export the Azure key locally; the script streams it directly to the remote Codex process without placing it in command arguments or writing it to a sandbox file: @@ -105,22 +110,26 @@ unset LOCALITY_PROFILE_KEY The key remains reusable until it expires or is revoked. The helper creates a uniquely named sandbox, publishes the workspace at `/home/amika/locality-snapshot`, uses only `/home/amika` paths in the prompt, -and leaves the sandbox running for inspection. Reuse an existing started -sandbox only with an explicit name: +and leaves the sandbox running for inspection. It refuses `--reuse`: repository +cleanliness cannot make an existing sandbox a trusted boundary for a Workspace +Profile key or Azure credential. A name collision fails before either +credential is sent. The helper never deletes or replaces the colliding sandbox. +If replacement is intended, make that destructive action separately and +explicitly, then rerun the helper: ```bash +amika sandbox delete --remote --force --delete-volumes saga-locality-snapshot printf '%s\n' "$LOCALITY_PROFILE_KEY" | \ scripts/init-amika-locality-snapshot.sh \ --api-url https://api.dev.locality.dev \ - --name saga-locality-snapshot \ - --reuse + --name saga-locality-snapshot ``` Revoke the temporary Workspace Profile key in Admin after testing. An existing generation-2 root is owned by the exact Profile key bytes used to -create it. Reuse therefore requires that same still-active key; creating a new -key for the same Workspace Profile does not authorize refresh of the old root. -After revocation, remove the old root and materialize a new one with the new key. +create it. Creating a new key for the same Workspace Profile does not authorize +refresh of that old root. After revocation, use a fresh sandbox and materialize +a new root with the new key. ## Provider Connections diff --git a/experiment/locality-mcp-comparison/setup-codex-azure.sh b/experiment/locality-mcp-comparison/setup-codex-azure.sh index 45fe1dff..665bf465 100755 --- a/experiment/locality-mcp-comparison/setup-codex-azure.sh +++ b/experiment/locality-mcp-comparison/setup-codex-azure.sh @@ -8,26 +8,160 @@ CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" mkdir -p "$CODEX_HOME" -cat > "$CODEX_HOME/config.toml" < "$AMIKA_AGENT_CWD/.codex/config.toml" <<'TOML' -sandbox_mode = "workspace-write" -TOML + merge_codex_config "$AMIKA_AGENT_CWD/.codex/config.toml" "workspace-write" fi codex --version || true diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index 6268b1c7..dda3c099 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -5,7 +5,7 @@ usage() { cat <<'EOF' Usage: init-amika-locality-snapshot.sh --api-url [options] -Creates or reuses a remote Amika sandbox, installs the verified Locality v0.3.7 +Creates a fresh remote Amika sandbox, installs the verified Locality v0.3.7 CLI, and materializes a scoped workspace snapshot. It then runs one inline Notion-only scenario and prints both the prompt and generated report. The Workspace Profile key created in Admin is read from standard input and is never @@ -14,7 +14,7 @@ passed in a command-line argument. Options: --api-url Locality backend API origin (required). --name Amika sandbox name. Default: locality-snapshot-. - --reuse Reuse the explicitly named, already-started sandbox. + --reuse Refused: credential-bearing sandboxes must be fresh. --model Model passed to codex exec. Default: CODEX_MODEL or gpt-5.6-sol. --reasoning Reasoning effort passed to codex exec. @@ -39,6 +39,75 @@ encode_remote_argv() { printf 'python3 -c '\''import base64, os, sys; argv = [os.fsdecode(item) for item in base64.b64decode(sys.argv[1]).split(b"\\0")[:-1]]; os.execvp(argv[0], argv)'\'' %s' "$payload" } +EXPECT_TRANSPORT_PROCS=' + proc child_status {result} { + if {[llength $result] >= 6 && [lindex $result 4] eq "CHILDKILLED"} { + array set signal_number { + SIGHUP 1 SIGINT 2 SIGQUIT 3 SIGKILL 9 SIGPIPE 13 SIGTERM 15 + } + set signal [lindex $result 5] + if {[info exists signal_number($signal)]} { + return [expr {128 + $signal_number($signal)}] + } + return 125 + } + if {[lindex $result 2] != 0} { + return 125 + } + return [lindex $result 3] + } + + proc restore_terminal {} { + global terminal_state + if {$terminal_state ne ""} { + catch {exec stty $terminal_state < /dev/tty} + } else { + catch {exec stty sane < /dev/tty} + } + } + + proc reap_child {{signal ""}} { + global child_active spawn_id + if {!$child_active} { + return + } + set child_pid "" + catch {set child_pid [exp_pid -i $spawn_id]} + if {$signal ne "" && [string is integer -strict $child_pid]} { + catch {exec kill -$signal -- -$child_pid} + after 1000 + catch {exec kill -KILL -- -$child_pid} + } + catch {close -i $spawn_id} + catch {wait -i $spawn_id} + set child_active 0 + } + + proc terminate_for_signal {signal number} { + catch {set ::secret ""} + reap_child $signal + restore_terminal + exit [expr {128 + $number}] + } + + proc initialize_transport {} { + global child_active terminal_state + set child_active 0 + set terminal_state "" + catch {set terminal_state [exec stty -g < /dev/tty]} + trap {terminate_for_signal SIGHUP 1} SIGHUP + trap {terminate_for_signal SIGINT 2} SIGINT + trap {terminate_for_signal SIGTERM 15} SIGTERM + } + + proc wait_for_child {} { + global child_active spawn_id + set result [wait -i $spawn_id] + set child_active 0 + return $result + } +' + amika_ssh() { local sandbox="$1" local remote_command @@ -48,34 +117,23 @@ amika_ssh() { fi remote_command="$(encode_remote_argv "$@")" command -v expect >/dev/null 2>&1 || fail "expect is required for Amika PTY transport" - AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ + AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ + AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ expect -c ' - proc child_status {result} { - if {[llength $result] >= 6 && [lindex $result 4] eq "CHILDKILLED"} { - array set signal_number { - SIGHUP 1 SIGINT 2 SIGQUIT 3 SIGKILL 9 SIGPIPE 13 SIGTERM 15 - } - set signal [lindex $result 5] - if {[info exists signal_number($signal)]} { - return [expr {128 + $signal_number($signal)}] - } - return 125 - } - if {[lindex $result 2] != 0} { - return 125 - } - return [lindex $result 3] - } + eval $env(AMIKA_EXPECT_COMMON) + initialize_transport set timeout 1800 spawn -noecho amika sandbox ssh -t $env(AMIKA_SANDBOX_NAME) -- $env(AMIKA_REMOTE_COMMAND) + set child_active 1 expect { eof { - set result [wait] + set result [wait_for_child] + restore_terminal exit [child_status $result] } timeout { - catch {close} - catch {wait} + reap_child SIGTERM + restore_terminal puts stderr "Amika operation did not finish within 30 minutes" exit 124 } @@ -98,40 +156,30 @@ amika_ssh_secret_line() { while [ "$attempt" -le 3 ]; do if printf '%s\n' "$secret" | \ + AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ expect -c ' - proc child_status {result} { - if {[llength $result] >= 6 && [lindex $result 4] eq "CHILDKILLED"} { - array set signal_number { - SIGHUP 1 SIGINT 2 SIGQUIT 3 SIGKILL 9 SIGPIPE 13 SIGTERM 15 - } - set signal [lindex $result 5] - if {[info exists signal_number($signal)]} { - return [expr {128 + $signal_number($signal)}] - } - return 125 - } - if {[lindex $result 2] != 0} { - return 125 - } - return [lindex $result 3] - } + eval $env(AMIKA_EXPECT_COMMON) + initialize_transport set timeout 30 if {[gets stdin secret] < 0} { + restore_terminal puts stderr "credential input closed before a secret was read" exit 65 } spawn -noecho amika sandbox ssh -t $env(AMIKA_SANDBOX_NAME) -- $env(AMIKA_REMOTE_COMMAND) + set child_active 1 expect { "__LOCALITY_STDIN_READY__" {} eof { - catch {wait} + catch {wait_for_child} + restore_terminal puts stderr "Amika SSH closed before requesting credential input" exit 75 } timeout { - catch {close} - catch {wait} + reap_child SIGTERM + restore_terminal puts stderr "Amika SSH did not request credential input within 30 seconds" exit 75 } @@ -141,12 +189,14 @@ amika_ssh_secret_line() { set timeout 1800 expect { eof { - set result [wait] + set result [wait_for_child] + restore_terminal exit [child_status $result] } timeout { - catch {close} - catch {wait} + set secret "" + reap_child SIGTERM + restore_terminal puts stderr "Amika credential operation did not finish within 30 minutes; it was not retried" exit 124 } @@ -170,7 +220,6 @@ amika_ssh_secret_line() { API_URL="" SANDBOX_NAME="locality-snapshot-$(date -u +%Y%m%d-%H%M%S)" -SANDBOX_NAME_EXPLICIT=false REUSE_SANDBOX=false REMOTE_ROOT="/home/amika/locality-snapshot" LOC_RELEASE_VERSION="0.3.7" @@ -193,7 +242,6 @@ while [ "$#" -gt 0 ]; do --name) [ "$#" -ge 2 ] || fail "--name requires a value" SANDBOX_NAME="$2" - SANDBOX_NAME_EXPLICIT=true shift 2 ;; --reuse) @@ -228,9 +276,8 @@ esac case "$SANDBOX_NAME" in *[!a-zA-Z0-9._-]*|'') fail "--name contains unsupported characters" ;; esac -if [ "$REUSE_SANDBOX" = true ] && [ "$SANDBOX_NAME_EXPLICIT" != true ]; then - fail "--reuse requires an explicit --name" -fi +[ "$REUSE_SANDBOX" != true ] || \ + fail "--reuse is refused because an existing sandbox is not a trusted credential boundary; choose a fresh --name" [ -n "$CODEX_MODEL" ] || fail "--model must not be empty" [ -n "$AZURE_OPENAI_API_KEY_VALUE" ] || fail "AZURE_OPENAI_API_KEY is required" case "$AZURE_OPENAI_BASE_URL" in @@ -301,16 +348,13 @@ case "$PROFILE_KEY" in *[!0-9a-f]*) fail "Workspace Profile key must be 64 lowercase hexadecimal characters" ;; esac -if [ "$REUSE_SANDBOX" = true ]; then - printf 'Reusing Amika sandbox %s...\n' "$SANDBOX_NAME" -else - printf 'Creating Amika sandbox %s...\n' "$SANDBOX_NAME" - (cd "$REPO_ROOT" && amika sandbox create \ - --remote \ - --name "$SANDBOX_NAME" \ - --no-git \ - --yes >/dev/null) -fi +printf 'Creating fresh Amika sandbox %s (existing sandboxes are never reused or replaced)...\n' "$SANDBOX_NAME" +(cd "$REPO_ROOT" && amika sandbox create \ + --remote \ + --name "$SANDBOX_NAME" \ + --no-git \ + --yes >/dev/null) || \ + fail "could not create fresh sandbox ${SANDBOX_NAME}; no credentials were transferred (choose a new name or explicitly delete the old sandbox)" printf 'Installing released loc CLI v%s in %s...\n' "$LOC_RELEASE_VERSION" "$SANDBOX_NAME" amika_ssh "$SANDBOX_NAME" -- sh -c ' @@ -331,26 +375,6 @@ amika_ssh "$SANDBOX_NAME" -- sh -c ' "$HOME/.local/bin/loc" sandbox init --help >/dev/null ' sh "$LOC_RELEASE_VERSION" "$LOC_RELEASE_DEB_SHA256" -if [ "$REUSE_SANDBOX" = true ]; then - printf 'Checking reused sandbox evidence boundary before authorization...\n' - amika_ssh "$SANDBOX_NAME" -- sh -c ' - set -eu - repo_dir=/home/amika/workspace/locality - if [ -e "$repo_dir" ]; then - test "$(git -C "$repo_dir" rev-parse --is-inside-work-tree)" = true - origin_url=$(git -C "$repo_dir" remote get-url origin) - case "$origin_url" in - https://github.com/codeflash-ai/locality|https://github.com/codeflash-ai/locality.git|git@github.com:codeflash-ai/locality.git) ;; - *) printf "unexpected Locality repository origin: %s\n" "$origin_url" >&2; exit 65 ;; - esac - test -z "$(git -C "$repo_dir" status --porcelain --untracked-files=all)" || { - printf "Locality evidence checkout is dirty; use a clean sandbox or remove the changes explicitly\n" >&2 - exit 65 - } - fi - ' sh -fi - printf 'Materializing scoped workspace at %s:%s...\n' "$SANDBOX_NAME" "$REMOTE_ROOT" amika_ssh_secret_line "$SANDBOX_NAME" "$PROFILE_KEY" -- sh -c ' set -eu diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index d79e1271..49bd202b 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPT="${ROOT}/scripts/init-amika-locality-snapshot.sh" +AZURE_SETUP_SCRIPT="${ROOT}/experiment/locality-mcp-comparison/setup-codex-azure.sh" fail() { printf 'init Amika Locality snapshot test: %s\n' "$*" >&2 @@ -15,6 +16,14 @@ assert_contains() { grep -F -q -- "$needle" "$path" || fail "missing ${needle} in ${path}" } +assert_not_contains() { + local path="$1" + local needle="$2" + if grep -F -q -- "$needle" "$path"; then + fail "unexpected ${needle} in ${path}" + fi +} + tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/loc-init-amika-snapshot-test.XXXXXX")" trap 'rm -rf "$tmp_root"' EXIT fake_bin="${tmp_root}/bin" @@ -25,6 +34,64 @@ prompt_input="${tmp_root}/scenario-prompt.input" fake_report="${tmp_root}/final_report.md" mkdir -p "$fake_bin" +cat > "${fake_bin}/codex" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +chmod +x "${fake_bin}/codex" + +setup_agent_root="${tmp_root}/setup-agent" +setup_config="${setup_agent_root}/.codex/config.toml" +mkdir -p "$(dirname "$setup_config")" +cat > "$setup_config" <<'TOML' +# Existing Codex settings must survive Azure setup. +approval_policy = "never" +model = "old-model" + +[model_providers.azure] +name = "Old Azure name" +base_url = "https://old.invalid/openai/v1" +env_key = "OLD_AZURE_KEY" +wire_api = "chat" +custom_setting = "preserved-provider-value" + +[features] +web_search_request = true +TOML + +PATH="${fake_bin}:$PATH" \ + CODEX_HOME="${setup_agent_root}/.codex" \ + AMIKA_AGENT_CWD="$setup_agent_root" \ + CODEX_MODEL="merged-model" \ + CODEX_REASONING_EFFORT="high" \ + AZURE_OPENAI_BASE_URL="https://merged.invalid/openai/v1" \ + "$AZURE_SETUP_SCRIPT" >/dev/null + +python3 - "$setup_config" <<'PY' +import os +import stat +import sys +import tomllib + +path = sys.argv[1] +with open(path, "rb") as source: + config = tomllib.load(source) +assert config["model"] == "merged-model" +assert config["model_provider"] == "azure" +assert config["model_reasoning_effort"] == "high" +assert config["sandbox_mode"] == "workspace-write" +assert config["approval_policy"] == "never" +assert config["features"]["web_search_request"] is True +provider = config["model_providers"]["azure"] +assert provider["name"] == "Azure OpenAI" +assert provider["base_url"] == "https://merged.invalid/openai/v1" +assert provider["env_key"] == "AZURE_OPENAI_API_KEY" +assert provider["wire_api"] == "responses" +assert provider["custom_setting"] == "preserved-provider-value" +assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 +PY +assert_contains "$setup_config" "# Existing Codex settings must survive Azure setup." + cat > "${fake_bin}/amika" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -36,6 +103,9 @@ done printf '\n' >> "$FAKE_AMIKA_LOG" if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "create" ]; then + if [ -n "${FAKE_CREATE_STATUS:-}" ]; then + exit "$FAKE_CREATE_STATUS" + fi printf 'created\n' exit 0 fi @@ -60,6 +130,13 @@ if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "ssh" ]; then if [ -n "${FAKE_SIGNAL_MATCH:-}" ] && grep -F -q -- "$FAKE_SIGNAL_MATCH" <<<"$decoded_log"; then kill -TERM "$$" fi + if [ -n "${FAKE_BLOCK_MATCH:-}" ] && grep -F -q -- "$FAKE_BLOCK_MATCH" <<<"$decoded_log"; then + trap '' HUP INT TERM + sleep 300 & + blocking_child=$! + printf '%s %s\n' "$$" "$blocking_child" > "${FAKE_BLOCK_PID_FILE:?}" + wait "$blocking_child" + fi case "$decoded_log" in *"--profile-key-stdin"*) failures="${FAKE_PRE_SENTINEL_FAILURES:-0}" @@ -177,28 +254,42 @@ grep -F -q -- 'Verified report body.' <<<"$output" || \ fail "terminal output did not include final_report.md" : > "$fake_log" +set +e reuse_output="$( + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + "$SCRIPT" \ + --api-url https://api.dev.locality.dev \ + --name existing-snapshot \ + --reuse &1 +)" +reuse_status=$? +set -e +[ "$reuse_status" -eq 2 ] || fail "--reuse should be refused with status 2" +[ ! -s "$fake_log" ] || fail "refused --reuse contacted Amika" +grep -F -q -- 'existing sandbox is not a trusted credential boundary' <<<"$reuse_output" || \ + fail "refused --reuse did not explain the trust boundary" + +: > "$fake_log" +set +e +create_failure_output="$( printf '%s\n' "$profile_key" | \ PATH="${fake_bin}:$PATH" \ AZURE_OPENAI_API_KEY="$azure_key" \ FAKE_AMIKA_LOG="$fake_log" \ - FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ - FAKE_AZURE_INPUT="$azure_input" \ - FAKE_PROMPT_INPUT="$prompt_input" \ - FAKE_REPORT="$fake_report" \ + FAKE_CREATE_STATUS=17 \ "$SCRIPT" \ --api-url https://api.dev.locality.dev \ - --name existing-snapshot \ - --reuse \ - --model test-model \ - --reasoning medium + --name colliding-snapshot 2>&1 )" -assert_contains "$fake_log" "sandbox ssh -t existing-snapshot" -if grep -F -q -- 'sandbox create' "$fake_log"; then - fail "--reuse must not create another sandbox" -fi -grep -F -q -- 'Reusing Amika sandbox existing-snapshot' <<<"$reuse_output" || \ - fail "reuse output did not identify the existing sandbox" +create_failure_status=$? +set -e +[ "$create_failure_status" -eq 2 ] || fail "fresh sandbox creation failure should fail closed" +assert_contains "$fake_log" "sandbox create --remote --name colliding-snapshot --no-git --yes" +assert_not_contains "$fake_log" "sandbox ssh" +grep -F -q -- 'no credentials were transferred' <<<"$create_failure_output" || \ + fail "sandbox collision failure did not explain credential safety" : > "$fake_log" pre_sentinel_count="${tmp_root}/pre-sentinel.count" @@ -216,7 +307,6 @@ retry_output="$( "$SCRIPT" \ --api-url https://api.dev.locality.dev \ --name retry-snapshot \ - --reuse \ --model test-model \ --reasoning medium 2>&1 )" @@ -236,12 +326,58 @@ signal_output="$( FAKE_PROMPT_INPUT="$prompt_input" \ FAKE_REPORT="$fake_report" \ FAKE_SIGNAL_MATCH='Locality_Linux_v' \ - "$SCRIPT" --api-url https://api.dev.locality.dev --name signaled --reuse 2>&1 + "$SCRIPT" --api-url https://api.dev.locality.dev --name signaled 2>&1 )" signal_status=$? set -e [ "$signal_status" -eq 143 ] || fail "signaled Amika child should return 143, got ${signal_status}: ${signal_output}" +: > "$fake_log" +block_pid_file="${tmp_root}/blocked-child.pids" +interrupt_output="${tmp_root}/interrupt.output" +set +e +printf '%s\n' "$profile_key" | \ + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ + FAKE_AZURE_INPUT="$azure_input" \ + FAKE_PROMPT_INPUT="$prompt_input" \ + FAKE_REPORT="$fake_report" \ + FAKE_BLOCK_MATCH='Locality_Linux_v' \ + FAKE_BLOCK_PID_FILE="$block_pid_file" \ + "$SCRIPT" --api-url https://api.dev.locality.dev --name interrupted >"$interrupt_output" 2>&1 & +interrupted_script_pid=$! +set -e +for _ in $(seq 1 100); do + [ -s "$block_pid_file" ] && break + sleep 0.05 +done +[ -s "$block_pid_file" ] || fail "interruption fixture did not start the remote child" +read -r blocked_amika_pid blocked_descendant_pid < "$block_pid_file" +expect_pid="$(ps -o ppid= -p "$blocked_amika_pid" | tr -d '[:space:]')" +[ -n "$expect_pid" ] || fail "could not find Expect wrapper for interruption fixture" +kill -TERM "$expect_pid" +set +e +wait "$interrupted_script_pid" +interrupt_status=$? +set -e +[ "$interrupt_status" -eq 143 ] || \ + fail "interrupted Expect wrapper should return 143, got ${interrupt_status}: $(cat "$interrupt_output")" +for _ in $(seq 1 100); do + if ! kill -0 "$blocked_amika_pid" 2>/dev/null && \ + ! kill -0 "$blocked_descendant_pid" 2>/dev/null; then + break + fi + sleep 0.05 +done +if kill -0 "$blocked_amika_pid" 2>/dev/null; then + fail "interrupted Expect wrapper left Amika child ${blocked_amika_pid} running" +fi +if kill -0 "$blocked_descendant_pid" 2>/dev/null; then + fail "interrupted Expect wrapper left descendant ${blocked_descendant_pid} running" +fi + : > "$fake_log" post_sentinel_count="${tmp_root}/post-sentinel.count" set +e @@ -256,7 +392,7 @@ post_sentinel_output="$( FAKE_REPORT="$fake_report" \ FAKE_PRE_SENTINEL_COUNT="$post_sentinel_count" \ FAKE_POST_SENTINEL_STATUS=23 \ - "$SCRIPT" --api-url https://api.dev.locality.dev --name post-sentinel --reuse 2>&1 + "$SCRIPT" --api-url https://api.dev.locality.dev --name post-sentinel 2>&1 )" post_sentinel_status=$? set -e @@ -306,20 +442,4 @@ set -e grep -F -q -- 'Workspace Profile key must be 64 lowercase hexadecimal characters' <<<"$invalid_output" || \ fail "empty Workspace Profile key error was not actionable" -: > "$fake_log" -set +e -implicit_reuse_output="$( - printf '%s\n' "$profile_key" | \ - PATH="${fake_bin}:$PATH" \ - AZURE_OPENAI_API_KEY="$azure_key" \ - FAKE_AMIKA_LOG="$fake_log" \ - "$SCRIPT" --api-url https://api.dev.locality.dev --reuse 2>&1 -)" -implicit_reuse_status=$? -set -e -[ "$implicit_reuse_status" -eq 2 ] || fail "--reuse without --name should return usage status 2" -[ ! -s "$fake_log" ] || fail "invalid --reuse should fail before contacting Amika" -grep -F -q -- '--reuse requires an explicit --name' <<<"$implicit_reuse_output" || \ - fail "invalid --reuse error was not actionable" - printf 'init Amika Locality snapshot tests passed\n' From 5cc563c3bebe70caf49d1b4d9f56f5599f8aac00 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 13:51:13 +0530 Subject: [PATCH 4/9] fix Amika snapshot interruption cleanup --- .../setup-codex-azure.sh | 390 ++++++++++++++---- scripts/init-amika-locality-snapshot.sh | 70 +++- tests/init_amika_locality_snapshot.sh | 122 ++++-- 3 files changed, 478 insertions(+), 104 deletions(-) diff --git a/experiment/locality-mcp-comparison/setup-codex-azure.sh b/experiment/locality-mcp-comparison/setup-codex-azure.sh index 665bf465..bdd07efe 100755 --- a/experiment/locality-mcp-comparison/setup-codex-azure.sh +++ b/experiment/locality-mcp-comparison/setup-codex-azure.sh @@ -14,9 +14,9 @@ merge_codex_config() { python3 - "$config_path" "$CODEX_MODEL" "$CODEX_REASONING_EFFORT" \ "$AZURE_OPENAI_BASE_URL" "$sandbox_mode" <<'PY' +import copy import json import os -import re import sys import tempfile import tomllib @@ -35,7 +35,7 @@ if existing: raise SystemExit(f"refusing to modify invalid Codex config {path}: {error}") text = existing.decode("utf-8") -lines = text.splitlines(keepends=True) +parsed_existing = tomllib.loads(text) if text else {} root_values = { "model": model, "model_provider": "azure", @@ -50,81 +50,317 @@ provider_values = { "wire_api": "responses", } -table_pattern = re.compile(r"^\s*\[\[?\s*([^]]+?)\s*]\]?\s*(?:#.*)?$") -key_pattern = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=') -azure_tables = {"model_providers.azure", 'model_providers."azure"'} -section = "" -seen_root = set() -seen_provider = set() -output = [] -root_inserted = False -provider_found = False -provider_inserted = False - -def setting(key, value): - return f"{key} = {json.dumps(value, ensure_ascii=False)}\n" - -def append_missing_root(): - global root_inserted - if root_inserted: - return - for key, value in root_values.items(): - if key not in seen_root: - output.append(setting(key, value)) - if output and output[-1].strip(): - output.append("\n") - root_inserted = True - -def append_missing_provider(): - global provider_inserted - if provider_inserted: - return - for key, value in provider_values.items(): - if key not in seen_provider: - output.append(setting(key, value)) - provider_inserted = True - -for line in lines: - table_match = table_pattern.match(line.rstrip("\r\n")) - if table_match: - if not root_inserted: - append_missing_root() - if section in azure_tables: - append_missing_provider() - section = table_match.group(1).strip() - if section in azure_tables: - if provider_found: - raise SystemExit(f"refusing to merge duplicate Azure provider tables in {path}") - provider_found = True - output.append(line) - continue +def decoded_key_path(source): + """Use tomllib itself to decode bare, quoted, and dotted key syntax.""" + try: + parsed_key = tomllib.loads(f"{source} = 0") + except tomllib.TOMLDecodeError as error: + raise SystemExit(f"refusing to modify unrecognized TOML key in {path}: {error}") + result = [] + cursor = parsed_key + while isinstance(cursor, dict) and len(cursor) == 1: + key, cursor = next(iter(cursor.items())) + result.append(key) + if cursor != 0: + raise SystemExit(f"refusing to modify ambiguous TOML key in {path}") + return tuple(result) + + +def line_end(start): + end = text.find("\n", start) + return len(text) if end < 0 else end + 1 + + +def scan_header(start): + array_table = text.startswith("[[", start) + opening = 2 if array_table else 1 + closing = "]]" if array_table else "]" + index = start + opening + quote = None + escaped = False + while index < len(text): + character = text[index] + if quote == '"': + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + quote = None + index += 1 + continue + if quote == "'": + if character == "'": + quote = None + index += 1 + continue + if character in "\"'": + quote = character + index += 1 + continue + if text.startswith(closing, index): + key_source = text[start + opening:index].strip() + return decoded_key_path(key_source), array_table, line_end(index + len(closing)) + index += 1 + raise SystemExit(f"refusing to modify unterminated TOML table header in {path}") + - key_match = key_pattern.match(line) - if section == "" and key_match and key_match.group(1) in root_values: - key = key_match.group(1) - if key not in seen_root: - output.append(setting(key, root_values[key])) - seen_root.add(key) +def scan_assignment(start): + index = start + quote = None + escaped = False + while index < len(text): + character = text[index] + if quote == '"': + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + quote = None + index += 1 + continue + if quote == "'": + if character == "'": + quote = None + index += 1 + continue + if character in "\"'": + quote = character + elif character == "=": + break + elif character in "\r\n#": + raise SystemExit(f"refusing to modify unrecognized TOML assignment in {path}") + index += 1 + if index == len(text): + raise SystemExit(f"refusing to modify incomplete TOML assignment in {path}") + + key = decoded_key_path(text[start:index].strip()) + value_start = index + 1 + while value_start < len(text) and text[value_start] in " \t": + value_start += 1 + + index = value_start + state = None + square_depth = 0 + brace_depth = 0 + while index < len(text): + if state == "basic": + if text[index] == "\\": + index = min(index + 2, len(text)) + elif text[index] == '"': + state = None + index += 1 + else: + index += 1 + continue + if state == "literal": + if text[index] == "'": + state = None + index += 1 + continue + if state in {"multiline-basic", "multiline-literal"}: + delimiter = '\"\"\"' if state == "multiline-basic" else "'''" + quote_character = delimiter[0] + if state == "multiline-basic" and text[index] == "\\": + index = min(index + 2, len(text)) + continue + if text.startswith(delimiter, index): + while index < len(text) and text[index] == quote_character: + index += 1 + state = None + else: + index += 1 + continue + + if text.startswith('\"\"\"', index): + state = "multiline-basic" + index += 3 + elif text.startswith("'''", index): + state = "multiline-literal" + index += 3 + elif text[index] == '"': + state = "basic" + index += 1 + elif text[index] == "'": + state = "literal" + index += 1 + elif text[index] == "[": + square_depth += 1 + index += 1 + elif text[index] == "]": + square_depth -= 1 + index += 1 + elif text[index] == "{": + brace_depth += 1 + index += 1 + elif text[index] == "}": + brace_depth -= 1 + index += 1 + elif text[index] == "#": + if square_depth == 0 and brace_depth == 0: + break + index = line_end(index) + elif text[index] in "\r\n" and square_depth == 0 and brace_depth == 0: + break + else: + index += 1 + + value_end = index + while value_end > value_start and text[value_end - 1] in " \t": + value_end -= 1 + return key, value_start, value_end, line_end(index) + + +assignments = [] +headers = [] +current_table = () +current_array_table = False +index = 0 +while index < len(text): + statement_start = index + while statement_start < len(text) and text[statement_start] in " \t": + statement_start += 1 + if statement_start == len(text): + break + if text[statement_start] in "\r\n": + index = line_end(statement_start) + continue + if text[statement_start] == "#": + index = line_end(statement_start) continue - if section in azure_tables and key_match and key_match.group(1) in provider_values: - key = key_match.group(1) - if key not in seen_provider: - output.append(setting(key, provider_values[key])) - seen_provider.add(key) + if text[statement_start] == "[": + if headers: + headers[-1]["section_end"] = statement_start + table_path, current_array_table, index = scan_header(statement_start) + current_table = table_path + headers.append({ + "path": table_path, + "array": current_array_table, + "start": statement_start, + "section_end": len(text), + }) continue - output.append(line) - -if not root_inserted: - append_missing_root() -if section in azure_tables: - append_missing_provider() -if not provider_found: - if output and output[-1].strip(): - output.append("\n") - output.append("[model_providers.azure]\n") - append_missing_provider() - -rendered = "".join(output) + key_path, value_start, value_end, index = scan_assignment(statement_start) + assignments.append({ + "path": current_table + key_path, + "context": current_table, + "array": current_array_table, + "value_start": value_start, + "value_end": value_end, + }) +if headers: + headers[-1]["section_end"] = len(text) + +first_header = headers[0]["start"] if headers else len(text) +azure_path = ("model_providers", "azure") +replacements = [] +insertions = {} + + +def replacement_for(target_path, value): + matches = [item for item in assignments if item["path"] == target_path] + if len(matches) > 1 or (matches and matches[0]["array"]): + raise SystemExit(f"refusing to modify ambiguous TOML setting {'.'.join(target_path)} in {path}") + if not matches: + return False + item = matches[0] + replacements.append((item["value_start"], item["value_end"], json.dumps(value, ensure_ascii=False))) + return True + + +def add_group(position, lines): + insertions.setdefault(position, []).append(lines) + + +missing_root = [] +for key, value in root_values.items(): + if not replacement_for((key,), value): + if key in parsed_existing: + raise SystemExit(f"refusing to modify non-source TOML setting {key} in {path}") + missing_root.append(f"{key} = {json.dumps(value, ensure_ascii=False)}") +if missing_root: + add_group(first_header, missing_root) + +model_providers = parsed_existing.get("model_providers", {}) +if not isinstance(model_providers, dict): + raise SystemExit(f"refusing to replace non-table model_providers in {path}") +provider = model_providers.get("azure") +if provider is not None and not isinstance(provider, dict): + raise SystemExit(f"refusing to replace non-table Azure provider in {path}") + +missing_provider = [] +for key, value in provider_values.items(): + if not replacement_for(azure_path + (key,), value): + if isinstance(provider, dict) and key in provider: + raise SystemExit(f"refusing to modify inline Azure provider setting {key} in {path}") + missing_provider.append(key) + +if missing_provider: + explicit_provider_tables = [ + header for header in headers + if header["path"] == azure_path and not header["array"] + ] + if len(explicit_provider_tables) > 1: + raise SystemExit(f"refusing to modify duplicate Azure provider tables in {path}") + if explicit_provider_tables: + header = explicit_provider_tables[0] + add_group(header["section_end"], [ + f"{key} = {json.dumps(provider_values[key], ensure_ascii=False)}" + for key in missing_provider + ]) + elif provider is None: + add_group(len(text), [ + "[model_providers.azure]", + *( + f"{key} = {json.dumps(provider_values[key], ensure_ascii=False)}" + for key in missing_provider + ), + ]) + else: + provider_children = [ + item for item in assignments + if not item["array"] + and len(item["path"]) > len(azure_path) + and item["path"][:len(azure_path)] == azure_path + and azure_path[:len(item["context"])] == item["context"] + ] + if not provider_children: + raise SystemExit(f"refusing to extend inline Azure provider table in {path}") + context = max((item["context"] for item in provider_children), key=len) + if context: + context_headers = [ + header for header in headers + if header["path"] == context and not header["array"] + ] + if len(context_headers) != 1: + raise SystemExit(f"refusing to extend ambiguous Azure provider context in {path}") + insertion_position = context_headers[0]["section_end"] + else: + insertion_position = first_header + relative_provider = azure_path[len(context):] + add_group(insertion_position, [ + f"{'.'.join((*relative_provider, key))} = {json.dumps(provider_values[key], ensure_ascii=False)}" + for key in missing_provider + ]) + +newline = "\r\n" if "\r\n" in text else "\n" +for position, groups in insertions.items(): + payload = (newline * 2).join(newline.join(group) for group in groups) + if position > 0 and text[position - 1] not in "\r\n": + payload = newline + payload + if position < len(text) or text.endswith(("\n", "\r")): + payload += newline + replacements.append((position, position, payload)) + +ordered = sorted(replacements, key=lambda item: (item[0], item[1])) +for previous, current in zip(ordered, ordered[1:]): + if previous[1] > current[0]: + raise SystemExit(f"refusing to apply overlapping TOML edits to {path}") +rendered = text +for start, end, replacement in reversed(ordered): + rendered = rendered[:start] + replacement + rendered[end:] + try: parsed = tomllib.loads(rendered) except tomllib.TOMLDecodeError as error: @@ -138,6 +374,12 @@ if any(parsed.get(key) != value for key, value in expected_root.items()): if any(provider.get(key) != value for key, value in expected_provider.items()): raise SystemExit(f"merged Azure provider settings failed validation for {path}") +expected = copy.deepcopy(parsed_existing) +expected.update(root_values) +expected.setdefault("model_providers", {}).setdefault("azure", {}).update(provider_values) +if parsed != expected: + raise SystemExit(f"refusing to write Codex config after unrelated TOML values changed in {path}") + directory = os.path.dirname(path) os.makedirs(directory, mode=0o700, exist_ok=True) fd, temporary = tempfile.mkstemp(prefix=".config.toml.", dir=directory) diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index dda3c099..20b3d6f0 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -33,6 +33,63 @@ fail() { exit 2 } +ACTIVE_EXPECT_PID="" +PENDING_TRANSPORT_SIGNAL="" + +terminate_active_transport() { + local signal="$1" + local signal_number="$2" + local expect_pid="${ACTIVE_EXPECT_PID:-}" + + trap '' HUP INT TERM + PROFILE_KEY="" + AZURE_OPENAI_API_KEY_VALUE="" + if [ -n "$expect_pid" ]; then + kill -s "$signal" "$expect_pid" 2>/dev/null || true + wait "$expect_pid" 2>/dev/null || true + fi + ACTIVE_EXPECT_PID="" + exit $((128 + signal_number)) +} + +trap 'terminate_active_transport HUP 1' HUP +trap 'terminate_active_transport INT 2' INT +trap 'terminate_active_transport TERM 15' TERM + +prepare_expect_launch() { + PENDING_TRANSPORT_SIGNAL="" + trap 'PENDING_TRANSPORT_SIGNAL="HUP 1"' HUP + trap 'PENDING_TRANSPORT_SIGNAL="INT 2"' INT + trap 'PENDING_TRANSPORT_SIGNAL="TERM 15"' TERM +} + +activate_expect() { + ACTIVE_EXPECT_PID="$1" + trap 'terminate_active_transport HUP 1' HUP + trap 'terminate_active_transport INT 2' INT + trap 'terminate_active_transport TERM 15' TERM + case "$PENDING_TRANSPORT_SIGNAL" in + "HUP 1") terminate_active_transport HUP 1 ;; + "INT 2") terminate_active_transport INT 2 ;; + "TERM 15") terminate_active_transport TERM 15 ;; + esac +} + +wait_for_active_expect() { + local expect_pid="$1" + local status + + if wait "$expect_pid"; then + status=0 + else + status=$? + fi + if [ "${ACTIVE_EXPECT_PID:-}" = "$expect_pid" ]; then + ACTIVE_EXPECT_PID="" + fi + return "$status" +} + encode_remote_argv() { local payload payload="$(printf '%s\0' "$@" | base64 | tr -d '\n')" @@ -117,6 +174,7 @@ amika_ssh() { fi remote_command="$(encode_remote_argv "$@")" command -v expect >/dev/null 2>&1 || fail "expect is required for Amika PTY transport" + prepare_expect_launch AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ expect -c ' @@ -138,7 +196,9 @@ amika_ssh() { exit 124 } } - ' + ' & + activate_expect "$!" + wait_for_active_expect "$ACTIVE_EXPECT_PID" } amika_ssh_secret_line() { @@ -155,8 +215,8 @@ amika_ssh_secret_line() { remote_command="$(encode_remote_argv "$@")" while [ "$attempt" -le 3 ]; do - if printf '%s\n' "$secret" | \ - AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ + prepare_expect_launch + AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ expect -c ' eval $env(AMIKA_EXPECT_COMMON) @@ -201,7 +261,9 @@ amika_ssh_secret_line() { exit 124 } } - '; then + ' <<<"$secret" & + activate_expect "$!" + if wait_for_active_expect "$ACTIVE_EXPECT_PID"; then secret="" return 0 else diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index 49bd202b..f89f9a2e 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -43,21 +43,40 @@ chmod +x "${fake_bin}/codex" setup_agent_root="${tmp_root}/setup-agent" setup_config="${setup_agent_root}/.codex/config.toml" mkdir -p "$(dirname "$setup_config")" -cat > "$setup_config" <<'TOML' +setup_config_text="$(cat <<'TOML' # Existing Codex settings must survive Azure setup. -approval_policy = "never" -model = "old-model" +"approval_policy" = "never" # keep quoted root key +"model" = "old-model" # keep target comment +"literal.key" = 'preserved literal value' -[model_providers.azure] -name = "Old Azure name" -base_url = "https://old.invalid/openai/v1" -env_key = "OLD_AZURE_KEY" -wire_api = "chat" +["model_providers"."azure"] # keep quoted provider table +"name" = "Old Azure name" # keep provider comment +"base_url" = "https://old.invalid/openai/v1" +"env_key" = "OLD_AZURE_KEY" +"wire_api" = "chat" custom_setting = "preserved-provider-value" [features] web_search_request = true +multiline_basic = """ +model = "not a real setting" +[model_providers.azure] +# this comment belongs to the string +""" +multiline_literal = ''' +wire_api = "also not a real setting" +''' +preserved_array = [ + "first", + "second", # keep array comment +] + +["quoted.table"] +"quoted.key" = "preserved without a trailing newline" TOML +)" +printf '%s' "$setup_config_text" > "$setup_config" +unset setup_config_text PATH="${fake_bin}:$PATH" \ CODEX_HOME="${setup_agent_root}/.codex" \ @@ -75,13 +94,20 @@ import tomllib path = sys.argv[1] with open(path, "rb") as source: - config = tomllib.load(source) + contents = source.read() +config = tomllib.loads(contents.decode("utf-8")) assert config["model"] == "merged-model" assert config["model_provider"] == "azure" assert config["model_reasoning_effort"] == "high" assert config["sandbox_mode"] == "workspace-write" assert config["approval_policy"] == "never" assert config["features"]["web_search_request"] is True +assert config["literal.key"] == "preserved literal value" +assert 'model = "not a real setting"' in config["features"]["multiline_basic"] +assert '[model_providers.azure]' in config["features"]["multiline_basic"] +assert 'wire_api = "also not a real setting"' in config["features"]["multiline_literal"] +assert config["features"]["preserved_array"] == ["first", "second"] +assert config["quoted.table"]["quoted.key"] == "preserved without a trailing newline" provider = config["model_providers"]["azure"] assert provider["name"] == "Azure OpenAI" assert provider["base_url"] == "https://merged.invalid/openai/v1" @@ -89,8 +115,50 @@ assert provider["env_key"] == "AZURE_OPENAI_API_KEY" assert provider["wire_api"] == "responses" assert provider["custom_setting"] == "preserved-provider-value" assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 +assert not contents.endswith((b"\n", b"\r")) +text = contents.decode("utf-8") +assert '# Existing Codex settings must survive Azure setup.' in text +assert '"approval_policy" = "never" # keep quoted root key' in text +assert '"model" = "merged-model" # keep target comment' in text +assert '["model_providers"."azure"] # keep quoted provider table' in text +assert '"name" = "Azure OpenAI" # keep provider comment' in text +assert '''multiline_basic = """ +model = "not a real setting" +[model_providers.azure] +# this comment belongs to the string +"""''' in text +assert '''multiline_literal = \'\'\' +wire_api = "also not a real setting" +\'\'\'''' in text +assert '''preserved_array = [ + "first", + "second", # keep array comment +]''' in text PY -assert_contains "$setup_config" "# Existing Codex settings must survive Azure setup." + +inline_setup_root="${tmp_root}/inline-setup" +inline_setup_config="${inline_setup_root}/config.toml" +mkdir -p "$inline_setup_root" +printf '%s' 'model = "old-model" +model_providers = { azure = { name = "inline Azure" } }' > "$inline_setup_config" +cp "$inline_setup_config" "${inline_setup_config}.expected" +set +e +inline_setup_output="$( + env -u AMIKA_AGENT_CWD \ + PATH="${fake_bin}:$PATH" \ + CODEX_HOME="$inline_setup_root" \ + CODEX_MODEL="merged-model" \ + CODEX_REASONING_EFFORT="high" \ + AZURE_OPENAI_BASE_URL="https://merged.invalid/openai/v1" \ + "$AZURE_SETUP_SCRIPT" 2>&1 +)" +inline_setup_status=$? +set -e +[ "$inline_setup_status" -ne 0 ] || fail "inline Azure provider table should fail closed" +cmp -s "$inline_setup_config" "${inline_setup_config}.expected" || \ + fail "failed inline Azure provider merge changed the original config" +grep -F -q -- 'refusing to modify inline Azure provider setting' <<<"$inline_setup_output" || \ + fail "inline Azure provider failure did not explain why the merge was refused" cat > "${fake_bin}/amika" <<'SH' #!/usr/bin/env bash @@ -134,7 +202,7 @@ if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "ssh" ]; then trap '' HUP INT TERM sleep 300 & blocking_child=$! - printf '%s %s\n' "$$" "$blocking_child" > "${FAKE_BLOCK_PID_FILE:?}" + printf '%s %s %s\n' "$PPID" "$$" "$blocking_child" > "${FAKE_BLOCK_PID_FILE:?}" wait "$blocking_child" fi case "$decoded_log" in @@ -335,9 +403,9 @@ set -e : > "$fake_log" block_pid_file="${tmp_root}/blocked-child.pids" interrupt_output="${tmp_root}/interrupt.output" -set +e -printf '%s\n' "$profile_key" | \ - PATH="${fake_bin}:$PATH" \ +interrupt_input="${tmp_root}/interrupt.input" +printf '%s\n' "$profile_key" > "$interrupt_input" +PATH="${fake_bin}:$PATH" \ AZURE_OPENAI_API_KEY="$azure_key" \ FAKE_AMIKA_LOG="$fake_log" \ FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ @@ -346,36 +414,38 @@ printf '%s\n' "$profile_key" | \ FAKE_REPORT="$fake_report" \ FAKE_BLOCK_MATCH='Locality_Linux_v' \ FAKE_BLOCK_PID_FILE="$block_pid_file" \ - "$SCRIPT" --api-url https://api.dev.locality.dev --name interrupted >"$interrupt_output" 2>&1 & -interrupted_script_pid=$! -set -e + "$SCRIPT" --api-url https://api.dev.locality.dev --name interrupted \ + <"$interrupt_input" >"$interrupt_output" 2>&1 & +public_script_pid=$! for _ in $(seq 1 100); do [ -s "$block_pid_file" ] && break sleep 0.05 done [ -s "$block_pid_file" ] || fail "interruption fixture did not start the remote child" -read -r blocked_amika_pid blocked_descendant_pid < "$block_pid_file" -expect_pid="$(ps -o ppid= -p "$blocked_amika_pid" | tr -d '[:space:]')" -[ -n "$expect_pid" ] || fail "could not find Expect wrapper for interruption fixture" -kill -TERM "$expect_pid" +read -r blocked_expect_pid blocked_amika_pid blocked_descendant_pid < "$block_pid_file" +kill -TERM "$public_script_pid" set +e -wait "$interrupted_script_pid" +wait "$public_script_pid" interrupt_status=$? set -e [ "$interrupt_status" -eq 143 ] || \ - fail "interrupted Expect wrapper should return 143, got ${interrupt_status}: $(cat "$interrupt_output")" + fail "TERM-interrupted public script should return 143, got ${interrupt_status}: $(cat "$interrupt_output")" for _ in $(seq 1 100); do - if ! kill -0 "$blocked_amika_pid" 2>/dev/null && \ + if ! kill -0 "$blocked_expect_pid" 2>/dev/null && \ + ! kill -0 "$blocked_amika_pid" 2>/dev/null && \ ! kill -0 "$blocked_descendant_pid" 2>/dev/null; then break fi sleep 0.05 done +if kill -0 "$blocked_expect_pid" 2>/dev/null; then + fail "interrupted public script left Expect wrapper ${blocked_expect_pid} running" +fi if kill -0 "$blocked_amika_pid" 2>/dev/null; then - fail "interrupted Expect wrapper left Amika child ${blocked_amika_pid} running" + fail "interrupted public script left Amika child ${blocked_amika_pid} running" fi if kill -0 "$blocked_descendant_pid" 2>/dev/null; then - fail "interrupted Expect wrapper left descendant ${blocked_descendant_pid} running" + fail "interrupted public script left descendant ${blocked_descendant_pid} running" fi : > "$fake_log" From 2daeb49c6a53c78b25b7513b50a6fa6b6c960fb1 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 14:07:41 +0530 Subject: [PATCH 5/9] fix Amika credential and startup interruption handling --- scripts/init-amika-locality-snapshot.sh | 174 +++++++++++++++++------- tests/init_amika_locality_snapshot.sh | 149 ++++++++++++++++++++ 2 files changed, 277 insertions(+), 46 deletions(-) diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index 20b3d6f0..53006d9a 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -33,59 +33,105 @@ fail() { exit 2 } -ACTIVE_EXPECT_PID="" -PENDING_TRANSPORT_SIGNAL="" +ACTIVE_CHILD_PID="" +PENDING_CHILD_SIGNAL="" +TERMINAL_STATE="" +TERMINAL_STATE_ACTIVE=false +ACTIVE_DELIVERY_DIR="" -terminate_active_transport() { +restore_terminal_state() { + local saved_state="${TERMINAL_STATE:-}" + + if [ "${TERMINAL_STATE_ACTIVE:-false}" != true ]; then + return + fi + TERMINAL_STATE_ACTIVE=false + TERMINAL_STATE="" + stty "$saved_state" < /dev/tty 2>/dev/null || stty echo < /dev/tty 2>/dev/null || true +} + +cleanup_delivery_marker() { + local delivery_dir="${ACTIVE_DELIVERY_DIR:-}" + + if [ -z "$delivery_dir" ]; then + return + fi + ACTIVE_DELIVERY_DIR="" + rm -f -- "$delivery_dir/delivered" 2>/dev/null || true + rmdir "$delivery_dir" 2>/dev/null || true +} + +forward_and_reap_active_child() { + local signal="$1" + local child_pid="${ACTIVE_CHILD_PID:-}" + + if [ -z "$child_pid" ]; then + return + fi + ACTIVE_CHILD_PID="" + kill -s "$signal" "$child_pid" 2>/dev/null || true + wait "$child_pid" 2>/dev/null || true +} + +cleanup_on_exit() { + local status="$1" + + trap - EXIT + trap '' HUP INT TERM + restore_terminal_state + forward_and_reap_active_child TERM + cleanup_delivery_marker + exit "$status" +} + +terminate_for_signal() { local signal="$1" local signal_number="$2" - local expect_pid="${ACTIVE_EXPECT_PID:-}" trap '' HUP INT TERM PROFILE_KEY="" AZURE_OPENAI_API_KEY_VALUE="" - if [ -n "$expect_pid" ]; then - kill -s "$signal" "$expect_pid" 2>/dev/null || true - wait "$expect_pid" 2>/dev/null || true - fi - ACTIVE_EXPECT_PID="" + restore_terminal_state + forward_and_reap_active_child "$signal" + cleanup_delivery_marker exit $((128 + signal_number)) } -trap 'terminate_active_transport HUP 1' HUP -trap 'terminate_active_transport INT 2' INT -trap 'terminate_active_transport TERM 15' TERM +trap 'cleanup_on_exit $?' EXIT +trap 'terminate_for_signal HUP 1' HUP +trap 'terminate_for_signal INT 2' INT +trap 'terminate_for_signal TERM 15' TERM -prepare_expect_launch() { - PENDING_TRANSPORT_SIGNAL="" - trap 'PENDING_TRANSPORT_SIGNAL="HUP 1"' HUP - trap 'PENDING_TRANSPORT_SIGNAL="INT 2"' INT - trap 'PENDING_TRANSPORT_SIGNAL="TERM 15"' TERM +prepare_child_launch() { + PENDING_CHILD_SIGNAL="" + trap 'PENDING_CHILD_SIGNAL="HUP 1"' HUP + trap 'PENDING_CHILD_SIGNAL="INT 2"' INT + trap 'PENDING_CHILD_SIGNAL="TERM 15"' TERM } -activate_expect() { - ACTIVE_EXPECT_PID="$1" - trap 'terminate_active_transport HUP 1' HUP - trap 'terminate_active_transport INT 2' INT - trap 'terminate_active_transport TERM 15' TERM - case "$PENDING_TRANSPORT_SIGNAL" in - "HUP 1") terminate_active_transport HUP 1 ;; - "INT 2") terminate_active_transport INT 2 ;; - "TERM 15") terminate_active_transport TERM 15 ;; +activate_child() { + ACTIVE_CHILD_PID="$1" + trap 'terminate_for_signal HUP 1' HUP + trap 'terminate_for_signal INT 2' INT + trap 'terminate_for_signal TERM 15' TERM + case "$PENDING_CHILD_SIGNAL" in + "HUP 1") terminate_for_signal HUP 1 ;; + "INT 2") terminate_for_signal INT 2 ;; + "TERM 15") terminate_for_signal TERM 15 ;; esac } -wait_for_active_expect() { - local expect_pid="$1" +wait_for_active_child() { + local child_pid="$1" local status - if wait "$expect_pid"; then + if wait "$child_pid"; then status=0 else status=$? fi - if [ "${ACTIVE_EXPECT_PID:-}" = "$expect_pid" ]; then - ACTIVE_EXPECT_PID="" + if [ "${ACTIVE_CHILD_PID:-}" = "$child_pid" ]; then + ACTIVE_CHILD_PID="" fi return "$status" } @@ -174,7 +220,7 @@ amika_ssh() { fi remote_command="$(encode_remote_argv "$@")" command -v expect >/dev/null 2>&1 || fail "expect is required for Amika PTY transport" - prepare_expect_launch + prepare_child_launch AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ expect -c ' @@ -197,14 +243,16 @@ amika_ssh() { } } ' & - activate_expect "$!" - wait_for_active_expect "$ACTIVE_EXPECT_PID" + activate_child "$!" + wait_for_active_child "$ACTIVE_CHILD_PID" } amika_ssh_secret_line() { local sandbox="$1" local secret="$2" local attempt=1 + local delivered + local delivery_marker local status local remote_command shift 2 @@ -213,11 +261,15 @@ amika_ssh_secret_line() { fi command -v expect >/dev/null 2>&1 || fail "expect is required for Amika credential transfer" remote_command="$(encode_remote_argv "$@")" + ACTIVE_DELIVERY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/amika-secret-delivery.XXXXXX")" || \ + fail "could not create credential delivery state directory" + delivery_marker="$ACTIVE_DELIVERY_DIR/delivered" while [ "$attempt" -le 3 ]; do - prepare_expect_launch + prepare_child_launch AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ + AMIKA_DELIVERY_MARKER="$delivery_marker" \ expect -c ' eval $env(AMIKA_EXPECT_COMMON) initialize_transport @@ -245,6 +297,16 @@ amika_ssh_secret_line() { } } send -- "$secret\n" + if {[catch { + set marker [open $env(AMIKA_DELIVERY_MARKER) {WRONLY CREAT EXCL}] + close $marker + }]} { + set secret "" + reap_child SIGTERM + restore_terminal + puts stderr "could not record credential delivery; it was not retried" + exit 125 + } set secret "" set timeout 1800 expect { @@ -262,16 +324,22 @@ amika_ssh_secret_line() { } } ' <<<"$secret" & - activate_expect "$!" - if wait_for_active_expect "$ACTIVE_EXPECT_PID"; then + activate_child "$!" + if wait_for_active_child "$ACTIVE_CHILD_PID"; then secret="" + cleanup_delivery_marker return 0 else status=$? fi - if [ "$status" -ne 75 ] || [ "$attempt" -eq 3 ]; then + delivered=false + if [ -f "$delivery_marker" ]; then + delivered=true + fi + if [ "$status" -ne 75 ] || [ "$delivered" = true ] || [ "$attempt" -eq 3 ]; then secret="" + cleanup_delivery_marker return "$status" fi printf 'Amika credential transport closed before secret delivery; retrying (%s/3)...\n' "$attempt" >&2 @@ -280,6 +348,22 @@ amika_ssh_secret_line() { done } +create_amika_sandbox() { + local sandbox="$1" + + prepare_child_launch + ( + cd "$REPO_ROOT" + exec amika sandbox create \ + --remote \ + --name "$sandbox" \ + --no-git \ + --yes + ) >/dev/null & + activate_child "$!" + wait_for_active_child "$ACTIVE_CHILD_PID" +} + API_URL="" SANDBOX_NAME="locality-snapshot-$(date -u +%Y%m%d-%H%M%S)" REUSE_SANDBOX=false @@ -395,12 +479,14 @@ command -v git >/dev/null 2>&1 || fail "git is not available on PATH" SOURCE_REVISION="$(git -C "$REPO_ROOT" rev-parse HEAD)" || fail "could not resolve source revision" if [ -t 0 ]; then - stty -echo + TERMINAL_STATE="$(stty -g < /dev/tty)" || fail "could not read terminal state" + TERMINAL_STATE_ACTIVE=true + stty -echo < /dev/tty || fail "could not disable terminal echo" IFS= read -r PROFILE_KEY || { - stty echo + restore_terminal_state fail "read the Workspace Profile key from standard input" } - stty echo + restore_terminal_state printf '\n' else IFS= read -r PROFILE_KEY || fail "read the Workspace Profile key from standard input" @@ -411,11 +497,7 @@ case "$PROFILE_KEY" in esac printf 'Creating fresh Amika sandbox %s (existing sandboxes are never reused or replaced)...\n' "$SANDBOX_NAME" -(cd "$REPO_ROOT" && amika sandbox create \ - --remote \ - --name "$SANDBOX_NAME" \ - --no-git \ - --yes >/dev/null) || \ +create_amika_sandbox "$SANDBOX_NAME" || \ fail "could not create fresh sandbox ${SANDBOX_NAME}; no credentials were transferred (choose a new name or explicitly delete the old sandbox)" printf 'Installing released loc CLI v%s in %s...\n' "$LOC_RELEASE_VERSION" "$SANDBOX_NAME" diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index f89f9a2e..5bb01ea5 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -171,6 +171,10 @@ done printf '\n' >> "$FAKE_AMIKA_LOG" if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "create" ]; then + if [ -n "${FAKE_CREATE_BLOCK_PID_FILE:-}" ]; then + printf '%s\n' "$$" > "$FAKE_CREATE_BLOCK_PID_FILE" + exec sleep 300 + fi if [ -n "${FAKE_CREATE_STATUS:-}" ]; then exit "$FAKE_CREATE_STATUS" fi @@ -254,6 +258,88 @@ chmod +x "${fake_bin}/amika" profile_key="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" azure_key="test-azure-key" + +tty_bin="${tmp_root}/tty-bin" +tty_stty_log="${tmp_root}/tty-stty.log" +tty_restored="${tmp_root}/tty-restored" +mkdir -p "$tty_bin" +cat > "${tty_bin}/stty" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "${FAKE_STTY_LOG:?}" +"${REAL_STTY:?}" "$@" +stty_status=$? +case "${1:-}" in + -g|-echo) ;; + *) + if [ "$stty_status" -eq 0 ]; then + printf 'restored\n' > "${FAKE_STTY_RESTORED:?}" + fi + ;; +esac +exit "$stty_status" +SH +chmod +x "${tty_bin}/stty" + +: > "$fake_log" +set +e +TTY_TEST_PATH="${tty_bin}:${fake_bin}:$PATH" \ + TTY_TEST_SCRIPT="$SCRIPT" \ + TTY_TEST_AZURE_KEY="$azure_key" \ + TTY_TEST_AMIKA_LOG="$fake_log" \ + TTY_TEST_STTY_LOG="$tty_stty_log" \ + TTY_TEST_RESTORED="$tty_restored" \ + TTY_TEST_REAL_STTY="$(command -v stty)" \ + expect -c ' + set timeout 10 + spawn -noecho env \ + PATH=$env(TTY_TEST_PATH) \ + AZURE_OPENAI_API_KEY=$env(TTY_TEST_AZURE_KEY) \ + FAKE_AMIKA_LOG=$env(TTY_TEST_AMIKA_LOG) \ + FAKE_STTY_LOG=$env(TTY_TEST_STTY_LOG) \ + FAKE_STTY_RESTORED=$env(TTY_TEST_RESTORED) \ + REAL_STTY=$env(TTY_TEST_REAL_STTY) \ + $env(TTY_TEST_SCRIPT) \ + --api-url https://api.dev.locality.dev \ + --name interrupted-read + set public_script_pid [exp_pid] + set echo_disabled 0 + for {set attempt 0} {$attempt < 100} {incr attempt} { + after 50 + if {[file exists $env(TTY_TEST_STTY_LOG)]} { + set handle [open $env(TTY_TEST_STTY_LOG) r] + set log [read $handle] + close $handle + if {[string first "-echo\n" $log] >= 0} { + set echo_disabled 1 + break + } + } + } + if {!$echo_disabled} { + catch {exec kill -TERM $public_script_pid} + catch {expect eof} + catch {wait} + exit 124 + } + exec kill -TERM $public_script_pid + expect eof + set result [wait] + if {[lindex $result 2] != 0} { + exit 125 + } + exit [lindex $result 3] + ' >/dev/null 2>&1 +tty_interrupt_status=$? +set -e +[ "$tty_interrupt_status" -eq 143 ] || \ + fail "TERM-interrupted public script credential read should return 143, got ${tty_interrupt_status}" +[ -s "$tty_restored" ] || \ + fail "credential read interruption did not restore the saved terminal state: $(cat "$tty_stty_log")" +grep -F -x -q -- '-g' "$tty_stty_log" || fail "credential read did not capture terminal state" +grep -F -x -q -- '-echo' "$tty_stty_log" || fail "credential read did not disable terminal echo" +[ ! -s "$fake_log" ] || fail "credential read interruption contacted Amika" + output="$( printf '%s\n' "$profile_key" | \ PATH="${fake_bin}:$PATH" \ @@ -359,6 +445,43 @@ assert_not_contains "$fake_log" "sandbox ssh" grep -F -q -- 'no credentials were transferred' <<<"$create_failure_output" || \ fail "sandbox collision failure did not explain credential safety" +: > "$fake_log" +create_block_pid_file="${tmp_root}/create-block.pid" +create_interrupt_input="${tmp_root}/create-interrupt.input" +create_interrupt_output="${tmp_root}/create-interrupt.output" +printf '%s\n' "$profile_key" > "$create_interrupt_input" +PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_CREATE_BLOCK_PID_FILE="$create_block_pid_file" \ + "$SCRIPT" --api-url https://api.dev.locality.dev --name interrupted-create \ + <"$create_interrupt_input" >"$create_interrupt_output" 2>&1 & +create_public_script_pid=$! +for _ in $(seq 1 100); do + [ -s "$create_block_pid_file" ] && break + sleep 0.05 +done +[ -s "$create_block_pid_file" ] || fail "sandbox creation interruption fixture did not start" +blocked_create_pid="$(cat "$create_block_pid_file")" +kill -TERM "$create_public_script_pid" +set +e +wait "$create_public_script_pid" +create_interrupt_status=$? +set -e +[ "$create_interrupt_status" -eq 143 ] || \ + fail "TERM-interrupted public script sandbox creation should return 143, got ${create_interrupt_status}: $(cat "$create_interrupt_output")" +for _ in $(seq 1 100); do + if ! kill -0 "$blocked_create_pid" 2>/dev/null; then + break + fi + sleep 0.05 +done +if kill -0 "$blocked_create_pid" 2>/dev/null; then + fail "interrupted public script left sandbox creation child ${blocked_create_pid} running" +fi +assert_contains "$fake_log" "sandbox create --remote --name interrupted-create --no-git --yes" +assert_not_contains "$fake_log" "sandbox ssh" + : > "$fake_log" pre_sentinel_count="${tmp_root}/pre-sentinel.count" retry_output="$( @@ -472,6 +595,32 @@ if grep -F -q -- 'credential transport closed' <<<"$post_sentinel_output"; then fail "post-sentinel failure was incorrectly classified as retryable transport" fi +: > "$fake_log" +post_sentinel_75_count="${tmp_root}/post-sentinel-75.count" +set +e +post_sentinel_75_output="$( + printf '%s\n' "$profile_key" | \ + PATH="${fake_bin}:$PATH" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_PROFILE_KEY_INPUT="$profile_key_input" \ + FAKE_AZURE_INPUT="$azure_input" \ + FAKE_PROMPT_INPUT="$prompt_input" \ + FAKE_REPORT="$fake_report" \ + FAKE_PRE_SENTINEL_COUNT="$post_sentinel_75_count" \ + FAKE_POST_SENTINEL_STATUS=75 \ + "$SCRIPT" --api-url https://api.dev.locality.dev --name post-sentinel-75 2>&1 +)" +post_sentinel_75_status=$? +set -e +[ "$post_sentinel_75_status" -eq 75 ] || \ + fail "post-sentinel child status 75 should be preserved, got ${post_sentinel_75_status}" +[ "$(cat "$post_sentinel_75_count")" -eq 1 ] || \ + fail "post-sentinel child status 75 replayed the credential" +if grep -F -q -- 'credential transport closed' <<<"$post_sentinel_75_output"; then + fail "post-sentinel child status 75 was incorrectly classified as pre-delivery transport failure" +fi + : > "$fake_log" set +e missing_azure_output="$( From 0591f6dc732f60629517e9ef7f11367e8f1358f7 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 14:41:44 +0530 Subject: [PATCH 6/9] fix(amika): harden profile-key sandbox startup --- .../setup-codex-azure.sh | 44 ++-- scripts/init-amika-locality-snapshot.sh | 66 +++++- tests/init_amika_locality_snapshot.sh | 208 ++++++++++++++---- 3 files changed, 251 insertions(+), 67 deletions(-) diff --git a/experiment/locality-mcp-comparison/setup-codex-azure.sh b/experiment/locality-mcp-comparison/setup-codex-azure.sh index bdd07efe..29297ed1 100755 --- a/experiment/locality-mcp-comparison/setup-codex-azure.sh +++ b/experiment/locality-mcp-comparison/setup-codex-azure.sh @@ -326,23 +326,39 @@ if missing_provider: and azure_path[:len(item["context"])] == item["context"] ] if not provider_children: - raise SystemExit(f"refusing to extend inline Azure provider table in {path}") - context = max((item["context"] for item in provider_children), key=len) - if context: - context_headers = [ + nested_provider_tables = [ header for header in headers - if header["path"] == context and not header["array"] + if not header["array"] + and len(header["path"]) > len(azure_path) + and header["path"][:len(azure_path)] == azure_path ] - if len(context_headers) != 1: - raise SystemExit(f"refusing to extend ambiguous Azure provider context in {path}") - insertion_position = context_headers[0]["section_end"] + if not nested_provider_tables: + raise SystemExit(f"refusing to extend inline Azure provider table in {path}") + first_nested_table = min(nested_provider_tables, key=lambda header: header["start"]) + add_group(first_nested_table["start"], [ + "[model_providers.azure]", + *( + f"{key} = {json.dumps(provider_values[key], ensure_ascii=False)}" + for key in missing_provider + ), + ]) else: - insertion_position = first_header - relative_provider = azure_path[len(context):] - add_group(insertion_position, [ - f"{'.'.join((*relative_provider, key))} = {json.dumps(provider_values[key], ensure_ascii=False)}" - for key in missing_provider - ]) + context = max((item["context"] for item in provider_children), key=len) + if context: + context_headers = [ + header for header in headers + if header["path"] == context and not header["array"] + ] + if len(context_headers) != 1: + raise SystemExit(f"refusing to extend ambiguous Azure provider context in {path}") + insertion_position = context_headers[0]["section_end"] + else: + insertion_position = first_header + relative_provider = azure_path[len(context):] + add_group(insertion_position, [ + f"{'.'.join((*relative_provider, key))} = {json.dumps(provider_values[key], ensure_ascii=False)}" + for key in missing_provider + ]) newline = "\r\n" if "\r\n" in text else "\n" for position, groups in insertions.items(): diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index 53006d9a..ce42febe 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash set -euo pipefail +set +a +unset PROFILE_KEY AZURE_OPENAI_API_KEY_VALUE usage() { cat <<'EOF' @@ -70,7 +72,42 @@ forward_and_reap_active_child() { fi ACTIVE_CHILD_PID="" kill -s "$signal" "$child_pid" 2>/dev/null || true - wait "$child_pid" 2>/dev/null || true + if wait_for_child_exit "$child_pid" 200; then + return + fi + if [ "$signal" != TERM ]; then + kill -TERM "$child_pid" 2>/dev/null || true + if wait_for_child_exit "$child_pid" 100; then + return + fi + fi + kill -KILL "$child_pid" 2>/dev/null || true + if ! wait_for_child_exit "$child_pid" 200; then + printf 'init Amika Locality snapshot: could not reap child %s after SIGKILL\n' "$child_pid" >&2 + fi +} + +wait_for_child_exit() { + local child_pid="$1" + local attempts="$2" + local state + + while [ "$attempts" -gt 0 ]; do + if ! kill -0 "$child_pid" 2>/dev/null; then + wait "$child_pid" 2>/dev/null || true + return 0 + fi + state="$(ps -o stat= -p "$child_pid" 2>/dev/null || true)" + case "$state" in + ''|*Z*) + wait "$child_pid" 2>/dev/null || true + return 0 + ;; + esac + attempts=$((attempts - 1)) + sleep 0.01 + done + return 1 } cleanup_on_exit() { @@ -221,9 +258,11 @@ amika_ssh() { remote_command="$(encode_remote_argv "$@")" command -v expect >/dev/null 2>&1 || fail "expect is required for Amika PTY transport" prepare_child_launch - AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ - AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ - expect -c ' + ( + trap - HUP INT TERM + AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ + AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ + exec expect -c ' eval $env(AMIKA_EXPECT_COMMON) initialize_transport set timeout 1800 @@ -242,7 +281,8 @@ amika_ssh() { exit 124 } } - ' & + ' + ) & activate_child "$!" wait_for_active_child "$ACTIVE_CHILD_PID" } @@ -267,10 +307,12 @@ amika_ssh_secret_line() { while [ "$attempt" -le 3 ]; do prepare_child_launch - AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ - AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ - AMIKA_DELIVERY_MARKER="$delivery_marker" \ - expect -c ' + ( + trap - HUP INT TERM + AMIKA_EXPECT_COMMON="$EXPECT_TRANSPORT_PROCS" \ + AMIKA_SANDBOX_NAME="$sandbox" AMIKA_REMOTE_COMMAND="$remote_command" \ + AMIKA_DELIVERY_MARKER="$delivery_marker" \ + exec expect -c ' eval $env(AMIKA_EXPECT_COMMON) initialize_transport set timeout 30 @@ -323,7 +365,8 @@ amika_ssh_secret_line() { exit 124 } } - ' <<<"$secret" & + ' + ) <<<"$secret" & activate_child "$!" if wait_for_active_child "$ACTIVE_CHILD_PID"; then secret="" @@ -353,6 +396,7 @@ create_amika_sandbox() { prepare_child_launch ( + trap - HUP INT TERM cd "$REPO_ROOT" exec amika sandbox create \ --remote \ @@ -374,6 +418,7 @@ CODEX_MODEL="${CODEX_MODEL:-gpt-5.6-sol}" CODEX_REASONING_EFFORT="${CODEX_REASONING_EFFORT:-low}" AZURE_OPENAI_BASE_URL="${AZURE_OPENAI_BASE_URL:-https://aseem-mp32maxp-eastus2.openai.azure.com/openai/v1}" AZURE_OPENAI_API_KEY_VALUE="${AZURE_OPENAI_API_KEY:-}" +export -n AZURE_OPENAI_API_KEY_VALUE unset AZURE_OPENAI_API_KEY SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -491,6 +536,7 @@ if [ -t 0 ]; then else IFS= read -r PROFILE_KEY || fail "read the Workspace Profile key from standard input" fi +export -n PROFILE_KEY [ "${#PROFILE_KEY}" -eq 64 ] || fail "Workspace Profile key must be 64 lowercase hexadecimal characters" case "$PROFILE_KEY" in *[!0-9a-f]*) fail "Workspace Profile key must be 64 lowercase hexadecimal characters" ;; diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index 5bb01ea5..ed0b2475 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -160,10 +160,65 @@ cmp -s "$inline_setup_config" "${inline_setup_config}.expected" || \ grep -F -q -- 'refusing to modify inline Azure provider setting' <<<"$inline_setup_output" || \ fail "inline Azure provider failure did not explain why the merge was refused" +nested_setup_root="${tmp_root}/nested-setup" +nested_setup_config="${nested_setup_root}/config.toml" +mkdir -p "$nested_setup_root" +nested_setup_text="$(cat <<'TOML' +["model_providers"."azure"."http_headers"] # nested provider comment +"x-preserved" = "nested-value" +TOML +)" +printf '%s' "$nested_setup_text" > "$nested_setup_config" +unset nested_setup_text +env -u AMIKA_AGENT_CWD \ + PATH="${fake_bin}:$PATH" \ + CODEX_HOME="$nested_setup_root" \ + CODEX_MODEL="nested-model" \ + CODEX_REASONING_EFFORT="medium" \ + AZURE_OPENAI_BASE_URL="https://nested.invalid/openai/v1" \ + "$AZURE_SETUP_SCRIPT" >/dev/null + +python3 - "$nested_setup_config" <<'PY' +import sys +import tomllib + +path = sys.argv[1] +with open(path, "rb") as source: + contents = source.read() +config = tomllib.loads(contents.decode("utf-8")) +provider = config["model_providers"]["azure"] +assert config["model"] == "nested-model" +assert config["model_provider"] == "azure" +assert config["model_reasoning_effort"] == "medium" +assert provider["name"] == "Azure OpenAI" +assert provider["base_url"] == "https://nested.invalid/openai/v1" +assert provider["env_key"] == "AZURE_OPENAI_API_KEY" +assert provider["wire_api"] == "responses" +assert provider["http_headers"] == {"x-preserved": "nested-value"} +text = contents.decode("utf-8") +assert not contents.endswith((b"\n", b"\r")) +assert '''["model_providers"."azure"."http_headers"] # nested provider comment +"x-preserved" = "nested-value"''' in text +assert text.index("[model_providers.azure]") < text.index('["model_providers"."azure"."http_headers"]') +PY + cat > "${fake_bin}/amika" <<'SH' #!/usr/bin/env bash set -euo pipefail +for secret_name in PROFILE_KEY AZURE_OPENAI_API_KEY_VALUE AZURE_OPENAI_API_KEY AMIKA_SECRET_LINE; do + if [ -n "${!secret_name+x}" ]; then + if [ -n "${FAKE_CHILD_ENV_CHECK_FILE:-}" ]; then + printf 'leaked %s\n' "$secret_name" > "$FAKE_CHILD_ENV_CHECK_FILE" + fi + printf '%s environment leak\n' "$secret_name" >&2 + exit 90 + fi +done +if [ -n "${FAKE_CHILD_ENV_CHECK_FILE:-}" ]; then + printf 'clean\n' > "$FAKE_CHILD_ENV_CHECK_FILE" +fi + printf 'amika' >> "${FAKE_AMIKA_LOG:?}" for arg in "$@"; do printf ' %q' "$arg" >> "$FAKE_AMIKA_LOG" @@ -173,6 +228,9 @@ printf '\n' >> "$FAKE_AMIKA_LOG" if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "create" ]; then if [ -n "${FAKE_CREATE_BLOCK_PID_FILE:-}" ]; then printf '%s\n' "$$" > "$FAKE_CREATE_BLOCK_PID_FILE" + if [ "${FAKE_CREATE_IGNORE_SIGNALS:-false}" = true ]; then + trap '' HUP INT TERM + fi exec sleep 300 fi if [ -n "${FAKE_CREATE_STATUS:-}" ]; then @@ -183,14 +241,6 @@ if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "create" ]; then fi if [ "${1:-}" = "sandbox" ] && [ "${2:-}" = "ssh" ]; then - [ -z "${AMIKA_SECRET_LINE:-}" ] || { - printf 'secret environment leak\n' >&2 - exit 90 - } - [ -z "${AZURE_OPENAI_API_KEY:-}" ] || { - printf 'Azure key environment leak\n' >&2 - exit 91 - } last_arg="" for arg in "$@"; do last_arg="$arg" @@ -259,6 +309,32 @@ chmod +x "${fake_bin}/amika" profile_key="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" azure_key="test-azure-key" +: > "$fake_log" +child_env_check="${tmp_root}/child-env-check" +set +e +allexport_output="$( + printf '%s\n' "$profile_key" | \ + env \ + PROFILE_KEY="inherited-exported-profile-key" \ + AZURE_OPENAI_API_KEY_VALUE="inherited-exported-azure-key" \ + AZURE_OPENAI_API_KEY="$azure_key" \ + PATH="${fake_bin}:$PATH" \ + FAKE_AMIKA_LOG="$fake_log" \ + FAKE_CHILD_ENV_CHECK_FILE="$child_env_check" \ + FAKE_CREATE_STATUS=17 \ + bash -a "$SCRIPT" \ + --api-url https://api.dev.locality.dev \ + --name allexport-secrets 2>&1 +)" +allexport_status=$? +set -e +[ "$allexport_status" -eq 2 ] || fail "allexport fixture should stop at sandbox creation" +[ "$(cat "$child_env_check")" = clean ] || \ + fail "secret shell variables leaked to a child under allexport: $(cat "$child_env_check")" +if grep -F -q -- 'environment leak' <<<"$allexport_output"; then + fail "inherited exported secret variables leaked to a child" +fi + tty_bin="${tmp_root}/tty-bin" tty_stty_log="${tmp_root}/tty-stty.log" tty_restored="${tmp_root}/tty-restored" @@ -445,42 +521,88 @@ assert_not_contains "$fake_log" "sandbox ssh" grep -F -q -- 'no credentials were transferred' <<<"$create_failure_output" || \ fail "sandbox collision failure did not explain credential safety" -: > "$fake_log" -create_block_pid_file="${tmp_root}/create-block.pid" -create_interrupt_input="${tmp_root}/create-interrupt.input" -create_interrupt_output="${tmp_root}/create-interrupt.output" -printf '%s\n' "$profile_key" > "$create_interrupt_input" -PATH="${fake_bin}:$PATH" \ - AZURE_OPENAI_API_KEY="$azure_key" \ - FAKE_AMIKA_LOG="$fake_log" \ - FAKE_CREATE_BLOCK_PID_FILE="$create_block_pid_file" \ - "$SCRIPT" --api-url https://api.dev.locality.dev --name interrupted-create \ - <"$create_interrupt_input" >"$create_interrupt_output" 2>&1 & -create_public_script_pid=$! -for _ in $(seq 1 100); do - [ -s "$create_block_pid_file" ] && break - sleep 0.05 -done -[ -s "$create_block_pid_file" ] || fail "sandbox creation interruption fixture did not start" -blocked_create_pid="$(cat "$create_block_pid_file")" -kill -TERM "$create_public_script_pid" -set +e -wait "$create_public_script_pid" -create_interrupt_status=$? -set -e -[ "$create_interrupt_status" -eq 143 ] || \ - fail "TERM-interrupted public script sandbox creation should return 143, got ${create_interrupt_status}: $(cat "$create_interrupt_output")" -for _ in $(seq 1 100); do - if ! kill -0 "$blocked_create_pid" 2>/dev/null; then - break +real_expect="$(command -v expect)" +run_create_interruption() { + local signal="$1" + local expected_status="$2" + local block_pid_file="${tmp_root}/create-block-${signal}.pid" + local input="${tmp_root}/create-interrupt-${signal}.input" + local output="${tmp_root}/create-interrupt-${signal}.output" + local ignore_signals=false + local interrupt_status + local blocked_create_pid + + if [ "$signal" = TERM ]; then + ignore_signals=true fi - sleep 0.05 -done -if kill -0 "$blocked_create_pid" 2>/dev/null; then - fail "interrupted public script left sandbox creation child ${blocked_create_pid} running" -fi -assert_contains "$fake_log" "sandbox create --remote --name interrupted-create --no-git --yes" -assert_not_contains "$fake_log" "sandbox ssh" + : > "$fake_log" + printf '%s\n' "$profile_key" > "$input" + set +e + CREATE_TEST_PATH="${fake_bin}:$PATH" \ + CREATE_TEST_SCRIPT="$SCRIPT" \ + CREATE_TEST_INPUT="$input" \ + CREATE_TEST_SIGNAL="$signal" \ + CREATE_TEST_AZURE_KEY="$azure_key" \ + CREATE_TEST_AMIKA_LOG="$fake_log" \ + CREATE_TEST_BLOCK_PID_FILE="$block_pid_file" \ + CREATE_TEST_IGNORE_SIGNALS="$ignore_signals" \ + "$real_expect" -c ' + set timeout 10 + spawn -noecho env \ + PATH=$env(CREATE_TEST_PATH) \ + AZURE_OPENAI_API_KEY=$env(CREATE_TEST_AZURE_KEY) \ + FAKE_AMIKA_LOG=$env(CREATE_TEST_AMIKA_LOG) \ + FAKE_CREATE_BLOCK_PID_FILE=$env(CREATE_TEST_BLOCK_PID_FILE) \ + FAKE_CREATE_IGNORE_SIGNALS=$env(CREATE_TEST_IGNORE_SIGNALS) \ + sh -c {exec "$1" --api-url https://api.dev.locality.dev --name "interrupted-create-$3" < "$2"} \ + sh $env(CREATE_TEST_SCRIPT) $env(CREATE_TEST_INPUT) $env(CREATE_TEST_SIGNAL) + set public_script_pid [exp_pid] + set child_started 0 + for {set attempt 0} {$attempt < 100} {incr attempt} { + after 50 + if {[file exists $env(CREATE_TEST_BLOCK_PID_FILE)] && [file size $env(CREATE_TEST_BLOCK_PID_FILE)] > 0} { + set child_started 1 + break + } + } + if {!$child_started} { + catch {exec kill -KILL $public_script_pid} + catch {expect eof} + catch {wait} + exit 124 + } + exec kill -$env(CREATE_TEST_SIGNAL) $public_script_pid + expect { + eof {} + timeout { + catch {exec kill -KILL $public_script_pid} + catch {expect eof} + catch {wait} + exit 124 + } + } + set result [wait] + if {[lindex $result 2] != 0} { + exit 125 + } + exit [lindex $result 3] + ' >"$output" 2>&1 + interrupt_status=$? + set -e + [ "$interrupt_status" -eq "$expected_status" ] || \ + fail "${signal}-interrupted public script sandbox creation should return ${expected_status}, got ${interrupt_status}: $(cat "$output")" + [ -s "$block_pid_file" ] || fail "${signal} sandbox creation interruption fixture did not start" + blocked_create_pid="$(cat "$block_pid_file")" + if kill -0 "$blocked_create_pid" 2>/dev/null; then + fail "${signal}-interrupted public script left sandbox creation child ${blocked_create_pid} running" + fi + assert_contains "$fake_log" "sandbox create --remote --name interrupted-create-${signal} --no-git --yes" + assert_not_contains "$fake_log" "sandbox ssh" +} + +run_create_interruption HUP 129 +run_create_interruption INT 130 +run_create_interruption TERM 143 : > "$fake_log" pre_sentinel_count="${tmp_root}/pre-sentinel.count" From 5624d3d4783a39e05ebe06199ad893188f77a6c5 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 14:45:35 +0530 Subject: [PATCH 7/9] fix(amika): clear inherited credentials before startup --- scripts/init-amika-locality-snapshot.sh | 10 +++++---- tests/init_amika_locality_snapshot.sh | 29 ++++++++++++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index ce42febe..6b53e188 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -1,7 +1,12 @@ #!/usr/bin/env bash set -euo pipefail set +a -unset PROFILE_KEY AZURE_OPENAI_API_KEY_VALUE +AZURE_OPENAI_API_KEY_CAPTURE="${AZURE_OPENAI_API_KEY:-}" +unset PROFILE_KEY LOCALITY_PROFILE_KEY AMIKA_SECRET_LINE \ + AZURE_OPENAI_API_KEY AZURE_OPENAI_API_KEY_VALUE +AZURE_OPENAI_API_KEY_VALUE="$AZURE_OPENAI_API_KEY_CAPTURE" +unset AZURE_OPENAI_API_KEY_CAPTURE +export -n AZURE_OPENAI_API_KEY_VALUE usage() { cat <<'EOF' @@ -417,9 +422,6 @@ LOC_RELEASE_DEB_SHA256="692b05460839ba44b85cd1e6b3b6969ad4a3f62f3e81f420c4651159 CODEX_MODEL="${CODEX_MODEL:-gpt-5.6-sol}" CODEX_REASONING_EFFORT="${CODEX_REASONING_EFFORT:-low}" AZURE_OPENAI_BASE_URL="${AZURE_OPENAI_BASE_URL:-https://aseem-mp32maxp-eastus2.openai.azure.com/openai/v1}" -AZURE_OPENAI_API_KEY_VALUE="${AZURE_OPENAI_API_KEY:-}" -export -n AZURE_OPENAI_API_KEY_VALUE -unset AZURE_OPENAI_API_KEY SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index ed0b2475..31db088a 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -206,7 +206,8 @@ cat > "${fake_bin}/amika" <<'SH' #!/usr/bin/env bash set -euo pipefail -for secret_name in PROFILE_KEY AZURE_OPENAI_API_KEY_VALUE AZURE_OPENAI_API_KEY AMIKA_SECRET_LINE; do +for secret_name in PROFILE_KEY LOCALITY_PROFILE_KEY AMIKA_SECRET_LINE \ + AZURE_OPENAI_API_KEY_VALUE AZURE_OPENAI_API_KEY; do if [ -n "${!secret_name+x}" ]; then if [ -n "${FAKE_CHILD_ENV_CHECK_FILE:-}" ]; then printf 'leaked %s\n' "$secret_name" > "$FAKE_CHILD_ENV_CHECK_FILE" @@ -215,10 +216,6 @@ for secret_name in PROFILE_KEY AZURE_OPENAI_API_KEY_VALUE AZURE_OPENAI_API_KEY A exit 90 fi done -if [ -n "${FAKE_CHILD_ENV_CHECK_FILE:-}" ]; then - printf 'clean\n' > "$FAKE_CHILD_ENV_CHECK_FILE" -fi - printf 'amika' >> "${FAKE_AMIKA_LOG:?}" for arg in "$@"; do printf ' %q' "$arg" >> "$FAKE_AMIKA_LOG" @@ -306,6 +303,21 @@ exit 2 SH chmod +x "${fake_bin}/amika" +cat > "${fake_bin}/date" <<'SH' +#!/usr/bin/env bash +set -euo pipefail + +for secret_name in PROFILE_KEY LOCALITY_PROFILE_KEY AMIKA_SECRET_LINE \ + AZURE_OPENAI_API_KEY_VALUE AZURE_OPENAI_API_KEY; do + if [ -n "${!secret_name+x}" ]; then + printf 'leaked %s\n' "$secret_name" > "${FAKE_CHILD_ENV_CHECK_FILE:?}" + exit 90 + fi +done +printf '20260803-000000\n' +SH +chmod +x "${fake_bin}/date" + profile_key="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" azure_key="test-azure-key" @@ -316,6 +328,8 @@ allexport_output="$( printf '%s\n' "$profile_key" | \ env \ PROFILE_KEY="inherited-exported-profile-key" \ + LOCALITY_PROFILE_KEY="inherited-documented-profile-key" \ + AMIKA_SECRET_LINE="inherited-legacy-secret" \ AZURE_OPENAI_API_KEY_VALUE="inherited-exported-azure-key" \ AZURE_OPENAI_API_KEY="$azure_key" \ PATH="${fake_bin}:$PATH" \ @@ -323,13 +337,12 @@ allexport_output="$( FAKE_CHILD_ENV_CHECK_FILE="$child_env_check" \ FAKE_CREATE_STATUS=17 \ bash -a "$SCRIPT" \ - --api-url https://api.dev.locality.dev \ - --name allexport-secrets 2>&1 + --api-url https://api.dev.locality.dev 2>&1 )" allexport_status=$? set -e [ "$allexport_status" -eq 2 ] || fail "allexport fixture should stop at sandbox creation" -[ "$(cat "$child_env_check")" = clean ] || \ +[ ! -e "$child_env_check" ] || \ fail "secret shell variables leaked to a child under allexport: $(cat "$child_env_check")" if grep -F -q -- 'environment leak' <<<"$allexport_output"; then fail "inherited exported secret variables leaked to a child" From 4670240775ae0b50ba1441bb3e732ffa43c3546d Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 15:09:38 +0530 Subject: [PATCH 8/9] fix(amika): bound signal cleanup latency --- scripts/init-amika-locality-snapshot.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index 6b53e188..ed54d4f6 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -77,17 +77,17 @@ forward_and_reap_active_child() { fi ACTIVE_CHILD_PID="" kill -s "$signal" "$child_pid" 2>/dev/null || true - if wait_for_child_exit "$child_pid" 200; then + if wait_for_child_exit "$child_pid" 50; then return fi if [ "$signal" != TERM ]; then kill -TERM "$child_pid" 2>/dev/null || true - if wait_for_child_exit "$child_pid" 100; then + if wait_for_child_exit "$child_pid" 25; then return fi fi kill -KILL "$child_pid" 2>/dev/null || true - if ! wait_for_child_exit "$child_pid" 200; then + if ! wait_for_child_exit "$child_pid" 50; then printf 'init Amika Locality snapshot: could not reap child %s after SIGKILL\n' "$child_pid" >&2 fi } From 098811ad403b62c91a85623ea45a1e756c0f2cfe Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Mon, 3 Aug 2026 16:09:45 +0530 Subject: [PATCH 9/9] fix(amika): make interrupted startup cleanup deterministic --- scripts/init-amika-locality-snapshot.sh | 30 +++++++++++--- tests/init_amika_locality_snapshot.sh | 55 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/scripts/init-amika-locality-snapshot.sh b/scripts/init-amika-locality-snapshot.sh index ed54d4f6..90d3d574 100755 --- a/scripts/init-amika-locality-snapshot.sh +++ b/scripts/init-amika-locality-snapshot.sh @@ -50,13 +50,33 @@ restore_terminal_state() { local saved_state="${TERMINAL_STATE:-}" if [ "${TERMINAL_STATE_ACTIVE:-false}" != true ]; then - return + return 0 fi TERMINAL_STATE_ACTIVE=false TERMINAL_STATE="" stty "$saved_state" < /dev/tty 2>/dev/null || stty echo < /dev/tty 2>/dev/null || true } +read_profile_key_from_terminal() { + local chunk + local status + + PROFILE_KEY="" + while true; do + chunk="" + if IFS= read -r -t 1 chunk; then + PROFILE_KEY+="$chunk" + return 0 + else + status=$? + fi + PROFILE_KEY+="$chunk" + if [ "$status" -le 128 ]; then + return "$status" + fi + done +} + cleanup_delivery_marker() { local delivery_dir="${ACTIVE_DELIVERY_DIR:-}" @@ -77,17 +97,17 @@ forward_and_reap_active_child() { fi ACTIVE_CHILD_PID="" kill -s "$signal" "$child_pid" 2>/dev/null || true - if wait_for_child_exit "$child_pid" 50; then + if wait_for_child_exit "$child_pid" 200; then return fi if [ "$signal" != TERM ]; then kill -TERM "$child_pid" 2>/dev/null || true - if wait_for_child_exit "$child_pid" 25; then + if wait_for_child_exit "$child_pid" 100; then return fi fi kill -KILL "$child_pid" 2>/dev/null || true - if ! wait_for_child_exit "$child_pid" 50; then + if ! wait_for_child_exit "$child_pid" 200; then printf 'init Amika Locality snapshot: could not reap child %s after SIGKILL\n' "$child_pid" >&2 fi } @@ -529,7 +549,7 @@ if [ -t 0 ]; then TERMINAL_STATE="$(stty -g < /dev/tty)" || fail "could not read terminal state" TERMINAL_STATE_ACTIVE=true stty -echo < /dev/tty || fail "could not disable terminal echo" - IFS= read -r PROFILE_KEY || { + read_profile_key_from_terminal || { restore_terminal_state fail "read the Workspace Profile key from standard input" } diff --git a/tests/init_amika_locality_snapshot.sh b/tests/init_amika_locality_snapshot.sh index 31db088a..c7086d62 100755 --- a/tests/init_amika_locality_snapshot.sh +++ b/tests/init_amika_locality_snapshot.sh @@ -429,6 +429,61 @@ grep -F -x -q -- '-g' "$tty_stty_log" || fail "credential read did not capture t grep -F -x -q -- '-echo' "$tty_stty_log" || fail "credential read did not disable terminal echo" [ ! -s "$fake_log" ] || fail "credential read interruption contacted Amika" +: > "$fake_log" +: > "$tty_stty_log" +TTY_TEST_PATH="${tty_bin}:${fake_bin}:$PATH" \ + TTY_TEST_SCRIPT="$SCRIPT" \ + TTY_TEST_AZURE_KEY="$azure_key" \ + TTY_TEST_AMIKA_LOG="$fake_log" \ + TTY_TEST_STTY_LOG="$tty_stty_log" \ + TTY_TEST_RESTORED="$tty_restored" \ + TTY_TEST_REAL_STTY="$(command -v stty)" \ + TTY_TEST_PROFILE_KEY="$profile_key" \ + expect -c ' + set timeout 10 + spawn -noecho env \ + PATH=$env(TTY_TEST_PATH) \ + AZURE_OPENAI_API_KEY=$env(TTY_TEST_AZURE_KEY) \ + FAKE_AMIKA_LOG=$env(TTY_TEST_AMIKA_LOG) \ + FAKE_STTY_LOG=$env(TTY_TEST_STTY_LOG) \ + FAKE_STTY_RESTORED=$env(TTY_TEST_RESTORED) \ + REAL_STTY=$env(TTY_TEST_REAL_STTY) \ + FAKE_CREATE_STATUS=17 \ + $env(TTY_TEST_SCRIPT) \ + --api-url https://api.dev.locality.dev \ + --name delayed-read + set echo_disabled 0 + for {set attempt 0} {$attempt < 100} {incr attempt} { + after 50 + if {[file exists $env(TTY_TEST_STTY_LOG)]} { + set handle [open $env(TTY_TEST_STTY_LOG) r] + set log [read $handle] + close $handle + if {[string first "-echo\n" $log] >= 0} { + set echo_disabled 1 + break + } + } + } + if {!$echo_disabled} { + catch {exec kill -TERM [exp_pid]} + catch {expect eof} + catch {wait} + exit 124 + } + set key $env(TTY_TEST_PROFILE_KEY) + send -- [string range $key 0 31] + after 1200 + send -- "[string range $key 32 end]\r" + expect eof + set result [wait] + if {[lindex $result 2] != 0 || [lindex $result 3] != 2} { + exit 125 + } + ' >/dev/null 2>&1 || fail "delayed terminal key input was not preserved across polling timeouts" +assert_contains "$fake_log" "sandbox create --remote --name delayed-read --no-git --yes" +grep -F -q -- "$profile_key" "$fake_log" && fail "delayed terminal key leaked to Amika arguments" + output="$( printf '%s\n' "$profile_key" | \ PATH="${fake_bin}:$PATH" \