diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f93be4e..b58bf47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,14 +39,22 @@ jobs: - name: Configure subordinate ID mappings run: | - username="$(id -un)" - for path in /etc/subuid /etc/subgid; do - sudo touch "$path" - if ! grep -q "^${username}:" "$path"; then - echo "${username}:100000:65536" | sudo tee -a "$path" - fi + for username in "$(id -un)" root; do + for path in /etc/subuid /etc/subgid; do + sudo touch "$path" + if ! grep -q "^${username}:" "$path"; then + echo "${username}:100000:65536" | sudo tee -a "$path" + fi + done done + - name: Allow unprivileged user namespaces + run: | + sudo sysctl -w kernel.unprivileged_userns_clone=1 + if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + - name: Run Go tests run: | go list ./... \ @@ -59,8 +67,39 @@ jobs: - name: Install opencode run: npm install -g "opencode-ai@${OPENCODE_VERSION}" + - name: Resolve native opencode binary + run: | + global_node_root="$(npm root -g)" + for candidate in \ + "$global_node_root/opencode-linux-x64/bin/opencode" \ + "$global_node_root/opencode-linux-x64-baseline/bin/opencode" \ + "$global_node_root/opencode-linux-x64-musl/bin/opencode" \ + "$global_node_root/opencode-linux-x64-baseline-musl/bin/opencode" \ + "$global_node_root/opencode-ai/node_modules/opencode-linux-x64/bin/opencode" \ + "$global_node_root/opencode-ai/node_modules/opencode-linux-x64-baseline/bin/opencode" \ + "$global_node_root/opencode-ai/node_modules/opencode-linux-x64-musl/bin/opencode" \ + "$global_node_root/opencode-ai/node_modules/opencode-linux-x64-baseline-musl/bin/opencode"; do + if [ -x "$candidate" ]; then + resolved_candidate="$(readlink -f "$candidate")" + sudo install -m 0755 "$resolved_candidate" /usr/local/bin/opencode-bbox + echo "OPENCODE_BIN=/usr/local/bin/opencode-bbox" >> "$GITHUB_ENV" + exit 0 + fi + done + echo "failed to resolve native opencode binary under $global_node_root" >&2 + exit 1 + + - name: Prepare opencode smoke PATH + run: | + smoke_path_dir="$GITHUB_WORKSPACE/.opencode-smoke-path" + rm -rf "$smoke_path_dir" + mkdir -p "$smoke_path_dir" + ln -sf /usr/bin/env "$smoke_path_dir/env" + ln -sf /usr/bin/sh "$smoke_path_dir/sh" + echo "OPENCODE_SMOKE_PATH_VALUE=$smoke_path_dir" >> "$GITHUB_ENV" + - name: Run opencode smoke suite - run: ./scripts/opencode-smoke.sh + run: env OPENCODE_BIN="$OPENCODE_BIN" OPENCODE_SMOKE_PATH_VALUE="$OPENCODE_SMOKE_PATH_VALUE" ./scripts/opencode-smoke.sh - name: Run local release smoke build run: make release-snapshot diff --git a/opencode_smoke_test.go b/opencode_smoke_test.go index 5c6aa25..bc20430 100644 --- a/opencode_smoke_test.go +++ b/opencode_smoke_test.go @@ -46,16 +46,91 @@ func TestOpenCodeSmokeBuilderCaseWritesDockerBuildConfig(t *testing.T) { func TestOpenCodeSmokeSkipsLoopbackSetupUnsupported(t *testing.T) { t.Parallel() - output := runOpenCodeSmokeScript(t, "loopback-unsupported") + output := runOpenCodeSmokeScript(t, "mixed-loopback-unsupported") if !strings.Contains(output, "SKIP loopback setup unsupported: proxy-enforce-copy-env") { t.Fatalf("expected loopback setup skip in output, got:\n%s", output) } + if !strings.Contains(output, "PASS expected auth failure: transparent-enforce-explicit-env") { + t.Fatalf("expected non-skipped cases to continue after loopback skip, got:\n%s", output) + } +} + +func TestOpenCodeSmokeFailsWhenAllCasesSkip(t *testing.T) { + t.Parallel() + + output, err := runOpenCodeSmokeScriptExpectError(t, "loopback-unsupported") + if err == nil { + t.Fatalf("expected all-skipped smoke run to fail, output:\n%s", output) + } + if !strings.Contains(output, "FAIL all selected opencode smoke cases skipped") { + t.Fatalf("expected all-skipped failure in output, got:\n%s", output) + } +} + +func TestOpenCodeSmokeUsesSandboxPathOverride(t *testing.T) { + t.Parallel() + + sandboxPathDir := filepath.Join(t.TempDir(), "sandbox-path") + if err := os.MkdirAll(sandboxPathDir, 0o755); err != nil { + t.Fatalf("mkdir sandbox path dir: %v", err) + } + for _, tool := range []string{"awk", "grep"} { + target, err := exec.LookPath(tool) + if err != nil { + t.Fatalf("resolve %s: %v", tool, err) + } + if err := os.Symlink(target, filepath.Join(sandboxPathDir, tool)); err != nil { + t.Fatalf("symlink %s: %v", tool, err) + } + } + + output, err := runOpenCodeSmokeScriptWithEnv(t, "auth-failure", map[string]string{ + "OPENCODE_SMOKE_PATH_VALUE": sandboxPathDir, + "EXPECT_CONFIG_PATH": sandboxPathDir, + }) + if err != nil { + t.Fatalf("run opencode smoke script with sandbox path override: %v\n%s", err, output) + } + if !strings.Contains(output, "PASS expected auth failure: transparent-enforce-explicit-env") { + t.Fatalf("expected explicit PATH case to succeed, got:\n%s", output) + } +} + +func TestOpenCodeSmokeCopyEnvCasesUseRepoOwnedSandboxPath(t *testing.T) { + t.Parallel() + + sandboxPathDir := filepath.Join(t.TempDir(), "sandbox-path") + if err := os.MkdirAll(sandboxPathDir, 0o755); err != nil { + t.Fatalf("mkdir sandbox path dir: %v", err) + } + for _, tool := range []string{"env", "sh"} { + target, err := exec.LookPath(tool) + if err != nil { + t.Fatalf("resolve %s: %v", tool, err) + } + if err := os.Symlink(target, filepath.Join(sandboxPathDir, tool)); err != nil { + t.Fatalf("symlink %s: %v", tool, err) + } + } + + output, err := runOpenCodeSmokeScriptWithEnv(t, "auth-failure", map[string]string{ + "OPENCODE_SMOKE_PATH_VALUE": sandboxPathDir, + "EXPECT_BBOX_PATH_MODE": "host-not-sandbox", + "EXPECT_COPY_ENV_MARKER": "OPENCODE_SMOKE_MARKER", + "EXPECT_COPY_ENV_CASES_EXPLICIT": "proxy-enforce-copy-env,proxy-audit-copy-env,proxy-enforce-docker-build", + }) + if err != nil { + t.Fatalf("run opencode smoke script with copy_env repo-owned sandbox path expectations: %v\n%s", err, output) + } + if !strings.Contains(output, "PASS expected auth failure: proxy-enforce-copy-env") { + t.Fatalf("expected copy_env PATH override case to succeed, got:\n%s", output) + } } func runOpenCodeSmokeScript(t *testing.T, fakeBBoxMode string) string { t.Helper() - output, err := runOpenCodeSmokeScriptExpectError(t, fakeBBoxMode) + output, err := runOpenCodeSmokeScriptWithEnv(t, fakeBBoxMode, nil) if err != nil { t.Fatalf("run opencode smoke script: %v\n%s", err, output) } @@ -64,6 +139,11 @@ func runOpenCodeSmokeScript(t *testing.T, fakeBBoxMode string) string { func runOpenCodeSmokeScriptExpectError(t *testing.T, fakeBBoxMode string) (string, error) { t.Helper() + return runOpenCodeSmokeScriptWithEnv(t, fakeBBoxMode, nil) +} + +func runOpenCodeSmokeScriptWithEnv(t *testing.T, fakeBBoxMode string, extraEnv map[string]string) (string, error) { + t.Helper() root := moduleRoot(t) fakeBinDir := filepath.Join(t.TempDir(), "fake-bin") @@ -91,6 +171,9 @@ func runOpenCodeSmokeScriptExpectError(t *testing.T, fakeBBoxMode string) (strin "OPENCODE_SMOKE_SKIP_SUBID_CHECK=1", "PATH="+fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH"), ) + for key, value := range extraEnv { + cmd.Env = append(cmd.Env, key+"="+value) + } output, err := cmd.CombinedOutput() return string(output), err } @@ -167,6 +250,15 @@ grep -q '"env": \["OPENAI_API_KEY"\]' "$opencode_config" || { echo "missing open grep -q '"model": "openai/gpt-4.1-mini"' "$opencode_config" || { echo "missing smoke model config" >&2; exit 98; } ! grep -q 'OPENAI_API_KEY=' "$config_path" || { echo "unexpected explicit OPENAI_API_KEY env" >&2; exit 99; } +if [ -n "${EXPECT_BBOX_PATH:-}" ] && [ "${PATH:-}" != "$EXPECT_BBOX_PATH" ]; then + echo "unexpected bbox PATH: ${PATH:-}" >&2 + exit 118 +fi +if [ "${EXPECT_BBOX_PATH_MODE:-}" = "host-not-sandbox" ] && [ "${PATH:-}" = "${OPENCODE_SMOKE_PATH_VALUE:-}" ]; then + echo "unexpected bbox PATH matches sandbox PATH override: ${PATH:-}" >&2 + exit 119 +fi + printf 'FAKE_BBOX %s\n' "$case_name" case "$case_name" in @@ -174,14 +266,31 @@ case "$case_name" in grep -q 'traffic_mode: proxy' "$config_path" || { echo "missing proxy mode" >&2; exit 93; } grep -q 'policy_mode: enforce' "$config_path" || { echo "missing enforce mode" >&2; exit 100; } grep -q 'copy_env:' "$config_path" || { echo "missing copy_env block" >&2; exit 101; } + if [ -n "${EXPECT_COPY_ENV_MARKER:-}" ]; then + grep -q " - $EXPECT_COPY_ENV_MARKER" "$config_path" || { echo "missing expected copy_env marker" >&2; exit 120; } + ! grep -q ' - PATH$' "$config_path" || { echo "unexpected PATH copy_env entry" >&2; exit 121; } + fi + if [ -n "${EXPECT_COPY_ENV_CASES_EXPLICIT:-}" ]; then + grep -q "PATH=$OPENCODE_SMOKE_PATH_VALUE" "$config_path" || { echo "missing explicit PATH override for copy_env case" >&2; exit 122; } + fi ;; transparent-enforce-explicit-env) grep -q 'traffic_mode: transparent' "$config_path" || { echo "missing transparent mode" >&2; exit 102; } grep -q 'env:' "$config_path" || { echo "missing env block" >&2; exit 103; } grep -q 'HOME=' "$config_path" || { echo "missing HOME env" >&2; exit 104; } + if [ -n "${EXPECT_CONFIG_PATH:-}" ]; then + grep -q "PATH=$EXPECT_CONFIG_PATH" "$config_path" || { echo "missing expected explicit PATH override" >&2; exit 117; } + fi ;; proxy-audit-copy-env) grep -q 'policy_mode: audit' "$config_path" || { echo "missing audit mode" >&2; exit 105; } + if [ -n "${EXPECT_COPY_ENV_MARKER:-}" ]; then + grep -q " - $EXPECT_COPY_ENV_MARKER" "$config_path" || { echo "missing expected copy_env marker" >&2; exit 123; } + ! grep -q ' - PATH$' "$config_path" || { echo "unexpected PATH copy_env entry" >&2; exit 124; } + fi + if [ -n "${EXPECT_COPY_ENV_CASES_EXPLICIT:-}" ]; then + grep -q "PATH=$OPENCODE_SMOKE_PATH_VALUE" "$config_path" || { echo "missing explicit PATH override for copy_env case" >&2; exit 125; } + fi ;; proxy-enforce-docker-build|transparent-audit-docker-build) grep -q 'docker_build:' "$config_path" || { echo "missing docker_build block" >&2; exit 106; } @@ -191,6 +300,13 @@ case "$case_name" in grep -q 'podman_path:' "$config_path" || { echo "missing podman_path" >&2; exit 110; } grep -q 'newuidmap_path:' "$config_path" || { echo "missing newuidmap_path" >&2; exit 111; } grep -q 'newgidmap_path:' "$config_path" || { echo "missing newgidmap_path" >&2; exit 112; } + if [ "$case_name" = "proxy-enforce-docker-build" ] && [ -n "${EXPECT_COPY_ENV_MARKER:-}" ]; then + grep -q " - $EXPECT_COPY_ENV_MARKER" "$config_path" || { echo "missing expected copy_env marker" >&2; exit 126; } + ! grep -q ' - PATH$' "$config_path" || { echo "unexpected PATH copy_env entry" >&2; exit 127; } + if [ -n "${EXPECT_COPY_ENV_CASES_EXPLICIT:-}" ]; then + grep -q "PATH=$OPENCODE_SMOKE_PATH_VALUE" "$config_path" || { echo "missing explicit PATH override for docker copy_env case" >&2; exit 128; } + fi + fi ;; esac @@ -217,6 +333,14 @@ case "$mode" in echo "start sandbox helper: read bbox-bridge-parent: connection reset by peer: bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted" >&2 exit 1 ;; + mixed-loopback-unsupported) + if [ "$case_name" = "proxy-enforce-copy-env" ]; then + echo "start sandbox helper: read bbox-bridge-parent: connection reset by peer: bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted" >&2 + exit 1 + fi + echo "OpenAI API key is missing. Pass it using the 'apiKey' parameter or the OPENAI_API_KEY environment variable." >&2 + exit 0 + ;; *) echo "unexpected fake bbox mode: $mode" >&2 exit 110 diff --git a/scripts/opencode-smoke.sh b/scripts/opencode-smoke.sh index 626a72e..82b1bbf 100755 --- a/scripts/opencode-smoke.sh +++ b/scripts/opencode-smoke.sh @@ -12,8 +12,13 @@ PROMPT=${OPENCODE_SMOKE_PROMPT:-Reply with exactly the word OK.} RUN_TIMEOUT=${OPENCODE_SMOKE_TIMEOUT:-90s} MODEL_ID=${OPENCODE_SMOKE_MODEL:-openai/gpt-4.1-mini} SANDBOX_ROOT=${OPENCODE_SMOKE_SANDBOX_ROOT:-/workspace} +SANDBOX_PATH_VALUE=${OPENCODE_SMOKE_PATH_VALUE:-${PATH:-/usr/bin:/bin}} +COPY_ENV_MARKER_KEY=${OPENCODE_SMOKE_COPY_ENV_MARKER_KEY:-OPENCODE_SMOKE_MARKER} case_filter=${OPENCODE_SMOKE_CASES:-} +total_cases=0 +passed_cases=0 +skipped_cases=0 resolve_repo_path() { path="$1" @@ -88,6 +93,7 @@ run_case() { * ) return 0 ;; esac fi + total_cases=$((total_cases + 1)) echo "RUN $case_name" @@ -134,6 +140,20 @@ exit 0 ' write_executable "$builder_tools_dir/podman" '#!/bin/sh set -eu +while [ "$#" -ge 1 ]; do + case "$1" in + --*) + shift + ;; + unshare) + break + ;; + *) + echo "unexpected podman invocation: $*" >&2 + exit 64 + ;; + esac +done [ "$#" -ge 1 ] && [ "$1" = "unshare" ] || { echo "unexpected podman invocation: $*" >&2 exit 64 @@ -163,11 +183,10 @@ newgidmap=$builder_tools_dir/newgidmap echo " read_only: false" echo "env:" echo " - HOME=$sandbox_home" + echo " - PATH=$SANDBOX_PATH_VALUE" if [ "$env_mode" = "copy" ]; then echo "copy_env:" - echo " - PATH" - else - echo " - PATH=${PATH:-/usr/bin:/bin}" + echo " - $COPY_ENV_MARKER_KEY" fi if [ "$docker_build_enabled" = "true" ]; then echo "docker_build:" @@ -183,7 +202,7 @@ newgidmap=$builder_tools_dir/newgidmap status=0 ( cd "$repo_root" - "$timeout_bin" "$RUN_TIMEOUT" "$bbox_bin" --config "$bbox_config" -- "$OPENCODE_BIN" run --pure --print-logs --model "$MODEL_ID" "$PROMPT" + "$timeout_bin" "$RUN_TIMEOUT" env "$COPY_ENV_MARKER_KEY=$case_name" "$bbox_bin" --config "$bbox_config" -- "$OPENCODE_BIN" run --pure --print-logs --model "$MODEL_ID" "$PROMPT" ) >"$output_file" 2>&1 || status=$? output=$(cat "$output_file") @@ -192,6 +211,7 @@ newgidmap=$builder_tools_dir/newgidmap case "$output_lc" in *"loopback: failed rtm_newaddr: operation not permitted"*) echo "SKIP loopback setup unsupported: $case_name" + skipped_cases=$((skipped_cases + 1)) rm -rf "$case_dir" return 0 ;; @@ -207,6 +227,7 @@ newgidmap=$builder_tools_dir/newgidmap case "$output_lc" in *auth*|*credential*|*"api key"*|*login*|*provider*|*missing*) echo "PASS expected auth failure: $case_name" + passed_cases=$((passed_cases + 1)) ;; *) if [ "$status" -eq 0 ]; then @@ -228,3 +249,13 @@ run_case "transparent-enforce-explicit-env" "transparent" "enforce" "explicit" " run_case "proxy-audit-copy-env" "proxy" "audit" "copy" "false" run_case "proxy-enforce-docker-build" "proxy" "enforce" "copy" "true" run_case "transparent-audit-docker-build" "transparent" "audit" "explicit" "true" + +if [ "$total_cases" -eq 0 ]; then + echo "FAIL no opencode smoke cases selected" >&2 + exit 1 +fi + +if [ "$passed_cases" -eq 0 ]; then + echo "FAIL all selected opencode smoke cases skipped" >&2 + exit 1 +fi