Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/contributing/runtime-implementation.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,10 @@ Vendoring commit messages use title + body (upload and stale delete). `github st
│ ▼ │
│ ┌──────────────────┐ │
│ │ ImportProfile() │ Import openshell provider profiles │
│ │ │ (from resolved openshell.profiles) │
│ │ │ (from resolved openshell.profiles; │
│ │ │ on GitLab, a fullsend-gitlab-forge │
│ │ │ profile is auto-generated from the │
│ │ │ forge host URL — see #6615) │
│ └──────┬───────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
Expand Down
8 changes: 6 additions & 2 deletions internal/cli/bootstrap_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (b *harnessBootstrapWithHooks) SandboxHookConfig() security.SandboxHookConf
return b.hooks
}

func newHarnessBootstrap(h *harness.Harness, sandboxName, agentName string) runtime.BootstrapInput {
func newHarnessBootstrap(h *harness.Harness, sandboxName, agentName, forgeEgressEntry string) runtime.BootstrapInput {
base := &harnessBootstrap{
sandboxName: sandboxName,
agentPath: h.Agent,
Expand All @@ -40,8 +40,12 @@ func newHarnessBootstrap(h *harness.Harness, sandboxName, agentName string) runt
if !h.SecurityEnabled() {
return base
}
hooks := security.SandboxHookConfigFromHarness(h)
if forgeEgressEntry != "" {
hooks = hooks.WithForgeEgressEntry(forgeEgressEntry)
}
return &harnessBootstrapWithHooks{
harnessBootstrap: base,
hooks: security.SandboxHookConfigFromHarness(h),
hooks: hooks,
}
}
18 changes: 16 additions & 2 deletions internal/cli/bootstrap_input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func TestNewHarnessBootstrap_WithoutSecurity(t *testing.T) {
Enabled: &disabled,
},
}
boot := newHarnessBootstrap(h, "sandbox-1", "test")
boot := newHarnessBootstrap(h, "sandbox-1", "test", "")

_, ok := boot.(agentruntime.SandboxHooksBootstrap)
assert.False(t, ok)
Expand All @@ -38,7 +38,7 @@ func TestNewHarnessBootstrap_WithSecurity(t *testing.T) {
},
},
}
boot := newHarnessBootstrap(h, "sandbox-1", "test")
boot := newHarnessBootstrap(h, "sandbox-1", "test", "")

hooksBoot, ok := boot.(agentruntime.SandboxHooksBootstrap)
require.True(t, ok)
Expand All @@ -48,3 +48,17 @@ func TestNewHarnessBootstrap_WithSecurity(t *testing.T) {
assert.Equal(t, []string{"plugins/p"}, boot.PluginDirs())
assert.Equal(t, harness.SkillSources(h.Skills), boot.SkillDirs())
}

func TestNewHarnessBootstrap_WithForgeEgressEntry(t *testing.T) {
h := &harness.Harness{
Agent: "agents/test.md",
Security: &harness.SecurityConfig{
SandboxHooks: &harness.SandboxHooks{},
},
}
boot := newHarnessBootstrap(h, "sandbox-1", "test", "gitlab.company.com:443")

hooksBoot, ok := boot.(agentruntime.SandboxHooksBootstrap)
require.True(t, ok)
assert.Equal(t, "gitlab.company.com:443", hooksBoot.SandboxHookConfig().ForgeEgressEntry())
}
16 changes: 9 additions & 7 deletions internal/cli/forge_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,18 @@ func newForgeClient(forgeName, gitlabToken, baseURL string, glOpts ...gl.Option)
return nil, err
}
}
// Base URL precedence: explicit arg > FULLSEND_GITLAB_URL > GITLAB_API_URL > CI_SERVER_URL
// Base URL precedence: explicit arg > env vars (via gl.URLEnvVars).
// The env-var precedence is shared with gl.ResolveForgeHostPort().
var opts []gl.Option
if baseURL != "" {
opts = append(opts, gl.WithBaseURL(baseURL))
} else if envURL := strings.TrimSpace(os.Getenv("FULLSEND_GITLAB_URL")); envURL != "" {
opts = append(opts, gl.WithBaseURL(envURL))
} else if envURL := strings.TrimSpace(os.Getenv("GITLAB_API_URL")); envURL != "" {
opts = append(opts, gl.WithBaseURL(envURL))
} else if envURL := strings.TrimSpace(os.Getenv("CI_SERVER_URL")); envURL != "" {
opts = append(opts, gl.WithBaseURL(envURL))
} else {
for _, env := range gl.URLEnvVars {
if envURL := strings.TrimSpace(os.Getenv(env)); envURL != "" {
opts = append(opts, gl.WithBaseURL(envURL))
break
}
}
}
opts = append(opts, glOpts...)
return gl.New(token, opts...)
Expand Down
79 changes: 79 additions & 0 deletions internal/cli/gitlab_profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cli

import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"

"gopkg.in/yaml.v3"

gl "github.com/fullsend-ai/fullsend/internal/forge/gitlab"
)

// gitlabForgeEndpoint is the YAML shape of a single provider endpoint.
type gitlabForgeEndpoint struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Protocol string `yaml:"protocol"`
Access string `yaml:"access"`
Enforcement string `yaml:"enforcement"`
}

// gitlabForgeProfileSpec is the YAML shape of a provider profile.
type gitlabForgeProfileSpec struct {
ID string `yaml:"id"`
DisplayName string `yaml:"display_name"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Endpoints []gitlabForgeEndpoint `yaml:"endpoints"`
Binaries []string `yaml:"binaries"`
}

// generateGitLabForgeProfile creates a temporary provider profile YAML
// for the GitLab forge host, analogous to the scaffold's
// fullsend-github.yaml. Returns the temp file path and a cleanup
// function, or ("", nil, nil) when no GitLab host can be resolved.
func generateGitLabForgeProfile() (string, func(), error) {
host, port := gl.ResolveForgeHostPort()
if host == "" {
return "", nil, nil
}
portNum, err := strconv.Atoi(port)
if err != nil {
return "", nil, fmt.Errorf("GitLab port %q is not a valid integer", port)
}

profile := gitlabForgeProfileSpec{
ID: "fullsend-gitlab-forge",
DisplayName: "Fullsend GitLab (auto)",
Description: "GitLab API and Git operations for fullsend agents (auto-generated from forge host)",
Category: "source_control",
Endpoints: []gitlabForgeEndpoint{
{Host: host, Port: portNum, Protocol: "rest", Access: "read-write", Enforcement: "enforce"},
},
Binaries: []string{"**/git", "**/glab", "**/node", "**/pre-commit"},
}

var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(&profile); err != nil {
return "", nil, fmt.Errorf("marshaling GitLab profile YAML: %w", err)
}
if err := enc.Close(); err != nil {
return "", nil, fmt.Errorf("closing YAML encoder: %w", err)
}

tmpDir, err := os.MkdirTemp("", "fullsend-gitlab-profile-*")
if err != nil {
return "", nil, fmt.Errorf("creating temp dir for GitLab profile: %w", err)
}
profilePath := filepath.Join(tmpDir, "fullsend-gitlab-forge.yaml")
if err := os.WriteFile(profilePath, buf.Bytes(), 0o644); err != nil {
os.RemoveAll(tmpDir)
return "", nil, fmt.Errorf("writing GitLab profile: %w", err)
}
return profilePath, func() { os.RemoveAll(tmpDir) }, nil
}
102 changes: 102 additions & 0 deletions internal/cli/gitlab_profile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cli

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGenerateGitLabForgeProfile(t *testing.T) {
tests := []struct {
name string
env map[string]string
wantEmpty bool
wantErr string
wantHost string
wantPort string
notContains string
}{
{
name: "default port from CI_SERVER_URL",
env: map[string]string{"CI_SERVER_URL": "https://gitlab.cee.redhat.com"},
wantHost: "host: gitlab.cee.redhat.com",
wantPort: "port: 443",
},
{
name: "non-standard port",
env: map[string]string{"CI_SERVER_URL": "https://gitlab.company.com:8443"},
wantHost: "host: gitlab.company.com",
wantPort: "port: 8443",
},
{
name: "no env vars",
env: map[string]string{},
wantEmpty: true,
},
{
name: "FULLSEND_GITLAB_URL takes precedence",
env: map[string]string{"FULLSEND_GITLAB_URL": "https://gitlab.company.com", "CI_SERVER_URL": "https://gitlab.other.com"},
wantHost: "host: gitlab.company.com",
wantPort: "port: 443",
notContains: "gitlab.other.com",
},
{
name: "gitlab.com",
env: map[string]string{"CI_SERVER_URL": "https://gitlab.com"},
wantHost: "host: gitlab.com",
wantPort: "port: 443",
},
{
name: "http scheme defaults to port 80",
env: map[string]string{"CI_SERVER_URL": "http://gitlab.internal"},
wantHost: "host: gitlab.internal",
wantPort: "port: 80",
},
{
name: "invalid URL yields empty result",
env: map[string]string{"CI_SERVER_URL": "https://gitlab.company.com:notaport"},
wantEmpty: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for _, key := range []string{"FULLSEND_GITLAB_URL", "GITLAB_API_URL", "CI_SERVER_URL"} {
t.Setenv(key, "")
}
for k, v := range tc.env {
t.Setenv(k, v)
}

profilePath, cleanup, err := generateGitLabForgeProfile()
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)

if tc.wantEmpty {
assert.Empty(t, profilePath)
assert.Nil(t, cleanup)
return
}

require.NotEmpty(t, profilePath)
defer cleanup()

data, err := os.ReadFile(profilePath)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, "id: fullsend-gitlab-forge")
assert.Contains(t, content, tc.wantHost)
assert.Contains(t, content, tc.wantPort)
assert.Contains(t, content, "category: source_control")
assert.Contains(t, content, "**/node")
if tc.notContains != "" {
assert.NotContains(t, content, tc.notContains)
}
})
}
}
32 changes: 31 additions & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"github.com/fullsend-ai/fullsend/internal/fetchsvc"
"github.com/fullsend-ai/fullsend/internal/forge"
gh "github.com/fullsend-ai/fullsend/internal/forge/github"
gl "github.com/fullsend-ai/fullsend/internal/forge/gitlab"
"github.com/fullsend-ai/fullsend/internal/gitfetch"
"github.com/fullsend-ai/fullsend/internal/harness"
"github.com/fullsend-ai/fullsend/internal/lock"
Expand Down Expand Up @@ -1090,6 +1091,23 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
// Dedupe URL-resolved providers (last-wins) so shadowed entries from
Comment thread
ggallen marked this conversation as resolved.
// base composition don't trigger false integrity errors.
result.Providers = dedupResolvedProviders(result.Providers)

// Auto-generate a GitLab provider profile when running on a self-hosted
// GitLab instance (#6615). Prepended so that a user-defined profile
// with the same ID wins via last-wins dedup. Inserted before the
// integrity check so providers referencing this ID are valid.
if forgePlatform == "gitlab" {
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
if profilePath, cleanupProfile, err := generateGitLabForgeProfile(); err != nil {
printer.StepWarn("Failed to auto-generate GitLab forge profile: " + err.Error())
} else if profilePath != "" {
defer cleanupProfile()
result.Profiles = append([]resolve.ResolvedProfile{{
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
ID: "fullsend-gitlab-forge",
LocalPath: profilePath,
}}, result.Profiles...)
}
}

dirProfileIDs, err := resolve.CollectProfileIDs(filepath.Join(absFullsendDir, "profiles"))
if err != nil {
return fmt.Errorf("scanning profiles directory: %w", err)
Expand Down Expand Up @@ -1527,7 +1545,19 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
// 7. Bootstrap sandbox.
bootstrapStart := time.Now()
printer.StepStart("Bootstrapping sandbox")
boot := newHarnessBootstrap(h, sandboxName, agentName)
// Resolve the forge egress entry for the sandbox SSRF allowlist.
// The runtime layer consumes this via SandboxHookConfig without
// importing forge-specific packages (#6615).
// NOTE: gl.ResolveForgeHostPort() is also called in
// generateGitLabForgeProfile() for the L7 proxy profile; both
// calls are deterministic env-var reads.
Comment thread
ggallen marked this conversation as resolved.
var forgeEgressEntry string
if forgePlatform == "gitlab" {
if host, port := gl.ResolveForgeHostPort(); host != "" {
Comment thread
ggallen marked this conversation as resolved.
forgeEgressEntry = host + ":" + port
}
}
boot := newHarnessBootstrap(h, sandboxName, agentName, forgeEgressEntry)
if h.SecurityEnabled() {
// Scan all runtime content before upload so warnings surface together.
// Host files could change between scan and upload; the runner owns the host FS here.
Expand Down
40 changes: 40 additions & 0 deletions internal/forge/gitlab/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package gitlab

import (
"net/url"
"os"
"strings"
)

// URLEnvVars is the ordered list of environment variables consulted
// to resolve the GitLab instance URL. Precedence matches forge_client.go.
var URLEnvVars = []string{"FULLSEND_GITLAB_URL", "GITLAB_API_URL", "CI_SERVER_URL"}
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.

Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
// ResolveForgeHostPort returns the GitLab forge hostname and port from
// environment variables. URL precedence matches forge_client.go:
// FULLSEND_GITLAB_URL > GITLAB_API_URL > CI_SERVER_URL.
// When the URL does not include an explicit port, the default is
// derived from the scheme ("80" for http, "443" otherwise).
// Returns ("", "") when no URL can be resolved.
func ResolveForgeHostPort() (host, port string) {
for _, env := range URLEnvVars {
raw := strings.TrimSpace(os.Getenv(env))
if raw == "" {
continue
}
u, err := url.Parse(raw)
Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
if err != nil || u.Hostname() == "" {
continue
}
p := u.Port()
if p == "" {
if u.Scheme == "http" {
p = "80"
} else {
p = "443"
}
}
return u.Hostname(), p
}
return "", ""
}
Loading
Loading