Skip to content

Commit 7aea644

Browse files
authored
Merge pull request #2144 from fullsend-ai/agent/2143-preflight-github-api-check
fix(#2143): add pre-flight GitHub API connectivity check in sandbox
2 parents 4cf2d94 + b252c40 commit 7aea644

3 files changed

Lines changed: 146 additions & 0 deletions

File tree

internal/cli/preflight_github.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/fullsend-ai/fullsend/internal/sandbox"
9+
)
10+
11+
const (
12+
// preflightGitHubTimeout is the maximum time to wait for the GitHub API
13+
// connectivity check inside the sandbox. Short because this is a fast
14+
// pre-flight — if the proxy is blocking, the connection attempt fails
15+
// quickly (HTTP 403 on CONNECT).
16+
preflightGitHubTimeout = 30 * time.Second
17+
)
18+
19+
// preflightGitHubResult captures the outcome of a sandbox-side GitHub API
20+
// connectivity check.
21+
type preflightGitHubResult struct {
22+
// Skipped is true when the check could not run (e.g., GH_TOKEN not set
23+
// or gh not on PATH inside the sandbox).
24+
Skipped bool
25+
// SkipReason explains why the check was skipped.
26+
SkipReason string
27+
}
28+
29+
// checkSandboxGitHubConnectivity runs a lightweight GitHub API check inside
30+
// the sandbox to verify that api.github.com is reachable through the proxy.
31+
//
32+
// The check sources the sandbox .env file (to pick up GH_TOKEN and PATH),
33+
// then calls `gh api /rate_limit`. This validates both network connectivity
34+
// (the HTTPS CONNECT tunnel through the proxy) and token validity in a
35+
// single low-cost API call.
36+
//
37+
// Returns a non-nil error when the API is unreachable (proxy 403, connection
38+
// refused, DNS failure, etc.). Returns a nil error with Skipped=true when the
39+
// check cannot run (no GH_TOKEN or no gh binary). Callers should treat a
40+
// non-nil error as fatal — the agent will waste its entire timeout retrying
41+
// doomed API calls. See #2143.
42+
func checkSandboxGitHubConnectivity(sandboxName string) (*preflightGitHubResult, error) {
43+
envFile := sandbox.SandboxWorkspace + "/.env"
44+
45+
// First check whether GH_TOKEN is set and gh is available. If neither is
46+
// present the agent does not need GitHub API access — skip silently.
47+
probeCmd := fmt.Sprintf(". %s 2>/dev/null; "+
48+
"if [ -z \"${GH_TOKEN:-}\" ]; then echo NOTOKEN; exit 0; fi; "+
49+
"if ! command -v gh >/dev/null 2>&1; then echo NOGH; exit 0; fi; "+
50+
"echo OK", envFile)
51+
52+
stdout, _, exitCode, err := sandbox.Exec(sandboxName, probeCmd, 10*time.Second)
53+
if err != nil {
54+
return &preflightGitHubResult{Skipped: true, SkipReason: "probe command failed: " + err.Error()}, nil
55+
}
56+
probe := strings.TrimSpace(stdout)
57+
if exitCode != 0 || probe == "NOTOKEN" {
58+
return &preflightGitHubResult{Skipped: true, SkipReason: "GH_TOKEN not set in sandbox"}, nil
59+
}
60+
if probe == "NOGH" {
61+
return &preflightGitHubResult{Skipped: true, SkipReason: "gh CLI not available in sandbox"}, nil
62+
}
63+
64+
// GH_TOKEN is set and gh is available — test actual connectivity.
65+
// Use /rate_limit as the lightest authenticated endpoint.
66+
checkCmd := fmt.Sprintf(". %s 2>/dev/null && gh api /rate_limit --silent 2>&1", envFile)
67+
stdout, stderr, exitCode, err := sandbox.Exec(sandboxName, checkCmd, preflightGitHubTimeout)
68+
if err != nil {
69+
return nil, fmt.Errorf("GitHub API connectivity check failed: %w", err)
70+
}
71+
if exitCode == 0 {
72+
return &preflightGitHubResult{}, nil
73+
}
74+
75+
// Non-zero exit — diagnose the failure.
76+
output := strings.TrimSpace(stdout + "\n" + stderr)
77+
if strings.Contains(output, "403") || strings.Contains(output, "Forbidden") {
78+
return nil, fmt.Errorf(
79+
"GitHub API unreachable from sandbox (HTTP 403 — proxy allowlist issue):\n%s\n\n"+
80+
"The sandbox proxy is blocking HTTPS CONNECT to api.github.com. "+
81+
"Check the OpenShell gateway network policy and proxy allowlist configuration",
82+
output)
83+
}
84+
if strings.Contains(output, "Could not resolve host") || strings.Contains(output, "Name or service not known") {
85+
return nil, fmt.Errorf(
86+
"GitHub API unreachable from sandbox (DNS resolution failed):\n%s\n\n"+
87+
"The sandbox cannot resolve api.github.com. "+
88+
"Check DNS configuration and network policies",
89+
output)
90+
}
91+
if strings.Contains(output, "Connection refused") || strings.Contains(output, "Connection timed out") {
92+
return nil, fmt.Errorf(
93+
"GitHub API unreachable from sandbox (connection failed):\n%s\n\n"+
94+
"The sandbox cannot connect to api.github.com or the HTTPS proxy. "+
95+
"Check network policies and proxy availability",
96+
output)
97+
}
98+
99+
return nil, fmt.Errorf("GitHub API connectivity check failed (exit %d):\n%s", exitCode, output)
100+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestPreflightGitHubResult_SkippedFields(t *testing.T) {
11+
// Verify the result struct reports skip reasons correctly.
12+
r := &preflightGitHubResult{Skipped: true, SkipReason: "GH_TOKEN not set in sandbox"}
13+
assert.True(t, r.Skipped)
14+
assert.Equal(t, "GH_TOKEN not set in sandbox", r.SkipReason)
15+
}
16+
17+
func TestPreflightGitHubResult_NotSkipped(t *testing.T) {
18+
r := &preflightGitHubResult{}
19+
assert.False(t, r.Skipped)
20+
assert.Empty(t, r.SkipReason)
21+
}
22+
23+
func TestPreflightGitHubTimeout(t *testing.T) {
24+
// Ensure the timeout constant is set to a reasonable value.
25+
require.Greater(t, preflightGitHubTimeout.Seconds(), float64(0))
26+
require.LessOrEqual(t, preflightGitHubTimeout.Seconds(), float64(60))
27+
}

internal/cli/run.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,25 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
651651
}
652652
}
653653

654+
// 9b-2. Pre-flight GitHub API connectivity check.
655+
// Validates that the sandbox can reach api.github.com through the proxy
656+
// before starting the agent. Without this, agents that depend on gh CLI
657+
// burn their entire timeout on doomed API calls. See #2143.
658+
{
659+
preflightStart := time.Now()
660+
printer.StepStart("Checking GitHub API connectivity from sandbox")
661+
result, connectErr := checkSandboxGitHubConnectivity(sandboxName)
662+
if connectErr != nil {
663+
printer.StepFail("GitHub API unreachable from sandbox")
664+
return fmt.Errorf("pre-flight connectivity check: %w", connectErr)
665+
}
666+
if result.Skipped {
667+
printer.StepInfo("GitHub API check skipped: " + result.SkipReason)
668+
} else {
669+
printer.StepDone(fmt.Sprintf("GitHub API reachable from sandbox (%.1fs)", time.Since(preflightStart).Seconds()))
670+
}
671+
}
672+
654673
// 9c. Run agent with validation loop.
655674
agentBaseName := agentName
656675
var pluginDirs []string

0 commit comments

Comments
 (0)