diff --git a/CHANGELOG.md b/CHANGELOG.md
index e408e5d..ec0bd5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,44 @@
## Unreleased
+### Added
+
+- **Workspace-level skill-builder LLM config (issue #92).** The `forge ui`
+ skill builder now reads its LLM configuration from
+ `/.forge/ui.yaml` (or `~/.forge/ui.yaml` as a machine-wide
+ fallback) instead of borrowing credentials from whichever agent the
+ operator picked. The skill-builder LLM is decoupled from any agent's
+ runtime LLM, so the same configuration works across every agent in
+ the workspace and is usable before any agent has been scaffolded.
+ - New `GET` / `PUT` endpoints at `/api/settings/skill-builder` plus a
+ Settings modal in the skill-builder UI.
+ - New `GET /api/skill-builder/provider` (path-less) for first-run
+ detection in an empty workspace.
+ - Status banner surfaces the resolution source (`workspace` / `user` /
+ `agent_fallback` / `unset`) and a deprecation warning when the
+ agent-fallback compat shim resolves.
+ - The Settings modal accepts the API key value inline (password field)
+ and persists it to `/.forge/.env` with mode 0600. An
+ auto-generated `/.forge/.gitignore` protects the file
+ from accidental commits. The key value never appears in `ui.yaml`
+ and is never echoed back by the GET endpoint.
+ - See `docs/ui/skill-builder-llm.md` for the configuration reference.
+
+### Changed
+
+- **`SkillBuilderCodegenModel` no longer overrides the operator's model
+ (issue #92).** The function previously forced `gpt-4.1` for openai and
+ `claude-opus-4-6` for anthropic regardless of what the agent (or
+ workspace) had configured. The override is removed; the operator's
+ chosen model is used verbatim. This unblocks agents pointed at custom
+ OpenAI-compatible endpoints (OpenRouter, vLLM, litellm, self-hosted
+ Kimi/Llama) where the hardcoded "stronger" model isn't hosted.
+- **Skill-builder handlers no longer call `os.Setenv` (issue #92).** The
+ pre-#92 handlers leaked the picked agent's `.env` into the `forge ui`
+ process's environment via `os.Setenv` calls, which caused cross-agent
+ credential stomping when switching agents in the UI. Credentials are
+ now threaded as request-scoped values.
+
### Fixed
- **`forge init` Custom provider now produces a runnable agent (issue #83).**
diff --git a/README.md b/README.md
index 91be171..91de470 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-# Forge — OpenClaw for Enterprise: A Secure, Portable AI Agent Runtime
+# Forge — An Open, Secure, Portable AI Agent Runtime for the Enterprise
@@ -11,8 +11,7 @@
-Build, run, and deploy AI agents from a single `SKILL.md` file.
-Secure by default. Runs anywhere — local, container, cloud, air-gapped.
+Forge is the open-source runtime for Anthropic's Agent Skills standard — built for the agent that runs next to a service, in your environment, on infrastructure you already operate. Write a `SKILL.md`. Compile to a portable, hardened agent. Deploy it anywhere containers run: Kubernetes, on-prem, air-gapped, embedded in CI, or as an A2A endpoint.
## Why Forge?
diff --git a/docs/reference/cli-reference.md b/docs/reference/cli-reference.md
index 4b6a077..905a9df 100644
--- a/docs/reference/cli-reference.md
+++ b/docs/reference/cli-reference.md
@@ -31,7 +31,7 @@ forge init [name] [flags]
| `--name` | `-n` | | Agent name |
| `--framework` | `-f` | | Framework: `crewai`, `langchain`, or `custom` |
| `--language` | `-l` | | Language: `python`, `typescript`, or `go` |
-| `--model-provider` | `-m` | | Model provider: `openai`, `anthropic`, `ollama`, or `custom` |
+| `--model-provider` | `-m` | | Model provider: `openai`, `anthropic`, `gemini`, `ollama`, or `custom` (alias for an OpenAI-compatible endpoint — scaffolds `provider: openai` + `OPENAI_BASE_URL` / `OPENAI_API_KEY`) |
| `--channels` | | | Channel adapters (e.g., `slack,telegram`) |
| `--tools` | | | Builtin tools to enable (e.g., `web_search,http_request`) |
| `--skills` | | | Registry skills to include (e.g., `github,weather`) |
diff --git a/docs/reference/forge-yaml-schema.md b/docs/reference/forge-yaml-schema.md
index 64a91bb..2c9a3f3 100644
--- a/docs/reference/forge-yaml-schema.md
+++ b/docs/reference/forge-yaml-schema.md
@@ -16,13 +16,17 @@ registry: "ghcr.io/org" # Container registry
entrypoint: "agent.py" # Required for crewai/langchain, omit for forge
model:
- provider: "openai" # openai, anthropic, gemini, ollama, custom
+ provider: "openai" # openai, anthropic, gemini, ollama
name: "gpt-4o" # Model name
organization_id: "org-xxx" # OpenAI Organization ID (enterprise, optional)
fallbacks: # Fallback providers (optional)
- provider: "anthropic"
name: "claude-sonnet-4-20250514"
organization_id: "" # Per-fallback org ID override (optional)
+# For OpenAI-compatible endpoints (OpenRouter, vLLM, litellm, self-hosted
+# Kimi/Llama): use provider: "openai" + set OPENAI_BASE_URL in .env.
+# The forge init wizard's "Custom" option normalizes to this shape — the
+# generated forge.yaml never carries provider: "custom".
tools:
- name: "web_search"
diff --git a/docs/reference/web-dashboard.md b/docs/reference/web-dashboard.md
index a92f9e9..79984d3 100644
--- a/docs/reference/web-dashboard.md
+++ b/docs/reference/web-dashboard.md
@@ -137,7 +137,16 @@ An AI-powered conversational tool for creating custom skills. Access it via the
### How It Works
-The Skill Builder uses the agent's own LLM provider to power a chat conversation that generates valid SKILL.md files and optional helper scripts. It automatically selects a stronger code-generation model when available (e.g. `gpt-4.1` for OpenAI, `claude-opus-4-6` for Anthropic). API key detection loads the agent's `.env` file and encrypted secrets (if unlocked) in addition to system environment variables.
+The Skill Builder uses a **workspace-level LLM** that's independent of any specific agent's runtime LLM. The same configuration applies across every agent in the workspace and works before any agent has been scaffolded.
+
+Configuration is persisted under `/.forge/`:
+
+- `ui.yaml` holds the non-secret fields (`provider`, `model`, optional `base_url`, optional `api_key_env`).
+- `.env` holds the API key value (mode `0600`, auto-protected by a sibling `.gitignore`).
+
+The status banner above the chat panel surfaces the resolution source — `workspace`, `user` (machine-wide fallback), `agent_fallback` (a deprecated compat shim used when no workspace/user config exists), or `unset`. When `unset`, the banner shows a **Configure** button that opens the settings modal.
+
+See [Skill Builder LLM](../ui/skill-builder-llm.md) for the full configuration reference, three-tier resolution precedence, and trust-boundary rationale.
### Features
@@ -182,11 +191,14 @@ The validator enforces the [SKILL.md format](../core-concepts/skill-md-format.md
| Method | Path | Description |
|--------|------|-------------|
-| `GET` | `/api/agents/{id}/skill-builder/provider` | Returns the agent's LLM provider, codegen model, and API key status |
+| `GET` | `/api/skill-builder/provider` | Returns the resolved workspace-level LLM (provider, model, `source`, `has_key`, deprecation `warning`). Use this for first-run detection before any agent is picked. |
+| `GET` | `/api/agents/{id}/skill-builder/provider` | Same shape as above, but consults the agent-fallback tier when no workspace/user config exists. |
| `GET` | `/api/agents/{id}/skill-builder/context` | Returns the system prompt used for skill generation |
-| `POST` | `/api/agents/{id}/skill-builder/chat` | Streams an LLM conversation via SSE (accepts `messages` array) |
+| `POST` | `/api/agents/{id}/skill-builder/chat` | Streams an LLM conversation via SSE (accepts `messages` array). Returns `400` when the workspace LLM is `unset` or has no resolvable API key. |
| `POST` | `/api/agents/{id}/skill-builder/validate` | Validates a SKILL.md and optional scripts |
| `POST` | `/api/agents/{id}/skill-builder/save` | Saves a validated skill to `skills/{name}/`, merges egress domains, writes env vars; returns `SkillSaveResult` with `path`, `egress_added`, `env_configured`, `env_missing` |
+| `GET` | `/api/settings/skill-builder` | Returns the workspace-level config + metadata for the Settings modal (`source`, `has_key`, `providers` list). The API key value is never echoed back. |
+| `PUT` | `/api/settings/skill-builder` | Persists `provider`, `model`, optional `base_url`, optional `api_key_env` to `/.forge/ui.yaml`. When an optional `api_key` field is present in the body, writes it to `/.forge/.env` (mode `0600`) under `api_key_env` (or the provider default). The key value never leaks to `ui.yaml`. Submit an empty / omitted `api_key` to leave the saved value untouched. |
## Architecture
@@ -199,6 +211,9 @@ forge-ui/
handlers.go Dashboard API (agents, start/stop, chat, sessions)
handlers_create.go Wizard API (create, config, skills, tools, OAuth)
handlers_skill_builder.go Skill Builder API (chat, validate, save, provider)
+ handlers_settings.go Workspace-level settings API (skill-builder LLM)
+ uiconfig/ Workspace ui.yaml + .env loader; SkillBuilderLLM
+ resolution + atomic .env writer with auto-.gitignore
skill_builder_context.go System prompt for the Skill Designer AI
skill_validator.go SKILL.md validation and artifact extraction
process.go Process manager (exec forge serve start/stop)
diff --git a/docs/skills/skills-cli.md b/docs/skills/skills-cli.md
index b22b790..1273339 100644
--- a/docs/skills/skills-cli.md
+++ b/docs/skills/skills-cli.md
@@ -69,7 +69,7 @@ Tooling can match on the substrings `(via policy)` or `(acknowledged by policy)`
## Skill Builder (Web UI)
-The [Web Dashboard](../reference/web-dashboard.md#skill-builder) includes an AI-powered Skill Builder that generates valid SKILL.md files and helper scripts through a conversational interface. It uses the agent's own LLM provider and includes server-side validation before saving to the agent's `skills/` directory. On save, the builder automatically parses the skill's requirements and:
+The [Web Dashboard](../reference/web-dashboard.md#skill-builder) includes an AI-powered Skill Builder that generates valid SKILL.md files and helper scripts through a conversational interface. It uses a [workspace-level LLM](../ui/skill-builder-llm.md) (independent of any specific agent's runtime LLM) and includes server-side validation before saving to the agent's `skills/` directory. On save, the builder automatically parses the skill's requirements and:
- **Merges egress domains** into `forge.yaml` `egress.allowed_domains` (deduplicated)
- **Writes user-provided env vars** to `.env` (skipping keys already present)
diff --git a/docs/ui/skill-builder-llm.md b/docs/ui/skill-builder-llm.md
new file mode 100644
index 0000000..b5b5edd
--- /dev/null
+++ b/docs/ui/skill-builder-llm.md
@@ -0,0 +1,173 @@
+# Skill Builder LLM (workspace-level)
+
+The `forge ui` skill builder generates SKILL.md files via an LLM. That
+LLM is configured at the **workspace level** — independent of any
+specific agent's runtime LLM — so the same configuration works across
+every agent in the workspace and is usable before any agent has been
+scaffolded.
+
+## Why workspace-level
+
+Pre-#92 the skill builder borrowed credentials from whichever agent
+the operator clicked into: it read the agent's `forge.yaml` and `.env`,
+applied a hardcoded model upgrade (`gpt-4.1` for openai, `claude-opus-4-6`
+for anthropic), and called the LLM from inside the `forge ui` process.
+That model conflated two distinct concerns ("agent runtime LLM" vs.
+"build-time codegen LLM"), broke against any agent pointed at a custom
+OpenAI-compatible endpoint (the upgrade requested a model the endpoint
+didn't host), caused cross-agent env-var stomping when switching agents,
+and had no answer for empty workspaces.
+
+The workspace-level model fixes all four:
+
+- One LLM for the operator's skill-building work, used across every
+ agent in the workspace.
+- Operator picks the model — no hardcoded upgrade.
+- Credentials threaded as request-scoped data; the UI process's
+ environment is never mutated by handler calls.
+- Works in an empty workspace before any agent is scaffolded.
+
+## Configuration file
+
+`/.forge/ui.yaml`:
+
+```yaml
+skill_builder:
+ provider: openai # openai | anthropic | gemini | ollama
+ model: gpt-4.1 # operator-chosen; no hardcoded upgrade
+ base_url: https://... # optional, for OpenAI-compatible endpoints
+ api_key_env: OPENAI_API_KEY # which env var holds the key (default per provider)
+```
+
+- `provider` (required) — one of `openai`, `anthropic`, `gemini`, `ollama`.
+- `model` (required) — operator picks. The skill builder uses this
+ verbatim; there is no `SkillBuilderCodegenModel` hardcoded upgrade.
+- `base_url` (optional, openai only) — set this for OpenAI-compatible
+ endpoints (OpenRouter, vLLM, litellm, etc.).
+- `api_key_env` (optional) — name of the environment variable the
+ `forge ui` process reads for the API key. Defaults per provider
+ (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`). Set this
+ if you keep the skill-builder credentials under a different name
+ (e.g. `WORKSPACE_LLM_KEY`) to avoid collisions with per-agent
+ runtime credentials.
+
+The API key itself is **never** stored in `ui.yaml` — only the env var
+name is. Set the env var in your shell before launching `forge ui`.
+
+## Resolution precedence
+
+The loader resolves the skill-builder LLM through three tiers, in order:
+
+1. `/.forge/ui.yaml` — primary, per-workspace.
+2. `~/.forge/ui.yaml` — fallback, operator's machine-wide default.
+3. The picked agent's `forge.yaml` + `.env` — **deprecated** fallback.
+ When this tier resolves, the UI banner shows a deprecation warning
+ prompting the operator to configure workspace settings. This
+ compatibility shim will be removed in a future release.
+
+If none of the tiers resolves and the skill builder is invoked, the
+chat handler returns a 400 with a message pointing to Settings.
+
+## Setting it up
+
+### Via the UI (recommended)
+
+1. Open `forge ui --dir ` and click any agent's
+ Skill Builder.
+2. If no workspace-level config exists, the banner reads
+ **"Workspace skill-builder LLM is not configured"** — click
+ **Configure**.
+3. Fill the form (provider, model, optional base URL, optional API key
+ env override) and **paste your API key** in the password field.
+4. Save. The key is written to `/.forge/.env` (mode 0600)
+ under the env var name shown in the form. An auto-generated
+ `.forge/.gitignore` protects the file from being committed.
+
+The key value is never sent back by the GET endpoint and never
+appears in `ui.yaml` — only `/.forge/.env` ever holds it.
+To rotate, open Settings and paste a new value; submitting an empty
+key leaves the saved value untouched.
+
+### Via the file
+
+```
+mkdir -p /.forge
+cat > /.forge/ui.yaml <<'YAML'
+skill_builder:
+ provider: openai
+ model: gpt-4.1
+YAML
+echo 'OPENAI_API_KEY=sk-...' > /.forge/.env
+chmod 600 /.forge/.env
+echo '.env' > /.forge/.gitignore
+```
+
+Then launch `forge ui --dir `. The `forge ui` process
+consults `/.forge/.env` for any env var named in `ui.yaml`,
+with the OS environment as a fallback.
+
+### Via the API
+
+```sh
+# Persist config + key in one PUT (api_key is optional; omit to leave
+# the saved key unchanged).
+curl -X PUT http://localhost:4200/api/settings/skill-builder \
+ -H 'Content-Type: application/json' \
+ -d '{"provider":"openai","model":"gpt-4.1","api_key":"sk-..."}'
+
+curl http://localhost:4200/api/settings/skill-builder
+```
+
+### Via the API
+
+```sh
+curl -X PUT http://localhost:4200/api/settings/skill-builder \
+ -H 'Content-Type: application/json' \
+ -d '{"provider":"openai","model":"gpt-4.1"}'
+
+curl http://localhost:4200/api/settings/skill-builder
+```
+
+## Status banner semantics
+
+The skill builder header shows the resolved configuration plus a hint
+about where it came from:
+
+| Banner says | What it means |
+|---|---|
+| `openai/gpt-4.1` (clean) | Workspace config resolved successfully; API key found. |
+| `openai/gpt-4.1` + **using agent fallback (deprecated)** | No workspace/user config exists; the picked agent's `forge.yaml` is being used. Configure workspace settings to migrate. |
+| `openai/gpt-4.1` + **API key not configured (env: OPENAI_API_KEY)** | Config resolved but the named env var is empty in the `forge ui` process. Set it and reload. |
+| **Workspace skill-builder LLM is not configured** | First-run state. Click Configure. |
+
+## Why split `ui.yaml` and `.env`?
+
+Same trust-boundary reasoning as `forge init`'s `forge.yaml` / `.env`
+split:
+
+- `ui.yaml` is non-secret (provider name, model name, base URL, env
+ var name). Operators may want to check this into their workspace
+ repo so the team shares the same skill-builder configuration.
+- `.env` is the secret material (API key value). It lives under
+ `.forge/` with mode 0600 and an auto-generated `.gitignore` that
+ protects it from being committed.
+
+If you want to keep skill-builder credentials separate from per-agent
+runtime credentials (recommended, especially when agents point at
+OpenAI-compatible endpoints other than openai.com), set
+`api_key_env: WORKSPACE_LLM_KEY` (or similar). The key value lands at
+`/.forge/.env` under that name; per-agent runtime
+credentials in each `/.env` stay untouched.
+
+## Decoupling rules the implementation enforces
+
+These are pinned by regression tests:
+
+- The skill builder LLM is independent of any agent's runtime LLM.
+ Agent A can ship with `provider: anthropic` and Claude while you
+ use GPT-4.1 to build skills.
+- The skill builder **never** calls `os.Setenv` on the `forge ui`
+ process. Credentials are passed via request-scoped values.
+- The previous `SkillBuilderCodegenModel` mapping (which forced
+ `gpt-4.1` / `claude-opus-4-6` regardless of the agent's configured
+ model) is removed. The operator's chosen model is used verbatim.
diff --git a/forge-cli/cmd/ui.go b/forge-cli/cmd/ui.go
index 8934f92..05599c4 100644
--- a/forge-cli/cmd/ui.go
+++ b/forge-cli/cmd/ui.go
@@ -9,13 +9,9 @@ import (
"strings"
"syscall"
- "github.com/initializ/forge/forge-cli/config"
"github.com/initializ/forge/forge-cli/internal/tui"
- "github.com/initializ/forge/forge-cli/runtime"
"github.com/initializ/forge/forge-core/llm"
- "github.com/initializ/forge/forge-core/llm/oauth"
"github.com/initializ/forge/forge-core/llm/providers"
- coreruntime "github.com/initializ/forge/forge-core/runtime"
"github.com/initializ/forge/forge-core/util"
forgeui "github.com/initializ/forge/forge-ui"
"github.com/spf13/cobra"
@@ -143,108 +139,36 @@ func runUI(cmd *cobra.Command, args []string) error {
}
// Build the LLMStreamFunc for skill builder conversations.
+ //
+ // Per issue #92, this callback consumes the workspace-level LLM
+ // configuration resolved by forge-ui (opts.LLM) and DOES NOT re-read
+ // the agent's forge.yaml / .env or mutate the UI process's env.
+ // Per-agent credentials live with the agent runtime, not the skill
+ // builder.
llmStreamFunc := func(ctx context.Context, opts forgeui.LLMStreamOptions) error {
- // Load agent config
- cfgPath := filepath.Join(opts.AgentDir, "forge.yaml")
- cfg, err := config.LoadForgeConfig(cfgPath)
+ if opts.LLM.Provider == "" {
+ return fmt.Errorf("skill-builder LLM is not configured (no workspace ui.yaml and no agent fallback available)")
+ }
+
+ // Construct the LLM client from the resolved workspace config.
+ // No env reading, no os.Setenv. OAuth is intentionally NOT
+ // supported on this path — workspace-level config requires an
+ // explicit API key under api_key_env. (Operators who want to
+ // use ChatGPT OAuth specifically can set OPENAI_API_KEY from
+ // the OAuth token themselves; the workspace-LLM design does
+ // not silently override an explicit endpoint via the codex
+ // backend, per the issue #83 guardrail.)
+ clientCfg := llm.ClientConfig{
+ Model: opts.LLM.Model,
+ APIKey: opts.LLM.APIKey,
+ BaseURL: opts.LLM.BaseURL,
+ }
+ client, err := providers.NewClient(opts.LLM.Provider, clientCfg)
if err != nil {
- return fmt.Errorf("loading config: %w", err)
+ return fmt.Errorf("creating LLM client: %w", err)
}
- // Load .env — force-set values so __oauth__ sentinels from prior
- // handler calls don't block real keys from encrypted secrets.
- envPath := filepath.Join(opts.AgentDir, ".env")
- envVars, err := runtime.LoadEnvFile(envPath)
- if err != nil {
- return fmt.Errorf("loading env: %w", err)
- }
- for k, v := range envVars {
- _ = os.Setenv(k, v)
- }
-
- // Clear __oauth__ sentinels so OverlaySecretsToEnv can replace them
- // with real keys from the encrypted secrets store.
- for _, key := range []string{"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"} {
- if os.Getenv(key) == "__oauth__" {
- _ = os.Unsetenv(key)
- }
- }
-
- // Overlay encrypted secrets
- runtime.OverlaySecretsToEnv(cfg, opts.AgentDir)
-
- // Build env map for model resolution. OS env takes priority over .env
- // file because OverlaySecretsToEnv may have replaced sentinels with
- // real keys from the encrypted store.
- envMap := make(map[string]string)
- for k, v := range envVars {
- if v != "__oauth__" {
- envMap[k] = v
- }
- }
- for _, kv := range os.Environ() {
- parts := strings.SplitN(kv, "=", 2)
- if len(parts) == 2 {
- envMap[parts[0]] = parts[1]
- }
- }
-
- mc := coreruntime.ResolveModelConfig(cfg, envMap, "")
- if mc == nil {
- return fmt.Errorf("unable to resolve model configuration")
- }
-
- // Resolve OAuth credentials when the API key is the __oauth__ sentinel
- // or empty. OAuth/Codex backend has its own model constraints, so
- // skip the codegen model upgrade for OAuth clients.
- var client llm.Client
- needsOAuth := mc.Provider == "openai" && (mc.Client.APIKey == "" || mc.Client.APIKey == "__oauth__")
- // OAuth precedence guardrail (issue #83). The ChatGPT OAuth
- // path's hardcoded chatgpt.com/backend-api/codex base URL is
- // mutually exclusive with an operator-supplied OPENAI_BASE_URL.
- // Without this guard, the skill builder would silently route
- // requests to ChatGPT instead of the agent's configured
- // OpenAI-compatible endpoint.
- if needsOAuth && mc.Client.BaseURL != "" {
- return fmt.Errorf(
- "OPENAI_BASE_URL is set to %q but no OPENAI_API_KEY was provided; "+
- "the OpenAI OAuth credentials path is disabled when an explicit "+
- "base URL is in use (it would silently override your endpoint with "+
- "chatgpt.com/backend-api/codex). Set OPENAI_API_KEY for the configured endpoint",
- mc.Client.BaseURL,
- )
- }
- if needsOAuth {
- token, oauthErr := oauth.LoadCredentials(mc.Provider)
- if oauthErr == nil && token != nil && token.RefreshToken != "" {
- oauthCfg := oauth.OpenAIConfig()
- baseURL := token.BaseURL
- if baseURL == "" {
- baseURL = oauthCfg.BaseURL
- }
- mc.Client.APIKey = token.AccessToken
- mc.Client.BaseURL = baseURL
- client = providers.NewOAuthClient(mc.Client, mc.Provider, oauthCfg)
- } else if mc.Client.APIKey == "" || mc.Client.APIKey == "__oauth__" {
- // No API key and OAuth failed — surface the error instead
- // of silently falling through to a client with no auth.
- if oauthErr != nil {
- return fmt.Errorf("loading OAuth credentials: %w", oauthErr)
- }
- return fmt.Errorf("no OpenAI API key or OAuth credentials found; run 'forge init' with OAuth or set OPENAI_API_KEY")
- }
- }
- if client == nil {
- // Only upgrade to the codegen model for non-OAuth (API key) clients.
- mc.Client.Model = forgeui.SkillBuilderCodegenModel(mc.Provider, mc.Client.Model)
- var clientErr error
- client, clientErr = providers.NewClient(mc.Provider, mc.Client)
- if clientErr != nil {
- return fmt.Errorf("creating LLM client: %w", clientErr)
- }
- }
-
- // Build chat request with system prompt + conversation messages
+ // Build chat request with system prompt + conversation messages.
messages := []llm.ChatMessage{
{Role: "system", Content: opts.SystemPrompt},
}
@@ -256,7 +180,7 @@ func runUI(cmd *cobra.Command, args []string) error {
}
req := &llm.ChatRequest{
- Model: mc.Client.Model,
+ Model: opts.LLM.Model,
Messages: messages,
Stream: true,
}
diff --git a/forge-ui/handlers_settings.go b/forge-ui/handlers_settings.go
new file mode 100644
index 0000000..2ead587
--- /dev/null
+++ b/forge-ui/handlers_settings.go
@@ -0,0 +1,122 @@
+package forgeui
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/initializ/forge/forge-ui/uiconfig"
+)
+
+// skillBuilderSettingsRequest is the PUT body shape. The api_key field
+// is INTENTIONALLY not part of uiconfig.SkillBuilderConfig — that
+// struct is the on-disk YAML shape and the key value must never be
+// persisted there. Instead, when api_key is present the handler writes
+// it to /.forge/.env under the api_key_env name. Keeping
+// the two flows in one PUT means the UI submits one form and the
+// operator doesn't have to coordinate two endpoints.
+type skillBuilderSettingsRequest struct {
+ uiconfig.SkillBuilderConfig
+ APIKey string `json:"api_key,omitempty"`
+}
+
+// handleGetSkillBuilderSettings returns the current workspace-level
+// skill-builder configuration plus enough metadata for the Settings
+// page UI to render its form (current source, available providers,
+// detected env var presence).
+func (s *UIServer) handleGetSkillBuilderSettings(w http.ResponseWriter, _ *http.Request) {
+ // AgentDir is empty here — Settings is workspace-level. The loader
+ // will return Source=unset if no workspace/user config exists.
+ llm, err := uiconfig.LoadSkillBuilderLLM(s.cfg.WorkDir, "", uiconfig.EnvLookupForWorkspace(s.cfg.WorkDir))
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "loading settings: "+err.Error())
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "provider": llm.Provider,
+ "model": llm.Model,
+ "base_url": llm.BaseURL,
+ "api_key_env": llm.APIKeyEnv,
+ "has_key": llm.HasCredentials(),
+ "source": llm.Source,
+ "warning": llm.Warning,
+ "providers": []string{"openai", "anthropic", "gemini", "ollama"},
+ })
+}
+
+// handlePutSkillBuilderSettings validates the submitted config and
+// persists it to /.forge/ui.yaml. When the request carries
+// an api_key value, it is written to /.forge/.env under
+// the api_key_env name (or the provider default) with 0600
+// permissions and a sibling .gitignore protecting it.
+func (s *UIServer) handlePutSkillBuilderSettings(w http.ResponseWriter, r *http.Request) {
+ var body skillBuilderSettingsRequest
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
+ return
+ }
+
+ if err := uiconfig.ValidateSkillBuilderConfig(body.SkillBuilderConfig); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ if err := uiconfig.SaveSkillBuilderLLM(s.cfg.WorkDir, body.SkillBuilderConfig); err != nil {
+ writeError(w, http.StatusInternalServerError, "saving settings: "+err.Error())
+ return
+ }
+
+ // Persist the API key to /.forge/.env when provided.
+ // The key NAME is taken from body.APIKeyEnv if set, otherwise the
+ // provider default — this matches what the loader will look up at
+ // request time.
+ if body.APIKey != "" {
+ envName := body.APIKeyEnv
+ if envName == "" {
+ envName = defaultAPIKeyEnv(body.Provider)
+ }
+ if envName == "" {
+ writeError(w, http.StatusBadRequest,
+ "cannot persist api_key for this provider (no default env var name); set api_key_env explicitly")
+ return
+ }
+ if err := uiconfig.SetEnvFileValue(s.cfg.WorkDir, envName, body.APIKey); err != nil {
+ writeError(w, http.StatusInternalServerError, "saving api key: "+err.Error())
+ return
+ }
+ }
+
+ // Echo back the resolved state so the UI can update its banner
+ // without a second round trip.
+ llm, err := uiconfig.LoadSkillBuilderLLM(s.cfg.WorkDir, "", uiconfig.EnvLookupForWorkspace(s.cfg.WorkDir))
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "reloading settings: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "provider": llm.Provider,
+ "model": llm.Model,
+ "base_url": llm.BaseURL,
+ "api_key_env": llm.APIKeyEnv,
+ "has_key": llm.HasCredentials(),
+ "source": llm.Source,
+ "warning": llm.Warning,
+ })
+}
+
+// defaultAPIKeyEnv mirrors uiconfig's internal mapping so the settings
+// handler knows where to persist the api_key when the operator didn't
+// override APIKeyEnv. Keeping a copy here (rather than exporting from
+// uiconfig) keeps the small list visible at the use site; if it grows
+// we can promote it.
+func defaultAPIKeyEnv(provider string) string {
+ switch provider {
+ case "openai":
+ return "OPENAI_API_KEY"
+ case "anthropic":
+ return "ANTHROPIC_API_KEY"
+ case "gemini":
+ return "GEMINI_API_KEY"
+ }
+ return ""
+}
diff --git a/forge-ui/handlers_settings_test.go b/forge-ui/handlers_settings_test.go
new file mode 100644
index 0000000..2a88a64
--- /dev/null
+++ b/forge-ui/handlers_settings_test.go
@@ -0,0 +1,256 @@
+package forgeui
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func newTestServerForSettings(t *testing.T) *UIServer {
+ t.Helper()
+ isolateHome(t)
+ root := t.TempDir()
+ return NewUIServer(UIServerConfig{
+ Port: 4200,
+ WorkDir: root,
+ })
+}
+
+func TestGetSkillBuilderSettings_Unset(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/settings/skill-builder", nil)
+ w := httptest.NewRecorder()
+ srv.handleGetSkillBuilderSettings(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if resp["source"] != "unset" {
+ t.Errorf("source = %q, want unset", resp["source"])
+ }
+ if resp["has_key"] != false {
+ t.Errorf("has_key = %v, want false", resp["has_key"])
+ }
+ providers, _ := resp["providers"].([]any)
+ if len(providers) == 0 {
+ t.Errorf("providers list should be populated")
+ }
+}
+
+func TestPutSkillBuilderSettings_PersistsAndEchoes(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ // Operator submits a workspace-level config.
+ body := map[string]string{
+ "provider": "openai",
+ "model": "gpt-4.1",
+ "base_url": "https://openrouter-ish.example.com/v1",
+ "api_key_env": "WORKSPACE_LLM_KEY",
+ }
+ raw, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", bytes.NewReader(raw))
+ w := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ for k, want := range map[string]string{
+ "provider": "openai",
+ "model": "gpt-4.1",
+ "base_url": "https://openrouter-ish.example.com/v1",
+ "api_key_env": "WORKSPACE_LLM_KEY",
+ "source": "workspace",
+ } {
+ if got, _ := resp[k].(string); got != want {
+ t.Errorf("response[%q] = %q, want %q", k, got, want)
+ }
+ }
+
+ // File on disk reflects the same shape.
+ path := filepath.Join(srv.cfg.WorkDir, ".forge", "ui.yaml")
+ raw2, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read ui.yaml: %v", err)
+ }
+ for _, want := range []string{
+ "provider: openai",
+ "model: gpt-4.1",
+ "base_url: https://openrouter-ish.example.com/v1",
+ "api_key_env: WORKSPACE_LLM_KEY",
+ } {
+ if !strings.Contains(string(raw2), want) {
+ t.Errorf("ui.yaml missing %q:\n%s", want, raw2)
+ }
+ }
+}
+
+func TestPutSkillBuilderSettings_RejectsInvalidProvider(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ body := `{"provider":"bogus","model":"x"}`
+ req := httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "unknown provider") {
+ t.Errorf("error body should mention unknown provider, got: %s", w.Body.String())
+ }
+}
+
+func TestPutSkillBuilderSettings_RejectsMissingModel(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ body := `{"provider":"openai"}`
+ req := httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "model is required") {
+ t.Errorf("expected model-required error, got: %s", w.Body.String())
+ }
+}
+
+func TestPutSkillBuilderSettings_PersistsAPIKeyToEnvFile(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ body := `{"provider":"openai","model":"gpt-4.1","api_key":"sk-from-modal"}`
+ req := httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if has, _ := resp["has_key"].(bool); !has {
+ t.Errorf("has_key should be true after persisting key, got %+v", resp)
+ }
+
+ // .env file written under .forge/ with mode 0600 and the right key.
+ envPath := filepath.Join(srv.cfg.WorkDir, ".forge", ".env")
+ raw, err := os.ReadFile(envPath)
+ if err != nil {
+ t.Fatalf("read env: %v", err)
+ }
+ if !strings.Contains(string(raw), "OPENAI_API_KEY=sk-from-modal") {
+ t.Errorf("env file missing key:\n%s", raw)
+ }
+ if info, _ := os.Stat(envPath); info.Mode().Perm() != 0o600 {
+ t.Errorf("env file perm = %o, want 0600", info.Mode().Perm())
+ }
+
+ // .gitignore for .forge/ auto-created.
+ giPath := filepath.Join(srv.cfg.WorkDir, ".forge", ".gitignore")
+ gi, err := os.ReadFile(giPath)
+ if err != nil {
+ t.Fatalf("read gitignore: %v", err)
+ }
+ if !strings.Contains(string(gi), ".env") {
+ t.Errorf(".gitignore should protect .env:\n%s", gi)
+ }
+
+ // The api_key value MUST NOT leak into ui.yaml.
+ yamlPath := filepath.Join(srv.cfg.WorkDir, ".forge", "ui.yaml")
+ yamlRaw, _ := os.ReadFile(yamlPath)
+ if strings.Contains(string(yamlRaw), "sk-from-modal") {
+ t.Errorf("API key leaked into ui.yaml — must be .env only:\n%s", yamlRaw)
+ }
+}
+
+func TestPutSkillBuilderSettings_APIKeyUsesCustomEnvVarName(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ body := `{"provider":"openai","model":"gpt-4.1","api_key_env":"WORKSPACE_LLM_KEY","api_key":"sk-custom"}`
+ req := httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
+ }
+
+ envPath := filepath.Join(srv.cfg.WorkDir, ".forge", ".env")
+ raw, _ := os.ReadFile(envPath)
+ if !strings.Contains(string(raw), "WORKSPACE_LLM_KEY=sk-custom") {
+ t.Errorf("expected key under custom env name, got:\n%s", raw)
+ }
+ if strings.Contains(string(raw), "OPENAI_API_KEY=") {
+ t.Errorf("default OPENAI_API_KEY should NOT be written when api_key_env is set:\n%s", raw)
+ }
+}
+
+func TestPutSkillBuilderSettings_OmitAPIKeyLeavesEnvFileUntouched(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ // First write a key.
+ _ = srv // satisfy linter
+ body := `{"provider":"openai","model":"gpt-4.1","api_key":"sk-original"}`
+ w := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(w, httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", strings.NewReader(body)))
+ if w.Code != http.StatusOK {
+ t.Fatalf("first PUT failed: %d %s", w.Code, w.Body.String())
+ }
+
+ // Second PUT updates the model but omits api_key. The .env file
+ // should keep the original key.
+ body2 := `{"provider":"openai","model":"gpt-4.1-mini"}`
+ w2 := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(w2, httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", strings.NewReader(body2)))
+ if w2.Code != http.StatusOK {
+ t.Fatalf("second PUT failed: %d %s", w2.Code, w2.Body.String())
+ }
+
+ raw, _ := os.ReadFile(filepath.Join(srv.cfg.WorkDir, ".forge", ".env"))
+ if !strings.Contains(string(raw), "OPENAI_API_KEY=sk-original") {
+ t.Errorf("omit-api_key second PUT should preserve existing key, got:\n%s", raw)
+ }
+}
+
+func TestGetSkillBuilderSettings_AfterPut_ReturnsWorkspaceSource(t *testing.T) {
+ srv := newTestServerForSettings(t)
+
+ put := `{"provider":"anthropic","model":"claude-sonnet-4"}`
+ wp := httptest.NewRecorder()
+ srv.handlePutSkillBuilderSettings(wp, httptest.NewRequest(http.MethodPut, "/api/settings/skill-builder", strings.NewReader(put)))
+ if wp.Code != http.StatusOK {
+ t.Fatalf("PUT failed: %d %s", wp.Code, wp.Body.String())
+ }
+
+ wg := httptest.NewRecorder()
+ srv.handleGetSkillBuilderSettings(wg, httptest.NewRequest(http.MethodGet, "/api/settings/skill-builder", nil))
+ if wg.Code != http.StatusOK {
+ t.Fatalf("GET failed: %d %s", wg.Code, wg.Body.String())
+ }
+ var resp map[string]any
+ _ = json.NewDecoder(wg.Body).Decode(&resp)
+ if resp["source"] != "workspace" {
+ t.Errorf("source = %q, want workspace", resp["source"])
+ }
+ if resp["provider"] != "anthropic" || resp["model"] != "claude-sonnet-4" {
+ t.Errorf("GET response did not reflect PUT: %+v", resp)
+ }
+}
diff --git a/forge-ui/handlers_skill_builder.go b/forge-ui/handlers_skill_builder.go
index 46e497f..753296c 100644
--- a/forge-ui/handlers_skill_builder.go
+++ b/forge-ui/handlers_skill_builder.go
@@ -4,27 +4,20 @@ import (
"encoding/json"
"fmt"
"net/http"
- "os"
- "path/filepath"
"strings"
- "github.com/initializ/forge/forge-cli/runtime"
- "github.com/initializ/forge/forge-core/llm/oauth"
- "github.com/initializ/forge/forge-core/types"
+ "github.com/initializ/forge/forge-ui/uiconfig"
)
-// SkillBuilderCodegenModel returns the preferred code-generation model for the
-// given provider. Skill generation is a complex task that benefits from stronger
-// models than the agent's default. Falls back to fallback if the provider is unknown.
-func SkillBuilderCodegenModel(provider, fallback string) string {
- switch provider {
- case "openai":
- return "gpt-4.1"
- case "anthropic":
- return "claude-opus-4-6"
- default:
- return fallback
- }
+// SkillBuilderCodegenModel previously hardcoded "gpt-4.1" / "claude-opus-4-6"
+// regardless of the agent's configured model. Issue #92 removed that override:
+// the skill builder now uses the operator-chosen model from workspace-level
+// ui.yaml (see uiconfig.LoadSkillBuilderLLM). The function is retained as a
+// no-op shim with a deprecation marker so any out-of-tree callers fail loudly.
+//
+// Deprecated: skill-builder model selection is now driven by uiconfig.
+func SkillBuilderCodegenModel(_, configured string) string {
+ return configured
}
// resolveAgentDir extracts agent ID from the request, looks up the agent,
@@ -51,82 +44,35 @@ func (s *UIServer) resolveAgentDir(w http.ResponseWriter, r *http.Request) strin
return agent.Directory
}
-// handleSkillBuilderProvider returns the agent's LLM provider info.
+// handleSkillBuilderProvider reports the resolved skill-builder LLM
+// configuration. Workspace-level config (forge-ui/uiconfig) is the
+// primary source; the agent's forge.yaml is consulted only when no
+// workspace/user config exists (deprecated fallback). The handler
+// never mutates the UI process's environment.
func (s *UIServer) handleSkillBuilderProvider(w http.ResponseWriter, r *http.Request) {
- agentDir := s.resolveAgentDir(w, r)
- if agentDir == "" {
- return
- }
-
- configPath := filepath.Join(agentDir, "forge.yaml")
- data, err := os.ReadFile(configPath)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "reading config: "+err.Error())
- return
+ // agentDir is only used for the deprecated fallback path. It's
+ // optional — first-run flow (no agent picked yet) is supported.
+ agentDir := ""
+ if r.PathValue("id") != "" {
+ agentDir = s.resolveAgentDir(w, r)
+ if agentDir == "" {
+ return // resolveAgentDir wrote the error response
+ }
}
- cfg, err := types.ParseForgeConfig(data)
+ llm, err := uiconfig.LoadSkillBuilderLLM(s.cfg.WorkDir, agentDir, uiconfig.EnvLookupForWorkspace(s.cfg.WorkDir))
if err != nil {
- writeError(w, http.StatusInternalServerError, "parsing config: "+err.Error())
+ writeError(w, http.StatusInternalServerError, "loading skill-builder config: "+err.Error())
return
}
- provider := cfg.Model.Provider
-
- // Load the agent's .env and encrypted secrets so we can check for API keys
- // that aren't in the UI process's own environment.
- envPath := filepath.Join(agentDir, ".env")
- envVars, _ := runtime.LoadEnvFile(envPath)
- for k, v := range envVars {
- // Don't pollute process env with __oauth__ sentinels — they block
- // OverlaySecretsToEnv from replacing them with real keys later.
- if v == "__oauth__" {
- continue
- }
- if os.Getenv(k) == "" {
- _ = os.Setenv(k, v)
- }
- }
- runtime.OverlaySecretsToEnv(cfg, agentDir)
-
- // Check if the provider's API key env var is set (excluding __oauth__ sentinel)
- keyVal := func(k string) bool {
- v := os.Getenv(k)
- return v != "" && v != "__oauth__"
- }
- hasKey := false
- isOAuth := false
- switch provider {
- case "openai":
- hasKey = keyVal("OPENAI_API_KEY")
- if !hasKey {
- // Check for stored OAuth credentials
- if token, err := oauth.LoadCredentials("openai"); err == nil && token != nil && token.RefreshToken != "" {
- hasKey = true
- isOAuth = true
- }
- }
- case "anthropic":
- hasKey = keyVal("ANTHROPIC_API_KEY")
- case "gemini":
- hasKey = keyVal("GEMINI_API_KEY")
- case "ollama":
- hasKey = true // Ollama doesn't need an API key
- default:
- hasKey = keyVal("LLM_API_KEY") || keyVal("MODEL_API_KEY")
- }
-
- // OAuth/Codex backend has model restrictions — use agent's configured model.
- // API key clients get upgraded to a stronger codegen model.
- model := cfg.Model.Name
- if !isOAuth {
- model = SkillBuilderCodegenModel(provider, model)
- }
-
writeJSON(w, http.StatusOK, map[string]any{
- "provider": provider,
- "model": model,
- "has_key": hasKey,
+ "provider": llm.Provider,
+ "model": llm.Model,
+ "base_url": llm.BaseURL,
+ "has_key": llm.HasCredentials(),
+ "source": llm.Source,
+ "warning": llm.Warning,
})
}
@@ -160,6 +106,29 @@ func (s *UIServer) handleSkillBuilderChat(w http.ResponseWriter, r *http.Request
return
}
+ // Resolve the workspace-level skill-builder LLM ONCE per request and
+ // pass it through LLMStreamOptions. The forge-cli callback consumes
+ // LLM directly — it must not re-read the agent's forge.yaml / .env,
+ // since that would re-introduce the per-agent env-stomping the
+ // workspace-LLM design replaced (issue #92).
+ llm, err := uiconfig.LoadSkillBuilderLLM(s.cfg.WorkDir, agentDir, uiconfig.EnvLookupForWorkspace(s.cfg.WorkDir))
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "loading skill-builder config: "+err.Error())
+ return
+ }
+ if llm.Source == uiconfig.SourceUnset {
+ writeError(w, http.StatusBadRequest,
+ "skill-builder LLM is not configured. Open Settings → Skill Builder to pick a provider, model, and API key env var.")
+ return
+ }
+ if !llm.HasCredentials() {
+ writeError(w, http.StatusBadRequest, fmt.Sprintf(
+ "skill-builder LLM is configured (%s) but no API key found in env var %q. "+
+ "Set that env var in the forge ui process and reload, or change api_key_env in Settings.",
+ llm.Provider, llm.APIKeyEnv))
+ return
+ }
+
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming not supported")
@@ -173,7 +142,8 @@ func (s *UIServer) handleSkillBuilderChat(w http.ResponseWriter, r *http.Request
var fullResponse strings.Builder
- err := s.cfg.LLMStreamFunc(r.Context(), LLMStreamOptions{
+ err = s.cfg.LLMStreamFunc(r.Context(), LLMStreamOptions{
+ LLM: llm,
AgentDir: agentDir,
SystemPrompt: skillBuilderSystemPrompt,
Messages: req.Messages,
diff --git a/forge-ui/handlers_skill_builder_envstomp_test.go b/forge-ui/handlers_skill_builder_envstomp_test.go
new file mode 100644
index 0000000..daaf069
--- /dev/null
+++ b/forge-ui/handlers_skill_builder_envstomp_test.go
@@ -0,0 +1,102 @@
+package forgeui
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestSkillBuilderProvider_DoesNotStompProcessEnvBetweenAgents pins one of
+// issue #92's acceptance criteria: switching the selected agent in the UI
+// must not modify the forge ui process's environment variables.
+//
+// Pre-#92 handleSkillBuilderProvider loaded the agent's .env into the UI
+// process via os.Setenv (lines 80-89 of the old handler). Picking agent A
+// then agent B left A's keys in the process env, then B's keys overwrote
+// them — cross-agent leakage of credentials through the shared process
+// state.
+//
+// Post-#92 the handler reads workspace-level config (uiconfig) and never
+// touches os.Setenv. This test pins that contract.
+func TestSkillBuilderProvider_DoesNotStompProcessEnvBetweenAgents(t *testing.T) {
+ isolateHome(t)
+ root := t.TempDir()
+
+ // Two agents in the same workspace, with different OPENAI_API_KEY
+ // values in their .env files. The pre-#92 handler would have read
+ // these into the UI process via os.Setenv on each fetch; we assert
+ // no such mutation happens.
+ agentA := filepath.Join(root, "agent-a")
+ if err := os.MkdirAll(agentA, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, filepath.Join(agentA, "forge.yaml"), `agent_id: agent-a
+version: 0.1.0
+framework: forge
+model:
+ provider: openai
+ name: gpt-4o
+`)
+ writeFile(t, filepath.Join(agentA, ".env"), "OPENAI_API_KEY=key-from-agent-a\n")
+
+ agentB := filepath.Join(root, "agent-b")
+ if err := os.MkdirAll(agentB, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, filepath.Join(agentB, "forge.yaml"), `agent_id: agent-b
+version: 0.1.0
+framework: forge
+model:
+ provider: openai
+ name: gpt-4o
+`)
+ writeFile(t, filepath.Join(agentB, ".env"), "OPENAI_API_KEY=key-from-agent-b\n")
+
+ // Snapshot the UI process's env before any handler call. Both agents'
+ // keys must remain ABSENT throughout — these are agent secrets, not
+ // UI-process secrets.
+ for _, k := range []string{"OPENAI_API_KEY"} {
+ if _, present := os.LookupEnv(k); present {
+ _ = os.Unsetenv(k)
+ t.Cleanup(func() { _ = os.Unsetenv(k) })
+ }
+ }
+
+ srv := NewUIServer(UIServerConfig{
+ Port: 4200,
+ WorkDir: root,
+ })
+
+ // Fetch provider info for agent A, then agent B. Assert process env
+ // is unchanged after EACH call (proving no os.Setenv leaks from the
+ // agent's .env into the UI process).
+ for _, id := range []string{"agent-a", "agent-b", "agent-a"} {
+ req := httptest.NewRequest(http.MethodGet, "/api/agents/"+id+"/skill-builder/provider", nil)
+ req.SetPathValue("id", id)
+ w := httptest.NewRecorder()
+ srv.handleSkillBuilderProvider(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("agent %q: status = %d, body: %s", id, w.Code, w.Body.String())
+ }
+
+ // Decode response to confirm it parsed.
+ var resp map[string]any
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("agent %q: decode: %v", id, err)
+ }
+
+ // The critical assertion: process env must NOT have absorbed
+ // either agent's OPENAI_API_KEY. Pre-#92, after the first call
+ // this would have been "key-from-agent-a"; after the second
+ // "key-from-agent-b". Post-#92, neither must be set.
+ got := os.Getenv("OPENAI_API_KEY")
+ if got == "key-from-agent-a" || got == "key-from-agent-b" {
+ t.Errorf("after fetching agent %q, OPENAI_API_KEY leaked from agent .env into UI process: %q",
+ id, got)
+ }
+ }
+}
diff --git a/forge-ui/handlers_skill_builder_test.go b/forge-ui/handlers_skill_builder_test.go
index 3217816..b9a0097 100644
--- a/forge-ui/handlers_skill_builder_test.go
+++ b/forge-ui/handlers_skill_builder_test.go
@@ -11,10 +11,35 @@ import (
"testing"
)
+// isolateHome relocates os.UserHomeDir() to a temp directory for the
+// duration of the test. Required because uiconfig.LoadSkillBuilderLLM's
+// tier-2 fallback resolves the user config via os.UserHomeDir; without
+// isolation, a real ~/.forge/ui.yaml on the dev machine would change
+// what these tests observe.
+func isolateHome(t *testing.T) {
+ t.Helper()
+ fake := t.TempDir()
+ origHome, hadHome := os.LookupEnv("HOME")
+ if err := os.Setenv("HOME", fake); err != nil {
+ t.Fatalf("setenv HOME: %v", err)
+ }
+ t.Cleanup(func() {
+ if hadHome {
+ _ = os.Setenv("HOME", origHome)
+ } else {
+ _ = os.Unsetenv("HOME")
+ }
+ })
+}
+
func setupTestServerWithSkillBuilder(t *testing.T) (*UIServer, string) {
t.Helper()
root := t.TempDir()
+ // Isolate HOME so uiconfig's tier-2 user fallback can't accidentally
+ // pick up the dev machine's ~/.forge/ui.yaml during tests.
+ isolateHome(t)
+
// Create test agent
agentDir := filepath.Join(root, "test-agent")
if err := os.MkdirAll(agentDir, 0o755); err != nil {
@@ -82,15 +107,32 @@ func TestSkillBuilderProvider(t *testing.T) {
if resp["provider"] != "openai" {
t.Errorf("provider = %q, want %q", resp["provider"], "openai")
}
- // Model is gpt-4.1 (API key codegen upgrade) or the agent's configured
- // model (OAuth — Codex backend has model restrictions).
- model := resp["model"].(string)
- if model != "gpt-4.1" && model != "gpt-4o" {
- t.Errorf("model = %q, want %q or %q", model, "gpt-4.1", "gpt-4o")
+ // Per issue #92: no hardcoded codegen upgrade. The agent-fallback path
+ // returns the operator's configured model verbatim. (Pre-#92 this was
+ // gpt-4.1 regardless of agent config.)
+ if model, _ := resp["model"].(string); model != "gpt-4o" {
+ t.Errorf("model = %q, want %q (no codegen upgrade)", model, "gpt-4o")
+ }
+ // Falling through to agent fallback should surface the deprecation
+ // warning so the UI can prompt the operator to configure workspace
+ // settings.
+ if source, _ := resp["source"].(string); source != "agent_fallback" {
+ t.Errorf("source = %q, want agent_fallback", source)
+ }
+ if warning, _ := resp["warning"].(string); warning == "" {
+ t.Errorf("agent_fallback path should emit a deprecation warning")
}
}
-func TestSkillBuilderProviderAnthropicOverride(t *testing.T) {
+// TestSkillBuilderProvider_AgentFallback_PreservesConfiguredModel pins the
+// post-#92 behavior: the skill builder reports the operator-configured
+// model verbatim — no SkillBuilderCodegenModel upgrade to claude-opus-4-6
+// (or gpt-4.1 for openai). Pre-#92 the agent's configured model was
+// overridden for the skill builder's LLM call, which broke any agent
+// pointed at a custom OpenAI-compatible endpoint that didn't host the
+// hardcoded "stronger" model.
+func TestSkillBuilderProvider_AgentFallback_PreservesConfiguredModel(t *testing.T) {
+ isolateHome(t)
root := t.TempDir()
agentDir := filepath.Join(root, "anthropic-agent")
@@ -128,8 +170,8 @@ model:
if resp["provider"] != "anthropic" {
t.Errorf("provider = %q, want %q", resp["provider"], "anthropic")
}
- if resp["model"] != "claude-opus-4-6" {
- t.Errorf("model = %q, want %q", resp["model"], "claude-opus-4-6")
+ if resp["model"] != "claude-sonnet-4-20250514" {
+ t.Errorf("model = %q, want %q (no codegen upgrade)", resp["model"], "claude-sonnet-4-20250514")
}
}
diff --git a/forge-ui/server.go b/forge-ui/server.go
index 613c9a5..b4d4a2e 100644
--- a/forge-ui/server.go
+++ b/forge-ui/server.go
@@ -101,6 +101,13 @@ func (s *UIServer) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/agents/{id}/skill-builder/save", s.handleSkillBuilderSave)
mux.HandleFunc("GET /api/agents/{id}/skill-builder/context", s.handleSkillBuilderContext)
mux.HandleFunc("GET /api/agents/{id}/skill-builder/provider", s.handleSkillBuilderProvider)
+ // Workspace-level skill-builder settings (issue #92). The
+ // path-less /api/skill-builder/provider lets the UI query the
+ // resolved config before any agent is picked — needed for first-run
+ // in an empty workspace.
+ mux.HandleFunc("GET /api/skill-builder/provider", s.handleSkillBuilderProvider)
+ mux.HandleFunc("GET /api/settings/skill-builder", s.handleGetSkillBuilderSettings)
+ mux.HandleFunc("PUT /api/settings/skill-builder", s.handlePutSkillBuilderSettings)
// Static file serving with SPA fallback. The embedded FS is rooted
// directly at the static assets — no "dist/" subdirectory (review #8
diff --git a/forge-ui/static/app.js b/forge-ui/static/app.js
index 2bf64ce..cea1d87 100644
--- a/forge-ui/static/app.js
+++ b/forge-ui/static/app.js
@@ -2254,6 +2254,159 @@ function SkillsPage() {
`;
}
+// ── Skill Builder Settings (issue #92) ───────────────────────
+
+async function fetchSkillBuilderSettings() {
+ const res = await fetch('/api/settings/skill-builder');
+ if (!res.ok) throw new Error(`Failed to fetch settings: ${res.status}`);
+ return res.json();
+}
+
+async function saveSkillBuilderSettings(body) {
+ const res = await fetch('/api/settings/skill-builder', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || `Save failed: ${res.status}`);
+ }
+ return res.json();
+}
+
+// SkillBuilderSettingsModal lets the operator pick the workspace-
+// level LLM the skill builder uses, decoupled from any agent's
+// runtime LLM. Persists to /.forge/ui.yaml via the
+// PUT /api/settings/skill-builder endpoint.
+function SkillBuilderSettingsModal({ initial, onClose, onSaved }) {
+ const [form, setForm] = useState({
+ provider: (initial && initial.provider) || 'openai',
+ model: (initial && initial.model) || '',
+ base_url: (initial && initial.base_url) || '',
+ api_key_env: (initial && initial.api_key_env) || '',
+ });
+ // API key is intentionally a separate piece of state — never persisted
+ // to ui.yaml, never echoed back from the server. Left blank on every
+ // open of the modal so an existing key isn't shown (or shadowed by an
+ // empty input). Submitting an empty api_key leaves the saved value
+ // untouched; submit a new value to rotate.
+ const [apiKey, setApiKey] = useState('');
+ const [showKey, setShowKey] = useState(false);
+ const [hasKey, setHasKey] = useState((initial && initial.has_key) || false);
+ const [saving, setSaving] = useState(false);
+ const [err, setErr] = useState(null);
+
+ // Reload from server when modal opens so we see the persisted state,
+ // not the partially-fallback shape that fetchSkillBuilderProvider
+ // returned (which folds in agent-fallback fields).
+ useEffect(() => {
+ fetchSkillBuilderSettings()
+ .then((s) => {
+ setForm({
+ provider: s.provider || 'openai',
+ model: s.model || '',
+ base_url: s.base_url || '',
+ api_key_env: s.api_key_env || '',
+ });
+ setHasKey(!!s.has_key);
+ })
+ .catch((e) => setErr(e.message));
+ }, []);
+
+ async function submit() {
+ setSaving(true);
+ setErr(null);
+ try {
+ // Build the request body. Include api_key only when the operator
+ // typed one — empty string means "leave the existing key alone."
+ const body = { ...form };
+ if (apiKey) body.api_key = apiKey;
+ const saved = await saveSkillBuilderSettings(body);
+ onSaved && onSaved(saved);
+ } catch (e) {
+ setErr(e.message);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const update = (k, v) => setForm({ ...form, [k]: v });
+
+ return html`
+
+
e.stopPropagation()} style="max-width: 540px;">
+
+
Skill Builder LLM
+
+
+
+
+ Workspace-level LLM used to generate skills. Independent of any
+ specific agent's runtime LLM. Persisted to
+ <workspace>/.forge/ui.yaml.
+