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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ Flags:
--local-path string use a local directory containing the RO-Crate metadata instead of fetching it from GitHub
--owner string GitHub owner that hosts the curated services (default "grycap")
--path string subdirectory inside the repository that contains the services
--preview show the resolved service FDL in YAML (secrets redacted) without accessing a cluster
--ref string Git reference (branch, tag, or commit) to query (default "main")
-n, --name string override the OSCAR service and primary bucket names during deployment
--repo string GitHub repository that hosts the curated services (default "oscar-hub")
Expand All @@ -255,6 +256,28 @@ When `--env-file` is provided, matching keys override values declared under
`environment.variables` and `environment.secrets` in the service FDL. Keys that
are not declared by the service are ignored.

For OSCAR Agent Profile 1.1 crates, `hub deploy` resolves `agentSoul` and
`agentSkills` from the crate's local RO-Crate files into declared `AGENT_SOUL`
and `AGENT_SKILLS` variables. Skills are sorted by `@id` and joined with two
newlines. `--env-file` values take precedence over the crate defaults. Use
`--preview` to inspect the resolved FDL, including embedded script and guidance
as readable multiline YAML. It applies `--name` to the service and storage paths,
redacts declared secrets and fields with credential-like names, and neither
requires a cluster configuration nor contacts the OSCAR API. If the FDL has
multiple services, preview and deploy use the same one: the first nonempty
entry in FDL order, choosing the lexicographically first cluster key within
that entry. It shows the source FDL prepared for deployment, not the
cluster-enriched HTTP payload.
Free-form text such as scripts or agent guidance may still contain sensitive
instructions; redirect preview output only to a safe location. An unresolved
`YOUR_AGENT_SOUL` or `YOUR_AGENT_SKILLS` blocks deploy.

For example, to inspect an agent from the local oscar-agents checkout:

```
oscar-cli hub deploy pdf-summarizer --local-path ../oscar-agents/crates --preview
```

Default curated source: https://github.com/grycap/oscar-hub/tree/main

##### validate
Expand Down
88 changes: 88 additions & 0 deletions cmd/hub_agent_deploy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/grycap/oscar/v4/pkg/types"
)

func TestHubAgentDeploySendsMaterializedGuidance(t *testing.T) {
const metadata = `{"@graph":[{"@id":"./","@type":["Dataset","Agent"],"conformsTo":{"@id":"https://github.com/grycap/oscar-agents/tree/devel/profiles/oscar-agent-1.1"},"agentSoul":{"@id":"SOUL.md"},"agentSkills":[{"@id":"skills/a/SKILL.md"}],"hasPart":[{"@id":"SOUL.md"},{"@id":"skills/a/SKILL.md"}]},{"@id":"SOUL.md","@type":["File","AgentSoul"],"encodingFormat":"text/markdown"},{"@id":"skills/a/SKILL.md","@type":["File","AgentSkill"],"encodingFormat":"text/markdown"}]}`
const fdl = `functions:
oscar:
- default:
name: agent-demo
script: script.sh
environment:
variables:
AGENT_SOUL: YOUR_AGENT_SOUL
AGENT_SKILLS: YOUR_AGENT_SKILLS
`
gitServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ref") != "devel" {
t.Errorf("unexpected Git ref: %q", r.URL.RawQuery)
}
switch r.URL.Path {
case "/repos/grycap/oscar-agents/contents/crates/agent-demo":
fmt.Fprint(w, `[{"name":"fdl.yml","type":"file"},{"name":"script.sh","type":"file"}]`)
case "/repos/grycap/oscar-agents/contents/crates/agent-demo/fdl.yml":
fmt.Fprint(w, fdl)
case "/repos/grycap/oscar-agents/contents/crates/agent-demo/script.sh":
fmt.Fprint(w, "#!/bin/sh\n")
case "/repos/grycap/oscar-agents/contents/crates/agent-demo/ro-crate-metadata.json":
fmt.Fprint(w, metadata)
case "/repos/grycap/oscar-agents/contents/crates/agent-demo/SOUL.md":
fmt.Fprint(w, "agent identity\n")
case "/repos/grycap/oscar-agents/contents/crates/agent-demo/skills/a/SKILL.md":
fmt.Fprint(w, "agent ability\n")
default:
t.Errorf("unexpected GitHub request: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer gitServer.Close()

var deployed types.Service
cluster := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/system/config":
fmt.Fprint(w, `{"name":"oscar","namespace":"oscar","services_namespace":"oscar-svc"}`)
case r.Method == http.MethodGet && r.URL.Path == "/system/services/agent-demo":
http.NotFound(w, r)
case r.Method == http.MethodPost && r.URL.Path == "/system/services":
if err := json.NewDecoder(r.Body).Decode(&deployed); err != nil {
t.Errorf("decoding payload: %v", err)
}
w.WriteHeader(http.StatusCreated)
default:
t.Errorf("unexpected OSCAR request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
defer cluster.Close()

config := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(config, []byte(fmt.Sprintf("oscar:\n test:\n endpoint: %s\ndefault: test\n", cluster.URL)), 0600); err != nil {
t.Fatal(err)
}
oldConfig := configPath
configPath = config
t.Cleanup(func() { configPath = oldConfig })
cmd := makeHubDeployCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetArgs([]string{"agent-demo", "--repo", "oscar-agents", "--ref", "devel", "--api-base", gitServer.URL, "--cluster", "test"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if deployed.Environment.Vars["AGENT_SOUL"] != "agent identity\n" || deployed.Environment.Vars["AGENT_SKILLS"] != "agent ability\n" || !strings.Contains(deployed.Script, "#!/bin/sh") {
t.Fatalf("deployment payload missing resolved guidance/script: %#v", deployed.Environment.Vars)
}
}
130 changes: 130 additions & 0 deletions cmd/hub_agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package cmd

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

func TestHubAgentPreviewAndOverrideWithoutCluster(t *testing.T) {
crate := filepath.Join(t.TempDir(), "demo")
if err := os.MkdirAll(crate, 0700); err != nil {
t.Fatal(err)
}
files := map[string]string{
"fdl.yml": `functions:
oscar:
- default:
name: demo
script: script.sh
environment:
variables:
AGENT_SOUL: YOUR_AGENT_SOUL
AGENT_SKILLS: YOUR_AGENT_SKILLS
secrets:
OPENAI_API_KEY: not-for-preview
input:
- storage_provider: minio.default
path: demo/input
`,
"script.sh": "#!/bin/sh\n",
"SOUL.md": "soul\nline two\n",
"ro-crate-metadata.json": `{"@graph":[{"@id":"./","@type":["Dataset","Agent"],"conformsTo":{"@id":"https://github.com/grycap/oscar-agents/tree/devel/profiles/oscar-agent-1.1"},"agentSoul":{"@id":"SOUL.md"},"hasPart":[{"@id":"SOUL.md"}]},{"@id":"SOUL.md","@type":["File","AgentSoul"],"encodingFormat":"text/markdown"}]}`,
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(crate, name), []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
envFile := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(envFile, []byte("OPENAI_API_KEY=secret-override\n"), 0600); err != nil {
t.Fatal(err)
}
oldConfig := configPath
configPath = filepath.Join(t.TempDir(), "nonexistent-config.yaml")
t.Cleanup(func() { configPath = oldConfig })
cmd := makeHubDeployCmd()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetArgs([]string{"demo", "--local-path", crate, "--preview", "--env-file", envFile, "--name", "renamed"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
text := output.String()
if !strings.Contains(text, "AGENT_SOUL: |") || !strings.Contains(text, "script: |") || !strings.Contains(text, "line two\n") {
t.Fatalf("expected multiline YAML in preview: %s", text)
}
if strings.Contains(text, "not-for-preview") || strings.Contains(text, "secret-override") || strings.Contains(text, "YOUR_AGENT_") {
t.Fatal("preview leaked secrets or unresolved placeholders")
}
var preview map[string]any
if err := yaml.Unmarshal(output.Bytes(), &preview); err != nil {
t.Fatalf("preview is not valid YAML: %v", err)
}
entry := preview["functions"].(map[string]any)["oscar"].([]any)[0].(map[string]any)["default"].(map[string]any)
if entry["name"] != "renamed" || entry["script"] != "#!/bin/sh\n" {
t.Fatalf("preview does not represent effective service: %#v", entry)
}
if _, ok := entry["alpine"]; ok {
t.Fatal("preview includes unconfigured zero-value service fields")
}
env := entry["environment"].(map[string]any)
if env["variables"].(map[string]any)["AGENT_SOUL"] != "soul\nline two\n" || env["secrets"].(map[string]any)["OPENAI_API_KEY"] != "[REDACTED]" {
t.Fatalf("unexpected guidance or secret handling: %#v", env)
}
if entry["input"].([]any)[0].(map[string]any)["path"] != "renamed/input" {
t.Fatal("preview did not apply name override to storage paths")
}
}

func TestHubPreviewRedactsCredentialFields(t *testing.T) {
data := map[string]any{
"environment": map[string]any{
"variables": map[string]any{"API_KEY": "var-sensitive", "SAFE": "visible"},
"secrets": map[string]any{"GENERIC": "secret-sensitive"},
},
"clusters": map[string]any{"example": map[string]any{"auth_password": "password-sensitive", "endpoint": "https://example.invalid"}},
"storage_providers": map[string]any{"minio": map[string]any{"access_key": "access-sensitive", "secret_key": "key-sensitive"}},
}
redactPreview(data, "")
encoded, err := yaml.Marshal(data)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
for _, value := range []string{"var-sensitive", "secret-sensitive", "password-sensitive", "access-sensitive", "key-sensitive"} {
if strings.Contains(text, value) {
t.Fatalf("credential leaked in preview: %s", value)
}
}
if !strings.Contains(text, "https://example.invalid") || !strings.Contains(text, "visible") {
t.Fatal("preview removed non-secret configuration")
}
}

func TestHubAgentPreviewRejectsUnresolvedGuidance(t *testing.T) {
crate := t.TempDir()
if err := os.WriteFile(filepath.Join(crate, "fdl.yml"), []byte(`functions:
oscar:
- default:
name: demo
script: script.sh
environment:
variables:
AGENT_SOUL: YOUR_AGENT_SOUL
`), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(crate, "script.sh"), []byte("#!/bin/sh\n"), 0600); err != nil {
t.Fatal(err)
}
cmd := makeHubDeployCmd()
cmd.SetArgs([]string{"demo", "--local-path", crate, "--preview"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "unresolved agent guidance") {
t.Fatalf("expected unresolved guidance error; got %v", err)
}
}
Loading
Loading