Skip to content
Open
22 changes: 13 additions & 9 deletions docs/ADRs/0070-portable-provider-profile-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,30 @@ profiles. Today, providers and profiles are an exception.

Extend the harness schema with two new fields:

1. **`openshell.profiles`** — A new list field accepting only HTTPS URLs with `#sha256=...`
integrity hashes. Profiles define openshell-level credential type schemas and
belong in shared repositories, not locally. No local-path form.

2. **`providers`** — Extend the existing list field to accept both local provider
names (existing behavior) and remote HTTPS URLs with `#sha256=...` hashes.
1. **`openshell.profiles`** — A new list field accepting HTTPS URLs with `#sha256=...`
integrity hashes, and local file paths (resolved relative to the harness
Comment thread
maruiz93 marked this conversation as resolved.
directory or inherited as absolute cache paths from `base:` composition).
Profiles define openshell-level credential type schemas.

2. **`providers`** — Extend the existing list field to accept local provider
names (existing behavior), local file paths (resolved relative to the
harness directory), and remote HTTPS URLs with `#sha256=...` hashes.
Mixed forms allowed in the same list.

### Schema

```yaml
# New openshell.profiles field (URL-only)
# openshell.profiles field (local paths or URLs)
openshell:
profiles:
- profiles/claude-code.yaml # Local path (resolved relative to harness)
- "https://github.com/org/profiles/tree/main/claude-code.yaml#sha256=abc..."
- "https://github.com/org/profiles/tree/main/google-vertex-ai.yaml#sha256=def..."

# Extended providers field (mixed local names and URLs)
# Extended providers field (mixed local names, paths, and URLs)
providers:
- "my-local-provider" # Local name (existing)
- "my-local-provider" # Local name: loaded from providers/my-local-provider.yaml
- providers/custom.yaml # Local path (resolved relative to harness)
- "https://github.com/org/repo/tree/main/providers/my-provider.yaml#sha256=789..." # Remote
```

Expand Down
6 changes: 4 additions & 2 deletions docs/guides/user/customizing-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ providers: # Inference providers (local names or URLs)
- vertex # Local name: references providers/vertex.yaml
- "https://github.com/org/repo/tree/main/providers/claude.yaml#sha256=abc..." # Remote URL

openshell: # Openshell provider profiles (URL-only, with integrity hash)
openshell: # Openshell provider profiles (local paths or URLs)
profiles:
- profiles/claude-code.yaml # Local path: resolved relative to harness
- "https://github.com/org/profiles/tree/main/claude-code.yaml#sha256=def..."

validation_loop: # script is required; these sub-fields are optional
Expand Down Expand Up @@ -116,11 +117,12 @@ providers:
- "https://github.com/org/repo/tree/main/providers/claude.yaml#sha256=abc..." # Remote
```

**`openshell.profiles`** accepts only HTTPS URLs (profiles are always remote):
**`openshell.profiles`** accepts local paths and HTTPS URLs:

```yaml
openshell:
profiles:
- profiles/claude-code.yaml # Local path (resolved relative to harness)
- "https://github.com/org/profiles/tree/main/claude-code.yaml#sha256=abc..."
```

Expand Down
20 changes: 16 additions & 4 deletions internal/cli/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -779,17 +779,29 @@ func resolveFromLock(h *harness.Harness, entry *lock.HarnessLock, workspaceRoot
}
h.Skills = filtered

// Strip URL entries from providers — URL-resolved providers are now in
// the ResolvedProvider list, mirroring resolve.ResolveHarness behavior.
// Strip URL and absolute-path entries from providers — URL-resolved
// providers are in the ResolvedProvider list, and absolute-path entries
// (from base composition cache) will be resolved by the local-only
// ResolveHarness pass. Keep only bare provider names.
remainingProviders := h.Providers[:0]
for _, p := range h.Providers {
if !harness.IsURL(p) {
if !harness.IsURL(p) && !harness.IsProviderPath(p) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] correctness — H1 is not actually fixed: local providers are still dropped in the lock path

The hasLocalProviders change (now !IsURL && IsProviderPath) can't take effect here, because this strip runs first and removes exactly the entries the gate looks for.

Trace: runAgent calls ResolveRelativeTo unconditionally at run.go:386, so a locally-declared providers/custom.yaml is already absolute by the time resolveFromLock runs. This loop then drops it (IsProviderPath is true for /abs/.../custom.yaml). Genuinely-local providers are never fetched, so ResolveHarness records no dependency for them — they're absent from the lock file and are not reconstructed. Back at run.go:486, hasLocalProviders(h) inspects the now-stripped h.Providers, returns false, and the compensating second pass never runs. Net: a harness with any URL reference (so a lock entry exists) plus a local provider drops that provider silently — no error, no warning. The same root cause hits the profile strip below (see my reply on that thread).

The strip is also asymmetric: providers strip on IsProviderPath (relative + absolute), profiles on filepath.IsAbs (absolute only). Post-ResolveRelativeTo both only ever hold absolute paths so they converge today, but the discriminator should be whether the entry was consumed by a lock dep, not its path shape.

Suggestion: In resolveFromLock, strip only entries backed by a reconstructed lock dep (URL-resolved); leave genuinely-local paths for the second pass to resolve — or evaluate the run.go:486 gate on a snapshot of h.Providers/h.OpenShell.Profiles taken before stripping. Please add a lock-path test: harness with a URL skill + providers/custom.yaml, locked, asserting the provider survives into result.Providers (no lock-path test currently covers a local path-form provider).

remainingProviders = append(remainingProviders, p)
}
}
h.Providers = remainingProviders
// Strip URL and absolute-path profiles — URL-resolved profiles are in
// the ResolvedProfile list from lock deps, and absolute-path entries
// (from base composition cache) are reconstructed from lock deps too.
// Keep only relative-path profiles for the local-only ResolveHarness pass.
if h.OpenShell != nil {
h.OpenShell.Profiles = nil
remaining := h.OpenShell.Profiles[:0]
for _, p := range h.OpenShell.Profiles {
Comment thread
maruiz93 marked this conversation as resolved.
if !harness.IsURL(p) && !filepath.IsAbs(p) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] logic-error

resolveFromLock strips ALL absolute-path profiles via !filepath.IsAbs(p) and all provider paths via !harness.IsProviderPath(p), but local-path profiles/providers resolved by ResolveRelativeTo are also absolute. When a harness combines URL references (triggering the lock-file path) with local-path profiles/providers, the local entries are stripped without appearing in the lock file. They cannot be recovered by the second ResolveHarness call because the guards find no remaining entries. A harness with a URL-referenced skill and a local profile path would silently lose the local profile when running with a lock file.

Suggested fix: Track which entries correspond to lock-file dependencies and only strip those. Alternatively, preserve remaining absolute-path entries that have no corresponding lock dep so the second ResolveHarness pass can process them.

remaining = append(remaining, p)
}
}
h.OpenShell.Profiles = remaining
}

return resolve.ResolveResult{
Expand Down
32 changes: 32 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,29 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
}
}

// When profiles or providers use local paths (from ResolveRelativeTo or
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
// base composition), ResolveHarness must still run to parse them into
// ResolvedProfile/ResolvedProvider — even without URL references.
// The lock-file and URL-resolution paths strip entries they consume;
// this pass handles whatever remains. Outputs are merged and deduped.
if len(h.OpenShellProfiles()) > 0 || hasLocalProviders(h) {
Comment thread
maruiz93 marked this conversation as resolved.
prev := result
var resolveErr error
result, resolveErr = resolve.ResolveHarness(ctx, h, resolve.ResolveOpts{
WorkspaceRoot: absFullsendDir,
})
if resolveErr != nil {
return fmt.Errorf("resolving local profiles/providers: %w", resolveErr)
}
result.Deps = append(prev.Deps, result.Deps...)
result.Profiles = append(prev.Profiles, result.Profiles...)
result.Providers = append(prev.Providers, result.Providers...)
result.Warnings = append(prev.Warnings, result.Warnings...)
}
for _, w := range result.Warnings {
printer.StepWarn(w)
}

if resolved, overridden := applySandboxImageOverride(h.Image); overridden {
printer.StepInfo(fmt.Sprintf("Image override via FULLSEND_SANDBOX_IMAGE: %s -> %s", h.Image, resolved))
h.Image = resolved
Expand Down Expand Up @@ -3174,6 +3197,15 @@ func mergeProviderDefs(localDefs []harness.ProviderDef, urlProviders []resolve.R
return allDefs, shadowed
}

func hasLocalProviders(h *harness.Harness) bool {
for _, p := range h.Providers {
if !harness.IsURL(p) && harness.IsProviderPath(p) {
return true
}
}
return false
}

// sandboxProviderNames returns the provider names that should be attached to
// the sandbox: harness-declared (local) names plus URL-resolved names.
// Directory providers not declared in the harness are excluded — they may
Expand Down
100 changes: 100 additions & 0 deletions internal/harness/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ func LoadWithBase(ctx context.Context, path string, opts ComposeOpts) (*Harness,
return nil, nil, fmt.Errorf("resolving URL-sourced host_files: %w", err)
}
deps = append(deps, hostFileDeps...)

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

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

if err := child.validateForge(); err != nil {
Expand Down Expand Up @@ -260,6 +272,18 @@ func loadBaseChain(
}
deps = append(deps, hostFileDeps...)

profileDeps, err := resolveBaseProfiles(ctx, base, baseRef, allowlist, opts)
if err != nil {
return nil, nil, fmt.Errorf("resolving base profiles from %s: %w", cleanURL, err)
}
deps = append(deps, profileDeps...)

providerDeps, err := resolveBaseProviders(ctx, base, baseRef, allowlist, opts)
if err != nil {
return nil, nil, fmt.Errorf("resolving base providers from %s: %w", cleanURL, err)
}
deps = append(deps, providerDeps...)

baseDir = childDir
} else {
// Local path base
Expand Down Expand Up @@ -774,6 +798,82 @@ func resolveBaseHostFiles(ctx context.Context, base *Harness, baseURL string, al
return deps, nil
}

// resolveBaseProfiles fetches profile files with relative paths from a
// URL-referenced base harness. For each openshell.profiles entry that is a
// non-empty relative path (not a URL or absolute path), the file is fetched
// from the base URL's directory, cached content-addressed, and the entry is
// rewritten to the local cache path. This matches the resolution behavior
// for agent, policy, skills, scripts, and host_files in base composition.
func resolveBaseProfiles(ctx context.Context, base *Harness, baseURL string, allowlist []string, opts ComposeOpts) ([]Dependency, error) {
if base.OpenShell == nil || len(base.OpenShell.Profiles) == 0 {
return nil, nil
}

baseURLDir := urlParentDirPrefix(baseURL)
if baseURLDir == "" {
return nil, fmt.Errorf("cannot determine directory from base URL")
}

var deps []Dependency

for i, p := range base.OpenShell.Profiles {
if p == "" || IsURL(p) || filepath.IsAbs(p) {
continue
}
fieldName := fmt.Sprintf("openshell.profiles[%d]", i)
if err := validateBaseRelPath(fieldName, p); err != nil {
return nil, err
}
dep, cachePath, err := fetchBaseFile(ctx, fieldName, baseURLDir, p, allowlist, opts, "resource", false)
if err != nil {
return nil, err
}
base.OpenShell.Profiles[i] = cachePath
deps = append(deps, dep)
}

return deps, nil
}

// resolveBaseProviders fetches provider files with relative paths from a
// URL-referenced base harness. For each providers entry that is a non-empty
// relative path (not a URL, absolute path, or bare provider name), the file
// is fetched from the base URL's directory, cached content-addressed, and
// the entry is rewritten to the local cache path.
func resolveBaseProviders(ctx context.Context, base *Harness, baseURL string, allowlist []string, opts ComposeOpts) ([]Dependency, error) {
if len(base.Providers) == 0 {
return nil, nil
}

baseURLDir := urlParentDirPrefix(baseURL)
if baseURLDir == "" {
return nil, fmt.Errorf("cannot determine directory from base URL")
}

var deps []Dependency

for i, p := range base.Providers {
if p == "" || IsURL(p) || filepath.IsAbs(p) {
continue
}
if !IsProviderPath(p) {
continue
}
fieldName := fmt.Sprintf("providers[%d]", i)
if err := validateBaseRelPath(fieldName, p); err != nil {
return nil, err
}
dep, cachePath, err := fetchBaseFile(ctx, fieldName, baseURLDir, p, allowlist, opts, "resource", false)
if err != nil {
return nil, err
}
base.Providers[i] = cachePath
deps = append(deps, dep)
}

return deps, nil
}

// validateBaseRelPath validates that a relative path inherited from a URL base
// is safe to resolve. Rejects null bytes, query/fragment markers, URLs,
// absolute paths, and path traversal segments.
Expand Down
Loading
Loading