Skip to content

Commit bf6588b

Browse files
authored
Add claude code in cloud s3 forwarding script (#291)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Touches credential handling and custom AWS request signing in the upload path; setup can create IAM users and print long-lived keys into cloud agent environments (POC-oriented, similar to GCS keys). > > **Overview** > Adds **Amazon S3** as an alternative to GCS for uploading cloud-agent `runtime.jsonl` from the sandbox. > > **Runtime (`cloudshuttle`)** — Uploads are selected via `BEACON_CLOUD_UPLOAD` (`gcs` default, `s3` explicit). S3 uses env-driven bucket/prefix/region/credentials (including session tokens), AWS SigV4-signed `PUT`s, and the same object key layout as GCS. GCS-only credential checks are replaced with `normalizeConfig` / `uploadConfigured` so preserve-on-reset and forced upload work for either backend. > > **CLI** — New `beacon cloud s3 setup` (`--print` / `--apply` / `--print-env`) provisions bucket (with public access block), a dedicated IAM user, prefix-scoped `s3:PutObject`, and prints `BEACON_CLOUD_S3_*` plus AWS keys. Shared `isNotFoundOutput` now recognizes common AWS 404 patterns. > > **Docs** — Claude Code cloud agents guide and CLI docs cover end-to-end S3 setup alongside GCS; new `cli/cloud-s3-setup` page and nav entry. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bf0ba55. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents d4ae5d1 + bf0ba55 commit bf6588b

9 files changed

Lines changed: 803 additions & 61 deletions

File tree

cli/beacon-hooks/internal/cloudshuttle/shuttle.go

Lines changed: 210 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"bytes"
55
"context"
66
"crypto"
7+
"crypto/hmac"
78
"crypto/rand"
89
"crypto/rsa"
910
"crypto/sha256"
1011
"crypto/x509"
1112
"encoding/base64"
13+
"encoding/hex"
1214
"encoding/json"
1315
"encoding/pem"
1416
"errors"
@@ -19,6 +21,7 @@ import (
1921
"os"
2022
"path"
2123
"path/filepath"
24+
"sort"
2225
"strings"
2326
"syscall"
2427
"time"
@@ -29,12 +32,16 @@ const (
2932
defaultStatePath = "/tmp/beacon/shuttle-state.json"
3033
defaultTokenURI = "https://oauth2.googleapis.com/token"
3134
defaultGCSEndpoint = "https://storage.googleapis.com"
35+
defaultS3Region = "us-east-1"
3236
contentTypeJSONL = "text/plain; charset=utf-8"
37+
uploadGCS = "gcs"
38+
uploadS3 = "s3"
3339
)
3440

3541
var httpClient = http.DefaultClient
3642

3743
type Config struct {
44+
Upload string
3845
LogPath string
3946
StatePath string
4047
Bucket string
@@ -45,6 +52,11 @@ type Config struct {
4552
UserID string
4653
Repository string
4754
GCSEndpoint string
55+
S3Region string
56+
S3AccessKeyID string
57+
S3SecretKey string
58+
S3SessionToken string
59+
S3Endpoint string
4860
}
4961

5062
type serviceAccount struct {
@@ -67,17 +79,30 @@ type tokenResponse struct {
6779

6880
func ConfigFromEnv() Config {
6981
statePath := firstEnvDefault(defaultStatePath, "BEACON_CLOUD_SHUTTLE_STATE")
82+
upload := cloudUploadFromEnv()
83+
bucket := strings.TrimSpace(os.Getenv("BEACON_CLOUD_GCS_BUCKET"))
84+
prefix := strings.Trim(strings.TrimSpace(os.Getenv("BEACON_CLOUD_GCS_PREFIX")), "/")
85+
if upload == uploadS3 {
86+
bucket = strings.TrimSpace(os.Getenv("BEACON_CLOUD_S3_BUCKET"))
87+
prefix = strings.Trim(strings.TrimSpace(os.Getenv("BEACON_CLOUD_S3_PREFIX")), "/")
88+
}
7089
return Config{
90+
Upload: upload,
7191
LogPath: firstEnvDefault(defaultLogPath, "BEACON_CLOUD_LOG_PATH", "BEACON_ENDPOINT_LOG", "BEACON_LOG_PATH", "BEACON_RUNTIME_LOG"),
7292
StatePath: statePath,
73-
Bucket: strings.TrimSpace(os.Getenv("BEACON_CLOUD_GCS_BUCKET")),
74-
Prefix: strings.Trim(strings.TrimSpace(os.Getenv("BEACON_CLOUD_GCS_PREFIX")), "/"),
93+
Bucket: bucket,
94+
Prefix: prefix,
7595
CredentialsB64: strings.TrimSpace(os.Getenv("BEACON_CLOUD_GCS_CREDENTIALS_B64")),
7696
Provider: firstEnvDefault("claude_code_web", "BEACON_RUN_PROVIDER"),
7797
RunID: resolveRunID(),
7898
UserID: firstEnvDefault("unknown", "BEACON_CLOUD_USER_ID_HASH", "BEACON_CLOUD_USER_ID"),
7999
Repository: firstEnv("BEACON_RUN_REPOSITORY"),
80100
GCSEndpoint: firstEnvDefault(defaultGCSEndpoint, "BEACON_CLOUD_GCS_ENDPOINT"),
101+
S3Region: firstEnvDefault(defaultS3Region, "BEACON_CLOUD_S3_REGION", "AWS_REGION", "AWS_DEFAULT_REGION"),
102+
S3AccessKeyID: firstEnv("AWS_ACCESS_KEY_ID"),
103+
S3SecretKey: firstEnv("AWS_SECRET_ACCESS_KEY"),
104+
S3SessionToken: firstEnv("AWS_SESSION_TOKEN"),
105+
S3Endpoint: firstEnv("BEACON_CLOUD_S3_ENDPOINT"),
81106
}
82107
}
83108

@@ -105,21 +130,13 @@ func ResetFromEnv() error {
105130
}
106131

107132
func Upload(ctx context.Context, cfg Config, force bool) error {
108-
if strings.TrimSpace(cfg.Bucket) == "" || strings.TrimSpace(cfg.CredentialsB64) == "" {
133+
cfg = normalizeConfig(cfg)
134+
if !uploadConfigured(cfg) {
109135
return nil
110136
}
111137
if strings.TrimSpace(cfg.RunID) == "" {
112138
return nil
113139
}
114-
if cfg.LogPath == "" {
115-
cfg.LogPath = defaultLogPath
116-
}
117-
if cfg.StatePath == "" {
118-
cfg.StatePath = defaultStatePath
119-
}
120-
if cfg.GCSEndpoint == "" {
121-
cfg.GCSEndpoint = defaultGCSEndpoint
122-
}
123140
info, err := os.Stat(cfg.LogPath)
124141
if err != nil {
125142
if os.IsNotExist(err) {
@@ -138,12 +155,8 @@ func Upload(ctx context.Context, cfg Config, force bool) error {
138155
return err
139156
}
140157
defer cleanup()
141-
token, err := accessToken(ctx, cfg.CredentialsB64)
142-
if err != nil {
143-
return err
144-
}
145158
objectName := ObjectName(cfg)
146-
if err := uploadObject(ctx, cfg.GCSEndpoint, cfg.Bucket, objectName, snapshot, token); err != nil {
159+
if err := uploadSnapshot(ctx, cfg, objectName, snapshot); err != nil {
147160
return err
148161
}
149162
return writeState(cfg.StatePath, state{
@@ -311,7 +324,20 @@ func parseRSAPrivateKey(raw string) (*rsa.PrivateKey, error) {
311324
return nil, errors.New("parse GCS private key")
312325
}
313326

314-
func uploadObject(ctx context.Context, endpoint, bucket, objectName, filePath, token string) error {
327+
func uploadSnapshot(ctx context.Context, cfg Config, objectName, filePath string) error {
328+
switch cfg.Upload {
329+
case uploadS3:
330+
return uploadS3Object(ctx, cfg, objectName, filePath)
331+
default:
332+
token, err := accessToken(ctx, cfg.CredentialsB64)
333+
if err != nil {
334+
return err
335+
}
336+
return uploadGCSObject(ctx, cfg.GCSEndpoint, cfg.Bucket, objectName, filePath, token)
337+
}
338+
}
339+
340+
func uploadGCSObject(ctx context.Context, endpoint, bucket, objectName, filePath, token string) error {
315341
file, err := os.Open(filePath)
316342
if err != nil {
317343
return err
@@ -339,6 +365,94 @@ func uploadObject(ctx context.Context, endpoint, bucket, objectName, filePath, t
339365
return nil
340366
}
341367

368+
func uploadS3Object(ctx context.Context, cfg Config, objectName, filePath string) error {
369+
file, err := os.Open(filePath)
370+
if err != nil {
371+
return err
372+
}
373+
defer file.Close()
374+
payloadHash, err := hashOpenFile(file)
375+
if err != nil {
376+
return err
377+
}
378+
if _, err := file.Seek(0, io.SeekStart); err != nil {
379+
return err
380+
}
381+
endpoint := strings.TrimRight(cfg.S3Endpoint, "/")
382+
if endpoint == "" {
383+
endpoint = "https://s3." + cfg.S3Region + ".amazonaws.com"
384+
}
385+
uploadURL := endpoint + "/" + escapePath(cfg.Bucket) + "/" + escapeObjectName(objectName)
386+
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, file)
387+
if err != nil {
388+
return err
389+
}
390+
if info, err := file.Stat(); err == nil {
391+
req.ContentLength = info.Size()
392+
}
393+
req.Header.Set("Content-Type", contentTypeJSONL)
394+
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
395+
req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z"))
396+
if cfg.S3SessionToken != "" {
397+
req.Header.Set("X-Amz-Security-Token", cfg.S3SessionToken)
398+
}
399+
signS3Request(req, cfg, payloadHash)
400+
resp, err := httpClient.Do(req)
401+
if err != nil {
402+
return err
403+
}
404+
defer resp.Body.Close()
405+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
406+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
407+
return fmt.Errorf("S3 upload failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
408+
}
409+
return nil
410+
}
411+
412+
func signS3Request(req *http.Request, cfg Config, payloadHash string) {
413+
amzDate := req.Header.Get("X-Amz-Date")
414+
dateStamp := amzDate[:8]
415+
credentialScope := dateStamp + "/" + cfg.S3Region + "/s3/aws4_request"
416+
headers := map[string]string{
417+
"content-type": req.Header.Get("Content-Type"),
418+
"host": req.URL.Host,
419+
"x-amz-content-sha256": payloadHash,
420+
"x-amz-date": amzDate,
421+
}
422+
if token := req.Header.Get("X-Amz-Security-Token"); token != "" {
423+
headers["x-amz-security-token"] = token
424+
}
425+
keys := make([]string, 0, len(headers))
426+
for key := range headers {
427+
keys = append(keys, key)
428+
}
429+
sort.Strings(keys)
430+
var canonicalHeaders strings.Builder
431+
for _, key := range keys {
432+
canonicalHeaders.WriteString(key)
433+
canonicalHeaders.WriteString(":")
434+
canonicalHeaders.WriteString(strings.TrimSpace(headers[key]))
435+
canonicalHeaders.WriteString("\n")
436+
}
437+
signedHeaders := strings.Join(keys, ";")
438+
canonicalRequest := strings.Join([]string{
439+
req.Method,
440+
req.URL.EscapedPath(),
441+
req.URL.RawQuery,
442+
canonicalHeaders.String(),
443+
signedHeaders,
444+
payloadHash,
445+
}, "\n")
446+
stringToSign := strings.Join([]string{
447+
"AWS4-HMAC-SHA256",
448+
amzDate,
449+
credentialScope,
450+
sha256Hex([]byte(canonicalRequest)),
451+
}, "\n")
452+
signature := hex.EncodeToString(hmacSHA256(s3SigningKey(cfg.S3SecretKey, dateStamp, cfg.S3Region), stringToSign))
453+
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+cfg.S3AccessKeyID+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature)
454+
}
455+
342456
func readState(path string) (state, error) {
343457
var st state
344458
data, err := os.ReadFile(path)
@@ -361,6 +475,7 @@ func writeState(path string, st state) error {
361475
}
362476

363477
func preserveExistingLog(cfg Config) error {
478+
cfg = normalizeConfig(cfg)
364479
info, err := os.Stat(cfg.LogPath)
365480
if err != nil {
366481
if os.IsNotExist(err) {
@@ -371,16 +486,14 @@ func preserveExistingLog(cfg Config) error {
371486
if info.Size() == 0 {
372487
return os.Remove(cfg.LogPath)
373488
}
374-
if st, err := readState(cfg.StatePath); err == nil && st.LastObject != "" && cfg.Bucket != "" && cfg.CredentialsB64 != "" {
489+
if st, err := readState(cfg.StatePath); err == nil && st.LastObject != "" && uploadConfigured(cfg) {
375490
snapshot, cleanup, err := snapshotLog(cfg.LogPath)
376491
if err == nil {
377492
defer cleanup()
378493
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
379494
defer cancel()
380-
if token, tokenErr := accessToken(ctx, cfg.CredentialsB64); tokenErr == nil {
381-
if uploadErr := uploadObject(ctx, cfg.GCSEndpoint, cfg.Bucket, st.LastObject, snapshot, token); uploadErr == nil {
382-
return os.Remove(cfg.LogPath)
383-
}
495+
if uploadErr := uploadSnapshot(ctx, cfg, st.LastObject, snapshot); uploadErr == nil {
496+
return os.Remove(cfg.LogPath)
384497
}
385498
}
386499
}
@@ -395,6 +508,80 @@ func resolveRunID() string {
395508
return firstEnv("BEACON_RUN_ID", "CLAUDE_CODE_REMOTE_SESSION_ID")
396509
}
397510

511+
func cloudUploadFromEnv() string {
512+
value := strings.ToLower(strings.TrimSpace(os.Getenv("BEACON_CLOUD_UPLOAD")))
513+
switch value {
514+
case uploadS3:
515+
return uploadS3
516+
case uploadGCS:
517+
return uploadGCS
518+
}
519+
return uploadGCS
520+
}
521+
522+
func normalizeConfig(cfg Config) Config {
523+
cfg.Upload = strings.ToLower(strings.TrimSpace(cfg.Upload))
524+
if cfg.Upload == "" {
525+
if cfg.S3AccessKeyID != "" || cfg.S3SecretKey != "" || cfg.S3Region != "" || strings.Contains(cfg.S3Endpoint, "s3") {
526+
cfg.Upload = uploadS3
527+
} else {
528+
cfg.Upload = uploadGCS
529+
}
530+
}
531+
if cfg.LogPath == "" {
532+
cfg.LogPath = defaultLogPath
533+
}
534+
if cfg.StatePath == "" {
535+
cfg.StatePath = defaultStatePath
536+
}
537+
if cfg.GCSEndpoint == "" {
538+
cfg.GCSEndpoint = defaultGCSEndpoint
539+
}
540+
if cfg.S3Region == "" {
541+
cfg.S3Region = defaultS3Region
542+
}
543+
cfg.Prefix = strings.Trim(cfg.Prefix, "/")
544+
return cfg
545+
}
546+
547+
func uploadConfigured(cfg Config) bool {
548+
if strings.TrimSpace(cfg.Bucket) == "" {
549+
return false
550+
}
551+
switch cfg.Upload {
552+
case uploadS3:
553+
return strings.TrimSpace(cfg.S3AccessKeyID) != "" && strings.TrimSpace(cfg.S3SecretKey) != ""
554+
default:
555+
return strings.TrimSpace(cfg.CredentialsB64) != ""
556+
}
557+
}
558+
559+
func hashOpenFile(file *os.File) (string, error) {
560+
hash := sha256.New()
561+
if _, err := io.Copy(hash, file); err != nil {
562+
return "", err
563+
}
564+
return hex.EncodeToString(hash.Sum(nil)), nil
565+
}
566+
567+
func sha256Hex(data []byte) string {
568+
sum := sha256.Sum256(data)
569+
return hex.EncodeToString(sum[:])
570+
}
571+
572+
func s3SigningKey(secret, dateStamp, region string) []byte {
573+
dateKey := hmacSHA256([]byte("AWS4"+secret), dateStamp)
574+
regionKey := hmacSHA256(dateKey, region)
575+
serviceKey := hmacSHA256(regionKey, "s3")
576+
return hmacSHA256(serviceKey, "aws4_request")
577+
}
578+
579+
func hmacSHA256(key []byte, value string) []byte {
580+
mac := hmac.New(sha256.New, key)
581+
mac.Write([]byte(value))
582+
return mac.Sum(nil)
583+
}
584+
398585
func cleanKeyParts(value string) []string {
399586
var parts []string
400587
for _, part := range strings.Split(strings.Trim(value, "/"), "/") {

0 commit comments

Comments
 (0)