Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b924266
docs(desktop): document bundled agent nodes and first-launch provisio…
santoshkumarradha Aug 19, 2026
e786ecc
feat(desktop): ship swe-planner and pr-af as bundled nodes
santoshkumarradha Aug 19, 2026
7f1a7a6
feat(desktop): provision bundled agent nodes on first launch
santoshkumarradha Aug 19, 2026
5161269
feat(desktop): show bundled agent provisioning in the Agents view
santoshkumarradha Aug 19, 2026
968c9fd
merge lane/a
santoshkumarradha Aug 19, 2026
4395e87
merge lane/b
santoshkumarradha Aug 19, 2026
5f462ec
merge lane/c
santoshkumarradha Aug 19, 2026
7f94778
fix(desktop): drop the duplicate bundled log prefix
santoshkumarradha Aug 19, 2026
6fddf10
docs(skill): make a missing agent key a blocking handoff in agentfiel…
santoshkumarradha Aug 19, 2026
997b1c4
feat(desktop): warn on the top bar when installed agents need keys
agentfield-bot Aug 19, 2026
e8a9dd6
merge lane/d
santoshkumarradha Aug 19, 2026
3eee3db
merge lane/e
santoshkumarradha Aug 19, 2026
28395ca
fix(desktop): keep the star prompt from stacking under the keys banner
santoshkumarradha Aug 19, 2026
9083684
feat(desktop): notify once when bundled agents land without their keys
santoshkumarradha Aug 19, 2026
d3e9159
merge lane/f
santoshkumarradha Aug 19, 2026
34b7845
docs(desktop): document the three missing-key surfaces
santoshkumarradha Aug 19, 2026
1fb6c33
feat(skills): rewrite agentfield-use around offloading (0.8.0)
santoshkumarradha Aug 19, 2026
ef6e10f
merge lane/g
santoshkumarradha Aug 19, 2026
9900155
fix(desktop): keep bundled provisioning local-only and make uninstall…
AbirAbbas Aug 19, 2026
c670e8e
fix(desktop): land a first launch on the arriving bundled rows, not t…
AbirAbbas Aug 19, 2026
7f2a5a3
fix(desktop): refresh env reports when the registry gains a row; hide…
AbirAbbas Aug 19, 2026
0904a7b
fix(desktop): keep bundled nodes reachable from the marketplace
AbirAbbas Aug 19, 2026
a4d4b5c
fix(control-plane,desktop): report keys supplied by the control plane…
AbirAbbas Aug 19, 2026
a57d490
fix(desktop): tell a spawned control plane its own URL for the agents…
AbirAbbas Aug 19, 2026
6057173
fix(desktop): seed the local control-plane port before the first poll
AbirAbbas Aug 19, 2026
d031993
fix(desktop): decide the cold-launch route only once the registry is …
AbirAbbas Aug 19, 2026
571d801
test(control-plane): cover the agent-secrets handler's default proces…
AbirAbbas Aug 19, 2026
4e7f900
test(control-plane): make the runner-resolution secrets test hermetic
AbirAbbas Aug 20, 2026
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
22 changes: 17 additions & 5 deletions control-plane/internal/handlers/ui/agent_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ui
import (
"encoding/json"
"net/http"
"os"
"regexp"
"sort"

Expand All @@ -23,18 +24,20 @@ var agentSecretKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
type AgentSecretsHandler struct {
storage storage.StorageProvider
agentfieldHome string
lookupEnv func(string) (string, bool)
}

// NewAgentSecretsHandler creates an AgentSecretsHandler.
func NewAgentSecretsHandler(storage storage.StorageProvider, agentfieldHome string) *AgentSecretsHandler {
return &AgentSecretsHandler{storage: storage, agentfieldHome: agentfieldHome}
return &AgentSecretsHandler{storage: storage, agentfieldHome: agentfieldHome, lookupEnv: os.LookupEnv}
}

type agentSecretStatus struct {
Key string `json:"key"`
IsSet bool `json:"is_set"`
Env bool `json:"env,omitempty"`
// Scope reports where the stored value lives ("node" or "global");
// empty when the key is not set anywhere.
// empty when there is no stored value, including environment-only keys.
Scope string `json:"scope,omitempty"`
DeclaredScope string `json:"declared_scope,omitempty"`
Description string `json:"description,omitempty"`
Expand Down Expand Up @@ -66,9 +69,10 @@ type setAgentSecretRequest struct {
}

// ListAgentSecretsHandler lists secret names and whether each resolves for
// this agent. Resolution mirrors the runner (EnvResolver): node scope first,
// then global. Undeclared node-scoped keys are included because the runner
// injects them; undeclared global keys are not injected, so they are omitted.
// this agent. Resolution mirrors the runner (EnvResolver): a non-empty process
// environment value first, then node store, then global store. Values are never
// returned. Undeclared node-scoped keys are included because the runner injects
// them; undeclared global keys are not injected, so they are omitted.
func (h *AgentSecretsHandler) ListAgentSecretsHandler(c *gin.Context) {
agentPackage, ok := h.resolveAgentPackage(c)
if !ok {
Expand Down Expand Up @@ -123,9 +127,17 @@ func (h *AgentSecretsHandler) ListAgentSecretsHandler(c *gin.Context) {
}
sort.Strings(keys)

lookupEnv := h.lookupEnv
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
secrets := make([]agentSecretStatus, 0, len(keys))
for _, key := range keys {
status := agentSecretStatus{Key: key}
if value, ok := lookupEnv(key); ok && value != "" {
status.Env = true
status.IsSet = true
}
switch {
case inNode[key]:
status.IsSet = true
Expand Down
53 changes: 53 additions & 0 deletions control-plane/internal/handlers/ui/agent_secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import (
const agentSecretsTestScope = "test-node"

func newAgentSecretsTestRouter(t *testing.T) (*gin.Engine, string) {
return newAgentSecretsTestRouterWithLookup(t, func(string) (string, bool) { return "", false })
}

func newAgentSecretsTestRouterWithLookup(
t *testing.T,
lookupEnv func(string) (string, bool),
) (*gin.Engine, string) {
t.Helper()
gin.SetMode(gin.TestMode)
agentfieldHome := t.TempDir()
Expand All @@ -44,6 +51,7 @@ func newAgentSecretsTestRouter(t *testing.T) (*gin.Engine, string) {
require.NoError(t, err)

handler := NewAgentSecretsHandler(store, agentfieldHome)
handler.lookupEnv = lookupEnv
router := gin.New()
router.GET("/agents/:agentId/secrets", handler.ListAgentSecretsHandler)
router.PUT("/agents/:agentId/secrets", handler.SetAgentSecretHandler)
Expand All @@ -52,6 +60,47 @@ func newAgentSecretsTestRouter(t *testing.T) (*gin.Engine, string) {
return router, agentfieldHome
}

func TestAgentSecretsListProcessEnvironmentResolution(t *testing.T) {
environment := map[string]string{
"OPENAI_API_KEY": "env-value",
"ANTHROPIC_API_KEY": "",
"NODE_SCOPED_KEY": "env-value",
}
router, home := newAgentSecretsTestRouterWithLookup(t, func(key string) (string, bool) {
value, ok := environment[key]
return value, ok
})
store, err := packages.NewSecretStore(home)
require.NoError(t, err)
require.NoError(t, store.Set(agentSecretsTestScope, "NODE_SCOPED_KEY", "stored-value"))

response := agentSecretsRequest(t, router, http.MethodGet, "/agents/agent-x/secrets?include=env", "")
require.Equal(t, http.StatusOK, response.Code)
require.NotContains(t, response.Body.String(), "env-value")
require.NotContains(t, response.Body.String(), "stored-value")
require.JSONEq(t, `{"secrets":[
{"key":"AGENTFIELD_SERVER","is_set":false,"declared_scope":"global","description":"Control-plane URL","default":"http://localhost:8080","requirement":"optional"},
{"key":"ANTHROPIC_API_KEY","is_set":false,"declared_scope":"global","description":"Anthropic key","secret":true,"requirement":"one_of","group":"llm_provider","group_description":"an LLM provider key"},
{"key":"NODE_SCOPED_KEY","is_set":true,"env":true,"scope":"node","declared_scope":"node","secret":true,"requirement":"required"},
{"key":"OPENAI_API_KEY","is_set":true,"env":true,"declared_scope":"global","description":"OpenAI key","secret":true,"requirement":"required"},
{"key":"SWE_DEFAULT_RUNTIME","is_set":false,"declared_scope":"global","description":"Coding runtime","requirement":"optional"}
]}`, response.Body.String())
}

// A handler built without an injected lookup (a zero-value struct rather than
// the constructor) must still consult the real process environment.
func TestAgentSecretsListDefaultsToProcessEnvironment(t *testing.T) {
router, _ := newAgentSecretsTestRouterWithLookup(t, nil)
t.Setenv("OPENAI_API_KEY", "from-process")
t.Setenv("ANTHROPIC_API_KEY", "")

response := agentSecretsRequest(t, router, http.MethodGet, "/agents/agent-x/secrets?include=env", "")
require.Equal(t, http.StatusOK, response.Code)
require.NotContains(t, response.Body.String(), "from-process")
require.Contains(t, response.Body.String(), `{"key":"OPENAI_API_KEY","is_set":true,"env":true,`)
require.Contains(t, response.Body.String(), `{"key":"ANTHROPIC_API_KEY","is_set":false,`)
}

func agentSecretsRequest(t *testing.T, router http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, path, strings.NewReader(body))
Expand All @@ -63,6 +112,10 @@ func agentSecretsRequest(t *testing.T, router http.Handler, method, path, body s

// Validation contract 1: PUT writes the node scope consumed by runner-side resolution.
func TestAgentSecretsPutResolvesForRunner(t *testing.T) {
// EnvResolver prefers a non-empty process env value, so a developer
// machine with this key exported would resolve the host value instead of
// the stored one. Empty counts as unset; this keeps the test hermetic.
t.Setenv("OPENAI_API_KEY", "")
router, home := newAgentSecretsTestRouter(t)
response := agentSecretsRequest(t, router, http.MethodPut, "/agents/agent-x/secrets",
`{"key":"OPENAI_API_KEY","value":"sk-test"}`)
Expand Down
17 changes: 9 additions & 8 deletions control-plane/internal/skillkit/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,17 @@ read this skill first`,
},
{
Name: "agentfield-use",
Version: "0.6.0",
Description: "Discover and call agents already running on a local or cloud AgentField control plane. Resolves the target server first (a desktop-configured cloud beats the local default, and an unreachable one is a stop-and-report, never a silent fallback), zero-setup MCP endpoint at <server>/mcp, health check, capability discovery, ranked reasoner search (af agent search), the reasoner's exact contract fetched before the first dispatch, entry-point-only targeting, concurrent sync/async execution, load-aware pacing (meta.load), in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.",
Version: "0.8.0",
Description: "Offload work to installed AgentField agents — your subharnesses — instead of doing it inline: whenever a task arises, check whether an installed agent covers it and dispatch to it by default when one does. Resolves the target server first (local or cloud: a desktop-configured cloud beats the local default, and an unreachable one is a stop-and-report, never a silent fallback), zero-setup MCP endpoint at <server>/mcp, health check, capability discovery, ranked reasoner search (af agent search), the reasoner's exact contract fetched before the first dispatch, entry-point-only targeting, async dispatch (af call --async) with wait/tail/batch-poll retrieval, a live run link at <server>/ui/runs/<run_id>, load-aware pacing (meta.load), in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), honest cost reporting from the usage/stats window aggregate, sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.",
EmbedRoot: "skill_data/agentfield-use",
EntryFile: "SKILL.md",
Trigger: `When the user asks you to use, call, query, or delegate work to an
installed AgentField agent, to list available agents or reasoners, or to
check on a running execution, you MUST read this skill first — it resolves
which control plane the work goes to (local or the desktop-configured
cloud) and requires fetching a reasoner's contract, and confirming it is an
entry point, before dispatching to it`,
Trigger: `Before you do a task yourself, and whenever the user asks you to
use, call, query, or delegate work to an installed AgentField agent, to
list available agents or reasoners, or to check on a running execution, you
MUST read this skill first — installed agents are subharnesses you offload
to by default; it resolves which control plane the work goes to (local or
the desktop-configured cloud) and requires fetching a reasoner's contract,
and confirming it is an entry point, before dispatching to it`,
},
}

Expand Down
100 changes: 98 additions & 2 deletions control-plane/internal/skillkit/catalog_agentfield_use_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ func TestAgentfieldUseSourceFallbackContract(t *testing.T) {
if err != nil {
t.Fatalf("parse source frontmatter: %v", err)
}
if frontmatter.Name != "agentfield-use" || frontmatter.Version != "0.6.0" {
t.Fatalf("source frontmatter = %+v, want name=agentfield-use version=0.6.0", frontmatter)
if frontmatter.Name != "agentfield-use" || frontmatter.Version != "0.8.0" {
t.Fatalf("source frontmatter = %+v, want name=agentfield-use version=0.8.0", frontmatter)
}

// The offer is available only after coverage is conclusively checked, it
Expand Down Expand Up @@ -147,6 +147,102 @@ func TestAgentfieldUseDispatchPreconditions(t *testing.T) {
}
}

// Contract for 0.7.0: an installed-but-unstarted node is the DEFAULT first-run
// state (the desktop ships swe-planner/pr-af provisioned but not started), so
// the skill must start the node before dispatching and must treat the resulting
// missing-key error as a blocking handoff. Without this the agent only ever
// sees "agent 'X' not found" and silently substitutes something else.
func TestAgentfieldUseMissingKeyHandoffContract(t *testing.T) {
content := string(skillSource(t, "agentfield-use"))
for _, needle := range []string{
// Start before dispatch, and why the start attempt is the diagnostic.
"### Start it before you dispatch — the start attempt is the diagnostic",
"run `af run <name>` BEFORE dispatching",
"missing required environment variables: OPENROUTER_API_KEY",
// The store-blind commands must stay called out by name.
"Do not use `af doctor` or `af config <pkg> --list` to decide",
// Blocking handoff, never a workaround.
"A missing key is a blocking handoff, not a problem to route around.",
"AgentField Desktop → Agents → <node> →",
"do NOT substitute a",
"Never ask the user to paste the secret value into the conversation",
// The observed not-found responses must be recognizable.
"agent 'X' not found",
"target \"X.y\" not found",
} {
if !strings.Contains(content, needle) {
t.Fatalf("agentfield-use SKILL.md is missing missing-key handoff text %q", needle)
}
}
}

// Contract for 0.8.0: installed agents are subharnesses a coding harness
// offloads to, and offloading is the DEFAULT path rather than an option to
// offer. Each clause below exists because dropping it turns the offload back
// into inline work the user never hears about — the failure this release was
// written to prevent.
func TestAgentfieldUseOffloadDoctrineContract(t *testing.T) {
content := string(skillSource(t, "agentfield-use"))
for _, needle := range []string{
// Offload by default — coverage decides, not the task's size, and the
// fleet is discovered at runtime rather than listed here.
"## Offload by default",
"default path, not an option to offer",
"**Coverage is the test, not size.**",
"**The check is cheap — that is the whole design.**",
"**Default-offload.**",
// Announce the offload, with the run's live UI link for the user.
"**Announce it, with a link.**",
"<server>/ui/runs/<run_id>",
"The link is **for the user** to watch in parallel.",
// The user keeps the override.
"**The user can always override.**",
// Never silent-wash: a failed or stalled run is reported, never redone
// inline and presented as the subharness's work.
"**Never silent-wash the offload.**",
"Do NOT quietly redo the work inline",
// The user-facing vocabulary rule.
"**Vocabulary rule.**",
"your AgentField subharnesses",
"subharnesses",
} {
if !strings.Contains(content, needle) {
t.Fatalf("agentfield-use SKILL.md is missing offload-doctrine text %q", needle)
}
}
// The doctrine must not re-introduce a size gate or a hardcoded list of
// offloadable roles: coverage is the only test, and the fleet is open-ended.
for _, forbidden := range []string{"substantial, multi-step work", "A security audit → "} {
if strings.Contains(content, forbidden) {
t.Fatalf("agentfield-use SKILL.md re-introduced retired offload gate %q", forbidden)
}
}
}

// Contract for 0.8.0: the async golden path the harness actually drives —
// client-side-validated dispatch, then a retrieval mode chosen deliberately.
// `af wait`'s exit 2 is a timeout, not a failure; a harness must never wait on
// a webhook it has no listener for; and cost is a window aggregate, never a
// per-run figure to invent.
func TestAgentfieldUseAsyncGoldenPathContract(t *testing.T) {
content := string(skillSource(t, "agentfield-use"))
for _, needle := range []string{
"af call <node>.<reasoner> --schema",
"--async",
"af wait <run_id>",
"af tail <run_id>",
"**Exit code 2 means TIMEOUT, not failure**",
"**Webhooks are not for you.**",
"/api/ui/v1/usage/stats",
"There is **no per-run cost endpoint today.**",
"**Duration is per-run truth; cost is window truth.**",
} {
if !strings.Contains(content, needle) {
t.Fatalf("agentfield-use SKILL.md is missing golden-path text %q", needle)
}
}
}

// Contract: the catalog entry is what a rules file and `af skill catalog` show
// — it must advertise the same preconditions the skill body enforces, or an
// agent choosing skills by description never learns they exist.
Expand Down
Loading
Loading