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
18 changes: 12 additions & 6 deletions docs/agents/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Default Agents
# Agents

Reference documentation for the default agents shipped by fullsend.
All agents below are enabled by default. The set of default agents is defined by
the YAML files in [`internal/scaffold/fullsend-repo/harness/`](../../internal/scaffold/fullsend-repo/harness/).
Reference documentation for the agents shipped by fullsend.
The default agents below are defined by the YAML files in
[`internal/scaffold/fullsend-repo/harness/`](../../internal/scaffold/fullsend-repo/harness/).
Custom agents can be registered via the `agents:` field in org or per-repo
config (see [ADR 0058](../ADRs/0058-agent-registration.md)).

| Agent | Summary |
|-------|---------|
Expand All @@ -23,5 +25,9 @@ a specific agent performs a specific task. See

## Custom Agents

Support for adding your own custom agents to the fullsend pipeline is coming
soon.
Custom agents can be added to the fullsend pipeline via the `agents:` field in
your org-level or per-repo `config.yaml`. Each entry is either a local path
(relative to the fullsend directory) or a pinned HTTPS URL with an integrity
hash. Config-registered agents override scaffold defaults when names collide
(case-insensitive). See [ADR 0058](../ADRs/0058-agent-registration.md) for
details.
2 changes: 1 addition & 1 deletion docs/guides/getting-started/configuring-github.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ Actions tab to see the Fullsend workflow in action. In some minutes the

* Read [Organization installation mode](org-mode.md) to learn how to share GCP project with other repositories
within your GitHub organization.
* Read the [Default Agents](../../agents/README.md) section to learn about the default agents Fullsend
* Read the [Agents](../../agents/README.md) section to learn about the default agents Fullsend
ships with.
* Explore other sections of this documentation for more information.
2 changes: 1 addition & 1 deletion docs/guides/getting-started/org-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,6 @@ Each command prompts for confirmation. Add `--yolo` to skip prompts. See the [st

## Next Steps

* Read the [Default Agents](../../agents/README.md) section to learn about the default agents Fullsend
* Read the [Agents](../../agents/README.md) section to learn about the default agents Fullsend
ships with.
* Explore other sections of this documentation for more information.
2 changes: 1 addition & 1 deletion docs/plans/adr-0045-forge-portable-harness-phase2.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ The `WorkflowsLayer` currently uses `scaffold.WalkFullsendRepo()` which skips `l

**`HarnessWrappersLayer` struct:**
- Fields: `org string`, `client forge.Client`, `printer *ui.Printer`, `agents []AgentCredentials`, `commitSHA string`, `existingHarnesses map[string]bool`
- `NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string) *HarnessWrappersLayer`
- `NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string, configAgentNames []string) *HarnessWrappersLayer`

**`Install() error`:**
1. For each agent in `agents`:
Expand Down
2 changes: 1 addition & 1 deletion docs/runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The sandbox has two key directories that map to Claude Code's config levels:
/sandbox/
├── claude-config/ ← CLAUDE_CONFIG_DIR (personal level)
│ ├── agents/
│ │ └── <name>.md Agent definition (filename derived from AgentName())
│ │ └── <name>.md Agent definition (filename derived from the agent name)
│ ├── skills/
│ │ ├── code-review/SKILL.md Built-in skills (personal level — wins on collision)
│ │ ├── pr-review/SKILL.md
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -1854,7 +1854,7 @@ func buildLayerStack(
return layers.NewStack(
layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo),
workflowsLayer(ctx, org, client, printer, user, version, vendor, vendorCollect, direct),
layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA),
layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA, configAgentNames(cfg.Agents)),
vendorLayer(org, client, printer, vendor, vendorFn, vendorCollect, analyzeFullsendSource),
layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(),
layers.NewInferenceLayer(org, client, inferenceProvider, printer),
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1288,7 +1288,7 @@ func TestCheckInstallScopes_SyncWithLayers(t *testing.T) {
stack := layers.NewStack(
layers.NewConfigRepoLayer("test-org", nil, emptyCfg, ui.New(&discardWriter{}), false),
layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), "", "test-version", false),
layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev"),
layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev", nil),
layers.NewSecretsLayer("test-org", nil, nil, ui.New(&discardWriter{})),
layers.NewInferenceLayer("test-org", nil, nil, ui.New(&discardWriter{})),
layers.NewOIDCDispatchLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})),
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/orgconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import (
"github.com/fullsend-ai/fullsend/internal/ui"
)

// configAgentNames extracts derived names from a list of agent entries.
func configAgentNames(agents []config.AgentEntry) []string {
names := make([]string, len(agents))
for i, a := range agents {
names[i] = a.DerivedName()
}
return names
}

// tryLoadOrgConfig attempts to load org config from the given path.
// Returns nil without error when the file is absent (best-effort).
// Logs warnings via printer for non-ENOENT read errors and parse errors.
Expand Down
137 changes: 113 additions & 24 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
}

// 1. Resolve and load harness.
harnessPath, err := resolveHarnessPath(absFullsendDir, agentName, printer)
if err != nil {
return err
}
harnessStart := time.Now()
printer.StepStart("Loading harness: " + harnessPath)

forgePlatform, err := detectForgePlatform(forgeFlag)
if err != nil {
Expand All @@ -188,50 +183,62 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
policy.Offline = rFlags.offline

// Best-effort org config loading — provides the allowlist for base
// harness fetching. If the file is missing or unparseable we proceed
// without it; HasURLReferences will enforce its presence later if needed.
// harness fetching and the agent registry for config-driven resolution.
// If the file is missing or unparseable we proceed without it;
// HasURLReferences will enforce its presence later if needed.
orgConfigPath := filepath.Join(absFullsendDir, "config.yaml")
orgCfg := tryLoadOrgConfig(orgConfigPath, printer)
var orgAllowlist []string
if orgCfg != nil {
orgAllowlist = orgCfg.AllowedRemoteResources
}

// If the harness has a URL base and org config failed to load,
// load it strictly now so LoadWithBase gets a proper error path
// rather than an unhelpful "URL base requires allowed_remote_resources".
if orgCfg == nil {
if rawH, rawErr := harness.LoadRaw(harnessPath); rawErr == nil && rawH.Base != "" && harness.IsURL(rawH.Base) {
var err error
orgCfg, err = requireOrgConfig(orgConfigPath, printer)
if err != nil {
return err
}
orgAllowlist = orgCfg.AllowedRemoteResources
}
}

var composeForgeClient forge.Client
if rFlags.forgeClient != nil {
composeForgeClient = rFlags.forgeClient
} else if token, tokenErr := resolveToken(); tokenErr == nil {
composeForgeClient = gh.New(token)
}

h, baseDeps, err := harness.LoadWithBase(ctx, harnessPath, harness.ComposeOpts{
composeOpts := harness.ComposeOpts{
WorkspaceRoot: absFullsendDir,
FetchPolicy: policy,
AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"),
ForgePlatform: forgePlatform,
OrgAllowlist: orgAllowlist,
ForgeClient: composeForgeClient,
})
}

// Resolve agent source: config agents take precedence over disk harnesses.
harnessPath, fetchDeps, err := resolveAgentSource(ctx, absFullsendDir, agentName, orgCfg, composeOpts, printer)
if err != nil {
return err
}

printer.StepStart("Loading harness: " + harnessPath)

// If the harness has a URL base and org config failed to load,
// load it strictly now so LoadWithBase gets a proper error path
// rather than an unhelpful "URL base requires allowed_remote_resources".
if orgCfg == nil {
if rawH, rawErr := harness.LoadRaw(harnessPath); rawErr == nil && rawH.Base != "" && harness.IsURL(rawH.Base) {
var err error
orgCfg, err = requireOrgConfig(orgConfigPath, printer)
if err != nil {
return err
}
composeOpts.OrgAllowlist = orgCfg.AllowedRemoteResources
}
}

h, baseDeps, err := harness.LoadWithBase(ctx, harnessPath, composeOpts)
if err != nil {
printer.StepFail("Failed to load harness")
return fmt.Errorf("loading harness: %w", err)
}

for _, dep := range baseDeps {
allDeps := append(fetchDeps, baseDeps...)
for _, dep := range allDeps {
if dep.CacheHit {
printer.StepInfo(fmt.Sprintf("Base: %s (cache hit)", dep.URL))
} else {
Expand Down Expand Up @@ -2344,3 +2351,85 @@ func validateRepoNames(repos []string) error {
}
return nil
}

// resolveAgentSource resolves the harness path for an agent, checking
// config-registered agents before falling back to disk-based lookup.
// Returns the local filesystem path to the harness (cached for URL sources)
// and any fetch dependencies from URL-based agent resolution.
func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, orgCfg *config.OrgConfig, composeOpts harness.ComposeOpts, printer *ui.Printer) (string, []harness.Dependency, error) {
if orgCfg == nil || len(orgCfg.Agents) == 0 {
path, err := resolveHarnessPath(fullsendDir, agentName, printer)
return path, nil, err
}

if err := config.ValidateAgentEntries(orgCfg.Agents, orgCfg.AllowedRemoteResources); err != nil {
return "", nil, fmt.Errorf("invalid agent config: %w", err)
}

sha := commitSHA
if sha == "dev" {
sha = ""
}
scaffoldNames, snErr := scaffold.HarnessNames()
if snErr != nil {
return "", nil, fmt.Errorf("listing scaffold harnesses: %w", snErr)
}

merged, err := config.MergedAgents(scaffoldNames, sha, orgCfg.Agents, scaffold.HarnessBaseURLWithHash)
if err != nil {
return "", nil, fmt.Errorf("building merged agent set: %w", err)
}

agent := config.LookupMergedAgent(merged, agentName)
if agent == nil || !agent.IsConfig {
path, err := resolveHarnessPath(fullsendDir, agentName, printer)
return path, nil, err
}

if harness.IsURL(agent.Source) {
printer.StepStart(fmt.Sprintf("Fetching agent harness: %s", agent.Name))
localPath, dep, err := harness.FetchAgentHarness(ctx, agent.Source, composeOpts)
if err != nil {
printer.StepFail("Failed to fetch agent harness")
return "", nil, fmt.Errorf("resolving config agent %s: %w", agent.Name, err)
}
printer.StepDone(fmt.Sprintf("Agent %s resolved from config (URL)", agent.Name))
return localPath, []harness.Dependency{dep}, nil
}

contained, err := containedLocalPath(fullsendDir, agent.Source)
if err != nil {
return "", nil, fmt.Errorf("config agent %s: %w", agent.Name, err)
}
if _, err := os.Stat(contained); err != nil {
return "", nil, fmt.Errorf("config agent %s: local path %s: %w", agent.Name, agent.Source, err)
}
printer.StepDone(fmt.Sprintf("Agent %s resolved from config (local path)", agent.Name))
return contained, nil, nil
}

// containedLocalPath resolves a relative source path against baseDir and
// verifies the result stays within baseDir. Returns an error for absolute
// paths or paths that escape via traversal.
func containedLocalPath(baseDir, source string) (string, error) {
if filepath.IsAbs(source) {
return "", fmt.Errorf("local path must be relative, not absolute")
}
resolved := filepath.Clean(filepath.Join(baseDir, source))
if rel, err := filepath.Rel(baseDir, resolved); err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("local path %q escapes fullsend directory", source)
}
// Resolve symlinks and re-check containment to prevent symlink escape.
real, err := filepath.EvalSymlinks(resolved)
if err != nil {
return "", err
}
realBase, err := filepath.EvalSymlinks(baseDir)
if err != nil {
return "", err
}
if rel, err := filepath.Rel(realBase, real); err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("local path %q escapes fullsend directory via symlink", source)
}
return real, nil
}
Loading
Loading