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
2 changes: 2 additions & 0 deletions docs/cli/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ fullsend github setup <owner/repo> \
| `--agents` | `fullsend,triage,coder,review,retro,prioritize` | Agent roles to provision |
| `--direct` | `false` | Push scaffold directly instead of creating a PR |
| `--runtime` | `claude` | Agent runtime backend (`claude` or `dummy`; `dummy` is for behaviour test orgs only) |
| `--config` | | Local file path or HTTPS URL to a vendor preset (committed as `.fullsend/config.base.yaml`; per-repo only) |
| `--config-hash` | | SHA-256 hex digest to validate the preset content (requires `--config`) |

### Required OAuth scopes

Expand Down
23 changes: 23 additions & 0 deletions docs/guides/getting-started/configuring-github.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ corresponding GitHub Apps for each agent you enable (see the table above).
For the full list of setup flags, see the
[CLI reference](../../cli/github.md#flags).

### Using a vendor preset

If your platform operator provides a curated preset configuration, you can
install it directly instead of relying on per-flag generation:

```bash
fullsend github setup <org>/<repo> \
--inference-project "<gcp-project>" \
--inference-wif-provider "<wif-provider-url>" \
--config "<path-or-url>" \
--config-hash "<sha256-hex>"
```

When `--config` is provided, the preset content is committed as
`.fullsend/config.base.yaml` and a stub overlay is written to
`.fullsend/config.yaml`. The `--config-hash` flag is optional but
recommended for remote URLs — it verifies the SHA-256 digest of the
fetched content before committing.

> **Note:** `--config` cannot be combined with `--runtime` or `--agents`,
> because the preset provides its own configuration. `--config` is only
> valid for per-repo mode.

## Testing Fullsend

After installing open a new issue or comment `/fs-triage` in an open issue. Then visit the
Expand Down
12 changes: 12 additions & 0 deletions docs/guides/infrastructure/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ Then follow [Getting Inference](../getting-started/getting-inference.md) and [Co

If the platform operator also provides a pre-existing WIF provider, skip `inference provision` and pass `--inference-wif-provider` directly to `github setup`.

If the platform operator distributes a vendor preset (a curated `config.base.yaml`), you can install it via `--config` instead of specifying individual flags:

```bash
fullsend github setup <owner/repo> \
--inference-project "<GCP_PROJECT>" \
--inference-wif-provider "<WIF_PROVIDER>" \
--config "<preset-path-or-url>" \
--config-hash "<sha256-hex>"
```

See [Configuring GitHub — Using a vendor preset](../getting-started/configuring-github.md#using-a-vendor-preset) for details.

If you have IAM access to the platform operator's GCP project, pass `--mint-project` and `--mint-region` to `admin install` to enable auto-discovery of shared app IDs and automatic validation of mint configuration. This requires `roles/cloudfunctions.developer` on the platform mint project.

> This section documents the **SaaS installation profile** — the default variant of the Managed model, where a platform operator pre-provisions the shared GitHub Apps and mint for you. See the [CLI reference](../../cli/github.md#flags) for the full flag list.
Expand Down
86 changes: 77 additions & 9 deletions internal/cli/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ type githubSetupConfig struct {
dryRun bool
direct bool
runtime string
configPreset string // --config: local path or HTTPS URL to a preset
configHash string // --config-hash: SHA-256 hex digest for preset validation
}

func newGitHubSetupCmd() *cobra.Command {
Expand Down Expand Up @@ -100,6 +102,26 @@ values (mint URL, WIF provider, project ID) are provided as flags.`,
return err
}

if cfg.configHash != "" && cfg.configPreset == "" {
return fmt.Errorf("--config-hash requires --config")
}

_, _, isRepoTarget := parseTarget(cfg.target)
if !isRepoTarget {
for _, name := range []string{"config", "config-hash"} {
if cmd.Flags().Changed(name) {
return fmt.Errorf("--%s is only valid for per-repo setup (fullsend github setup <owner/repo>)", name)
}
}
}
if cfg.configPreset != "" {
for _, name := range []string{"runtime", "agents"} {
if cmd.Flags().Changed(name) {
return fmt.Errorf("--%s cannot be used with --config (the preset provides its own configuration)", name)
}
}
}

if err := validateMintURLHTTPS(cfg.mintURL); err != nil {
return err
}
Expand Down Expand Up @@ -146,6 +168,8 @@ values (mint URL, WIF provider, project ID) are provided as flags.`,
cmd.Flags().BoolVar(&cfg.direct, "direct", false, "push scaffold files directly to the default branch instead of creating a PR")
cmd.Flags().StringVar(&cfg.runtime, "runtime", "", "agent runtime for per-repo config (e.g. claude, dummy)")
addVendorFlags(cmd, &cfg.vendor, &cfg.fullsendBinary, &cfg.fullsendSource)
Comment thread
ifireball marked this conversation as resolved.
Resolved
cmd.Flags().StringVar(&cfg.configPreset, "config", "", "local file path or HTTPS URL to a vendor preset (committed as .fullsend/config.base.yaml)")
cmd.Flags().StringVar(&cfg.configHash, "config-hash", "", "SHA-256 hex digest to validate the preset content")

return cmd
}
Expand Down Expand Up @@ -214,17 +238,54 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui
printer.StepInfo("Reusing existing FULLSEND_GCP_WIF_PROVIDER from " + cfg.target)
Comment thread
ifireball marked this conversation as resolved.
Resolved
}

perRepoCfg := config.NewPerRepoConfig(roles, cfg.target)
if cfg.runtime != "" {
perRepoCfg.SetRuntime(cfg.runtime)
}
if err := perRepoCfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
// --- Preset handling (--config / --config-hash) ---
var presetData []byte
if cfg.configPreset != "" {
printer.StepStart("Fetching preset from " + cfg.configPreset)
var fetchErr error
presetData, fetchErr = fetchPreset(cfg.configPreset)
if fetchErr != nil {
printer.StepFail("Failed to fetch preset")
return fetchErr
}
printer.StepDone(fmt.Sprintf("Fetched preset (%d bytes)", len(presetData)))

if cfg.configHash != "" {
printer.StepStart("Validating preset hash")
if hashErr := validatePresetHash(presetData, cfg.configHash); hashErr != nil {
printer.StepFail("Preset hash validation failed")
return hashErr
}
printer.StepDone("Preset hash validated")
} else if isRemotePreset(cfg.configPreset) {
printer.StepWarn("Remote preset fetched without --config-hash; content integrity is not verified")
}

if yamlErr := validatePresetYAML(presetData); yamlErr != nil {
printer.StepFail("Preset YAML validation failed")
return yamlErr
}
}

cfgYAML, err := perRepoCfg.Marshal()
if err != nil {
return fmt.Errorf("marshaling per-repo config: %w", err)
// --- Build config files ---
var cfgYAML []byte
if presetData == nil {
// No preset: generate a full per-repo config.yaml as before.
perRepoCfg := config.NewPerRepoConfig(roles, cfg.target)
if cfg.runtime != "" {
perRepoCfg.SetRuntime(cfg.runtime)
}
if err := perRepoCfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}

cfgYAML, err = perRepoCfg.Marshal()
if err != nil {
return fmt.Errorf("marshaling per-repo config: %w", err)
}
} else {
// Preset provided: config.yaml is a stub overlay.
cfgYAML = []byte(stubConfigYAML)
}

upstreamRef, upstreamTag := resolveUpstreamRef()
Expand All @@ -241,6 +302,13 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui
Mode: f.Mode,
})
}
if presetData != nil {
files = append(files, forge.TreeFile{
Path: ".fullsend/config.base.yaml",
Content: presetData,
Mode: "100644",
})
}
files = append(files, forge.TreeFile{
Path: ".fullsend/config.yaml",
Content: cfgYAML,
Expand Down
144 changes: 144 additions & 0 deletions internal/cli/preset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package cli

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"

"gopkg.in/yaml.v3"
)

const (
presetFetchTimeout = 30 * time.Second
presetMaxSize = 1 << 20 // 1 MiB
)

// stubConfigYAML is the stub overlay committed as .fullsend/config.yaml
// when a preset base is provided via --config. It contains comments
// explaining the layered relationship and minimal empty override fields
// for the adopter to customize.
const stubConfigYAML = `# fullsend per-repo configuration (overlay)
# https://github.com/fullsend-ai/fullsend
#
# This file is the per-repo overlay for fullsend configuration.
# Base settings are provided by config.base.yaml (vendor preset).
# Values set here override the base layer. Omitted fields inherit
# from config.base.yaml, then from compiled-in code defaults.
#
# See ADR 0069 for the layered configuration model.

# Uncomment and customize fields as needed:
# roles: []
# runtime: ""
# agents: []
# kill_switch: false
`

// fetchPreset reads a preset configuration from a local file path or
// an HTTPS URL. Returns the raw content bytes.
func fetchPreset(source string) ([]byte, error) {
if strings.HasPrefix(strings.ToLower(source), "https://") {
return fetchPresetHTTPS(source)
}
if strings.Contains(source, "://") {
Comment thread
ifireball marked this conversation as resolved.
Resolved
return nil, fmt.Errorf("unsupported URL scheme in --config %q: only local paths and https:// URLs are supported", source)
}
return fetchPresetLocal(source)
Comment thread
ifireball marked this conversation as resolved.
Resolved
}

// fetchPresetLocal reads a preset from the local filesystem.
func fetchPresetLocal(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading preset file %q: %w", path, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("preset file %q is empty", path)
}
Comment thread
ifireball marked this conversation as resolved.
Resolved
if len(data) > presetMaxSize {
return nil, fmt.Errorf("preset file %q exceeds maximum size (%d bytes)", path, presetMaxSize)
}
return data, nil
}

// fetchPresetHTTPS fetches a preset from an HTTPS URL.
func fetchPresetHTTPS(rawURL string) ([]byte, error) {
client := &http.Client{
Timeout: presetFetchTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("preset redirect to non-HTTPS URL %q is not allowed", req.URL.Redacted())
}
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
}

resp, err := client.Get(rawURL) //nolint:gosec // URL is user-provided via --config flag
if err != nil {
return nil, fmt.Errorf("fetching preset from %q: %w", rawURL, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching preset from %q: HTTP %d", rawURL, resp.StatusCode)
}

data, err := io.ReadAll(io.LimitReader(resp.Body, presetMaxSize+1))
if err != nil {
return nil, fmt.Errorf("reading preset from %q: %w", rawURL, err)
}
if len(data) > presetMaxSize {
return nil, fmt.Errorf("preset from %q exceeds maximum size (%d bytes)", rawURL, presetMaxSize)
}
if len(data) == 0 {
return nil, fmt.Errorf("preset from %q is empty", rawURL)
}
return data, nil
}

// isRemotePreset returns true if the source string is a URL (not a local path).
func isRemotePreset(source string) bool {
u, err := url.Parse(source)
if err != nil {
return false
}
return u.Scheme != ""
}

// validatePresetYAML checks that data is syntactically valid YAML.
func validatePresetYAML(data []byte) error {
var probe any
if err := yaml.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("preset is not valid YAML: %w", err)
}
return nil
}

// validatePresetHash checks that the SHA-256 hash of data matches the
// expected hex-encoded hash string. Returns nil on match.
func validatePresetHash(data []byte, expectedHex string) error {
expectedHex = strings.ToLower(strings.TrimSpace(expectedHex))
if len(expectedHex) != 64 {
return fmt.Errorf("--config-hash must be a 64-character hex-encoded SHA-256 hash, got %d characters", len(expectedHex))
}
if _, err := hex.DecodeString(expectedHex); err != nil {
return fmt.Errorf("--config-hash is not valid hex: %w", err)
}

actual := sha256.Sum256(data)
actualHex := hex.EncodeToString(actual[:])

if actualHex != expectedHex {
return fmt.Errorf("preset hash mismatch: expected %s, got %s", expectedHex, actualHex)
}
return nil
}
Loading
Loading