diff --git a/internal/cli/fetchserver.go b/internal/cli/fetchserver.go new file mode 100644 index 0000000000..70f0b3646e --- /dev/null +++ b/internal/cli/fetchserver.go @@ -0,0 +1,79 @@ +package cli + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "fmt" + "net" + "net/http" + + "github.com/fullsend-ai/fullsend/internal/fetchsvc" +) + +// startFetchService starts an HTTP server that proxies runtime skill fetch +// requests from agents inside the sandbox to the fetchsvc handler. It returns +// the listener address, a bearer token for authentication, and a shutdown +// function that should be deferred by the caller. +func startFetchService(ctx context.Context, cfg fetchsvc.ServiceConfig) (addr string, token string, shutdown func(), err error) { + token, err = generateToken() + if err != nil { + return "", "", nil, fmt.Errorf("generating fetch service token: %w", err) + } + + svc := fetchsvc.New(cfg) + handler := withBearerAuth(token, svc) + + mux := http.NewServeMux() + mux.Handle("/fetch", handler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + ln, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + return "", "", nil, fmt.Errorf("listening for fetch service: %w", err) + } + + server := &http.Server{Handler: mux} + go func() { + if err := server.Serve(ln); err != nil && err != http.ErrServerClosed { + fmt.Printf("fetch service error: %v\n", err) + } + }() + + shutdownFn := func() { + server.Shutdown(ctx) + } + + return ln.Addr().String(), token, shutdownFn, nil +} + +// withBearerAuth wraps an http.Handler with bearer token authentication. +// Uses timing-safe comparison to prevent token timing attacks. +func withBearerAuth(token string, next http.Handler) http.Handler { + tokenBytes := []byte(token) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if len(auth) < len(prefix) || auth[:len(prefix)] != prefix { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + provided := []byte(auth[len(prefix):]) + if subtle.ConstantTimeCompare(provided, tokenBytes) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// generateToken produces a 32-byte hex-encoded random token. +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return fmt.Sprintf("%x", b), nil +} diff --git a/internal/cli/fetchserver_test.go b/internal/cli/fetchserver_test.go new file mode 100644 index 0000000000..83daeab843 --- /dev/null +++ b/internal/cli/fetchserver_test.go @@ -0,0 +1,229 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/fullsend-ai/fullsend/internal/fetch" + "github.com/fullsend-ai/fullsend/internal/fetchsvc" + "github.com/fullsend-ai/fullsend/internal/harness" +) + +func TestStartFetchService_StartsAndStops(t *testing.T) { + cfg := fetchsvc.ServiceConfig{ + Harness: &harness.Harness{Agent: "agents/test.md"}, + WorkspaceRoot: t.TempDir(), + MaxFetches: 10, + } + + addr, token, shutdown, err := startFetchService(context.Background(), cfg) + if err != nil { + t.Fatalf("startFetchService: %v", err) + } + defer shutdown() + + if addr == "" { + t.Fatal("addr should not be empty") + } + if token == "" { + t.Fatal("token should not be empty") + } + if len(token) != 64 { + t.Fatalf("token length = %d, want 64 hex chars", len(token)) + } + + // Health endpoint should be reachable without auth. + resp, err := http.Get(fmt.Sprintf("http://%s/healthz", addr)) + if err != nil { + t.Fatalf("healthz request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } +} + +func TestStartFetchService_FetchEndpoint(t *testing.T) { + tmpDir := t.TempDir() + files := map[string][]byte{ + "SKILL.md": []byte("---\nname: srv-test\n---\n# Test\n"), + } + hash := fetch.ComputeTreeHash(files) + fetch.CachePutDir(tmpDir, "https://github.com/org/repo/tree/abc/skills/test", files) + + cfg := fetchsvc.ServiceConfig{ + Harness: &harness.Harness{ + Agent: "agents/test.md", + AllowedRemoteResources: []string{"https://github.com/org/repo/"}, + }, + WorkspaceRoot: tmpDir, + MaxFetches: 10, + SkillDestDir: "/sandbox/skills", + } + + addr, token, shutdown, err := startFetchService(context.Background(), cfg) + if err != nil { + t.Fatalf("startFetchService: %v", err) + } + defer shutdown() + + body, _ := json.Marshal(fetchsvc.FetchRequest{ + URL: "https://github.com/org/repo/tree/abc/skills/test#sha256=" + hash, + }) + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/fetch", addr), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("fetch request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var fetchResp fetchsvc.FetchResponse + json.NewDecoder(resp.Body).Decode(&fetchResp) + if fetchResp.LocalPath == "" { + t.Fatal("expected non-empty LocalPath") + } + if fetchResp.Error != "" { + t.Fatalf("unexpected error: %s", fetchResp.Error) + } +} + +func TestStartFetchService_RejectsWithoutAuth(t *testing.T) { + cfg := fetchsvc.ServiceConfig{ + Harness: &harness.Harness{Agent: "agents/test.md"}, + WorkspaceRoot: t.TempDir(), + MaxFetches: 10, + } + + addr, _, shutdown, err := startFetchService(context.Background(), cfg) + if err != nil { + t.Fatalf("startFetchService: %v", err) + } + defer shutdown() + + body, _ := json.Marshal(fetchsvc.FetchRequest{URL: "https://example.com/skill#sha256=aaa"}) + resp, err := http.Post(fmt.Sprintf("http://%s/fetch", addr), "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestStartFetchService_RejectsWrongToken(t *testing.T) { + cfg := fetchsvc.ServiceConfig{ + Harness: &harness.Harness{Agent: "agents/test.md"}, + WorkspaceRoot: t.TempDir(), + MaxFetches: 10, + } + + addr, _, shutdown, err := startFetchService(context.Background(), cfg) + if err != nil { + t.Fatalf("startFetchService: %v", err) + } + defer shutdown() + + body, _ := json.Marshal(fetchsvc.FetchRequest{URL: "https://example.com/skill#sha256=aaa"}) + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/fetch", addr), bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer wrong-token") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestWithBearerAuth_ValidToken(t *testing.T) { + var called bool + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + handler := withBearerAuth("my-secret", inner) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Authorization", "Bearer my-secret") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if !called { + t.Fatal("inner handler should have been called") + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestWithBearerAuth_InvalidToken(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called with invalid token") + }) + + handler := withBearerAuth("my-secret", inner) + + tests := []struct { + name string + auth string + }{ + {"empty", ""}, + {"no bearer prefix", "my-secret"}, + {"wrong token", "Bearer wrong"}, + {"basic auth", "Basic dXNlcjpwYXNz"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + if tc.auth != "" { + req.Header.Set("Authorization", tc.auth) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + }) + } +} + +func TestGenerateToken_Unique(t *testing.T) { + t1, err := generateToken() + if err != nil { + t.Fatalf("generateToken: %v", err) + } + t2, err := generateToken() + if err != nil { + t.Fatalf("generateToken: %v", err) + } + + if t1 == t2 { + t.Fatal("two generated tokens should not be equal") + } + if len(t1) != 64 { + t.Fatalf("token length = %d, want 64", len(t1)) + } + if strings.Trim(t1, "0123456789abcdef") != "" { + t.Fatalf("token %q should be lowercase hex", t1) + } +} diff --git a/internal/cli/fetchskill.go b/internal/cli/fetchskill.go new file mode 100644 index 0000000000..6d56a18ced --- /dev/null +++ b/internal/cli/fetchskill.go @@ -0,0 +1,92 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/fullsend-ai/fullsend/internal/fetchsvc" +) + +const fetchSkillTimeout = 120 * time.Second + +func newFetchSkillCmd() *cobra.Command { + return &cobra.Command{ + Use: "fetch-skill ", + Short: "Fetch a skill at runtime from inside the sandbox", + Long: `Requests a skill directory from the runner-side fetch service. The URL must +include a #sha256= integrity hash and match the harness's +allowed_remote_resources prefixes. + +On success, prints the sandbox-local skill directory path to stdout. +On failure, prints the error to stderr and exits with code 1. + +This command is intended to be called by agents running inside a sandbox. +It requires the FULLSEND_FETCH_URL and FULLSEND_FETCH_TOKEN environment +variables, which are set automatically during sandbox bootstrap.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runFetchSkill(args[0], cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } +} + +func runFetchSkill(skillURL string, stdout, stderr io.Writer) error { + fetchURL := os.Getenv("FULLSEND_FETCH_URL") + if fetchURL == "" { + fmt.Fprintln(stderr, "FULLSEND_FETCH_URL is not set (is runtime fetch enabled in the harness?)") + return fmt.Errorf("FULLSEND_FETCH_URL not set") + } + + token := os.Getenv("FULLSEND_FETCH_TOKEN") + if token == "" { + fmt.Fprintln(stderr, "FULLSEND_FETCH_TOKEN is not set") + return fmt.Errorf("FULLSEND_FETCH_TOKEN not set") + } + + body, err := json.Marshal(fetchsvc.FetchRequest{URL: skillURL}) + if err != nil { + fmt.Fprintf(stderr, "failed to marshal request: %v\n", err) + return err + } + + req, err := http.NewRequest(http.MethodPost, fetchURL, bytes.NewReader(body)) + if err != nil { + fmt.Fprintf(stderr, "failed to create request: %v\n", err) + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: fetchSkillTimeout} + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(stderr, "fetch request failed: %v\n", err) + return err + } + defer resp.Body.Close() + + var fetchResp fetchsvc.FetchResponse + if err := json.NewDecoder(resp.Body).Decode(&fetchResp); err != nil { + fmt.Fprintf(stderr, "failed to decode response: %v\n", err) + return err + } + + if resp.StatusCode != http.StatusOK || fetchResp.Error != "" { + msg := fetchResp.Error + if msg == "" { + msg = fmt.Sprintf("fetch service returned status %d", resp.StatusCode) + } + fmt.Fprintln(stderr, msg) + return fmt.Errorf("%s", msg) + } + + fmt.Fprintln(stdout, fetchResp.LocalPath) + return nil +} diff --git a/internal/cli/fetchskill_test.go b/internal/cli/fetchskill_test.go new file mode 100644 index 0000000000..d5f02be9ce --- /dev/null +++ b/internal/cli/fetchskill_test.go @@ -0,0 +1,209 @@ +package cli + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/fullsend-ai/fullsend/internal/fetchsvc" +) + +func TestRunFetchSkill_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fetchsvc.FetchResponse{ + LocalPath: "/sandbox/claude-config/skills/abc12345-my-skill", + }) + })) + defer srv.Close() + + t.Setenv("FULLSEND_FETCH_URL", srv.URL) + t.Setenv("FULLSEND_FETCH_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + err := runFetchSkill("https://github.com/org/repo/tree/abc/skills/my-skill#sha256=abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", &stdout, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := stdout.String() + want := "/sandbox/claude-config/skills/abc12345-my-skill\n" + if got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } + if stderr.Len() != 0 { + t.Fatalf("stderr should be empty, got %q", stderr.String()) + } +} + +func TestRunFetchSkill_Forbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(fetchsvc.FetchResponse{ + Error: "url not in allowed_remote_resources", + }) + })) + defer srv.Close() + + t.Setenv("FULLSEND_FETCH_URL", srv.URL) + t.Setenv("FULLSEND_FETCH_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + err := runFetchSkill("https://github.com/evil/repo/tree/abc/skills/bad#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &stdout, &stderr) + if err == nil { + t.Fatal("expected error for forbidden URL") + } + if stdout.Len() != 0 { + t.Fatalf("stdout should be empty on error, got %q", stdout.String()) + } + if stderr.Len() == 0 { + t.Fatal("stderr should contain error message") + } +} + +func TestRunFetchSkill_RateLimited(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(fetchsvc.FetchResponse{ + Error: "runtime fetch rate limit exceeded", + }) + })) + defer srv.Close() + + t.Setenv("FULLSEND_FETCH_URL", srv.URL) + t.Setenv("FULLSEND_FETCH_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + err := runFetchSkill("https://github.com/org/repo/tree/abc/skills/foo#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &stdout, &stderr) + if err == nil { + t.Fatal("expected error for rate-limited request") + } +} + +func TestRunFetchSkill_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(fetchsvc.FetchResponse{ + Error: "failed to cache skill directory", + }) + })) + defer srv.Close() + + t.Setenv("FULLSEND_FETCH_URL", srv.URL) + t.Setenv("FULLSEND_FETCH_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + err := runFetchSkill("https://github.com/org/repo/tree/abc/skills/foo#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &stdout, &stderr) + if err == nil { + t.Fatal("expected error for server error") + } +} + +func TestRunFetchSkill_MissingFetchURL(t *testing.T) { + t.Setenv("FULLSEND_FETCH_URL", "") + t.Setenv("FULLSEND_FETCH_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + err := runFetchSkill("https://example.com/skill#sha256=aaa", &stdout, &stderr) + if err == nil { + t.Fatal("expected error for missing FULLSEND_FETCH_URL") + } + if stderr.Len() == 0 { + t.Fatal("stderr should explain the missing env var") + } +} + +func TestRunFetchSkill_MissingToken(t *testing.T) { + t.Setenv("FULLSEND_FETCH_URL", "http://localhost:9999/fetch") + t.Setenv("FULLSEND_FETCH_TOKEN", "") + + var stdout, stderr bytes.Buffer + err := runFetchSkill("https://example.com/skill#sha256=aaa", &stdout, &stderr) + if err == nil { + t.Fatal("expected error for missing FULLSEND_FETCH_TOKEN") + } +} + +func TestRunFetchSkill_AuthTokenSent(t *testing.T) { + var receivedAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fetchsvc.FetchResponse{LocalPath: "/path"}) + })) + defer srv.Close() + + t.Setenv("FULLSEND_FETCH_URL", srv.URL) + t.Setenv("FULLSEND_FETCH_TOKEN", "secret-abc-123") + + var stdout, stderr bytes.Buffer + _ = runFetchSkill("https://github.com/org/repo/tree/abc/skills/foo#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &stdout, &stderr) + + if receivedAuth != "Bearer secret-abc-123" { + t.Fatalf("Authorization header = %q, want %q", receivedAuth, "Bearer secret-abc-123") + } +} + +func TestRunFetchSkill_RequestBody(t *testing.T) { + var receivedReq fetchsvc.FetchRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedReq) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fetchsvc.FetchResponse{LocalPath: "/path"}) + })) + defer srv.Close() + + t.Setenv("FULLSEND_FETCH_URL", srv.URL) + t.Setenv("FULLSEND_FETCH_TOKEN", "t") + + skillURL := "https://github.com/org/repo/tree/abc/skills/foo#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + var stdout, stderr bytes.Buffer + _ = runFetchSkill(skillURL, &stdout, &stderr) + + if receivedReq.URL != skillURL { + t.Fatalf("request URL = %q, want %q", receivedReq.URL, skillURL) + } +} + +func TestRunFetchSkill_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fetchsvc.FetchResponse{LocalPath: "/path"}) + })) + defer srv.Close() + + t.Setenv("FULLSEND_FETCH_URL", srv.URL) + t.Setenv("FULLSEND_FETCH_TOKEN", "t") + + // Override timeout for testing — the production timeout is 120s, + // but we verify the client honours its deadline with a fast mock. + origTimeout := fetchSkillTimeout + // We can't override const, but the test verifies the client + // connects and handles the response correctly even with delay. + _ = origTimeout + + var stdout, stderr bytes.Buffer + err := runFetchSkill("https://github.com/org/repo/tree/abc/skills/foo#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &stdout, &stderr) + // With 200ms delay and 120s timeout, this should succeed. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewFetchSkillCmd_Registration(t *testing.T) { + cmd := newFetchSkillCmd() + if cmd.Use != "fetch-skill " { + t.Fatalf("Use = %q, want %q", cmd.Use, "fetch-skill ") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index ab41c91c28..fef2c8229f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -27,6 +27,7 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newInferenceCmd()) cmd.AddCommand(newLockCmd()) cmd.AddCommand(newMintCmd()) + cmd.AddCommand(newFetchSkillCmd()) cmd.AddCommand(newRunCmd()) cmd.AddCommand(newScanCmd()) cmd.AddCommand(newPostReviewCmd()) diff --git a/internal/cli/run.go b/internal/cli/run.go index b32ba9f4f6..c593f1a6fe 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -21,6 +21,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/config" "github.com/fullsend-ai/fullsend/internal/envfile" "github.com/fullsend-ai/fullsend/internal/fetch" + "github.com/fullsend-ai/fullsend/internal/fetchsvc" "github.com/fullsend-ai/fullsend/internal/forge" gh "github.com/fullsend-ai/fullsend/internal/forge/github" "github.com/fullsend-ai/fullsend/internal/harness" @@ -530,6 +531,39 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep repoName := filepath.Base(hostRepositoryDir) remoteRepositoryDir := fmt.Sprintf("%s/%s", sandbox.SandboxWorkspace, repoName) + // 5. Generate trace ID for security finding and audit log correlation. + traceID := security.GenerateTraceID() + + // 6. Start runtime fetch service (Phase 4, ADR-0038). + // The service listens on a dynamic TCP port and authenticates requests + // with a per-run bearer token. It remains idle unless an agent invokes + // `fullsend fetch-skill`. PR 3 will gate this behind allow_runtime_fetch. + var fetchEnvVal fetchServiceEnv + var runtimeForgeClient forge.Client + if rFlags.forgeClient != nil { + runtimeForgeClient = rFlags.forgeClient + } else if h.HasURLSkills() || len(h.AllowedRemoteResources) > 0 { + if token, tokenErr := resolveToken(); tokenErr == nil { + runtimeForgeClient = gh.New(token) + } + } + fetchAddr, fetchToken, fetchShutdown, fetchErr := startFetchService(ctx, fetchsvc.ServiceConfig{ + Harness: h, + ForgeClient: runtimeForgeClient, + FetchPolicy: fetch.DefaultPolicy, + WorkspaceRoot: absFullsendDir, + AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"), + TraceID: traceID, + SandboxName: sandboxName, + Uploader: &fetchsvc.SandboxUploader{}, + }) + if fetchErr != nil { + printer.StepWarn("Runtime fetch service failed to start: " + fetchErr.Error()) + } else { + defer fetchShutdown() + fetchEnvVal = fetchServiceEnv{addr: fetchAddr, token: fetchToken} + } + // 7. Bootstrap sandbox. backend := agentruntime.Default() rt := backend.Runtime @@ -549,7 +583,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep printer.StepFail("Failed to bootstrap sandbox") return err } - if err := bootstrapEnv(sandboxName, remoteRepositoryDir, h, rt.EnvExports()); err != nil { + if err := bootstrapEnv(sandboxName, remoteRepositoryDir, h, rt.EnvExports(), fetchEnvVal); err != nil { printer.StepFail("Failed to bootstrap sandbox") return err } @@ -632,8 +666,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep } } - // 9a. Generate trace ID for security finding correlation. - traceID := security.GenerateTraceID() + // 9a. Display trace ID (generated earlier for fetch service audit logging). printer.KeyValue("Trace ID", traceID) if err := injectTraceID(sandboxName, traceID); err != nil { printer.StepWarn("Could not inject trace ID into sandbox: " + err.Error()) @@ -1031,7 +1064,16 @@ func bootstrapCommon(sandboxName, fullsendBinary string, h *harness.Harness) err // host_files entries copy files from the host into the sandbox at specified // destination paths. Src values may contain ${VAR} references expanded from // the host environment. When expand is true, file content is also expanded. -func bootstrapEnv(sandboxName, remoteRepositoryDir string, h *harness.Harness, runtimeEnvExports []string) error { +// fetchServiceEnv holds the address and token of the runtime fetch service +// started by the runner. When non-empty, bootstrapEnv injects them as +// environment variables so the in-sandbox fullsend fetch-skill subcommand +// can reach the runner. +type fetchServiceEnv struct { + addr string // host:port + token string // bearer token +} + +func bootstrapEnv(sandboxName, remoteRepositoryDir string, h *harness.Harness, runtimeEnvExports []string, fetchEnv ...fetchServiceEnv) error { remoteEnvFile := sandbox.SandboxWorkspace + "/.env" outputDir := sandbox.SandboxWorkspace + "/output" @@ -1070,6 +1112,12 @@ func bootstrapEnv(sandboxName, remoteRepositoryDir string, h *harness.Harness, r lines = append(lines, fmt.Sprintf("export FULLSEND_OUTPUT_FILE='%s'", strings.ReplaceAll(outputFile, "'", "'\\''"))) } + // Runtime fetch service env vars (Phase 4, ADR-0038). + if len(fetchEnv) > 0 && fetchEnv[0].addr != "" { + lines = append(lines, fmt.Sprintf("export FULLSEND_FETCH_URL=http://%s/fetch", fetchEnv[0].addr)) + lines = append(lines, fmt.Sprintf("export FULLSEND_FETCH_TOKEN=%s", fetchEnv[0].token)) + } + // Source all env files from .env.d/ (populated by host_files with expand: true). lines = append(lines, fmt.Sprintf("for f in %s/.env.d/*.env; do [ -f \"$f\" ] && . \"$f\"; done", sandbox.SandboxWorkspace)) diff --git a/internal/fetchsvc/service.go b/internal/fetchsvc/service.go index b06adbf68d..c5eadf966a 100644 --- a/internal/fetchsvc/service.go +++ b/internal/fetchsvc/service.go @@ -185,11 +185,12 @@ func (s *Service) HandleFetch(ctx context.Context, req FetchRequest) (FetchRespo } } - if _, err := fetch.CachePutDir(s.workspaceRoot, cleanURL, files); err != nil { + treeHash, err := fetch.CachePutDir(s.workspaceRoot, cleanURL, files) + if err != nil { return FetchResponse{}, &fetchError{"failed to cache skill directory", http.StatusInternalServerError} } - cachePath, err := fetch.CachePath(s.workspaceRoot, expectedHash) + cachePath, err := fetch.CachePath(s.workspaceRoot, treeHash) if err != nil { return FetchResponse{}, &fetchError{"internal error computing cache path", http.StatusInternalServerError} } @@ -215,7 +216,7 @@ func (s *Service) HandleFetch(ctx context.Context, req FetchRequest) (FetchRespo } if s.auditLogPath != "" { - _ = fetch.AppendFetchAudit(s.auditLogPath, fetch.FetchAuditEntry{ + if err := fetch.AppendFetchAudit(s.auditLogPath, fetch.FetchAuditEntry{ TraceID: s.traceID, FetchTime: fetchedAt, URL: cleanURL, @@ -223,7 +224,9 @@ func (s *Service) HandleFetch(ctx context.Context, req FetchRequest) (FetchRespo FetchType: "runtime", AllowedBy: allowedBy, CacheHit: cacheHit, - }) + }); err != nil { + return FetchResponse{}, &fetchError{"failed to write audit log", http.StatusInternalServerError} + } } committed = true @@ -241,6 +244,11 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { var req FetchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeJSON(w, http.StatusRequestEntityTooLarge, FetchResponse{Error: "request body too large"}) + return + } writeJSON(w, http.StatusBadRequest, FetchResponse{Error: "invalid request body"}) return } diff --git a/internal/fetchsvc/service_test.go b/internal/fetchsvc/service_test.go index 028d098d81..47fbb74cc7 100644 --- a/internal/fetchsvc/service_test.go +++ b/internal/fetchsvc/service_test.go @@ -419,6 +419,29 @@ func TestServeHTTP_BadJSON(t *testing.T) { } } +func TestServeHTTP_OversizedBody(t *testing.T) { + svc := New(ServiceConfig{ + Harness: testHarness("https://github.com/org/"), + WorkspaceRoot: t.TempDir(), + MaxFetches: 10, + }) + + longURL := `{"url":"https://example.com/` + strings.Repeat("a", maxRequestBytes) + `"}` + req := httptest.NewRequest(http.MethodPost, "/fetch", strings.NewReader(longURL)) + rec := httptest.NewRecorder() + + svc.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413", rec.Code) + } + var resp FetchResponse + json.NewDecoder(rec.Body).Decode(&resp) + if resp.Error != "request body too large" { + t.Fatalf("error = %q, want 'request body too large'", resp.Error) + } +} + func TestServeHTTP_Forbidden(t *testing.T) { svc := New(ServiceConfig{ Harness: testHarness("https://github.com/allowed-org/"), diff --git a/internal/fetchsvc/uploader.go b/internal/fetchsvc/uploader.go new file mode 100644 index 0000000000..21537bec72 --- /dev/null +++ b/internal/fetchsvc/uploader.go @@ -0,0 +1,11 @@ +package fetchsvc + +import "github.com/fullsend-ai/fullsend/internal/sandbox" + +// SandboxUploader implements the Uploader interface by delegating to +// sandbox.UploadDir for tarball-based directory transfer into the sandbox. +type SandboxUploader struct{} + +func (u *SandboxUploader) UploadSkillDir(sandboxName, localPath, remotePath string) error { + return sandbox.UploadDir(sandboxName, localPath, remotePath) +}