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 }