Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions src/cmd/ai_claude_environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import (
"context"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -35,6 +39,9 @@ const (
claudeHookKickBudget = 1500 * time.Millisecond
)

// claudeEnvironmentGOOS picks the liveness guard; tests override it.
var claudeEnvironmentGOOS = runtime.GOOS

type claudeEnvironmentStatus struct {
Schema int `json:"schema"`
PID int `json:"pid"`
Expand Down Expand Up @@ -70,7 +77,7 @@ type claudeRuntimeHealth struct {

// renderClaudeEnvironment produces the POSIX shell a harness sources before each
// command. Proxy routing, Phase runtime markers, and trust overrides live inside
// a liveness guard on the issuing proxy PID, so a crashed proxy can never strand
// a liveness guard on the issuing proxy, so a crashed proxy can never strand
// a shell behind a dead port. Decoys, adapter endpoints, and explicit unsets
// stay outside the guard: while Phase AI is enabled, provider tools either reach
// the Phase proxy or fail on the decoy. They never fall through to an ambient
Expand All @@ -79,6 +86,13 @@ func renderClaudeEnvironment(pid int, plan phaseproxy.ProcessEnvironmentPlan, pr
if proxyLive && pid <= 0 {
return "", errors.New("Phase AI proxy PID is required for a live environment")
}
var guard string
if proxyLive {
var err error
if guard, err = claudeProxyLivenessGuard(pid, plan); err != nil {
return "", err
}
}
keys := make([]string, 0, len(plan.Set))
for key, value := range plan.Set {
if !phaseproxy.ValidShellEnvironmentKey(key) {
Expand Down Expand Up @@ -115,7 +129,7 @@ func renderClaudeEnvironment(pid int, plan phaseproxy.ProcessEnvironmentPlan, pr
}
if proxyLive {
out.WriteString(claudeProxyBlockBegin + "\n")
fmt.Fprintf(&out, "if kill -0 %d 2>/dev/null; then\n", pid)
out.WriteString("if " + guard + "; then\n")
for _, key := range keys {
if !phaseproxy.RuntimeControlEnvironmentKey(key) {
continue
Expand All @@ -133,6 +147,35 @@ func renderClaudeEnvironment(pid int, plan phaseproxy.ProcessEnvironmentPlan, pr
return out.String(), nil
}

// claudeProxyLivenessGuard keeps routing on only while the proxy is alive. Git
// Bash cannot signal native Windows PIDs, so on Windows it probes the port.
func claudeProxyLivenessGuard(pid int, plan phaseproxy.ProcessEnvironmentPlan) (string, error) {
if claudeEnvironmentGOOS != "windows" {
return fmt.Sprintf("kill -0 %d 2>/dev/null", pid), nil
}
raw := ""
for _, key := range []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"} {
if raw = plan.Set[key]; raw != "" {
break
}
}
if raw == "" {
return "false", nil
}
proxyURL, err := url.Parse(raw)
if err != nil {
return "", errors.New("Phase AI proxy URL is invalid")
}
host, port, err := net.SplitHostPort(proxyURL.Host)
number, portErr := strconv.Atoi(port)
if err != nil || portErr != nil || net.ParseIP(host).To4() == nil || number < 1 || number > 65535 {
return "", fmt.Errorf("Phase AI proxy address %q is not an IPv4 address and port", proxyURL.Host)
}
// true is a regular builtin, so a refused connection fails the test without
// ending a non-interactive shell.
return fmt.Sprintf("true 2>/dev/null </dev/tcp/%s/%s", host, port), nil
}

// stripClaudeProxyBlock removes the proxy-routing block from rendered content.
// Hooks use it to retire routing for a daemon that is gone without needing the
// original environment plan.
Expand Down
109 changes: 109 additions & 0 deletions src/cmd/ai_claude_environment_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"net"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -35,7 +36,15 @@ func testClaudeEnvironmentPlan() phaseproxy.ProcessEnvironmentPlan {
}
}

func withClaudeEnvironmentGOOS(t *testing.T, goos string) {
t.Helper()
previous := claudeEnvironmentGOOS
claudeEnvironmentGOOS = goos
t.Cleanup(func() { claudeEnvironmentGOOS = previous })
}

func TestRenderClaudeEnvironmentSeparatesRoutingFromDecoys(t *testing.T) {
withClaudeEnvironmentGOOS(t, "linux")
content, err := renderClaudeEnvironment(4242, testClaudeEnvironmentPlan(), true)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -86,6 +95,37 @@ func TestRenderClaudeEnvironmentSeparatesRoutingFromDecoys(t *testing.T) {
}
}

func TestRenderClaudeEnvironmentProbesTheProxyPortOnWindows(t *testing.T) {
withClaudeEnvironmentGOOS(t, "windows")
content, err := renderClaudeEnvironment(4242, testClaudeEnvironmentPlan(), true)
if err != nil {
t.Fatal(err)
}
if strings.Contains(content, "kill -0") ||
!strings.Contains(content, claudeProxyBlockBegin+"\nif true 2>/dev/null </dev/tcp/127.0.0.1/9543; then\n") {
t.Fatalf("Windows liveness guard:\n%s", content)
}
unrouted := testClaudeEnvironmentPlan()
delete(unrouted.Set, "HTTPS_PROXY")
delete(unrouted.Set, "https_proxy")
content, err = renderClaudeEnvironment(4242, unrouted, true)
if err != nil || !strings.Contains(content, "\nif false; then\n") {
t.Fatalf("a plan without a proxy must never route: err=%v\n%s", err, content)
}
for name, proxy := range map[string]string{
"hostname": "http://phase:capability@localhost:9543",
"IPv6": "http://phase:capability@[::1]:9543",
"no port": "http://phase:capability@127.0.0.1",
"bad port": "http://phase:capability@127.0.0.1:99999",
} {
plan := testClaudeEnvironmentPlan()
plan.Set["HTTPS_PROXY"] = proxy
if _, err := renderClaudeEnvironment(4242, plan, true); err == nil {
t.Fatalf("%s proxy was accepted", name)
}
}
}

func TestRenderClaudeEnvironmentJSONSplitsRoutingFromDecoys(t *testing.T) {
payload, err := renderClaudeEnvironmentJSON(4242, testClaudeEnvironmentPlan(), true)
if err != nil {
Expand Down Expand Up @@ -216,6 +256,75 @@ func TestClaudeEnvironmentSourcesInRealShell(t *testing.T) {
}
}

// findTestBash returns Git Bash on Windows (System32\bash.exe is WSL), else bash.
func findTestBash(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
candidate := filepath.Join(os.Getenv("ProgramFiles"), "Git", "bin", "bash.exe")
if _, err := os.Stat(candidate); err != nil {
t.Skip("Git Bash is not installed")
}
return candidate
}
path, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not installed")
}
return path
}

func TestClaudeEnvironmentWindowsGuardSourcesInBash(t *testing.T) {
bash := findTestBash(t)
withClaudeEnvironmentGOOS(t, "windows")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
connection, err := listener.Accept()
if err != nil {
return
}
connection.Close()
}
}()
proxy := "http://phase:capability@" + listener.Addr().String()
plan := testClaudeEnvironmentPlan()
plan.Set["HTTPS_PROXY"], plan.Set["https_proxy"] = proxy, proxy
directory := t.TempDir()
if err := writeClaudeEnvironment(directory, 4242, "generation-one", time.Now().UTC(), plan, nil, true); err != nil {
t.Fatal(err)
}
inherited := make([]string, 0)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
switch strings.ToUpper(key) {
case "HTTPS_PROXY", "HTTP_PROXY", "PHASE_AI_ENABLED", "AWS_ACCESS_KEY_ID":
continue
}
inherited = append(inherited, entry)
}
source := func() string {
script := `. "$1"; status=$?; printf '%s|%s|%s|%s' "${HTTPS_PROXY:-}" "${AWS_ACCESS_KEY_ID:-}" "${PHASE_AI_ENABLED:-}" "$status"`
command := exec.Command(bash, "-c", script, "bash", filepath.ToSlash(claudeEnvironmentPath(directory)))
command.Env = inherited
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("sourcing failed: %v\n%s", err, output)
}
return string(output)
}
if got := source(); got != proxy+"|AKIADECOY|1|0" {
t.Fatalf("live proxy environment, got %q", got)
}
listener.Close()
if got := source(); got != "|AKIADECOY||0" {
t.Fatalf("stopped proxy must drop routing and keep decoys, got %q", got)
}
}

func TestWriteClaudeEnvironmentIsOwnerOnlyAndRemovable(t *testing.T) {
directory := withClaudeHookRuntimeDirectory(t)
connections := []aiproxy.EndpointConnection{{ConnectionID: "conn-1", Name: "aws-prod", ServiceType: "aws", Protocol: "http"}}
Expand Down
10 changes: 7 additions & 3 deletions src/cmd/ai_claude_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ func TestClaudeDaemonRecyclesSameProcessAndGenerationOnOneEngine(t *testing.T) {
t.Fatalf("managed recycle did not publish a newer cycle start: %v then %v", managed.states[0].StartedAt, managed.states[1].StartedAt)
}
for index, environment := range managed.environments {
if !strings.Contains(environment, fmt.Sprintf("if kill -0 %d 2>/dev/null; then", os.Getpid())) ||
guard, err := claudeProxyLivenessGuard(os.Getpid(), phaseproxy.ProcessEnvironmentPlan{Set: map[string]string{"HTTP_PROXY": managed.states[index].ProxyURL}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(environment, "if "+guard+"; then") ||
!strings.Contains(environment, "export HTTP_PROXY='"+managed.states[index].ProxyURL+"'") ||
!strings.Contains(environment, "export AWS_ACCESS_KEY_ID='AKIADECOY'") {
t.Fatalf("cycle %d did not publish a live sourced environment:\n%s", index+1, environment)
Expand All @@ -289,7 +293,7 @@ func TestClaudeDaemonRecyclesSameProcessAndGenerationOnOneEngine(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(final), "kill -0") || strings.Contains(string(final), "HTTP_PROXY") ||
if strings.Contains(string(final), claudeProxyBlockBegin) || strings.Contains(string(final), "HTTP_PROXY") ||
!strings.Contains(string(final), "export AWS_ACCESS_KEY_ID='AKIADECOY'") {
t.Fatalf("daemon exit left proxy routing in the sourced environment:\n%s", final)
}
Expand Down Expand Up @@ -929,7 +933,7 @@ func TestClaudeHeartbeatReportsStatusAndRetiresStaleRouting(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(environment), "kill -0") || strings.Contains(string(environment), "HTTPS_PROXY") ||
if strings.Contains(string(environment), claudeProxyBlockBegin) || strings.Contains(string(environment), "HTTPS_PROXY") ||
!strings.Contains(string(environment), "export AWS_ACCESS_KEY_ID='AKIADECOY'") {
t.Fatalf("heartbeat left routing for a dead proxy:\n%s", environment)
}
Expand Down
Loading
Loading