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
7 changes: 7 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep

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

// If the agent was fetched from a URL, forward the source URL so
// LoadWithBase can resolve relative resources even without a base:
// field (ADR-0045 resource resolution for config-registered agents).
if len(fetchDeps) > 0 && fetchDeps[0].URL != "" {
composeOpts.SourceURL = fetchDeps[0].URL
}

// 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".
Expand Down
1 change: 1 addition & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ func TestRunAgent_ConfigAgentURL(t *testing.T) {

srv, policy := newLockTestServer(t, map[string][]byte{
"/harness/triage.yaml": harnessContent,
"/agents/remote.md": []byte("You are a remote agent."),
})

dir := t.TempDir()
Expand Down
30 changes: 28 additions & 2 deletions internal/harness/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ type ComposeOpts struct {
// set GITHUB_TOKEN or use 'fullsend lock' for offline pre-caching.
ForgeClient forge.Client

// SourceURL is the URL from which the harness was fetched (e.g., via
// FetchAgentHarness for config-registered agents). When set and the harness
// has no base: field, LoadWithBase resolves relative resource paths (agent,
// policy, skills, scripts) against this URL using the same infrastructure
// as base composition (ADR-0045). If empty, no URL resolution is performed
// for no-base harnesses.
SourceURL string

// allowSelfAllowlist permits using the child harness's own AllowedRemoteResources
// when OrgAllowlist is empty. This is for testing only; production callers should
// always provide OrgAllowlist from config.yaml. Unexported to prevent misuse.
Expand Down Expand Up @@ -93,7 +101,25 @@ func LoadWithBase(ctx context.Context, path string, opts ComposeOpts) (*Harness,
}

if child.Base == "" {
// No base — same as LoadWithOpts
// No base — resolve URL-sourced resources if the harness was
// fetched from a URL (ADR-0045). Config-registered agents fetched
// via FetchAgentHarness have relative paths that must be resolved
// against the source URL before validation.
var deps []Dependency
if opts.SourceURL != "" {
scriptDeps, err := resolveBaseScripts(ctx, child, opts.SourceURL, opts.OrgAllowlist, opts)
if err != nil {
return nil, nil, fmt.Errorf("resolving URL-sourced scripts: %w", err)
}
deps = append(deps, scriptDeps...)

resourceDeps, err := resolveBaseResources(ctx, child, opts.SourceURL, opts.OrgAllowlist, opts)
if err != nil {
return nil, nil, fmt.Errorf("resolving URL-sourced resources: %w", err)
}
deps = append(deps, resourceDeps...)
}

if err := child.validateForge(); err != nil {
return nil, nil, fmt.Errorf("invalid harness: %w", err)
}
Expand All @@ -103,7 +129,7 @@ func LoadWithBase(ctx context.Context, path string, opts ComposeOpts) (*Harness,
if err := child.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid harness: %w", err)
}
return child, nil, nil
return child, deps, nil
}

// Org allowlist is the authority for URL bases.
Expand Down
170 changes: 170 additions & 0 deletions internal/harness/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3124,3 +3124,173 @@ func TestResolveBaseHostFiles_EmptyHostFiles(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, deps)
}

// --- Tests for URL-sourced harnesses without base: field (SourceURL) ---

func TestLoadWithBase_SourceURL_ResolvesResources(t *testing.T) {
// A URL-sourced harness with no base: field should have its relative
// resource paths resolved against the source URL (ADR-0045).
agentContent := []byte("# triage agent definition")
policyContent := []byte("# triage policy")
preScript := []byte("#!/bin/bash\necho pre")
postScript := []byte("#!/bin/bash\necho post")

harnessContent := []byte(`
role: triage
slug: triage
agent: agents/triage.md
policy: policies/triage.yaml
pre_script: scripts/pre-triage.sh
post_script: scripts/post-triage.sh
`)

server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/harness/triage.yaml":
w.Write(harnessContent)
case "/agents/triage.md":
w.Write(agentContent)
case "/policies/triage.yaml":
w.Write(policyContent)
case "/scripts/pre-triage.sh":
w.Write(preScript)
case "/scripts/post-triage.sh":
w.Write(postScript)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

policy := fetch.NewTestPolicy(
server.Client().Transport.(*http.Transport).TLSClientConfig,
[]string{"127.0.0.1"},
[]string{server.Listener.Addr().String()[len("127.0.0.1:"):]},
)

dir := t.TempDir()
cacheDir := filepath.Join(dir, "cache")

// Write the harness locally (simulating FetchAgentHarness caching it)
path := writeTestHarness(t, dir, "triage.yaml", string(harnessContent))

sourceURL := server.URL + "/harness/triage.yaml"

h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{
WorkspaceRoot: cacheDir,
FetchPolicy: policy,
OrgAllowlist: []string{server.URL + "/"},
SourceURL: sourceURL,
})
require.NoError(t, err)

// All resource paths should be resolved to local cache paths
assert.True(t, filepath.IsAbs(h.Agent), "agent should be absolute cache path, got %s", h.Agent)
assert.True(t, filepath.IsAbs(h.Policy), "policy should be absolute cache path, got %s", h.Policy)
assert.True(t, filepath.IsAbs(h.PreScript), "pre_script should be absolute cache path")
assert.True(t, filepath.IsAbs(h.PostScript), "post_script should be absolute cache path")

// Verify cached content matches
gotAgent, err := os.ReadFile(h.Agent)
require.NoError(t, err)
assert.Equal(t, agentContent, gotAgent)

gotPolicy, err := os.ReadFile(h.Policy)
require.NoError(t, err)
assert.Equal(t, policyContent, gotPolicy)

gotPre, err := os.ReadFile(h.PreScript)
require.NoError(t, err)
assert.Equal(t, preScript, gotPre)

gotPost, err := os.ReadFile(h.PostScript)
require.NoError(t, err)
assert.Equal(t, postScript, gotPost)

// Dependencies should include scripts and resources
assert.NotEmpty(t, deps)
fieldNames := map[string]bool{}
for _, d := range deps {
fieldNames[d.Field] = true
}
assert.True(t, fieldNames["pre_script"], "should have pre_script dep")
assert.True(t, fieldNames["post_script"], "should have post_script dep")
assert.True(t, fieldNames["agent"], "should have agent dep")
assert.True(t, fieldNames["policy"], "should have policy dep")
}

func TestLoadWithBase_SourceURL_NoRelativePaths(t *testing.T) {
// A URL-sourced harness with no relative paths should be a no-op.
harnessContent := []byte(`
role: test
slug: test-agent
agent: /absolute/path/agent.md
`)

dir := t.TempDir()
path := writeTestHarness(t, dir, "test.yaml", string(harnessContent))

h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{
SourceURL: "https://example.com/harness/test.yaml",
})
require.NoError(t, err)
assert.Empty(t, deps)
assert.Equal(t, "test", h.Role)
}

func TestLoadWithBase_SourceURL_ScriptResolutionError(t *testing.T) {
// When resolveBaseScripts fails (e.g., script URL not in allowlist),
// LoadWithBase should return the error.
harnessContent := []byte(`
role: triage
slug: triage
pre_script: scripts/pre-triage.sh
`)

dir := t.TempDir()
path := writeTestHarness(t, dir, "triage.yaml", string(harnessContent))

_, _, err := LoadWithBase(context.Background(), path, ComposeOpts{
SourceURL: "https://example.com/harness/triage.yaml",
OrgAllowlist: []string{"https://other.example.com/"}, // not matching
})
require.Error(t, err)
assert.Contains(t, err.Error(), "resolving URL-sourced scripts")
}

func TestLoadWithBase_SourceURL_ResourceResolutionError(t *testing.T) {
// When resolveBaseResources fails (e.g., agent URL not in allowlist),
// LoadWithBase should return the error.
harnessContent := []byte(`
role: triage
slug: triage
agent: agents/triage.md
`)

dir := t.TempDir()
path := writeTestHarness(t, dir, "triage.yaml", string(harnessContent))

_, _, err := LoadWithBase(context.Background(), path, ComposeOpts{
SourceURL: "https://example.com/harness/triage.yaml",
OrgAllowlist: []string{"https://other.example.com/"}, // not matching
})
require.Error(t, err)
assert.Contains(t, err.Error(), "resolving URL-sourced resources")
}

func TestLoadWithBase_NoSourceURL_NoResolution(t *testing.T) {
// Without SourceURL, a no-base harness should not attempt URL resolution
// (original behavior preserved).
harnessContent := []byte(`
role: test
agent: agents/test.md
`)

dir := t.TempDir()
path := writeTestHarness(t, dir, "test.yaml", string(harnessContent))

h, deps, err := LoadWithBase(context.Background(), path, ComposeOpts{})
require.NoError(t, err)
assert.Empty(t, deps)
assert.Equal(t, "agents/test.md", h.Agent, "agent should remain relative without SourceURL")
}
Loading