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
43 changes: 43 additions & 0 deletions docs/guides/dev/behaviour-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,49 @@ Current budget: **45 minutes** for both the CI job timeout and `go test -timeout

Reference: [`.github/workflows/e2e.yml`](../../../.github/workflows/e2e.yml) behaviour job `timeout-minutes` and `Makefile` `behaviour-test` target.

## URL-sourced harness scenarios

URL dispatch scenarios test `FetchAgentHarness` URL resolution for agents whose harness YAML lives in a separate hosting repository rather than the local config directory.

### Harness-hosting repository

The `Given a harness-hosting repository "<name>"` step creates a public repository in the pool org to host harness YAML files. The repo is:

- **Ephemeral / per-scenario** — created per-scenario and deleted by `CleanupScenario` (same lifecycle as fork repos). When a leased repo is in use, the logical name is remapped via `resolveHostRepoName` (e.g. `"url-harness-host"` + leased `"test-repo-07"` → `"test-repo-07-url-harness-host"`) so parallel scenarios each get their own isolated hosting repo.
- **Public** — required for unauthenticated `raw.githubusercontent.com` access. The step calls `EnsureRepoPublic` to detect and fix org policies that force repos private.

### URL-sourced custom harness

The `Given a URL-sourced custom harness "<name>" with:` step:

1. Commits the harness YAML to the hosting repo at `harness/<name>.yaml`
2. Commits any relative resources (agent, policy files) referenced in the YAML (ADR-0045)
3. Verifies accessibility via the Contents API and unauthenticated raw URL
4. Registers the agent in `config.yaml` with the raw URL (including `#sha256=` integrity hash)
5. Adds the hosting repo URL prefix to `allowed_remote_resources`

Variants:
- `with bad integrity hash:` — injects a wrong SHA256 to test integrity failure
- `not in allowlist with:` — omits the URL prefix from the allowlist to test validation

### Background step usage

URL dispatch scenarios share a common `Background:` block:

```gherkin
Background:
Given the enrolled test repository
And a harness-hosting repository "url-harness-host"
```

### FetchPolicy and binary freshness

URL-dispatch scenarios require a vendored CLI binary that includes `FetchPolicy`-aware harness dispatch. Production dispatch uses `fetch.DefaultPolicy` (allows `github.com` and `raw.githubusercontent.com`) when `Options.FetchPolicy` is nil — this is what enables URL-sourced agents to resolve `raw.githubusercontent.com` URLs.

The `RepoEnsurer` always re-vendors the CLI binary (`github setup --vendor`) even when a prior install's post-install validation passes. This guarantees leased pool repos run the binary built from the current checkout rather than a stale binary from a previous CI run. Without re-vendoring, pool repos that passed validation would keep a pre-fix binary and silently fail to dispatch URL-sourced agents.

The settle step (polling for GitHub Actions workflow readiness) is skipped on re-vendors since the workflow file already existed — only fresh installs incur the settle wait.

## Version pinning for `fullsend-ai/agents`

External behaviour runners import the shared libraries from this module:
Expand Down
128 changes: 128 additions & 0 deletions e2e/behaviour/features/dispatch/url-dispatch.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
Feature: URL-sourced harness dispatch
Comment thread
ifireball marked this conversation as resolved.

Background:
Given the enrolled test repository
And a harness-hosting repository "url-harness-host"

Scenario: URL-sourced harness with CEL trigger dispatches agent
Given a URL-sourced custom harness "url-ping" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-url-ping
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-url-ping"
"""
And a dummy agent that would:
| description | op | args |
| Issue URL set | assert_env | GITHUB_ISSUE_URL |
| Prove URL execution | write_fixture| output/dispatch-url-ok.json, fixtures/dispatch/ok.json |
And an issue
When the issue is labeled "ready-for-url-ping"
Then the harness "url-ping" workflow completes successfully
And the agent will succeed to Prove URL execution

Scenario: Config mixes URL-sourced and local harnesses
Given a custom harness "local-ping" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-local-ping
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-mixed-ping"
"""
And a URL-sourced custom harness "url-mixed" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-url-mixed
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-url-mixed-ping"
"""
And a dummy agent that would:
| description | op | args |
| Prove local execution | write_fixture| output/dispatch-local-ok.json, fixtures/dispatch/ok.json |
And an issue
When the issue is labeled "ready-for-mixed-ping"
Then the harness "local-ping" workflow completes successfully
Comment thread
ifireball marked this conversation as resolved.
And the agent will succeed to Prove local execution

Scenario: URL source with bad integrity hash is skipped and dispatch continues
Given a custom harness "good-local" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-good-local
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-integrity-test"
"""
And a URL-sourced custom harness "bad-hash" with bad integrity hash:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-bad-hash
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-integrity-test"
"""
And a dummy agent that would:
| description | op | args |
| Prove fallback execution | write_fixture| output/dispatch-fallback-ok.json, fixtures/dispatch/ok.json |
And an issue
When the issue is labeled "ready-for-integrity-test"
Then the harness "good-local" workflow completes successfully
And the agent will succeed to Prove fallback execution
And the harness "bad-hash" agent did not run

Scenario: URL source not in allowlist fails config validation
# Production ValidateAgentEntries hard-fails the entire config when any
# URL agent is outside allowed_remote_resources. No agents dispatch —
# including the valid local harness — because config validation fails
# before agent resolution begins.
Given a custom harness "good-allowed" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-good-allowed
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-allowlist-test"
"""
And a URL-sourced custom harness "no-allow" not in allowlist with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-no-allow
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "work_item"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-allowlist-test"
"""
And an issue
When the issue is labeled "ready-for-allowlist-test"
Then the harness "good-allowed" agent did not run
And the harness "no-allow" agent did not run
28 changes: 26 additions & 2 deletions internal/forge/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,13 +316,13 @@ func (f *FakeClient) CreateRepo(_ context.Context, org, name, description string
// Check for duplicates in pre-populated repos.
for _, r := range f.Repos {
if r.FullName == fullName {
return nil, fmt.Errorf("repository already exists: %s", fullName)
return nil, fmt.Errorf("%w: %s", ErrAlreadyExists, fullName)
}
}
// Check for duplicates in previously created repos.
for _, r := range f.CreatedRepos {
if r.FullName == fullName {
return nil, fmt.Errorf("repository already exists: %s", fullName)
return nil, fmt.Errorf("%w: %s", ErrAlreadyExists, fullName)
}
}

Expand Down Expand Up @@ -359,6 +359,30 @@ func (f *FakeClient) GetRepo(_ context.Context, owner, repo string) (*Repository
return nil, fmt.Errorf("%w: %s/%s", ErrNotFound, owner, repo)
}

func (f *FakeClient) UpdateRepoVisibility(_ context.Context, owner, repo string, private bool) error {
f.mu.Lock()
defer f.mu.Unlock()

if e := f.err("UpdateRepoVisibility"); e != nil {
return e
}

fullName := owner + "/" + repo
for i := range f.Repos {
if f.Repos[i].FullName == fullName {
f.Repos[i].Private = private
return nil
}
}
for i := range f.CreatedRepos {
if f.CreatedRepos[i].FullName == fullName {
f.CreatedRepos[i].Private = private
return nil
}
}
return fmt.Errorf("%w: %s/%s", ErrNotFound, owner, repo)
}

func (f *FakeClient) DeleteRepo(_ context.Context, owner, repo string) error {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down
63 changes: 63 additions & 0 deletions internal/forge/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ func TestFakeClient_CreateRepo(t *testing.T) {
assert.Equal(t, "new-repo", fc.CreatedRepos[0].Name)
}

func TestFakeClient_CreateRepo_DuplicateReturnsErrAlreadyExists(t *testing.T) {
ctx := context.Background()
fc := &FakeClient{}

_, err := fc.CreateRepo(ctx, "org", "repo", "desc", false)
require.NoError(t, err)

// Second create of the same repo should return ErrAlreadyExists.
_, err = fc.CreateRepo(ctx, "org", "repo", "desc", false)
require.Error(t, err)
assert.True(t, IsAlreadyExists(err), "duplicate CreateRepo should wrap ErrAlreadyExists")
}

func TestFakeClient_CreateFile(t *testing.T) {
ctx := context.Background()
fc := &FakeClient{}
Expand Down Expand Up @@ -679,6 +692,10 @@ func TestFakeClient_ErrorInjection(t *testing.T) {
{"DeleteRepo", func(fc *FakeClient) error { return fc.DeleteRepo(ctx, "o", "r") }},
{"CreateFile", func(fc *FakeClient) error { return fc.CreateFile(ctx, "o", "r", "p", "m", nil) }},
{"CreateOrUpdateFile", func(fc *FakeClient) error { return fc.CreateOrUpdateFile(ctx, "o", "r", "p", "m", nil) }},
{"UpdateRepoVisibility", func(fc *FakeClient) error {
fc.Repos = []Repository{{Name: "r", FullName: "o/r"}}
return fc.UpdateRepoVisibility(ctx, "o", "r", true)
}},
{"GetFileContent", func(fc *FakeClient) error { _, err := fc.GetFileContent(ctx, "o", "r", "p"); return err }},
{"CreateBranch", func(fc *FakeClient) error { return fc.CreateBranch(ctx, "o", "r", "b") }},
{"DeleteRef", func(fc *FakeClient) error { return fc.DeleteRef(ctx, "o", "r", "heads/b") }},
Expand Down Expand Up @@ -814,6 +831,7 @@ func TestFakeClient_ThreadSafety(t *testing.T) {
defer wg.Done()
_, _ = fc.ListOrgRepos(ctx, "org", false)
_, _ = fc.CreateRepo(ctx, "org", "r", "d", false)
_ = fc.UpdateRepoVisibility(ctx, "org", "repo1", false)
_ = fc.DeleteRepo(ctx, "o", "r")
_ = fc.CreateFile(ctx, "o", "r", "p", "m", []byte("data"))
_ = fc.CreateOrUpdateFile(ctx, "o", "r", "p", "m", []byte("data"))
Expand Down Expand Up @@ -1362,6 +1380,51 @@ func TestFakeClient_GetRepo(t *testing.T) {
require.ErrorIs(t, err, ErrNotFound)
}

func TestFakeClient_UpdateRepoVisibility(t *testing.T) {
ctx := context.Background()

t.Run("updates repo in Repos", func(t *testing.T) {
fc := &FakeClient{
Repos: []Repository{{Name: "repo", FullName: "org/repo", Private: false}},
}
err := fc.UpdateRepoVisibility(ctx, "org", "repo", true)
require.NoError(t, err)
assert.True(t, fc.Repos[0].Private)

err = fc.UpdateRepoVisibility(ctx, "org", "repo", false)
require.NoError(t, err)
assert.False(t, fc.Repos[0].Private)
})

t.Run("updates repo in CreatedRepos", func(t *testing.T) {
fc := &FakeClient{}
_, err := fc.CreateRepo(ctx, "org", "new-repo", "desc", true)
require.NoError(t, err)
assert.True(t, fc.CreatedRepos[0].Private)

err = fc.UpdateRepoVisibility(ctx, "org", "new-repo", false)
require.NoError(t, err)
assert.False(t, fc.CreatedRepos[0].Private)
})

t.Run("returns ErrNotFound for missing repo", func(t *testing.T) {
fc := &FakeClient{}
err := fc.UpdateRepoVisibility(ctx, "org", "missing", true)
require.Error(t, err)
assert.True(t, IsNotFound(err))
})

t.Run("returns injected error", func(t *testing.T) {
fc := &FakeClient{
Repos: []Repository{{Name: "repo", FullName: "org/repo"}},
Errors: map[string]error{"UpdateRepoVisibility": errors.New("forbidden")},
}
err := fc.UpdateRepoVisibility(ctx, "org", "repo", true)
require.Error(t, err)
assert.Contains(t, err.Error(), "forbidden")
})
}

func TestFakeClient_GetOrgPlan(t *testing.T) {
ctx := context.Background()
fc := &FakeClient{}
Expand Down
2 changes: 2 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ type Client interface {
ListOrgRepos(ctx context.Context, org string, includePrivate bool) ([]Repository, error)
GetRepo(ctx context.Context, owner, repo string) (*Repository, error)
CreateRepo(ctx context.Context, org, name, description string, private bool) (*Repository, error)
// UpdateRepoVisibility sets a repository's visibility to public or private.
Comment thread
ifireball marked this conversation as resolved.
Comment thread
ifireball marked this conversation as resolved.
UpdateRepoVisibility(ctx context.Context, owner, repo string, private bool) error
DeleteRepo(ctx context.Context, owner, repo string) error

// FindExistingFork checks whether the authenticated user already has
Expand Down
9 changes: 9 additions & 0 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,15 @@ func (c *LiveClient) GetRepo(ctx context.Context, owner, repo string) (*forge.Re
}, nil
}

// UpdateRepoVisibility sets a repository's visibility to public or private.
func (c *LiveClient) UpdateRepoVisibility(ctx context.Context, owner, repo string, private bool) error {
body := struct {
Private bool `json:"private"`
}{Private: private}
_, err := c.patch(ctx, fmt.Sprintf("/repos/%s/%s", owner, repo), body)
return err
}

// DeleteRepo deletes a repository.
func (c *LiveClient) DeleteRepo(ctx context.Context, owner, repo string) error {
return c.delete_(ctx, fmt.Sprintf("/repos/%s/%s", owner, repo))
Expand Down
10 changes: 10 additions & 0 deletions internal/forge/gitlab/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,16 @@ func (c *LiveClient) CreateRepo(ctx context.Context, org, name, description stri
}, nil
}

func (c *LiveClient) UpdateRepoVisibility(ctx context.Context, owner, repo string, private bool) error {
visibility := "public"
if private {
visibility = "private"
}
body := map[string]string{"visibility": visibility}
_, err := c.put(ctx, fmt.Sprintf("/projects/%s", projectPath(owner, repo)), body)
return err
}

func (c *LiveClient) DeleteRepo(ctx context.Context, owner, repo string) error {
return c.delete_(ctx, fmt.Sprintf("/projects/%s", projectPath(owner, repo)))
}
Expand Down
8 changes: 7 additions & 1 deletion internal/harnessdispatch/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import (
"fmt"

"github.com/fullsend-ai/fullsend/internal/config"
"github.com/fullsend-ai/fullsend/internal/fetch"
"github.com/fullsend-ai/fullsend/internal/normevent"
)

// Options configures a dispatch run.
type Options struct {
ConfigDir string
Event *normevent.Event

// FetchPolicy controls SSRF protection for URL-sourced agent harnesses.
// When nil, fetch.DefaultPolicy is used (allows github.com and
// raw.githubusercontent.com). Set this in tests to allow httptest domains.
FetchPolicy *fetch.FetchPolicy
}

// Dispatch evaluates authorization, kill switch, harness triggers, and returns execution refs.
Expand All @@ -36,7 +42,7 @@ func Dispatch(ctx context.Context, opts Options) ([]ExecutionRef, error) {
return nil, nil
}

candidates, err := ListTriggeredHarnesses(ctx, opts.ConfigDir, dirCfg)
candidates, err := ListTriggeredHarnesses(ctx, opts.ConfigDir, dirCfg, opts.FetchPolicy)
Comment thread
ifireball marked this conversation as resolved.
if err != nil {
return nil, err
}
Expand Down
Loading
Loading