fix: harden widget and provider auth handling

This commit is contained in:
Mikael Hugo 2026-05-07 17:20:52 +02:00
parent 3c84bd2fed
commit b1a7749763
24 changed files with 621 additions and 96 deletions

View file

@ -1,29 +1,236 @@
# CLI Agent Code Survey — 2026-05-07
We compared Forge-relevant CLI agent implementations to pull workflow and autonomy patterns into SF planning.
This record compares the local coding-agent checkouts under `/home/mhugo/code/`
against Forge. It is planning evidence, not an instruction to copy another
product's architecture.
## What was checked
## Product Boundary
- `claude-code`
Forge remains the product, and UOK remains the internal execution kernel.
External CLIs are reference implementations used to sharpen Forge, not
destination architectures.
Hard boundary: Forge must stay an MCP client only. Do not add, restore, or plan
an SF MCP server. External control belongs in daemon, RPC, and headless
interfaces.
## Local Checkouts Inspected
Primary references:
- `singularity-forge`
- `codex`
- `gemini-cli`
- `opencode`
- `claude-code`
- `ace-coder`
Additional coder references:
- `aider`
- `goose`
- `qwen-code`
- `crush`
- `Agentless`
- `RA.Aid`
- `plandex`
- agentless-style repos: `Agentless`, `open-codex`, `RA.Aid`, `letta-code`, `neovate-code`, `amazon-q-developer-cli`
- `ace-coder` for curator, memory, and autonomy patterns
- `goose`
- `gemini-cli`
- `qwen-code`
- `opencode`
- `crush`
- `amazon-q-developer-cli`
- `open-codex`
- `letta-code`
- `neovate-code`
## Where the code lives
The local `claude-code` checkout is a leaked-source/sourcemap research mirror,
not a clean upstream dependency. Treat it as ergonomics evidence only.
All reference checkouts are local under `/home/mhugo/code/`.
## Comparison Matrix
## Takeaways
| Reference | Strongest Fit For Forge | Borrow | Avoid |
|---|---|---|---|
| `plandex` | Large task planning and review workflow | Cumulative diff sandbox, plan versioning, explicit chat-to-plan-to-apply flow, context indexing for big repos | Server/product coupling and any cloud-hosting assumptions |
| `codex` | Execution hardening and protocol boundaries | Typed non-interactive event stream, sandbox permission profiles, app-server protocol shape, Rust crate boundaries, config schema rigor, plugin/skill manager discipline | Treating MCP server code as a Forge product direction |
| `claude-code` | Interactive ergonomics | Permission UX, command discoverability, plugin surfaces, subagent UX, memory/context commands, MCP client config flows | Copying leaked-source implementation or making it an upstream dependency |
| `ace-coder` | Owned multi-repo governance | Reviewer roles, hard quality gates, skill/subagent routing policy, explicit MCP-client contract style | Collapsing ACE and Forge into one product surface |
| `aider` | Tight edit loop, repo maps, and benchmark culture | Token-budgeted repo-map ranking, reproducible benchmark reports with model, edit format, commit hash, dirty state, pass rates, malformed output counts | Early auto-commit posture before validation and commit gates |
| `Agentless` | Bug-fix eval pipeline | Localization, candidate repair, regression-test selection, reproduction-test generation, validation-based patch reranking | SWE-bench-specific harness assumptions |
| `RA.Aid` | Stage boundaries and trajectory records | Explicit research/planning/implementation phases, research-only mode, durable session/tool trajectory records | Broad autonomous shell posture and external Aider outsourcing |
| `goose` | Desktop/CLI/API distribution and diagnostics | Provider breadth, diagnostics/reporting, API embedding, extension packaging, MCP client lifecycle patterns | Built-in/re-exported MCP servers or broad general-agent scope |
| `gemini-cli` | Release/test/docs automation | Release channels, generated settings schema/docs, behavioral eval incubation, sandbox integration tests, perf/memory baselines, GitHub Action workflows | Provider-specific product assumptions or unstable evals as hard CI gates |
| `qwen-code` | Claude-like terminal workflow | Skills/subagents, forked subagent design, trust-gated workspace config, terminal-capture regressions, flexible provider config | OAuth/provider policy coupling or ungated project-local config |
| `opencode` | Mode split and schema boundary | Read-only `plan` mode vs full-access `build` mode, client/server framing, LSP opt-in, project-local commands/tools, schema-first domain boundary | Bun-specific implementation style for Forge |
| `crush` | SQLite state, hooks, permissions, TUI | SQLite migrations/query discipline, hook engine, permission layering, session DB, tool markdown descriptions, LSP, pub/sub, MCP client status UX | Replacing Forge's TypeScript extension architecture with Go |
| `letta-code` | Long-lived memory-agent UX | Memory lifecycle, skill learning, approval recovery tests, channel/remote control ideas, MCP OAuth/connect UX | Treating memory as unstructured product magic instead of DB-backed state |
| `neovate-code` | Design-doc and terminal UX iteration | Small design records, queued-message designs, subagent design notes, command/terminal UX records | Pulling in provider-specific branding or immature UX churn |
| `amazon-q-developer-cli` | Rust auth/security reference | Auth/security/workspace patterns and Rust CLI lessons where applicable | Product direction; local README says the open source project is no longer actively maintained |
| `open-codex` | Older/forked approval-mode comparison | Approval-mode vocabulary and provider abstraction history | Fork-specific Chat Completions direction as a primary architecture |
- `plandex` is the closest workflow match for SF planning.
- `claude-code`, `aider`, `codex`, and `gemini-cli` are the best ergonomics references.
- `ace-coder` is our own codebase and the long-term direction is convergence between Forge and ACE.
- The code survey is done; future planning can rely on the local checkouts instead of rescanning remote repos.
## Forge Already Has
- DB-backed workflow state and project-local planning artifacts.
- Headless/RPC surfaces for automation.
- UOK safety and recovery concepts.
- Extension loading and bundled tool surfaces.
- Purpose-first TDD and PDD field contracts.
- Provider abstraction through `pi-ai`.
Those are the center of gravity. Borrowed patterns should strengthen these
surfaces instead of adding parallel state systems.
## Gaps Worth Pulling Into The Roadmap
1. **Execution and permission hardening**
- Use Codex and Crush as the references.
- Target Forge surfaces: `exec-sandbox`, production mutation approval,
command permissions, headless/RPC mutation gates, DB-recorded tool-call
evidence, and permission profiles that specify filesystem, network,
`.git`, metadata, writable-root, and denied-path behavior.
2. **Plan/build mode separation**
- Use OpenCode, Plandex, and Qwen Code as the references.
- Target Forge surfaces: explicit read-only planning mode, full-access build
mode, and clearer mode transitions in auto/headless.
3. **Typed headless event stream**
- Use Codex and OpenCode as the references.
- Target Forge surfaces: stable machine-readable events such as
`thread.started`, `turn.started`, `turn.completed`, `turn.failed`,
`item.started`, `item.updated`, and `item.completed`, with typed payloads
for commands, patches, MCP calls, web/context lookups, todos, and UOK
evidence.
4. **Reviewable cumulative diffs**
- Use Plandex and Aider as the references.
- Target Forge surfaces: cumulative patch review, apply/reject/revise
workflow, conflict analysis before apply/rewind, and commit metadata tied
to model, prompt, dirty state, and evidence.
5. **Eval and bug-fix pipeline**
- Use Aider and Agentless as the references.
- Target Forge surfaces: reproducible eval reports, localization -> repair
-> validation cases, candidate patch sampling, reproduction-test
generation, and validation-based failure reranking.
6. **Memory lifecycle and recovery**
- Use Letta Code and ACE as references, while keeping Forge DB-first.
- Target Forge surfaces: durable memory extraction, turn recovery policy,
approval recovery, stale-state reconciliation, typed memory records, and
per-tool trajectory records for auto-mode postmortems.
7. **Terminal UX and command discoverability**
- Use Claude Code, Crush, OpenCode, and Neovate as references.
- Target Forge surfaces: command catalog, permission prompts, status line,
queued-message behavior, and compact TUI/headless diagnostics.
8. **Config and schema generation**
- Use Gemini CLI, Codex, Qwen Code, and Crush as references.
- Target Forge surfaces: typed settings, generated docs, environment schema,
DB migrations, and strict versioned JSON projections when JSON is only a
compatibility/export format.
9. **MCP client lifecycle**
- Use Crush, Amazon Q, Claude Code, Letta Code, and Neovate as references.
- Target Forge surfaces: explicit client states (`disabled`, `starting`,
`connected`, `error`), reconnect behavior, scoped project/global/managed
config, atomic config writes, tool namespacing such as
`mcp__server__tool`, schema cleanup, resource list/read commands, OAuth
connect UX, status counts, and evidence logging.
- Stop rule: do not implement any SF MCP server, MCP worker backend, or
bundled/re-exported MCP server.
## Priority Order
P0:
- Keep Forge MCP-client-only; reject any MCP-server plan.
- Harden command/tool execution policy and mutation gates.
- Add typed headless event DTOs for auto/headless consumers.
- Make DB-backed state the structured source of truth for planner/runtime
records, with JSON/Markdown only as projections, imports, exports, or
promoted human docs.
- Add trust gating for project-local config, hooks, tools, `.env`, and automatic
memory loading before expanding those surfaces.
P1:
- Add explicit plan/build mode semantics.
- Add cumulative diff review and evidence metadata.
- Expand UOK evals with Agentless-style localization/repair/validation cases.
- Add MCP client state/status/config hardening without adding any MCP server.
P2:
- Improve terminal command discovery and permission UX.
- Generate settings/environment docs from typed schemas.
- Compare memory lifecycle/recovery against Letta and ACE.
## Evidence Pointers
The follow-up subagent pass inspected these concrete local paths:
- `aider/aider/repomap.py`, `aider/aider/coders/base_coder.py`,
`aider/aider/linter.py`, and `aider/benchmark/README.md`.
- `Agentless/agentless/fl/localize.py`,
`Agentless/agentless/repair/rerank.py`,
`Agentless/agentless/test/generate_reproduction_tests.py`.
- `RA.Aid/ra_aid/agents/`, `RA.Aid/ra_aid/tools/programmer.py`,
`RA.Aid/ra_aid/database/models.py`.
- `plandex/app/cli/lib/apply.go`, `plandex/app/cli/lib/rewind.go`,
`plandex/app/server/db/diff_helpers.go`.
- `codex/codex-rs/exec/src/exec_events.rs`,
`codex/codex-rs/linux-sandbox/README.md`,
`codex/codex-rs/linux-sandbox/src/linux_run_main.rs`.
- `gemini-cli/evals/README.md`, `gemini-cli/perf-tests/README.md`,
`gemini-cli/memory-tests/`.
- `qwen-code/docs/users/configuration/trusted-folders.md`,
`qwen-code/docs/design/fork-subagent/fork-subagent-design.md`,
`qwen-code/integration-tests/terminal-capture/`.
- `opencode/.opencode/`, `opencode/specs/v2/session.md`,
`opencode/packages/opencode/specs/effect/schema.md`,
`opencode/packages/opencode/src/session/schema.ts`.
- `crush/internal/db/`, `crush/internal/hooks/`,
`crush/internal/permission/`, `crush/internal/agent/tools/mcp/`.
- `claude-code/src/services/mcp/`, `claude-code/src/commands/mcp/`,
`claude-code/src/tools/ListMcpResourcesTool/`,
`claude-code/src/tools/ReadMcpResourceTool/`.
- `letta-code/src/cli/components/McpConnectFlow.tsx`,
`letta-code/src/cli/helpers/mcpOauth.ts`,
`letta-code/src/agent/approval-recovery.ts`.
- `neovate-code/src/mcp.ts`, `neovate-code/src/commands/mcp.ts`,
`neovate-code/src/slash-commands/builtin/mcp.tsx`.
- `amazon-q-developer-cli/crates/agent/src/agent/mcp/`,
`amazon-q-developer-cli/crates/chat-cli/src/cli/mcp.rs`.
- `ace-coder/docs/MCP_SERVER.md`,
`ace-coder/docs/plans/2026-04-05-mcp-daemon-refactor.md`,
`ace-coder/python/ai_dev/mcp/`.
## Context7 Cross-Check
Context7 was used after the local-source pass as a secondary check for indexed
public references. Local source remains the evidence of record because it is the
snapshot available on this machine.
- `/openai/codex` confirmed the relevant Codex public patterns: interactive and
non-interactive CLI modes, `app-server`, `AGENTS.md` project guidance,
approval policy, and sandbox modes (`read-only`, `workspace-write`,
`danger-full-access`) with writable roots and network controls.
- `/plandex-ai/plandex` confirmed the relevant Plandex public patterns:
semi/full autonomy levels, smart context loading, cumulative diff review
sandbox, review/apply/debug workflow, and large multi-file task focus.
- `/ai-christianson/ra.aid` confirmed the relevant RA.Aid public patterns:
research-only and research-and-plan-only modes, the research -> planning ->
implementation workflow, logging/cost visibility, and the risky
`--cowboy-mode` shell approval bypass that Forge should not copy.
- Context7 also resolves these remaining comparison targets for later deeper
checks:
- Aider: `/websites/aider_chat` and `/aider-ai/aider`.
- Qwen Code: `/qwenlm/qwen-code`,
`/websites/qwenlm_github_io_qwen-code-docs`, and
`/websites/qwenlm_github_io_qwen-code-docs_en`.
- OpenCode: `/anomalyco/opencode`.
## Resulting Direction
Forge should absorb proven patterns into UOK and the existing DB-first runtime:
structured state, explicit modes, stronger permissions, reproducible evidence,
and better review UX. The goal is not feature parity with every coder. The goal
is a purpose-to-software compiler whose autonomy is inspectable, recoverable,
and safe enough to run repeatedly.

View file

@ -7,5 +7,5 @@ Repo-memory audits, decision ledgers, context-gardening notes, and records-keepe
| Date | Note | Summary |
|------|------|---------|
| 2026-05-01 | [repo-vcs and notifications](./2026-05-01-repo-vcs-and-notifications.md) | repo-vcs skill landed; notification specs drafted; JSDoc annotations added; placeholder docs filled |
| 2026-05-07 | [cli agent code survey](./2026-05-07-cli-agent-code-survey.md) | compared local CLI agent checkouts; Plandex is the workflow analog; ACE is owned code and future convergence target |
| 2026-05-07 | [cli agent code survey](./2026-05-07-cli-agent-code-survey.md) | compared local CLI agent checkouts plus Context7 cross-checks; priority pulls are execution permissions, typed headless events, DB-first state, trust gating, cumulative diffs, eval pipelines, and MCP-client-only lifecycle hardening |
| 2026-05-07 | [strategy alignment](./2026-05-07-strategy-alignment.md) | aligned top-level docs and roadmap framing around Forge as product, UOK as kernel, and external CLIs as sharpening inputs |

View file

@ -32,7 +32,7 @@ Step-by-step setup instructions for every LLM provider SF supports. If you ran t
|----------|-------------|-------------|-------------|
| Anthropic | API key | `ANTHROPIC_API_KEY` | — |
| OpenAI | API key | `OPENAI_API_KEY` | — |
| Google Gemini | API key | `GEMINI_API_KEY` | — |
| Google Gemini | Gemini CLI Core auth | — | `~/.gemini/oauth_creds.json` |
| OpenRouter | API key | `OPENROUTER_API_KEY` | Optional `models.json` |
| Groq | API key | `GROQ_API_KEY` | — |
| xAI | API key | `XAI_API_KEY` | — |
@ -85,11 +85,15 @@ Or run `sf config` and choose "Paste an API key" then "OpenAI".
### Google Gemini
SF routes Gemini-family models through `google-gemini-cli` / Gemini CLI Core.
Authenticate there once and let SF reuse the stored auth state.
```bash
export GEMINI_API_KEY="..."
gemini login
```
**Get a key:** [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)
SF intentionally ignores ambient `GEMINI_API_KEY` and
`GOOGLE_GENERATIVE_AI_API_KEY` values for Forge runtime selection.
### OpenRouter

View file

@ -8,7 +8,7 @@ Step-by-step setup instructions for every LLM provider SF supports. If you ran t
|----------|-------------|---------------------|
| Anthropic | OAuth or API key | `ANTHROPIC_API_KEY` |
| OpenAI | API key | `OPENAI_API_KEY` |
| Google Gemini | API key | `GEMINI_API_KEY` or `GOOGLE_GENERATIVE_AI_API_KEY` |
| Google Gemini | Gemini CLI Core auth | `~/.gemini/oauth_creds.json` |
| OpenRouter | API key | `OPENROUTER_API_KEY` |
| Groq | API key | `GROQ_API_KEY` |
| xAI (Grok) | API key | `XAI_API_KEY` |
@ -52,12 +52,15 @@ Or run `sf config` and choose "Paste an API key" then "OpenAI".
### Google Gemini
Authenticate Gemini CLI Core once and let SF reuse that state:
```bash
export GEMINI_API_KEY="..."
# or
export GOOGLE_GENERATIVE_AI_API_KEY="..."
gemini login
```
SF intentionally ignores `GEMINI_API_KEY` and `GOOGLE_GENERATIVE_AI_API_KEY`
for Forge runtime selection.
### OpenRouter
OpenRouter aggregates 200+ models from multiple providers behind a single API key.

View file

@ -18,8 +18,8 @@
|----------|----------|
| `ANTHROPIC_API_KEY` | Anthropic (Claude) |
| `OPENAI_API_KEY` | OpenAI |
| `GEMINI_API_KEY` | Google Gemini |
| `GOOGLE_GENERATIVE_AI_API_KEY` | Google Gemini (models.dev / AI SDK alias) |
| `GEMINI_API_KEY` | Google Gemini (ignored by Forge runtime; Gemini CLI Core auth is used instead) |
| `GOOGLE_GENERATIVE_AI_API_KEY` | Google Gemini alias (ignored by Forge runtime) |
| `OPENROUTER_API_KEY` | OpenRouter |
| `GROQ_API_KEY` | Groq |
| `XAI_API_KEY` | xAI (Grok) |

View file

@ -3,7 +3,7 @@ import { describe, it } from "vitest";
import { getEnvApiKey } from "./env-api-keys.js";
describe("getEnvApiKey", () => {
it("uses GEMINI_API_KEY for google when present", () => {
it("ignores GEMINI_API_KEY for google when present", () => {
const savedGemini = process.env.GEMINI_API_KEY;
const savedGoogleGenerative = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
@ -11,7 +11,8 @@ describe("getEnvApiKey", () => {
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "google-generative-key";
try {
assert.equal(getEnvApiKey("google"), "gemini-key");
assert.equal(getEnvApiKey("google"), undefined);
assert.equal(getEnvApiKey("google-gemini-cli"), undefined);
} finally {
if (savedGemini === undefined) delete process.env.GEMINI_API_KEY;
else process.env.GEMINI_API_KEY = savedGemini;
@ -21,7 +22,7 @@ describe("getEnvApiKey", () => {
}
});
it("accepts models.dev GOOGLE_GENERATIVE_AI_API_KEY for google", () => {
it("ignores GOOGLE_GENERATIVE_AI_API_KEY for google", () => {
const savedGemini = process.env.GEMINI_API_KEY;
const savedGoogleGenerative = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
@ -29,7 +30,7 @@ describe("getEnvApiKey", () => {
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "google-generative-key";
try {
assert.equal(getEnvApiKey("google"), "google-generative-key");
assert.equal(getEnvApiKey("google"), undefined);
} finally {
if (savedGemini === undefined) delete process.env.GEMINI_API_KEY;
else process.env.GEMINI_API_KEY = savedGemini;

View file

@ -73,6 +73,13 @@ function hasVertexAdcCredentials(): boolean {
export function getEnvApiKey(provider: KnownProvider): string | undefined;
export function getEnvApiKey(provider: string): string | undefined;
export function getEnvApiKey(provider: any): string | undefined {
// Forge routes Gemini-family models through google-gemini-cli, which owns
// auth via Gemini CLI Core state. Intentionally ignore Google API-key env vars
// here so ambient GEMINI_API_KEY values do not change provider selection.
if (provider === "google" || provider === "google-gemini-cli") {
return undefined;
}
// Fall back to environment variables
if (provider === "github-copilot") {
return (
@ -154,7 +161,6 @@ export function getEnvApiKey(provider: any): string | undefined {
const envMap: Record<string, string | string[]> = {
openai: "OPENAI_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
google: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",

View file

@ -37,6 +37,10 @@ function hasVertexAdcCredentials(): boolean {
export function getEnvApiKey(provider: KnownProvider): string | undefined;
export function getEnvApiKey(provider: string): string | undefined;
export function getEnvApiKey(provider: string): string | undefined {
if (provider === "google" || provider === "google-gemini-cli") {
return undefined;
}
if (provider === "github-copilot") {
return (
process.env.COPILOT_GITHUB_TOKEN ||
@ -89,7 +93,6 @@ export function getEnvApiKey(provider: string): string | undefined {
const envMap: Record<string, string | string[]> = {
openai: "OPENAI_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
google: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",

View file

@ -60,7 +60,7 @@ const GOOGLE_API_KEY_PROVIDERS = new Set(["google"]);
* Google's OAuth2 token endpoint and are not valid as AI Studio API keys.
*
* Users who installed Google's Gemini CLI may have these tokens and
* mistakenly set them as GEMINI_API_KEY.
* mistakenly expose them via environment variables.
*/
export function isGoogleOAuthToken(key: string): boolean {
return key.startsWith("ya29.");
@ -77,10 +77,8 @@ function validateNotGoogleOAuthToken(provider: string, key: string): void {
`The provided key for "${provider}" appears to be a Google OAuth access token (ya29.*), ` +
`not a valid API key. Google AI Studio requires an API key starting with "AIza...". ` +
`\n\nIf you're using Google's Gemini CLI, its OAuth tokens are not compatible. ` +
`Either:\n` +
` 1. Get an API key from https://aistudio.google.com/apikey and set GEMINI_API_KEY\n` +
` 2. Use the google-gemini-cli provider, which delegates OAuth handling ` +
`to @google/gemini-cli-core`,
`Use the google-gemini-cli provider, which delegates OAuth handling ` +
`to @google/gemini-cli-core.`,
);
}
}
@ -984,7 +982,8 @@ export class AuthStorage {
// All credentials backed off or unresolvable - fall through to env/fallback
}
// Fall back to environment variable
// Fall back to environment variable. Gemini-family providers intentionally
// ignore ambient GEMINI_API_KEY values via getEnvApiKey().
const envKey = getEnvApiKey(providerId);
if (envKey) {
// Block Google OAuth tokens from environment variables (e.g., GEMINI_API_KEY=ya29.*)
@ -995,7 +994,7 @@ export class AuthStorage {
this.recordError(
new Error(
`GEMINI_API_KEY contains a Google OAuth access token (ya29.*), not an API key. ` +
`Get an API key from https://aistudio.google.com/apikey, or authenticate with ` +
`Authenticate with ` +
`the google-gemini-cli provider, which delegates OAuth handling to @google/gemini-cli-core.`,
),
);

View file

@ -10,6 +10,7 @@ import { getApiProvider } from "@singularity-forge/pi-ai";
import { describe, it } from "vitest";
import { AuthStorage, type AuthStorageData } from "./auth-storage.js";
import { ModelRegistry } from "./model-registry.js";
import { type Settings, SettingsManager } from "./settings-manager.js";
function createRegistry(
hasAuthFn?: (provider: string) => boolean,
@ -30,6 +31,17 @@ function createInMemoryRegistry(data: AuthStorageData = {}): ModelRegistry {
return new ModelRegistry(AuthStorage.inMemory(data), undefined);
}
function createRegistryWithSettings(
settings: Partial<Settings>,
data: AuthStorageData = {},
): ModelRegistry {
return new ModelRegistry(
AuthStorage.inMemory(data),
undefined,
SettingsManager.inMemory(settings),
);
}
function createProviderModel(
id: string,
api?: string,
@ -624,6 +636,35 @@ describe("ModelRegistry authMode — getApiKey", () => {
});
});
describe("ModelRegistry availability — disabled config", () => {
it("excludes disabled providers from available models", () => {
const registry = createRegistryWithSettings(
{ disabledProviders: ["anthropic"] },
{ anthropic: { type: "api_key", key: "sk-ant" } },
);
assert.equal(registry.isProviderRequestReady("anthropic"), false);
assert.equal(
findModel(registry, "anthropic", "claude-sonnet-4.5"),
undefined,
);
});
it("excludes disabled models while keeping the provider available", () => {
const registry = createRegistryWithSettings(
{ disabledModels: ["anthropic/claude-sonnet-4.5"] },
{ anthropic: { type: "api_key", key: "sk-ant" } },
);
assert.equal(registry.isProviderRequestReady("anthropic"), true);
assert.equal(
findModel(registry, "anthropic", "claude-sonnet-4.5"),
undefined,
);
assert.ok(findModel(registry, "anthropic", "claude-3-7-sonnet-20250219"));
});
});
// ─── streamSimple apiKey stripping ────────────────────────────────────────────
describe("ModelRegistry authMode — streamSimple apiKey boundary", () => {

View file

@ -39,6 +39,7 @@ import {
getDiscoveryAdapter,
} from "./model-discovery.js";
import { resolveConfigValue, resolveHeaders } from "./resolve-config-value.js";
import type { SettingsManager } from "./settings-manager.js";
const Ajv = (AjvModule as any).default || AjvModule;
const ajv = new Ajv();
@ -481,6 +482,7 @@ export class ModelRegistry {
getAgentDir(),
"models.json",
),
private readonly settingsManager?: SettingsManager,
discoveryCache?: ModelDiscoveryCache,
) {
this.discoveryCache = discoveryCache ?? new ModelDiscoveryCache();
@ -860,8 +862,9 @@ export class ModelRegistry {
*/
getAvailable(providerModelAllow?: ProviderModelAllowList): Model<Api>[] {
return this.filterProviderModelAllow(
this.getAllWithDiscovered().filter((m) =>
this.isProviderRequestReady(m.provider),
this.getAllWithDiscovered().filter(
(m) =>
this.isModelEnabled(m) && this.isProviderRequestReady(m.provider),
),
providerModelAllow,
);
@ -885,6 +888,7 @@ export class ModelRegistry {
* Whether a provider can be used for requests/fallback without hard auth gating.
*/
isProviderRequestReady(provider: string): boolean {
if (this.settingsManager?.isProviderDisabled(provider)) return false;
const config = this.registeredProviders.get(provider);
if (config?.isReady) return config.isReady();
const authMode = this.getProviderAuthMode(provider);
@ -897,10 +901,20 @@ export class ModelRegistry {
*/
find(provider: string, modelId: string): Model<Api> | undefined {
return this.filterProviderModelAllow(
this.models.filter((m) => m.provider === provider && m.id === modelId),
this.models.filter(
(m) =>
m.provider === provider && m.id === modelId && this.isModelEnabled(m),
),
)[0];
}
isModelEnabled(model: Pick<Model<Api>, "provider" | "id">): boolean {
if (this.settingsManager?.isProviderDisabled(model.provider)) return false;
if (this.settingsManager?.isModelDisabled(model.provider, model.id))
return false;
return true;
}
/**
* Get API key for a model.
* Returns undefined for externalCli/none providers (no key needed).

View file

@ -114,4 +114,28 @@ describe("SettingsManager — global-only security settings", () => {
// Normal fields: project overrides global
assert.equal(sm.getQuietStartup(), true);
});
it("toggles disabled providers in scoped settings", () => {
const sm = SettingsManager.inMemory();
assert.equal(sm.isProviderDisabled("google-gemini-cli"), false);
assert.equal(sm.toggleProviderDisabled("google-gemini-cli"), true);
assert.equal(sm.isProviderDisabled("google-gemini-cli"), true);
assert.equal(sm.toggleProviderDisabled("google-gemini-cli"), false);
assert.equal(sm.isProviderDisabled("google-gemini-cli"), false);
});
it("toggles disabled models in scoped settings", () => {
const sm = SettingsManager.inMemory();
assert.equal(sm.isModelDisabled("anthropic", "claude-sonnet-4.5"), false);
assert.equal(
sm.toggleModelDisabled("anthropic", "claude-sonnet-4.5"),
true,
);
assert.equal(sm.isModelDisabled("anthropic", "claude-sonnet-4.5"), true);
assert.equal(
sm.toggleModelDisabled("anthropic", "claude-sonnet-4.5"),
false,
);
assert.equal(sm.isModelDisabled("anthropic", "claude-sonnet-4.5"), false);
});
});

View file

@ -100,6 +100,13 @@ export interface ProxySettings {
providerPriority?: Record<string, string[]>;
}
export type ProviderEnvAuthMode = "auto" | "on" | "off";
export interface ProviderEnvAuthSettings {
default?: ProviderEnvAuthMode;
providers?: Record<string, ProviderEnvAuthMode>;
}
export type TransportSetting = Transport;
/**
@ -174,6 +181,9 @@ export interface Settings {
allowedCommandPrefixes?: string[]; // Override built-in SAFE_COMMAND_PREFIXES for !command resolution (global-only — ignored in project settings)
fetchAllowedUrls?: string[]; // Hostnames exempted from SSRF blocklist in fetch_page (global-only — ignored in project settings)
proxy?: ProxySettings;
disabledProviders?: string[]; // Provider IDs hidden from normal model availability and selection
disabledModels?: string[]; // provider/model IDs hidden from normal model availability and selection
providerEnvAuth?: ProviderEnvAuthSettings; // Per-provider policy for environment-based auth detection
}
/** Settings keys that are only respected from global config — project settings cannot override these. */
@ -939,6 +949,69 @@ export class SettingsManager {
this.setGlobalSetting("quietStartup", quiet);
}
getDisabledProviders(): string[] {
return [...(this.settings.disabledProviders ?? [])];
}
isProviderDisabled(provider: string): boolean {
return this.getDisabledProviders().includes(provider);
}
setDisabledProviders(providers: string[]): void {
this.setScopedSetting("disabledProviders", [...new Set(providers)].sort());
}
toggleProviderDisabled(provider: string): boolean {
const next = new Set(this.getDisabledProviders());
if (next.has(provider)) {
next.delete(provider);
this.setDisabledProviders([...next]);
return false;
}
next.add(provider);
this.setDisabledProviders([...next]);
return true;
}
getDisabledModels(): string[] {
return [...(this.settings.disabledModels ?? [])];
}
isModelDisabled(provider: string, modelId: string): boolean {
return this.getDisabledModels().includes(`${provider}/${modelId}`);
}
setDisabledModels(models: string[]): void {
this.setScopedSetting("disabledModels", [...new Set(models)].sort());
}
toggleModelDisabled(provider: string, modelId: string): boolean {
const modelKey = `${provider}/${modelId}`;
const next = new Set(this.getDisabledModels());
if (next.has(modelKey)) {
next.delete(modelKey);
this.setDisabledModels([...next]);
return false;
}
next.add(modelKey);
this.setDisabledModels([...next]);
return true;
}
getProviderEnvAuthMode(provider: string): ProviderEnvAuthMode {
return (
this.settings.providerEnvAuth?.providers?.[provider] ??
this.settings.providerEnvAuth?.default ??
"auto"
);
}
setProviderEnvAuthMode(provider: string, mode: ProviderEnvAuthMode): void {
const current = structuredClone(this.settings.providerEnvAuth ?? {});
current.providers = { ...(current.providers ?? {}), [provider]: mode };
this.setScopedSetting("providerEnvAuth", current);
}
getShellCommandPrefix(): string | undefined {
return this.settings.shellCommandPrefix;
}

View file

@ -490,7 +490,11 @@ export async function main(args: string[]) {
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager, "startup");
const authStorage = AuthStorage.create();
const modelRegistry = new ModelRegistry(authStorage, getModelsPath());
const modelRegistry = new ModelRegistry(
authStorage,
getModelsPath(),
settingsManager,
);
// Offline mode validation / auto-detection
if (offlineMode) {
@ -740,7 +744,9 @@ export async function main(args: string[]) {
if (!isInteractive && !session.model) {
console.error(chalk.red("No models available."));
console.error(chalk.yellow("\nSet an API key environment variable:"));
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
console.error(
" ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, etc.",
);
console.error(chalk.yellow(`\nOr create ${getModelsPath()}`));
process.exit(1);
}

View file

@ -96,6 +96,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
private modelRegistry: ModelRegistry;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private onConfigChange?: () => void;
private errorMessage?: string;
private tui: TUI;
private scopedModels: ReadonlyArray<ScopedModelItem>;
@ -111,6 +112,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>) => void,
onCancel: () => void,
onConfigChange?: () => void,
initialSearchInput?: string,
) {
super();
@ -128,6 +130,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.scope = hasReadyScopedModel ? "scoped" : "all";
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
this.onConfigChange = onConfigChange;
// Add top border
this.addChild(new DynamicBorder());
@ -141,7 +144,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.addChild(this.scopeHintText);
} else {
const hintText =
"Only showing models with configured API keys (see README for details)";
"Browse all models; disabled or unready entries cannot be selected";
this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
}
this.addChild(new Spacer(1));
@ -203,8 +206,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
// Load available models (built-in models still work even if models.json failed)
try {
const availableModels = this.modelRegistry.getAvailable();
models = availableModels.map((model: Model<any>) => ({
const allModels = this.modelRegistry.getAll();
models = allModels.map((model: Model<any>) => ({
provider: model.provider,
id: model.id,
model,
@ -222,18 +225,12 @@ export class ModelSelectorComponent extends Container implements Focusable {
}
this.allModels = this.sortModelsWithinProvider(models);
// Scoped models must also be filtered by provider readiness so users
// can't pick a scoped model whose provider has no API key / OAuth.
this.scopedModelItems = this.sortModelsWithinProvider(
this.scopedModels
.filter((scoped) =>
this.modelRegistry.isProviderRequestReady(scoped.model.provider),
)
.map((scoped) => ({
provider: scoped.model.provider,
id: scoped.model.id,
model: scoped.model,
})),
this.scopedModels.map((scoped) => ({
provider: scoped.model.provider,
id: scoped.model.id,
model: scoped.model,
})),
);
this.activeModels =
this.scope === "scoped" ? this.scopedModelItems : this.allModels;
@ -348,7 +345,11 @@ export class ModelSelectorComponent extends Container implements Focusable {
}
private getScopeHintText(): string {
return keyHint("tab", "scope") + theme.fg("muted", " (all/scoped)");
return (
keyHint("tab", "scope") +
theme.fg("muted", " (all/scoped) ") +
keyHint("d", "disable")
);
}
private setScope(scope: ModelScope): void {
@ -433,6 +434,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
const isSelected = i === this.selectedFlatIndex;
const isCurrent = modelsAreEqual(this.currentModel, item.model);
const statusBadge = this.modelStatusBadge(item.model);
const selectable = this.isModelSelectable(item.model);
const ctx = formatTokenCount(item.model.contextWindow);
const ctxBadge = theme.fg("muted", `${ctx}`);
@ -445,9 +448,13 @@ export class ModelSelectorComponent extends Container implements Focusable {
let line: string;
if (isSelected) {
const prefix = theme.fg("accent", "→ ");
line = `${prefix}${theme.fg("accent", item.id)} ${ctxBadge} ${providerBadge}${checkmark}`;
const modelText = selectable
? theme.fg("accent", item.id)
: theme.fg("muted", item.id);
line = `${prefix}${modelText} ${ctxBadge} ${providerBadge}${statusBadge}${checkmark}`;
} else {
line = ` ${item.id} ${ctxBadge} ${providerBadge}${checkmark}`;
const modelText = selectable ? item.id : theme.fg("muted", item.id);
line = ` ${modelText} ${ctxBadge} ${providerBadge}${statusBadge}${checkmark}`;
}
this.listContainer.addChild(new Text(line, 0, 0));
@ -511,21 +518,28 @@ export class ModelSelectorComponent extends Container implements Focusable {
if (row.kind === "header") {
// Provider group header — always unselectable
const providerLabel = theme.fg(
"borderAccent",
i === this.selectedGroupIndex ? "accent" : "borderAccent",
providerDisplayName(row.provider),
);
const count = theme.fg("muted", ` (${row.count})`);
const disabledBadge = this.settingsManager.isProviderDisabled(
row.provider,
)
? theme.fg("warning", " [disabled]")
: "";
// Add blank line before header if not the very first visible row
if (i > startIndex) {
this.listContainer.addChild(new Text("", 0, 0));
}
this.listContainer.addChild(
new Text(` ${providerLabel}${count}`, 0, 0),
new Text(` ${providerLabel}${count}${disabledBadge}`, 0, 0),
);
} else {
// Model row
const isSelected = i === this.selectedGroupIndex;
const isCurrent = modelsAreEqual(this.currentModel, row.item.model);
const statusBadge = this.modelStatusBadge(row.item.model);
const selectable = this.isModelSelectable(row.item.model);
const ctx = formatTokenCount(row.item.model.contextWindow);
const ctxBadge = theme.fg("muted", ` ${ctx}`);
@ -533,9 +547,15 @@ export class ModelSelectorComponent extends Container implements Focusable {
let line: string;
if (isSelected) {
line = ` ${theme.fg("accent", "→")} ${theme.fg("accent", row.item.id)}${ctxBadge}${checkmark}`;
const modelText = selectable
? theme.fg("accent", row.item.id)
: theme.fg("muted", row.item.id);
line = ` ${theme.fg("accent", "→")} ${modelText}${ctxBadge}${statusBadge}${checkmark}`;
} else {
line = ` ${row.item.id}${ctxBadge}${checkmark}`;
const modelText = selectable
? row.item.id
: theme.fg("muted", row.item.id);
line = ` ${modelText}${ctxBadge}${statusBadge}${checkmark}`;
}
this.listContainer.addChild(new Text(line, 0, 0));
@ -571,6 +591,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
m.name,
`ctx: ${formatTokenCount(m.contextWindow)}`,
`out: ${formatTokenCount(m.maxTokens)}`,
this.modelStatusText(m),
m.reasoning ? "thinking" : "",
m.input.includes("image") ? "vision" : "",
]
@ -593,6 +614,11 @@ export class ModelSelectorComponent extends Container implements Focusable {
return;
}
if (keyData === "d" || keyData === "D") {
this.handleDisableToggle();
return;
}
// Navigation keys
if (kb.matches(keyData, "selectUp")) {
this.moveUp();
@ -659,14 +685,6 @@ export class ModelSelectorComponent extends Container implements Focusable {
let next = this.selectedGroupIndex - 1;
// Wrap
if (next < 0) next = this.groupedRows.length - 1;
// Skip headers
while (next > 0 && this.groupedRows[next]?.kind === "header") {
next--;
}
// If landed on header at 0, wrap to bottom
if (this.groupedRows[next]?.kind === "header") {
next = this.groupedRows.length - 1;
}
this.selectedGroupIndex = next;
this.updateList();
}
@ -687,27 +705,75 @@ export class ModelSelectorComponent extends Container implements Focusable {
let next = this.selectedGroupIndex + 1;
// Wrap
if (next >= this.groupedRows.length) next = 0;
// Skip headers
while (
next < this.groupedRows.length - 1 &&
this.groupedRows[next]?.kind === "header"
) {
next++;
}
// If landed on header at end, wrap to first model
if (this.groupedRows[next]?.kind === "header") {
next = this.modelRowIndices[0] ?? 0;
}
this.selectedGroupIndex = next;
this.updateList();
}
private handleSelect(model: Model<any>): void {
if (!this.isModelSelectable(model)) return;
// Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model);
}
private isModelSelectable(model: Model<any>): boolean {
return (
this.modelRegistry.isModelEnabled(model) &&
this.modelRegistry.isProviderRequestReady(model.provider)
);
}
private modelStatusText(model: Model<any>): string {
if (this.settingsManager.isProviderDisabled(model.provider)) {
return "provider disabled";
}
if (this.settingsManager.isModelDisabled(model.provider, model.id)) {
return "model disabled";
}
if (!this.modelRegistry.isProviderRequestReady(model.provider)) {
return "auth unavailable";
}
return "";
}
private modelStatusBadge(model: Model<any>): string {
const status = this.modelStatusText(model);
return status ? theme.fg("warning", ` [${status}]`) : "";
}
private handleDisableToggle(): void {
if (this.isSearching) {
const item = this.filteredModels[this.selectedFlatIndex];
if (!item) return;
this.settingsManager.toggleModelDisabled(item.provider, item.id);
} else {
const row = this.groupedRows[this.selectedGroupIndex];
if (!row) return;
if (row.kind === "header") {
this.settingsManager.toggleProviderDisabled(row.provider);
} else {
this.settingsManager.toggleModelDisabled(
row.item.provider,
row.item.id,
);
}
}
this.onConfigChange?.();
this.modelRegistry.refresh();
void this.loadModels().then(() => {
if (this.isSearching) {
this.filterModels(this.searchInput.getValue());
} else {
this.buildGroupedRows();
if (this.selectedGroupIndex >= this.groupedRows.length) {
this.selectedGroupIndex = Math.max(0, this.groupedRows.length - 1);
}
this.updateList();
}
this.tui.requestRender();
});
}
getSearchInput(): Input {
return this.searchInput;
}

View file

@ -3491,6 +3491,10 @@ export class InteractiveMode {
done();
this.ui.requestRender();
},
() => {
void this.updateAvailableProviderCount();
this.footer.invalidate();
},
initialSearchInput,
);
return { component: selector, focus: selector };

View file

@ -628,18 +628,22 @@ const authStorage = AuthStorage.create(authFilePath);
markStartup("AuthStorage.create");
loadStoredEnvKeys(authStorage);
migratePiCredentials(authStorage);
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
applySecurityOverrides(settingsManager);
markStartup("SettingsManager.create");
// Resolve models.json path with fallback to ~/.pi/agent/models.json
const { resolveModelsJsonPath } = await import("./models-resolver.js");
const modelsJsonPath = resolveModelsJsonPath();
const modelRegistry = new ModelRegistry(authStorage, modelsJsonPath);
const modelRegistry = new ModelRegistry(
authStorage,
modelsJsonPath,
settingsManager,
);
markStartup("ModelRegistry");
await warmDiscoveryBackedProviders(modelRegistry);
markStartup("ModelRegistry.discovery");
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
applySecurityOverrides(settingsManager);
markStartup("SettingsManager.create");
// Run onboarding wizard on first launch (no LLM provider configured)
if (

View file

@ -32,6 +32,7 @@ import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
import { parseUnitId } from "./unit-id.js";
import { readUokDiagnostics } from "./uok/diagnostic-synthesis.js";
import { safeSetWidget } from "./widget-safe.js";
import { logWarning } from "./workflow-logger.js";
import { getCurrentBranch } from "./worktree.js";
import { getActiveWorktreeName } from "./worktree-command.js";
@ -602,7 +603,7 @@ export function updateProgressWidget(
refreshLastCommit(accessors.getBasePath());
// Cache the effective service tier at widget creation time (reads preferences)
const effectiveServiceTier = getEffectiveServiceTier();
ctx.ui.setWidget("sf-progress", (tui, theme) => {
safeSetWidget(ctx, "sf-progress", (tui, theme) => {
let cachedLines;
let cachedWidth;
let cachedRtkLabel;

View file

@ -94,6 +94,7 @@ import {
reconcileDurableCompleteUnitRuntimeRecords,
reconcileStaleCompleteSliceRecords,
} from "./uok/unit-runtime.js";
import { safeSetWidget } from "./widget-safe.js";
import { logError, logWarning } from "./workflow-logger.js";
import {
@ -1068,7 +1069,7 @@ export async function bootstrapAutoSession(
ctx.ui.setFooter(hideFooter);
// Hide sf-health during AUTO — sf-progress is the single source of truth
// for last-commit / cost / health signal while auto is running.
ctx.ui.setWidget("sf-health", undefined);
safeSetWidget(ctx, "sf-health", undefined);
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
const pendingCount = (state.registry ?? []).filter(
(m) => m.status !== "complete" && m.status !== "parked",

View file

@ -178,6 +178,7 @@ import {
recordUokKernelTermination,
runAutoLoopWithUok,
} from "./uok/kernel.js";
import { safeSetWidget } from "./widget-safe.js";
import { logWarning, setLogBasePath } from "./workflow-logger.js";
import {
autoCommitCurrentBranch,
@ -722,7 +723,7 @@ function cleanupAfterLoopExit(ctx) {
// visible so the user still has a resumable auto-mode signal on screen.
if (!s.paused) {
ctx.ui.setStatus("sf-auto", undefined);
ctx.ui.setWidget("sf-progress", undefined);
safeSetWidget(ctx, "sf-progress", undefined);
ctx.ui.setFooter(undefined);
initHealthWidget(ctx);
}

View file

@ -26,6 +26,7 @@ import {
nativeLastCommitEpoch,
} from "./native-git-bridge.js";
import { loadEffectiveSFPreferences } from "./preferences.js";
import { safeSetWidget } from "./widget-safe.js";
// ── Data loader ────────────────────────────────────────────────────────────────
function loadHealthWidgetData(basePath) {
@ -104,12 +105,13 @@ export function initHealthWidget(ctx) {
// String-array fallback — used in RPC mode (factory is a no-op there).
// The factory call below overwrites this when the host supports factories.
ctx.ui.setWidget("sf-health", buildHealthLines(initialData), {
safeSetWidget(ctx, "sf-health", buildHealthLines(initialData), {
placement: "belowEditor",
});
// Factory-based widget for TUI mode — replaces the string-array above.
ctx.ui.setWidget(
safeSetWidget(
ctx,
"sf-health",
(_tui, _theme) => {
let data = initialData;

View file

@ -7,6 +7,7 @@ import {
onNotificationStoreChange,
} from "./notification-store.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
import { safeSetWidget } from "./widget-safe.js";
// ─── Pure rendering ─────────────────────────────────────────────────────
/**
* Build the notification widget UI lines. Returns empty array if no unread
@ -31,12 +32,13 @@ export function initNotificationWidget(ctx) {
// String-array fallback for RPC mode.
// The factory call below overwrites this when the host supports factories.
ctx.ui.setWidget("sf-notifications", buildNotificationWidgetLines(), {
safeSetWidget(ctx, "sf-notifications", buildNotificationWidgetLines(), {
placement: "belowEditor",
});
// Factory-based widget for TUI mode
ctx.ui.setWidget(
safeSetWidget(
ctx,
"sf-notifications",
(_tui, _theme) => {
let cachedLines;

View file

@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { safeSetWidget } from "../widget-safe.js";
describe("safeSetWidget", () => {
it("safeSetWidget_when_ui_missing_returns_false", () => {
expect(safeSetWidget({}, "sf-health", ["ready"])).toBe(false);
expect(safeSetWidget({ ui: {} }, "sf-health", ["ready"])).toBe(false);
});
it("safeSetWidget_when_setWidget_throws_returns_false", () => {
const ctx = {
ui: {
setWidget() {
throw new TypeError("host.setExtensionWidget is not a function");
},
},
};
expect(safeSetWidget(ctx, "sf-health", ["ready"])).toBe(false);
});
it("safeSetWidget_when_setWidget_exists_calls_it_and_returns_true", () => {
const setWidget = vi.fn();
const ctx = { ui: { setWidget } };
expect(
safeSetWidget(ctx, "sf-health", ["ready"], {
placement: "belowEditor",
}),
).toBe(true);
expect(setWidget).toHaveBeenCalledWith("sf-health", ["ready"], {
placement: "belowEditor",
});
});
});

View file

@ -0,0 +1,27 @@
/**
* widget-safe.js - tolerant widget registration for mixed SF runtimes.
*
* Purpose: keep bundled SF extensions loadable when the installed host is older
* than the source extension and does not expose widget APIs yet.
*
* Consumer: ambient SF widgets that are useful in TUI hosts but optional in
* headless, RPC, or stale extension hosts.
*/
/**
* Set or clear a widget when the active host supports widgets.
*
* Purpose: prevent optional UI registration from aborting extension startup
* when ctx.ui.setWidget is missing or throws in a partially upgraded runtime.
*
* Consumer: SF health, notification, and auto-progress widgets.
*/
export function safeSetWidget(ctx, key, content, options) {
try {
if (typeof ctx?.ui?.setWidget !== "function") return false;
ctx.ui.setWidget(key, content, options);
return true;
} catch {
return false;
}
}