From b1a77497630e423eb5b4ea87c02904798d7148ad Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 7 May 2026 17:20:52 +0200 Subject: [PATCH] fix: harden widget and provider auth handling --- .../2026-05-07-cli-agent-code-survey.md | 241 ++++++++++++++++-- docs/records/index.md | 2 +- docs/user-docs/providers.md | 10 +- gitbook/configuration/providers.md | 11 +- gitbook/reference/environment-variables.md | 4 +- packages/pi-ai/src/env-api-keys.test.ts | 9 +- packages/pi-ai/src/env-api-keys.ts | 8 +- .../pi-ai/src/web-runtime-env-api-keys.ts | 5 +- .../pi-coding-agent/src/core/auth-storage.ts | 13 +- .../src/core/model-registry-auth-mode.test.ts | 41 +++ .../src/core/model-registry.ts | 20 +- .../core/settings-manager-security.test.ts | 24 ++ .../src/core/settings-manager.ts | 73 ++++++ packages/pi-coding-agent/src/main.ts | 10 +- .../interactive/components/model-selector.ts | 146 ++++++++--- .../src/modes/interactive/interactive-mode.ts | 4 + src/cli.ts | 12 +- src/resources/extensions/sf/auto-dashboard.js | 3 +- src/resources/extensions/sf/auto-start.js | 3 +- src/resources/extensions/sf/auto.js | 3 +- src/resources/extensions/sf/health-widget.js | 6 +- .../extensions/sf/notification-widget.js | 6 +- .../extensions/sf/tests/widget-safe.test.mjs | 36 +++ src/resources/extensions/sf/widget-safe.js | 27 ++ 24 files changed, 621 insertions(+), 96 deletions(-) create mode 100644 src/resources/extensions/sf/tests/widget-safe.test.mjs create mode 100644 src/resources/extensions/sf/widget-safe.js diff --git a/docs/records/2026-05-07-cli-agent-code-survey.md b/docs/records/2026-05-07-cli-agent-code-survey.md index 6ecd13d76..5d2324b88 100644 --- a/docs/records/2026-05-07-cli-agent-code-survey.md +++ b/docs/records/2026-05-07-cli-agent-code-survey.md @@ -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. diff --git a/docs/records/index.md b/docs/records/index.md index 82e5633bd..655e00375 100644 --- a/docs/records/index.md +++ b/docs/records/index.md @@ -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 | diff --git a/docs/user-docs/providers.md b/docs/user-docs/providers.md index 89946f812..64ecf2c8c 100644 --- a/docs/user-docs/providers.md +++ b/docs/user-docs/providers.md @@ -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 diff --git a/gitbook/configuration/providers.md b/gitbook/configuration/providers.md index a872ef5e9..3e99f5563 100644 --- a/gitbook/configuration/providers.md +++ b/gitbook/configuration/providers.md @@ -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. diff --git a/gitbook/reference/environment-variables.md b/gitbook/reference/environment-variables.md index c06f121ee..5e3e80eb1 100644 --- a/gitbook/reference/environment-variables.md +++ b/gitbook/reference/environment-variables.md @@ -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) | diff --git a/packages/pi-ai/src/env-api-keys.test.ts b/packages/pi-ai/src/env-api-keys.test.ts index 80f612140..ff1908f9b 100644 --- a/packages/pi-ai/src/env-api-keys.test.ts +++ b/packages/pi-ai/src/env-api-keys.test.ts @@ -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; diff --git a/packages/pi-ai/src/env-api-keys.ts b/packages/pi-ai/src/env-api-keys.ts index a016211fd..2a0af2c70 100644 --- a/packages/pi-ai/src/env-api-keys.ts +++ b/packages/pi-ai/src/env-api-keys.ts @@ -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 = { 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", diff --git a/packages/pi-ai/src/web-runtime-env-api-keys.ts b/packages/pi-ai/src/web-runtime-env-api-keys.ts index 950f848ec..75e90d12f 100644 --- a/packages/pi-ai/src/web-runtime-env-api-keys.ts +++ b/packages/pi-ai/src/web-runtime-env-api-keys.ts @@ -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 = { 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", diff --git a/packages/pi-coding-agent/src/core/auth-storage.ts b/packages/pi-coding-agent/src/core/auth-storage.ts index 98f9c0f13..841b4c198 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.ts @@ -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.`, ), ); diff --git a/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts b/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts index 3aa7419d3..cad56cacd 100644 --- a/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts +++ b/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts @@ -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, + 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", () => { diff --git a/packages/pi-coding-agent/src/core/model-registry.ts b/packages/pi-coding-agent/src/core/model-registry.ts index 525cb7437..d2c142c5c 100644 --- a/packages/pi-coding-agent/src/core/model-registry.ts +++ b/packages/pi-coding-agent/src/core/model-registry.ts @@ -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[] { 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 | 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, "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). diff --git a/packages/pi-coding-agent/src/core/settings-manager-security.test.ts b/packages/pi-coding-agent/src/core/settings-manager-security.test.ts index 3767c565d..fee506c3f 100644 --- a/packages/pi-coding-agent/src/core/settings-manager-security.test.ts +++ b/packages/pi-coding-agent/src/core/settings-manager-security.test.ts @@ -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); + }); }); diff --git a/packages/pi-coding-agent/src/core/settings-manager.ts b/packages/pi-coding-agent/src/core/settings-manager.ts index 60c69d36b..1693ecebe 100644 --- a/packages/pi-coding-agent/src/core/settings-manager.ts +++ b/packages/pi-coding-agent/src/core/settings-manager.ts @@ -100,6 +100,13 @@ export interface ProxySettings { providerPriority?: Record; } +export type ProviderEnvAuthMode = "auto" | "on" | "off"; + +export interface ProviderEnvAuthSettings { + default?: ProviderEnvAuthMode; + providers?: Record; +} + 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; } diff --git a/packages/pi-coding-agent/src/main.ts b/packages/pi-coding-agent/src/main.ts index fd9842987..92115d8db 100644 --- a/packages/pi-coding-agent/src/main.ts +++ b/packages/pi-coding-agent/src/main.ts @@ -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); } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts index 469c08510..0de1f9b66 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts @@ -96,6 +96,7 @@ export class ModelSelectorComponent extends Container implements Focusable { private modelRegistry: ModelRegistry; private onSelectCallback: (model: Model) => void; private onCancelCallback: () => void; + private onConfigChange?: () => void; private errorMessage?: string; private tui: TUI; private scopedModels: ReadonlyArray; @@ -111,6 +112,7 @@ export class ModelSelectorComponent extends Container implements Focusable { scopedModels: ReadonlyArray, onSelect: (model: Model) => 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) => ({ + const allModels = this.modelRegistry.getAll(); + models = allModels.map((model: Model) => ({ 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): void { + if (!this.isModelSelectable(model)) return; // Save as new default this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.onSelectCallback(model); } + private isModelSelectable(model: Model): boolean { + return ( + this.modelRegistry.isModelEnabled(model) && + this.modelRegistry.isProviderRequestReady(model.provider) + ); + } + + private modelStatusText(model: Model): 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): 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; } diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 263e7b9fe..cf0fb8652 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -3491,6 +3491,10 @@ export class InteractiveMode { done(); this.ui.requestRender(); }, + () => { + void this.updateAvailableProviderCount(); + this.footer.invalidate(); + }, initialSearchInput, ); return { component: selector, focus: selector }; diff --git a/src/cli.ts b/src/cli.ts index 3cd140508..3ce47d86f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ( diff --git a/src/resources/extensions/sf/auto-dashboard.js b/src/resources/extensions/sf/auto-dashboard.js index e3bff8a58..f61d36e41 100644 --- a/src/resources/extensions/sf/auto-dashboard.js +++ b/src/resources/extensions/sf/auto-dashboard.js @@ -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; diff --git a/src/resources/extensions/sf/auto-start.js b/src/resources/extensions/sf/auto-start.js index 45365f84a..078f36cfa 100644 --- a/src/resources/extensions/sf/auto-start.js +++ b/src/resources/extensions/sf/auto-start.js @@ -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", diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index 2a4ad1f2d..8ae86e00e 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -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); } diff --git a/src/resources/extensions/sf/health-widget.js b/src/resources/extensions/sf/health-widget.js index 49b5b6f38..2a01b90f5 100644 --- a/src/resources/extensions/sf/health-widget.js +++ b/src/resources/extensions/sf/health-widget.js @@ -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; diff --git a/src/resources/extensions/sf/notification-widget.js b/src/resources/extensions/sf/notification-widget.js index e2ce1204f..4917cba8f 100644 --- a/src/resources/extensions/sf/notification-widget.js +++ b/src/resources/extensions/sf/notification-widget.js @@ -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; diff --git a/src/resources/extensions/sf/tests/widget-safe.test.mjs b/src/resources/extensions/sf/tests/widget-safe.test.mjs new file mode 100644 index 000000000..c9bd23b71 --- /dev/null +++ b/src/resources/extensions/sf/tests/widget-safe.test.mjs @@ -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", + }); + }); +}); diff --git a/src/resources/extensions/sf/widget-safe.js b/src/resources/extensions/sf/widget-safe.js new file mode 100644 index 000000000..d797472a9 --- /dev/null +++ b/src/resources/extensions/sf/widget-safe.js @@ -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; + } +}