github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/environments/worker_secret_test.go (about)

     1  package environments
     2  
     3  // Tests for the per-item work secret: the sessions token extracted from a
     4  // claimed item's secret payload is preferred over the environment key for
     5  // that item's heartbeat / force-stop / skill-download / session calls, while
     6  // polling stays on the environment key. The payload and the token must never
     7  // reach the logs — asserted on happy and error paths alike.
     8  
     9  import (
    10  	"bytes"
    11  	"context"
    12  	"encoding/base64"
    13  	"encoding/json"
    14  	"log/slog"
    15  	"net/http"
    16  	"strings"
    17  	"sync"
    18  	"testing"
    19  	"time"
    20  
    21  	"github.com/stretchr/testify/require"
    22  )
    23  
    24  // encodeSecret builds a work-item secret the way the control plane does:
    25  // URL-safe base64 of a JSON payload, without padding.
    26  func encodeSecret(t *testing.T, payload map[string]any) string {
    27  	t.Helper()
    28  	body, err := json.Marshal(payload)
    29  	require.NoError(t, err)
    30  	return base64.RawURLEncoding.EncodeToString(body)
    31  }
    32  
    33  func TestSessionsTokenFromSecret(t *testing.T) {
    34  	body, err := json.Marshal(map[string]any{
    35  		"sessions_token": "sessions-token-abc",
    36  		"auth":           []map[string]string{{"type": "anthropic_oauth", "token": "oauth"}},
    37  	})
    38  	require.NoError(t, err)
    39  	unpadded := base64.RawURLEncoding.EncodeToString(body)
    40  	padded := base64.URLEncoding.EncodeToString(body)
    41  
    42  	// The payload decodes from URL-safe base64 JSON, with or without padding.
    43  	require.Equal(t, "sessions-token-abc", sessionsTokenFromSecret(unpadded))
    44  	require.Equal(t, "sessions-token-abc", sessionsTokenFromSecret(padded))
    45  	// Anything malformed or token-less resolves to "".
    46  	require.Empty(t, sessionsTokenFromSecret(""))
    47  	require.Empty(t, sessionsTokenFromSecret("!!! not base64 !!!"))
    48  	// Valid base64 but not JSON / not an object / missing or empty token.
    49  	require.Empty(t, sessionsTokenFromSecret(base64.RawURLEncoding.EncodeToString([]byte("plain text"))))
    50  	require.Empty(t, sessionsTokenFromSecret(encodeSecret(t, map[string]any{"session_ingress_token": "ingress-token-1"})))
    51  	require.Empty(t, sessionsTokenFromSecret(encodeSecret(t, map[string]any{"sessions_token": ""})))
    52  }
    53  
    54  // scriptWorkServer wires the handlers a full worker pass needs: one poll
    55  // yielding work, ack, heartbeat, the skill-setup session lookup, an event
    56  // stream that terminates the session once a heartbeat landed, and stop.
    57  func scriptWorkServer(t *testing.T, server *fakeWorkServer, work string) {
    58  	t.Helper()
    59  	pollCount := 0
    60  	server.HandlePoll = func(w http.ResponseWriter, _ *http.Request) {
    61  		pollCount++
    62  		w.Header().Set("Content-Type", "application/json")
    63  		if pollCount == 1 {
    64  			_, _ = w.Write([]byte(work))
    65  		} else {
    66  			w.WriteHeader(http.StatusNoContent)
    67  		}
    68  	}
    69  	server.HandleAck = func(w http.ResponseWriter, _ *http.Request) {
    70  		w.Header().Set("Content-Type", "application/json")
    71  		_, _ = w.Write([]byte(work))
    72  	}
    73  
    74  	heartbeatSeen := make(chan struct{})
    75  	var heartbeatOnce sync.Once
    76  	server.HandleHeartbeat = func(w http.ResponseWriter, _ *http.Request) {
    77  		heartbeatOnce.Do(func() { close(heartbeatSeen) })
    78  		w.Header().Set("Content-Type", "application/json")
    79  		_, _ = w.Write([]byte(`{"last_heartbeat":"2026-05-11T12:00:00Z","lease_extended":true,"state":"active","ttl_seconds":30,"type":"work_heartbeat"}`))
    80  	}
    81  	server.HandleSessionGet = func(w http.ResponseWriter, _ *http.Request) {
    82  		w.Header().Set("Content-Type", "application/json")
    83  		_, _ = w.Write([]byte(`{"agent":{"skills":[]}}`))
    84  	}
    85  	server.HandleList = func(w http.ResponseWriter, _ *http.Request) {
    86  		w.Header().Set("Content-Type", "application/json")
    87  		_, _ = w.Write([]byte(`{"data":[],"first_id":null,"has_more":false,"last_id":null}`))
    88  	}
    89  	server.HandleStream = func(w http.ResponseWriter, r *http.Request) {
    90  		w.Header().Set("Content-Type", "text/event-stream")
    91  		flusher, ok := w.(http.Flusher)
    92  		require.True(t, ok, "stream response writer must support flushing")
    93  		w.WriteHeader(http.StatusOK)
    94  		flusher.Flush()
    95  		select {
    96  		case <-heartbeatSeen:
    97  		case <-r.Context().Done():
    98  			return
    99  		case <-time.After(5 * time.Second):
   100  			t.Error("heartbeat was never observed before stream terminate")
   101  		}
   102  		_, _ = w.Write([]byte("event: session.status_terminated\n" +
   103  			`data: {"type":"session.status_terminated","id":"evt_term","processed_at":"2026-05-11T12:00:00Z"}` +
   104  			"\n\n"))
   105  		flusher.Flush()
   106  	}
   107  	server.HandleStop = func(w http.ResponseWriter, _ *http.Request) {
   108  		w.WriteHeader(http.StatusNoContent)
   109  	}
   110  }
   111  
   112  // bearersByLeg partitions the recorded calls into the polling leg (poll/ack),
   113  // the per-item leg (heartbeat, session lookup, event stream/list/send), and
   114  // the worker's exit-path stop, and returns the set of Authorization values
   115  // seen on each.
   116  func bearersByLeg(calls []recordedCall) (poll, item, stop map[string]bool) {
   117  	poll, item, stop = map[string]bool{}, map[string]bool{}, map[string]bool{}
   118  	for _, c := range calls {
   119  		switch {
   120  		case strings.HasSuffix(c.path, "/work/poll"), strings.HasSuffix(c.path, "/ack"):
   121  			poll[c.auth] = true
   122  		case strings.HasSuffix(c.path, "/stop"):
   123  			stop[c.auth] = true
   124  		case strings.HasSuffix(c.path, "/heartbeat"), strings.Contains(c.path, "/sessions/"):
   125  			item[c.auth] = true
   126  		}
   127  	}
   128  	return poll, item, stop
   129  }
   130  
   131  func TestEnvironmentWorker_PrefersWorkItemSecret(t *testing.T) {
   132  	// A claimed item carrying a per-item secret payload authenticates that
   133  	// item's heartbeat / force-stop / skill-download / session-runner calls
   134  	// with the sessions token extracted from it; polling stays on the
   135  	// environment key. Neither the payload nor the token reaches the logs.
   136  	secret := encodeSecret(t, map[string]any{
   137  		"sessions_token":        "sessions-token-item-1",
   138  		"session_ingress_token": "ingress-token-1",
   139  	})
   140  	server := newFakeWorkServer(t)
   141  	scriptWorkServer(t, server, workJSONWithSecret("work_1", "env_1", "session", secret))
   142  
   143  	var logBuf bytes.Buffer
   144  
   145  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   146  	defer cancel()
   147  	// End the run after the worker's own force-stop: the second poll returns
   148  	// 204, and cancelling there lets Run return nil promptly.
   149  	stopSeen := make(chan struct{})
   150  	var stopOnce sync.Once
   151  	prevStop := server.HandleStop
   152  	server.HandleStop = func(w http.ResponseWriter, r *http.Request) {
   153  		prevStop(w, r)
   154  		stopOnce.Do(func() { close(stopSeen) })
   155  	}
   156  	go func() {
   157  		select {
   158  		case <-stopSeen:
   159  		case <-ctx.Done():
   160  		}
   161  		cancel()
   162  	}()
   163  
   164  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   165  		EnvironmentID:  "env_1",
   166  		EnvironmentKey: "env_key",
   167  		Workdir:        t.TempDir(),
   168  		Logger:         slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})),
   169  	})
   170  	require.NoError(t, worker.Run(ctx))
   171  
   172  	poll, item, stop := bearersByLeg(server.Calls())
   173  	require.Equal(t, map[string]bool{"Bearer env_key": true}, poll,
   174  		"polling must stay on the environment key")
   175  	require.Equal(t, map[string]bool{"Bearer sessions-token-item-1": true}, item,
   176  		"per-item calls must switch to the sessions token")
   177  	require.Equal(t, map[string]bool{"Bearer sessions-token-item-1": true}, stop,
   178  		"the worker force-stop must carry the sessions token and be the item's only stop")
   179  
   180  	// The credentials are opaque — neither the payload nor the extracted
   181  	// token may appear in log output.
   182  	logs := logBuf.String()
   183  	require.NotContains(t, logs, secret)
   184  	require.NotContains(t, logs, "sessions-token-item-1")
   185  }
   186  
   187  func TestEnvironmentWorker_FallsBackWhenSecretUndecodable(t *testing.T) {
   188  	// A secret payload that doesn't decode falls back to the environment
   189  	// key, with a warning that doesn't include the payload itself.
   190  	server := newFakeWorkServer(t)
   191  	scriptWorkServer(t, server, workJSON("work_1", "env_1", "session"))
   192  
   193  	var logBuf bytes.Buffer
   194  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   195  		EnvironmentKey: "env_key",
   196  		Workdir:        t.TempDir(),
   197  		Logger:         slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})),
   198  	})
   199  
   200  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   201  	defer cancel()
   202  	require.NoError(t, worker.HandleItem(ctx, HandleItemOptions{
   203  		WorkID:        "work_1",
   204  		EnvironmentID: "env_1",
   205  		SessionID:     "sesn_test",
   206  		WorkSecret:    "not-a-valid-payload",
   207  	}))
   208  
   209  	_, item, stop := bearersByLeg(server.Calls())
   210  	require.Equal(t, map[string]bool{"Bearer env_key": true}, item,
   211  		"an undecodable secret must fall back to the environment key")
   212  	require.Equal(t, map[string]bool{"Bearer env_key": true}, stop,
   213  		"the force-stop must fall back to the environment key too")
   214  
   215  	logs := logBuf.String()
   216  	require.Contains(t, logs, "no sessions token could be extracted")
   217  	require.NotContains(t, logs, "not-a-valid-payload")
   218  }
   219  
   220  func TestEnvironmentWorker_SecretNotLoggedOnErrorPaths(t *testing.T) {
   221  	// Error-path logging (heartbeat shutdown, force-stop failure) must not
   222  	// leak the per-item secret payload or its sessions token.
   223  	secret := encodeSecret(t, map[string]any{"sessions_token": "sessions-token-err"})
   224  	server := newFakeWorkServer(t)
   225  	scriptWorkServer(t, server, workJSONWithSecret("work_1", "env_1", "session", secret))
   226  	// The heartbeat reports "stopping" so the shutdown branch logs, and the
   227  	// force-stop fails so its error branch logs too.
   228  	server.HandleHeartbeat = func(w http.ResponseWriter, _ *http.Request) {
   229  		w.Header().Set("Content-Type", "application/json")
   230  		_, _ = w.Write([]byte(`{"last_heartbeat":"2026-05-11T12:00:00Z","lease_extended":true,"state":"stopping","ttl_seconds":30,"type":"work_heartbeat"}`))
   231  	}
   232  	server.HandleStream = func(w http.ResponseWriter, r *http.Request) {
   233  		w.Header().Set("Content-Type", "text/event-stream")
   234  		flusher, ok := w.(http.Flusher)
   235  		require.True(t, ok, "stream response writer must support flushing")
   236  		w.WriteHeader(http.StatusOK)
   237  		flusher.Flush()
   238  		<-r.Context().Done() // the heartbeat's shutdown cancels the session
   239  	}
   240  	server.HandleStop = func(w http.ResponseWriter, _ *http.Request) {
   241  		http.Error(w, `{"error":{"type":"api_error","message":"force-stop exploded"}}`, http.StatusInternalServerError)
   242  	}
   243  
   244  	var logBuf bytes.Buffer
   245  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   246  		EnvironmentKey: "env_key",
   247  		Workdir:        t.TempDir(),
   248  		Logger:         slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})),
   249  	})
   250  
   251  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   252  	defer cancel()
   253  	_ = worker.HandleItem(ctx, HandleItemOptions{
   254  		WorkID:        "work_1",
   255  		EnvironmentID: "env_1",
   256  		SessionID:     "sesn_test",
   257  		WorkSecret:    secret,
   258  	})
   259  
   260  	logs := logBuf.String()
   261  	// The error branches actually ran...
   262  	require.Contains(t, logs, "force-stop on exit failed")
   263  	// ...and none of them leaked the payload or the token inside it.
   264  	require.NotContains(t, logs, secret)
   265  	require.NotContains(t, logs, "sessions-token-err")
   266  }
   267  
   268  func TestHandleItem_UsesWorkSecretArgument(t *testing.T) {
   269  	// An explicit WorkSecret supplies the per-item Bearer credential (its
   270  	// sessions token); EnvironmentKey is still required but only a fallback.
   271  	secret := encodeSecret(t, map[string]any{"sessions_token": "sessions-token-arg"})
   272  	server := newFakeWorkServer(t)
   273  	scriptWorkServer(t, server, workJSON("work_1", "env_1", "session"))
   274  
   275  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   276  		Workdir: t.TempDir(),
   277  		Logger:  silentLogger,
   278  	})
   279  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   280  	defer cancel()
   281  	require.NoError(t, worker.HandleItem(ctx, HandleItemOptions{
   282  		WorkID:         "work_1",
   283  		EnvironmentID:  "env_1",
   284  		SessionID:      "sesn_test",
   285  		EnvironmentKey: "env_key",
   286  		WorkSecret:     secret,
   287  	}))
   288  
   289  	_, item, stop := bearersByLeg(server.Calls())
   290  	require.Equal(t, map[string]bool{"Bearer sessions-token-arg": true}, item)
   291  	require.Equal(t, map[string]bool{"Bearer sessions-token-arg": true}, stop)
   292  }
   293  
   294  func TestHandleItem_FallsBackToWorkSecretEnvVar(t *testing.T) {
   295  	// WorkSecret falls back to ANTHROPIC_WORK_SECRET (the env var the
   296  	// `worker poll --on-work` command sets alongside the others); when
   297  	// neither is present the environment key is used — the existing
   298  	// HandleItem tests cover that path.
   299  	secret := encodeSecret(t, map[string]any{"sessions_token": "sessions-token-env"})
   300  	server := newFakeWorkServer(t)
   301  	scriptWorkServer(t, server, workJSON("w_env", "e_env", "session"))
   302  
   303  	t.Setenv("ANTHROPIC_WORK_ID", "w_env")
   304  	t.Setenv("ANTHROPIC_ENVIRONMENT_ID", "e_env")
   305  	t.Setenv("ANTHROPIC_SESSION_ID", "sesn_test")
   306  	t.Setenv("ANTHROPIC_ENVIRONMENT_KEY", "key_env")
   307  	t.Setenv("ANTHROPIC_WORK_SECRET", secret)
   308  
   309  	worker := NewEnvironmentWorker(server.Client(), EnvironmentWorkerOptions{
   310  		Workdir: t.TempDir(),
   311  		Logger:  silentLogger,
   312  	})
   313  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   314  	defer cancel()
   315  	require.NoError(t, worker.HandleItem(ctx, HandleItemOptions{}))
   316  
   317  	_, item, stop := bearersByLeg(server.Calls())
   318  	require.Equal(t, map[string]bool{"Bearer sessions-token-env": true}, item)
   319  	require.Equal(t, map[string]bool{"Bearer sessions-token-env": true}, stop)
   320  }