Compare commits

..
Author SHA1 Message Date
KRATOS 3518bfc64e fix(tools): resolve CodeRabbit residual capability/key issues
- Join apply_patch diff paths under cwd for conflict keys
- Validate constructor-declared capabilities before normalization
- Unify session/process ID key extraction helper
2026-07-16 21:58:05 +05:30
KRATOS fee94fc949 fix(tools): keep multiFileResourceKeys used by apply_patch keys
Satisfies unused lint after switching apply_patch to applyPatchResourceKeys.
2026-07-16 21:51:17 +05:30
KRATOS 29e47d879d fix(tools): address review feedback on effect metadata
Harden capability normalization and resource keys before PR6 consumes them:

- Force ThreadSafe=false for every non-ReadOnly effect at read time
- Reject duplicate builtin names in ValidateBuiltinCatalog
- Coerce numeric session ids and accept "session" for process keys
- Strip path/query before userinfo in endpoint host parsing
- Wire apply_patch keys from unified-diff paths + cwd
- Reclassify bash as WorkspaceWrite (one-shot shell mutator)
- Expand regression tests for keys, normalization, and gates
2026-07-16 21:50:58 +05:30
KRATOS a3a19b26b6 perf(tools): add explicit tool effect metadata
Introduce fail-closed EffectClass and ToolCapabilities on every built-in
tool so a later concurrent read-only batch PR can decide safety from
explicit metadata rather than tool names. Plugin and MCP tools stay
EffectUnknown. No concurrent execution is enabled.
2026-07-16 21:22:07 +05:30
65 changed files with 278 additions and 7509 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.4.0"}
{".":"0.3.0"}
+4 -60
View File
@@ -6,60 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
### Features
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
* **cli:** wire MCP serve WorkspaceRoot and --add-dir scope ([#694](https://github.com/Gitlawb/zero/issues/694)) ([75a78e7](https://github.com/Gitlawb/zero/commit/75a78e715bf23154f69c8c79cb58eb4b535b2a2a))
* **npm:** ship the native binary as platform optionalDependencies ([#626](https://github.com/Gitlawb/zero/issues/626)) ([5e1405d](https://github.com/Gitlawb/zero/commit/5e1405d0b7abff5b3ccb3cfdb66d64d6d3322922))
* **perf:** emit prompt-prefix hash fingerprint per turn ([#704](https://github.com/Gitlawb/zero/issues/704)) ([1c5c6e7](https://github.com/Gitlawb/zero/commit/1c5c6e78a8a0e228bdf53d7be90934fbce9d98c3))
* **providers:** add AI/ML API preset (rebased onto main) ([#621](https://github.com/Gitlawb/zero/issues/621)) ([d66a9dd](https://github.com/Gitlawb/zero/commit/d66a9dda69c32aa59f4bea903cfefe00d4b7adef))
* **providers:** refresh MiniMax model coverage ([#665](https://github.com/Gitlawb/zero/issues/665)) ([fa3052a](https://github.com/Gitlawb/zero/commit/fa3052a1422a4ad30a3a6295829564f42dee31a8))
* **skills:** discover shared ~/.agents/skills with multi-root skill loading ([#696](https://github.com/Gitlawb/zero/issues/696)) ([7d57999](https://github.com/Gitlawb/zero/commit/7d579996b43741a2e57e3e38fcdd8484fcfc34e9))
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
### Bug Fixes
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
* **cron:** prevent cron job Mutate from clobbering concurrent updates ([#630](https://github.com/Gitlawb/zero/issues/630)) ([e4bd703](https://github.com/Gitlawb/zero/commit/e4bd703cfb28dab2dfa3c2ddba46237e1bb2e164))
* **daemon:** handle os.ErrPermission as collision during O_EXCL lock creation ([#616](https://github.com/Gitlawb/zero/issues/616)) ([8ea5384](https://github.com/Gitlawb/zero/commit/8ea53841a5d54c37a153779ae86bea010659433c))
* **exec:** stop false INCOMPLETE downgrades on conversational final messages ([#608](https://github.com/Gitlawb/zero/issues/608)) ([b6117af](https://github.com/Gitlawb/zero/commit/b6117af86d6bc87a4ee66910e99d76cb16b03fed))
* harden MCP credential boundaries ([#597](https://github.com/Gitlawb/zero/issues/597)) ([fdddb05](https://github.com/Gitlawb/zero/commit/fdddb05ba84b1600ae6c3a20028bf83afe474c44))
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
* **openai:** omit prompt_cache_key for openai-compatible providers ([#636](https://github.com/Gitlawb/zero/issues/636)) ([1af5882](https://github.com/Gitlawb/zero/commit/1af58828eb3c22567599c000736c913a290959d2)), closes [#624](https://github.com/Gitlawb/zero/issues/624)
* **plugins:** resolve relative executable paths against plugin root ([#627](https://github.com/Gitlawb/zero/issues/627)) ([2efe6d5](https://github.com/Gitlawb/zero/commit/2efe6d539e29374b3ef39c2290bdea81f33a228b))
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
* **tui:** resolve pending askUser callbacks to prevent runner hangs ([#620](https://github.com/Gitlawb/zero/issues/620)) ([aa73a76](https://github.com/Gitlawb/zero/commit/aa73a76f1bd1b6fe97bac2fbff2d61b7474139f2))
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
### Performance Improvements
* **tools:** add explicit effect metadata for safe concurrency ([#705](https://github.com/Gitlawb/zero/issues/705)) ([8ef8576](https://github.com/Gitlawb/zero/commit/8ef8576df7d0775a5b815bc0776a794cadb75c34))
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
@@ -188,12 +134,10 @@ tagged. Until then, source builds report the version `dev`.
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`,
`solarized-light`, `dune`, and `neon` — selectable via `/theme <name>`, `--theme <name>`, or
`ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally
audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light
theme was reworked for legibility.
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
+1 -4
View File
@@ -191,8 +191,6 @@ Useful controls:
|---|---|
| `Enter` | send the prompt |
| `/` | open slash-command suggestions |
| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) |
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
| `Shift+Tab` | cycle permission mode |
| `Ctrl+B` | show/hide the sidebar |
| `Ctrl+C` | cancel or exit |
@@ -341,7 +339,7 @@ manifest.
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
@@ -388,7 +386,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [Install](docs/INSTALL.md)
- [Update flow](docs/UPDATE.md)
- [Themes](docs/THEMES.md)
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
- [Specialists](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
+1 -2
View File
@@ -234,7 +234,7 @@ zero update 检查更新版本
| 控制 | 效果 |
|---|---|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
@@ -261,7 +261,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [安装](docs/INSTALL.md)
- [更新流程](docs/UPDATE.md)
- [主题](docs/THEMES.md)
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
- [专家](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
-39
View File
@@ -1,39 +0,0 @@
# TUI Themes
Zero's TUI ships a set of built-in color themes. Pick one with `--theme <name>`,
the `ZERO_THEME` environment variable, or the `/theme <name>` command while
running. `auto` (the default) follows the terminal's detected background.
Run `/theme` with no argument to open a picker: move through the list to
live-preview each theme, press Enter to apply the highlighted one, or Esc to
cancel and restore the previously active theme. Run `/theme list` to print
the active theme and the registered names without opening the picker.
## Dune (`dune`)
A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft
amber accent.
## Neon (`neon`)
A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent.
## Adding a theme
Every theme is a `palette` (a table of color hex tokens) plus one entry in
`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in
`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style`
set every renderer reads from the active `zeroTheme`. Adding a theme means
adding a new `palette{...}` literal, a `themeRegistry` entry, and test
coverage for the new palette (see below).
Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic
WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the
selected-row band for every entry. The rendered-surface invariants beyond
those (permission surfaces, selected-row secondary text, diff gutters, and
the xterm-256 downsampling checks) are asserted per palette, not against the
whole registry: `TestExtendedThemeContrastInvariants` and
`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new
theme must be added to those tests (or given equivalent palette-specific
assertions), or CI can stay green while its permission, selected-row, and
diff surfaces ship unreadable.
+1 -25
View File
@@ -185,11 +185,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result = Result{Messages: copyMessages(messages)}
dispatchSessionStart(ctx, options)
defer func() {
// Use a context detached from ctx's cancellation: on Esc/Ctrl+C the run
// context is already canceled by the time this defer runs, and a canceled
// parent would make Hooks.Dispatch's context.WithTimeout derive an
// already-expired context, so the sessionEnd hook would never actually run.
dispatchSessionEnd(context.WithoutCancel(ctx), options, result, err)
dispatchSessionEnd(ctx, options, result, err)
}()
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
@@ -217,26 +213,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
toolPartitionSpan.End()
// Prompt-prefix fingerprint: hash the seven cacheable sub-components
// of this turn's request (system prompt sections, project context,
// skills, partitioned tool list, tool schemas) and emit one trace
// event. Stable across turns => cacheable. Drift in any sub-hash
// names the sub-component that broke the cache for this turn. The
// computation is pure and cheap (seven SHA-256s of small strings)
// and is a no-op when tracing is off.
if options.Trace != nil {
fp := ComputePrefixFingerprint(options, exposed)
options.Trace.EmitPrefixHash(trace.PrefixHash{
BaseInstructionsHash: fp.BaseInstructionsHash,
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
ProjectContextHash: fp.ProjectContextHash,
SkillsHash: fp.SkillsHash,
ToolsHash: fp.ToolsHash,
SchemaHash: fp.SchemaHash,
CompletePrefixHash: fp.CompletePrefixHash,
})
}
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
// request. A no-op when ContextWindow == 0 (compaction disabled).
-82
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -94,87 +93,6 @@ func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
}
}
// cancelingProvider simulates an Esc/Ctrl+C abort: it cancels the run's own
// context mid-stream (as the TUI's interrupt handler would) and returns no
// events, so Run observes ctx.Err() and returns early with an already-canceled
// context in scope for the deferred sessionEnd dispatch.
type cancelingProvider struct {
cancel context.CancelFunc
}
func (provider *cancelingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.cancel()
ch := make(chan zeroruntime.StreamEvent)
close(ch)
return ch, nil
}
// TestRunDispatchesSessionEndHookAfterContextCancellation is a regression test
// for a bug where an interrupted run (Esc/Ctrl+C) canceled ctx before the
// deferred sessionEnd dispatch ran, so Hooks.Dispatch's context.WithTimeout(ctx, ...)
// derived an already-canceled context and the hook command never actually launched.
func TestRunDispatchesSessionEndHookAfterContextCancellation(t *testing.T) {
goBinary, err := exec.LookPath("go")
if err != nil {
// The test binary runs on the build machine, so its build-time GOROOT
// still identifies the toolchain that produced it.
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
goBinary = filepath.Join(goRoot, "bin", "go")
if runtime.GOOS == "windows" {
goBinary += ".exe"
}
if _, statErr := os.Stat(goBinary); statErr != nil {
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
}
}
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
},
},
Audit: audit,
})
ctx, cancel := context.WithCancel(context.Background())
provider := &cancelingProvider{cancel: cancel}
_, err = Run(ctx, "hi", provider, Options{
SessionID: "session-cancel",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run err = %v, want context.Canceled", err)
}
events, err := audit.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
var completedStatus hooks.AuditStatus
found := false
for _, event := range events {
if event.Type == "hook_execution_completed" && event.Event == hooks.EventSessionEnd {
completedStatus = event.Status
found = true
}
}
if !found {
t.Fatalf("no sessionEnd hook_execution_completed audit event, events=%#v", events)
}
if completedStatus != hooks.AuditCompleted {
t.Fatalf("sessionEnd hook status = %q, want %q (hook must actually run despite ctx cancellation)", completedStatus, hooks.AuditCompleted)
}
}
type recordingWebSearchTool struct {
calls []map[string]any
}
-300
View File
@@ -1,300 +0,0 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// promptSubstrings are the seven cacheable sub-components of the prompt a run
// sends to a model. Each is the literal content the sub-component would emit
// into the system prompt or tool list, hashed so a downstream observer can
// detect drift turn-over-turn. The substrings are produced by
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
type promptSubstrings struct {
baseInstructions string
confirmationPolicy string
projectContext string
skills string
tools string
schema string
}
// buildPromptSubstrings assembles the seven cacheable sub-components of the
// prompt without joining them. The system prompt sections are produced by the
// same private builders that buildSystemPrompt uses, so the substrings are
// byte-identical to what would have appeared in the joined prompt. The tools
// and schema substrings are derived from the partitioned tool list (caller-
// provided) so this helper does not need to re-run the partition.
//
// This is the seam ComputePrefixFingerprint reads from. It exists as a
// separate function (rather than returning both prompt and substrings from
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
// and the substrings helper is independently testable.
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
core := strings.TrimSpace(options.SystemPrompt)
if core == "" {
core = strings.TrimSpace(coreSystemPrompt)
}
if core == "" {
core = fallbackSystemPrompt
}
// The confirmation policy is appended unconditionally in buildSystemPrompt;
// the substring here is the same constant.
policy := strings.TrimSpace(confirmationPolicy)
// projectContext is the joined output of workspaceContext, which itself
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
// joined string captures every guideline file's contribution and the
// ordering between them.
project := workspaceContext(options.Cwd)
// skills is the joined output of skillsContext, with each skill on its own
// line. The substring is the literal text the model would see.
skills := skillsContext(options)
// tools and schema are derived from the partitioned tool list. The list
// passed in is already in stable order (partitionToolsCached is the
// canonical stable partitioner) so concatenating in slice order is
// sufficient — we sort names again defensively in case a future caller
// hands us a list from a different partitioner.
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
return promptSubstrings{
baseInstructions: core,
confirmationPolicy: policy,
projectContext: project,
skills: skills,
tools: toolsSubstr,
schema: schemaSubstr,
}
}
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
// the partition produces a different hash. The schema substring is the
// concatenation of each tool's JSON schema in name order, so a schema change
// is detected independently of the tool ordering.
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
if len(exposed) == 0 {
return "", ""
}
names := make([]string, 0, len(exposed))
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
for _, def := range exposed {
names = append(names, def.Name)
byName[def.Name] = def
}
sort.Strings(names)
var toolsSB, schemaSB strings.Builder
for i, name := range names {
if i > 0 {
toolsSB.WriteByte('\n')
}
toolsSB.WriteString(name)
def := byName[name]
// Parameters is rendered to a canonical JSON string per tool. The
// schema-render cache in internal/agent (used by partitionToolsCached)
// guarantees the same render is byte-identical across turns for the
// same tool, so this substring is a stable hash input.
schemaSB.WriteString(name)
schemaSB.WriteByte('\n')
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
schemaSB.WriteByte('\n')
}
return toolsSB.String(), schemaSB.String()
}
// canonicalSchemaString renders a tool's parameter schema to a stable string.
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
// a different hash for the same Parameters value across calls and defeat
// the fingerprint. encoding/json marshals maps with keys sorted
// alphabetically, so json.Marshal is the primary stable render. The
// fallback for the rare non-JSON-compatible value (functions, channels,
// NaN/Inf floats, cyclic references) is a stable key-sorted stringifier
// that walks the value with sorted keys for maps and a leading
// "__non_json:" prefix so a future schema change is visible in the trace
// (a SchemaHash collision from the same value) rather than a silent hash
// drift.
//
// Note: SchemaHash is a stability signal, not a wire-identity signal. The
// bytes json.Marshal produces for a Go map[string]any may differ from
// the bytes the provider actually sends on the wire (provider encoders
// use their own JSON conventions; an Anthropic schema may serialize with
// different whitespace, key order, or number formatting). A consumer
// must NOT assume SchemaHash matches the provider's wire schema — only
// that two turns with the same Go-side Parameters produce the same
// SchemaHash. This is sufficient for the trace's purpose (drift
// detection) but not for a content-based equality check.
func canonicalSchemaString(params map[string]any) string {
if len(params) == 0 {
return ""
}
data, err := json.Marshal(params)
if err != nil {
return "__non_json:" + stableStringify(params)
}
return string(data)
}
// stableStringify renders v to a deterministic string. Maps have their keys
// sorted alphabetically; slices are walked in order; primitives use their
// natural Go format. The function is recursive but bounded by the size of
// the input, so a cyclic reference is not reachable here (the json.Marshal
// fallback is hit when json.Marshal itself returns an error, which for the
// common case is a non-JSON-compatible value, not a cycle — cycles are
// rare in tool schemas and acceptable to mis-render in the fallback path
// since the trace's contract is "the hash is stable for the same input,"
// not "the fallback is lossless"). Used only when json.Marshal fails.
func stableStringify(v any) string {
var sb strings.Builder
writeStable(&sb, v)
return sb.String()
}
func writeStable(sb *strings.Builder, v any) {
switch x := v.(type) {
case nil:
sb.WriteString("null")
case bool:
if x {
sb.WriteString("true")
} else {
sb.WriteString("false")
}
case string:
sb.WriteByte('"')
sb.WriteString(x)
sb.WriteByte('"')
case float64:
// Use %g for compact, deterministic float rendering. NaN and Inf are
// not JSON-encodable (which is why we are in the fallback path) and
// render as "NaN" / "+Inf" / "-Inf" — distinct, stable strings.
fmt.Fprintf(sb, "%g", x)
case float32:
fmt.Fprintf(sb, "%g", float64(x))
case int:
fmt.Fprintf(sb, "%d", x)
case int64:
fmt.Fprintf(sb, "%d", x)
case int32:
fmt.Fprintf(sb, "%d", x)
case uint:
fmt.Fprintf(sb, "%d", x)
case uint64:
fmt.Fprintf(sb, "%d", x)
case uint32:
fmt.Fprintf(sb, "%d", x)
case map[string]any:
sb.WriteByte('{')
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for i, k := range keys {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteByte('"')
sb.WriteString(k)
sb.WriteString(`":`)
writeStable(sb, x[k])
}
sb.WriteByte('}')
case []any:
sb.WriteByte('[')
for i, item := range x {
if i > 0 {
sb.WriteByte(',')
}
writeStable(sb, item)
}
sb.WriteByte(']')
default:
// Last-resort: include the type so distinct values produce distinct
// strings even if their default format collides. Without the type
// tag, fmt.Sprintf("%v", x) for two different types could produce
// the same bytes (rare, but the type prefix makes it impossible).
fmt.Fprintf(sb, "<%T:%v>", x, x)
}
}
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
// independent SHA-256s of the corresponding sub-component; the complete-prefix
// hash is a SHA-256 of the canonical concatenation of the other six, so any
// sub-component drift is observable both individually and in aggregate.
//
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
// context work is performed separately by buildSystemPrompt a few lines
// later in the request-build path, so on every traced turn the file and
// git reads happen twice. The duplication is intentional for this PR (a
// follow-up will pass substrings through) and is a known perf cost only on
// the opt-in trace path.
//
// A stable CompletePrefixHash across turns means the four captured
// sub-components (baseInstructions, confirmationPolicy, projectContext,
// skills) are byte-identical. It does NOT rule out drift in the seven
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
// userGuidelines, specialistDelegationContext, responseStyleContext). For
// default Options the four captured substrings are the full prompt; for
// non-default Options the seven uncaptured sections may contribute, and a
// consumer correlating CompletePrefix stability with cached_input_tokens
// must cross-check the model_switches counter (modelPromptAddendum changes
// on a model switch) to disambiguate "no drift" from "drift in an
// uncaptured section."
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
subs := buildPromptSubstrings(options, exposed)
base := sha256hex(subs.baseInstructions)
policy := sha256hex(subs.confirmationPolicy)
project := sha256hex(subs.projectContext)
skills := sha256hex(subs.skills)
toolsH := sha256hex(subs.tools)
schema := sha256hex(subs.schema)
complete := sha256hex(strings.Join([]string{
base, policy, project, skills, toolsH, schema,
}, "|"))
return prefixFingerprint{
BaseInstructionsHash: base,
ConfirmationPolicyHash: policy,
ProjectContextHash: project,
SkillsHash: skills,
ToolsHash: toolsH,
SchemaHash: schema,
CompletePrefixHash: complete,
}
}
// prefixFingerprint is the agent-side shape of a prompt-prefix fingerprint. It
// is converted to a trace.PrefixHash at the loop boundary (see EmitPrefixHash
// in loop.go) so the trace package does not need to import this one. The field
// names match the trace.PrefixHash JSON tags 1:1.
type prefixFingerprint struct {
BaseInstructionsHash string
ConfirmationPolicyHash string
ProjectContextHash string
SkillsHash string
ToolsHash string
SchemaHash string
CompletePrefixHash string
}
// sha256hex returns the hex-encoded SHA-256 of s. Empty input produces the
// hash of the empty string, which is a constant; callers that want to
// distinguish "absent" from "empty" should check s before calling.
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
-243
View File
@@ -1,243 +0,0 @@
package agent
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
// the trace is meant to expose: two ComputePrefixFingerprint calls over the
// same Options and the same exposed tool list produce byte-identical hashes.
// This is the regression catch for "did anyone introduce non-determinism into
// the prompt prefix."
func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
exposed := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
{Name: "grep", Parameters: map[string]any{"type": "object"}},
}
first := ComputePrefixFingerprint(opts, exposed)
second := ComputePrefixFingerprint(opts, exposed)
if first != second {
t.Fatalf("fingerprint must be stable across calls with the same input:\n first=%+v\n second=%+v", first, second)
}
if first.CompletePrefixHash == "" {
t.Fatalf("CompletePrefixHash must be non-empty for a non-trivial prompt")
}
}
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
// name-set sensitivity of the ToolsHash. The test name used to read as
// "reorder changes hash" but the assertion is the opposite: the hash is
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
// names before hashing). The partitioner is expected to produce a stable
// order, so a name-order change is not a hash signal — a name-set change
// is. The test asserts both: permutation of the same tools produces the
// same hash, and adding a tool produces a different hash.
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
a := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
}
b := []zeroruntime.ToolDefinition{
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
}
// toolSubstrings sorts names internally, so the ToolsHash is identical
// for a permutation of the same tools. We assert that here to document
// the contract: name-order independence, name-set sensitivity.
fpA := ComputePrefixFingerprint(opts, a)
fpB := ComputePrefixFingerprint(opts, b)
if fpA.ToolsHash != fpB.ToolsHash {
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
}
// A different tool set must produce a different ToolsHash.
c := append([]zeroruntime.ToolDefinition{}, a...)
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
fpC := ComputePrefixFingerprint(opts, c)
if fpA.ToolsHash == fpC.ToolsHash {
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
}
}
// TestComputePrefixFingerprintSchemaChangeChangesSchemaHash asserts that a
// change to a tool's Parameters schema is visible in the SchemaHash. A schema
// edit that should be cache-stable (e.g. field reorder, doc tweak) will move
// the hash and surface in the trace; a schema edit that legitimately
// invalidates the cache also moves the hash, which is the right behavior.
func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
a := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"version": "v1"}},
}
b := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"version": "v2"}},
}
fpA := ComputePrefixFingerprint(opts, a)
fpB := ComputePrefixFingerprint(opts, b)
if fpA.SchemaHash == fpB.SchemaHash {
t.Fatalf("SchemaHash must change when tool Parameters change: a=%s b=%s", fpA.SchemaHash, fpB.SchemaHash)
}
}
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
// model addendum change) must move the base-instructions hash, which moves
// the complete-prefix hash, which is what the trace is for.
func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T) {
optsA := Options{Cwd: t.TempDir(), SystemPrompt: "core v1"}
optsB := Options{Cwd: t.TempDir(), SystemPrompt: "core v2"}
exposed := []zeroruntime.ToolDefinition{{Name: "noop", Parameters: map[string]any{}}}
fpA := ComputePrefixFingerprint(optsA, exposed)
fpB := ComputePrefixFingerprint(optsB, exposed)
if fpA.BaseInstructionsHash == fpB.BaseInstructionsHash {
t.Fatalf("BaseInstructionsHash must change when SystemPrompt changes: a=%s b=%s", fpA.BaseInstructionsHash, fpB.BaseInstructionsHash)
}
if fpA.CompletePrefixHash == fpB.CompletePrefixHash {
t.Fatalf("CompletePrefixHash must change when any sub-hash changes: a=%s b=%s", fpA.CompletePrefixHash, fpB.CompletePrefixHash)
}
}
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
// This is the regression catch for "did anyone reorder or drop a sub-hash
// from the canonical join without updating CompletePrefixHash."
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
fp := ComputePrefixFingerprint(opts, exposed)
// Manually compute what the canonical join should be.
expected := sha256hex(strings.Join([]string{
fp.BaseInstructionsHash,
fp.ConfirmationPolicyHash,
fp.ProjectContextHash,
fp.SkillsHash,
fp.ToolsHash,
fp.SchemaHash,
}, "|"))
if fp.CompletePrefixHash != expected {
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
}
}
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
// substrings helper for default Options: which substrings are non-empty,
// which are empty, and that the substring-to-hash round-trip is lossless.
func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
opts := Options{
Cwd: t.TempDir(),
SystemPrompt: "test core",
}
subs := buildPromptSubstrings(opts, nil)
// Invariants the trace depends on (default Options):
// 1. baseInstructions substring equals the core system prompt bytes.
// 2. confirmationPolicy substring is non-empty (the embedded policy
// is always present, post TrimSpace).
// 3. skills, tools, and schema substrings are empty (no skills or
// tools configured for default Options).
if subs.baseInstructions != "test core" {
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
}
if subs.confirmationPolicy == "" {
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
}
if subs.skills != "" {
t.Fatalf("skills substring must be empty for default Options: got %q", subs.skills)
}
// Round-trip: the corresponding fingerprint hashes must equal
// sha256hex of the substrings, with no truncation or reformatting
// between the two layers.
fp := ComputePrefixFingerprint(opts, nil)
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
}
if fp.ConfirmationPolicyHash != sha256hex(subs.confirmationPolicy) {
t.Fatalf("ConfirmationPolicyHash in fingerprint must equal sha256hex of the substring")
}
}
// TestCanonicalSchemaStringStableForMapParameters asserts the headline
// property the trace needs: the same map[string]any produces the same
// canonical string across calls, even though Go's fmt.Sprintf("%v", m)
// iterates a map in random order. json.Marshal sorts map keys
// alphabetically, which is the fix.
func TestCanonicalSchemaStringStableForMapParameters(t *testing.T) {
params := map[string]any{
"description": "read a file",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []any{"path"},
"type": "object",
}
first := canonicalSchemaString(params)
second := canonicalSchemaString(params)
if first != second {
t.Fatalf("canonicalSchemaString must be stable across calls for the same Parameters:\n first=%s\n second=%s", first, second)
}
if first == "" {
t.Fatal("canonicalSchemaString must produce a non-empty string for a non-empty map")
}
// Two maps with the same key/value pairs (different declaration
// order in source) must produce identical canonical strings. This
// is the property that defeats Go's map iteration randomization.
reordered := map[string]any{
"type": "object",
"required": []any{"path"},
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"description": "read a file",
}
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
t.Fatal("canonicalSchemaString must produce identical output for maps with the same key/value pairs in different declaration orders")
}
}
// TestCanonicalSchemaStringFallbackStableForNonJSONValue exercises the
// non-JSON fallback in canonicalSchemaString: when Parameters contains a
// value json.Marshal cannot encode (here, a channel), the function must
// fall back to a key-sorted stringification that is stable across calls
// and across map-iteration order. The previous fallback used
// fmt.Sprintf("%v", params), which iterates the map in random order and
// produced a different hash for the same value across calls — defeating
// the fingerprint. The current fallback is stable by construction.
func TestCanonicalSchemaStringFallbackStableForNonJSONValue(t *testing.T) {
ch := make(chan int) // channels are not JSON-encodable
params := map[string]any{
"description": "a tool with a non-JSON value",
"channel": ch,
"z_first": 1,
"a_second": 2,
}
first := canonicalSchemaString(params)
second := canonicalSchemaString(params)
if first == "" {
t.Fatal("canonicalSchemaString must produce a non-empty string for the fallback path")
}
if first != second {
t.Fatalf("canonicalSchemaString fallback must be stable across calls:\n first=%s\n second=%s", first, second)
}
// A re-ordered map (same key/value pairs, different declaration order)
// must produce the same fallback string. This is the property the
// key-sorted stringifier provides that fmt.Sprintf("%v", m) does not.
reordered := map[string]any{
"a_second": 2,
"z_first": 1,
"channel": ch,
"description": "a tool with a non-JSON value",
}
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
t.Fatalf("canonicalSchemaString fallback must produce identical output for maps with the same key/value pairs in different declaration orders:\n first=%s\n second=%s", first, canonicalSchemaString(reordered))
}
// The fallback must include the "__non_json:" prefix so a consumer
// can tell the bytes are not a JSON-marshaled schema (a hash collision
// between a json.Marshal result and a stableStringify result is
// possible in theory; the prefix makes the source observable).
if !strings.HasPrefix(first, "__non_json:") {
t.Fatalf("canonicalSchemaString fallback must start with the __non_json: prefix, got: %s", first)
}
}
-259
View File
@@ -1,259 +0,0 @@
package aimlapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// PaymentMethod selects how a partner-checkout top-up is paid for.
type PaymentMethod string
const (
PaymentMethodCard PaymentMethod = "card"
PaymentMethodCrypto PaymentMethod = "crypto"
)
// SessionStatus is the lifecycle state of a partner-checkout session, as reported
// by the aimlapi.com backend while polling for payment.
type SessionStatus string
const (
SessionStatusPendingAuth SessionStatus = "pending_auth"
SessionStatusPendingPayment SessionStatus = "pending_payment"
SessionStatusPaid SessionStatus = "paid"
SessionStatusExchanging SessionStatus = "exchanging"
SessionStatusExchanged SessionStatus = "exchanged"
SessionStatusCancelled SessionStatus = "cancelled"
SessionStatusExpired SessionStatus = "expired"
SessionStatusFailed SessionStatus = "failed"
)
// AuthResult is the bearer token minted by a passwordless sign-in / sign-up.
type AuthResult struct {
Token string `json:"token"`
Exp int64 `json:"exp"`
}
// PartnerCheckoutSession is a partner-attributed top-up session; its SessionToken
// addresses it on later pay/exchange/poll calls.
type PartnerCheckoutSession struct {
ID string `json:"id"`
SessionToken string `json:"sessionToken"`
PartnerID string `json:"partnerId"`
PartnerName *string `json:"partnerName"`
UserID *int64 `json:"userId"`
AmountUSDMinor *int64 `json:"amountUsdMinor"`
Status SessionStatus `json:"status"`
IssuedKeyID *string `json:"issuedKeyId"`
ReturnURL *string `json:"returnUrl"`
}
// PaymentSession is the hosted payment-provider checkout; PayURL is the page the
// user opens to pay.
type PaymentSession struct {
ProviderSessionID string `json:"providerSessionId"`
PayURL string `json:"payUrl"`
}
// PayResult pairs the hosted checkout with the updated partner-checkout session
// returned when a top-up payment is initiated.
type PayResult struct {
Checkout PaymentSession `json:"checkout"`
PartnerCheckout PartnerCheckoutSession `json:"partnerCheckout"`
}
// ExchangeResult holds the API key minted when a paid session is exchanged.
type ExchangeResult struct {
APIKey string `json:"apiKey"`
APIKeyID string `json:"apiKeyId"`
}
// TopUpByKeyRequest funds a partner-checkout session with the API key that owns
// the account (Path A / env key), instead of an email session.
type TopUpByKeyRequest struct {
SessionToken string
AmountUSDMinor int
PaymentSessionID string // idempotency: same id → same checkout, never a second charge
AutoTopUp bool // enroll the account in automatic top-up at checkout (card, saved off-session)
SuccessURL string
CancelURL string
}
// TopUpByKeyResult is the hosted checkout returned by TopUpByKey.
type TopUpByKeyResult struct {
Checkout PaymentSession `json:"checkout"`
}
// APIError is a non-2xx HTTP response from the aimlapi.com API. Status carries the
// code so callers can branch (e.g. 401 = bad key, 5xx = retryable).
type APIError struct {
Message string
Status int
Body string
}
// Error renders the method, endpoint, status, and (when present) the response body.
func (e APIError) Error() string {
if strings.TrimSpace(e.Body) != "" {
return fmt.Sprintf("%s: HTTP %d: %s", e.Message, e.Status, strings.TrimSpace(e.Body))
}
return fmt.Sprintf("%s: HTTP %d", e.Message, e.Status)
}
// Client talks to the aimlapi.com auth, app, and inference APIs for the onboarding
// and partner-checkout top-up flows.
type Client struct {
endpoints Endpoints
httpClient *http.Client
}
func NewClient(endpoints Endpoints, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 60 * time.Second}
}
return &Client{endpoints: endpoints, httpClient: httpClient}
}
// CreateSession opens a partner-checkout session attributed to partnerID; the
// returned SessionToken addresses it on the pay/exchange/poll calls.
func (c *Client) CreateSession(ctx context.Context, partnerID string, partnerName string, returnURL string) (PartnerCheckoutSession, error) {
body := map[string]any{"partnerId": partnerID}
if strings.TrimSpace(partnerName) != "" {
body["partnerName"] = strings.TrimSpace(partnerName)
}
if strings.TrimSpace(returnURL) != "" {
body["returnUrl"] = strings.TrimSpace(returnURL)
}
var result PartnerCheckoutSession
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions", "", body, &result)
return result, err
}
// GetSession fetches the current state of a partner-checkout session; used to poll
// for payment completion.
func (c *Client) GetSession(ctx context.Context, sessionToken string) (PartnerCheckoutSession, error) {
var result PartnerCheckoutSession
err := c.request(ctx, http.MethodGet, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken), "", nil, &result)
return result, err
}
// Pay initiates payment for a session and returns the hosted checkout URL.
// autoTopUp enrolls the account in automatic top-up at checkout time.
func (c *Client) Pay(ctx context.Context, bearer string, sessionToken string, amountUSDMinor int, paymentSessionID string, method PaymentMethod, successURL string, cancelURL string, autoTopUp bool) (PayResult, error) {
body := map[string]any{
"amountUsdMinor": amountUSDMinor,
"paymentSessionId": paymentSessionID,
"method": method,
}
if strings.TrimSpace(successURL) != "" {
body["successUrl"] = strings.TrimSpace(successURL)
}
if strings.TrimSpace(cancelURL) != "" {
body["cancelUrl"] = strings.TrimSpace(cancelURL)
}
// Only sent when enabled: enrolls the account in automatic top-up. The backend
// honours this field at checkout time.
if autoTopUp {
body["autoTopUp"] = true
}
var result PayResult
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/pay", bearer, body, &result)
return result, err
}
// Exchange converts a paid session into a freshly minted API key (new-account flow).
func (c *Client) Exchange(ctx context.Context, bearer string, sessionToken string) (ExchangeResult, error) {
var result ExchangeResult
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/exchange", bearer, nil, &result)
return result, err
}
// TopUpByKey funds a partner-checkout session using the raw API key that owns the
// account (Path A / env key), returning the hosted checkout. It binds the session
// to the key's account server-side, so no email session is needed and no key is
// exchanged. req.PaymentSessionID makes a retry idempotent (same id → same
// checkout, never a second charge).
func (c *Client) TopUpByKey(ctx context.Context, apiKey string, req TopUpByKeyRequest) (TopUpByKeyResult, error) {
body := map[string]any{
"sessionToken": req.SessionToken,
"amountUsdMinor": req.AmountUSDMinor,
"paymentSessionId": req.PaymentSessionID,
}
if req.AutoTopUp {
body["autoTopUp"] = true
}
if strings.TrimSpace(req.SuccessURL) != "" {
body["successUrl"] = strings.TrimSpace(req.SuccessURL)
}
if strings.TrimSpace(req.CancelURL) != "" {
body["cancelUrl"] = strings.TrimSpace(req.CancelURL)
}
var result TopUpByKeyResult
err := c.request(ctx, http.MethodPost, c.billingV2URL("/billing/topup"), apiKey, body, &result)
return result, err
}
// billingV2URL builds a v2 billing API URL from the inference base. The top-up
// endpoint lives next to the balance endpoint on the API gateway, one version up
// (".../v1" → ".../v2/billing/..."), so it re-anchors the version while keeping any
// host/prefix an override supplied.
func (c *Client) billingV2URL(path string) string {
base := strings.TrimRight(strings.TrimSpace(c.endpoints.InferenceBaseURL), "/")
base = strings.TrimSuffix(base, "/v1")
return strings.TrimRight(base, "/") + "/v2" + path
}
func (c *Client) request(ctx context.Context, method string, endpoint string, bearer string, body any, out any) error {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(data)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
if strings.TrimSpace(bearer) != "" {
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("network request to %s failed: %w", endpoint, err)
}
defer response.Body.Close()
text, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil {
return readErr
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return APIError{Message: method + " " + endpoint, Status: response.StatusCode, Body: string(text)}
}
if out == nil {
return nil
}
if len(bytes.TrimSpace(text)) == 0 {
return APIError{Message: method + " " + endpoint + " returned empty body", Status: response.StatusCode}
}
if err := json.Unmarshal(text, out); err != nil {
return APIError{Message: method + " " + endpoint + " returned non-JSON body", Status: response.StatusCode, Body: string(text)}
}
return nil
}
func urlPathEscape(value string) string {
return url.PathEscape(value)
}
-106
View File
@@ -1,106 +0,0 @@
package aimlapi
import (
"context"
"fmt"
"net/http"
"strings"
)
// Additional client methods backing the interactive TUI onboarding flow. They
// target the endpoints shipped in the aimlapi backend:
// - GET {inference}/billing/balance (Path A: validate key + balance)
// - PATCH{auth}/v1/auth/account (Path B: does the account exist?)
// - POST {auth}/v1/auth/sign-in/code(/verify) (Path B existing: passwordless login)
// - POST {auth}/v1/auth/account/passwordless (Path B new: email-only signup)
// - POST {app}/v1/keys (mint a key once we hold a session)
// BalanceResult is the wallet balance returned by GetBalance, with the backend's
// low-balance flag and threshold.
type BalanceResult struct {
Balance float64 `json:"balance"`
LowBalance bool `json:"lowBalance"`
LowBalanceThreshold float64 `json:"lowBalanceThreshold"`
}
// CheckResult reports whether an email already has an account, driving the
// sign-in vs sign-up branch of the onboarding flow.
type CheckResult struct {
// "sign-in" (account exists) or "sign-up" (no account yet).
Action string `json:"action"`
Provider *string `json:"provider"`
}
// CreatedKey is an API key minted by CreateKey.
type CreatedKey struct {
Key string `json:"key"`
ID string `json:"id"`
}
func (c *Client) authURL(path string) string {
return strings.TrimRight(c.endpoints.AuthBaseURL, "/") + path
}
func (c *Client) appURL(path string) string {
return strings.TrimRight(c.endpoints.AppBaseURL, "/") + path
}
func (c *Client) inferenceURL(path string) string {
return strings.TrimRight(c.endpoints.InferenceBaseURL, "/") + path
}
// GetBalance doubles as key validation: a 401 means the key is invalid, a 2xx
// returns the wallet balance. Auth is the raw API key itself.
func (c *Client) GetBalance(ctx context.Context, apiKey string) (BalanceResult, error) {
var result BalanceResult
err := c.request(ctx, http.MethodGet, c.inferenceURL("/billing/balance"), apiKey, nil, &result)
return result, err
}
// CheckAccount reports whether an email already has an aimlapi.com account
// (Action "sign-in") or not ("sign-up").
func (c *Client) CheckAccount(ctx context.Context, email string) (CheckResult, error) {
var result CheckResult
err := c.request(ctx, http.MethodPatch, c.authURL("/v1/auth/account"), "", map[string]any{"email": email}, &result)
return result, err
}
// SendSignInCode emails a 6-digit sign-in code to an existing account.
func (c *Client) SendSignInCode(ctx context.Context, email string) error {
return c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code"), "", map[string]any{"email": email}, nil)
}
// VerifySignInCode exchanges an emailed code for a session bearer token.
func (c *Client) VerifySignInCode(ctx context.Context, email string, code string) (AuthResult, error) {
var result AuthResult
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code/verify"), "", map[string]any{"email": email, "code": code}, &result)
if err == nil && strings.TrimSpace(result.Token) == "" {
err = fmt.Errorf("aimlapi.com did not return an auth token")
}
return result, err
}
// CreatePasswordlessAccount registers a new email-only account and returns its
// session bearer token.
func (c *Client) CreatePasswordlessAccount(ctx context.Context, email string) (AuthResult, error) {
var result AuthResult
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/account/passwordless"), "", map[string]any{"email": email}, &result)
if err == nil && strings.TrimSpace(result.Token) == "" {
err = fmt.Errorf("aimlapi.com did not return an auth token")
}
return result, err
}
// CreateKey mints a fresh API key for a session-holding user.
func (c *Client) CreateKey(ctx context.Context, bearer string, name string) (CreatedKey, error) {
body := map[string]any{}
if strings.TrimSpace(name) != "" {
body["name"] = strings.TrimSpace(name)
}
var result CreatedKey
err := c.request(ctx, http.MethodPost, c.appURL("/v1/keys"), bearer, body, &result)
if err == nil && strings.TrimSpace(result.Key) == "" {
err = fmt.Errorf("aimlapi.com did not return an API key")
}
return result, err
}
-159
View File
@@ -1,159 +0,0 @@
package aimlapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestCreateKeySendsAuthenticatedRequestAndDecodesKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/keys" {
t.Fatalf("request = %s %s, want POST /v1/keys", r.Method, r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer session-token" {
t.Fatalf("Authorization = %q", got)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["name"] != "Zero CLI" {
t.Fatalf("name = %q", body["name"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"key":"sk-issued","id":"key-id"}`))
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
key, err := client.CreateKey(context.Background(), " session-token ", " Zero CLI ")
if err != nil {
t.Fatal(err)
}
if key.Key != "sk-issued" || key.ID != "key-id" {
t.Fatalf("key = %#v", key)
}
}
func TestCreateKeyRejectsEmptyKeyResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"key-id"}`))
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
if _, err := client.CreateKey(context.Background(), "session-token", "Zero"); err == nil {
t.Fatal("CreateKey accepted a response without an API key")
}
}
func TestAuthMethodsRejectEmptyTokenResponses(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":""}`))
}))
defer server.Close()
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
tests := []struct {
name string
call func() error
}{
{
name: "verify sign-in code",
call: func() error {
_, err := client.VerifySignInCode(context.Background(), "[email protected]", "123456")
return err
},
},
{
name: "create passwordless account",
call: func() error {
_, err := client.CreatePasswordlessAccount(context.Background(), "[email protected]")
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := test.call(); err == nil {
t.Fatal("accepted a response without an auth token")
}
})
}
}
func TestClientReturnsTypedAPIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "invalid key", http.StatusUnauthorized)
}))
defer server.Close()
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
_, err := client.GetBalance(context.Background(), "bad-key")
var apiErr APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %T %v, want APIError", err, err)
}
if apiErr.Status != http.StatusUnauthorized || apiErr.Body != "invalid key\n" {
t.Fatalf("APIError = %#v", apiErr)
}
}
func TestTypedResponseRejectsEmptySuccessBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
if _, err := client.GetBalance(context.Background(), "key"); err == nil {
t.Fatal("GetBalance accepted an empty successful response")
}
}
func TestBodylessOperationAcceptsEmptySuccessBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
if err := client.SendSignInCode(context.Background(), "[email protected]"); err != nil {
t.Fatalf("SendSignInCode() error = %v", err)
}
}
func TestParseAmountUSD(t *testing.T) {
tests := []struct {
name string
value string
want int
wantErr bool
}{
{name: "default", want: DefaultAmountUSDMinor},
{name: "minimum", value: "20", want: 2000},
{name: "cents", value: "25.49", want: 2549},
{name: "below minimum", value: "19.99", wantErr: true},
{name: "above maximum", value: "10001", wantErr: true},
{name: "not a number", value: "twenty", wantErr: true},
{name: "nan", value: "NaN", wantErr: true},
{name: "infinity", value: "Inf", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ParseAmountUSD(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("ParseAmountUSD(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("ParseAmountUSD(%q) = %d, want %d", test.value, got, test.want)
}
})
}
}
-113
View File
@@ -1,113 +0,0 @@
package aimlapi
import (
"net/url"
"os"
"strings"
)
type Endpoints struct {
AuthBaseURL string
AppBaseURL string
InferenceBaseURL string
PayBaseURL string
// VerificationBaseURL is the aimlapi web-app frontend base. The browser is sent
// back here after the partner checkout completes (see BuildPartnerReturnURL), so
// it must point at the same environment's front as the one the CLI is talking to
// to land the user there. Empty falls back to DefaultReturnURL.
VerificationBaseURL string
}
const (
DefaultPartnerID = "part_62yQoGYDq4Yqnrj2R1iGrDNJ"
DefaultPartnerName = "Gitlawb"
PartnerHeaderName = "X-AIMLAPI-Partner-ID"
// DefaultReturnURL is the https fallback the browser is sent to after the
// checkout / OAuth consent finishes when no frontend base is known. It must be
// a real web page the browser can open — NOT a custom scheme like
// zero://aimlapi/complete, which fails with "the scheme does not have a
// registered handler" because the CLI registers no OS protocol handler. The
// CLI learns of success by polling; this URL is purely the browser's landing.
DefaultReturnURL = "https://aimlapi.com/app"
DefaultModel = "anthropic/claude-sonnet-5"
MinAmountUSDMinor = 2000
MaxAmountUSDMinor = 1000000
DefaultAmountUSDMinor = 2500
)
func ResolveEndpoints() Endpoints {
return Endpoints{
AuthBaseURL: envOrDefault("AIMLAPI_AUTH_URL", "https://auth.aimlapi.com"),
AppBaseURL: envOrDefault("AIMLAPI_APP_URL", "https://app.aimlapi.com"),
InferenceBaseURL: envOrDefault("AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1"),
PayBaseURL: envOrDefault("AIMLAPI_PAY_URL", "https://pay.aimlapi.com"),
VerificationBaseURL: envOrDefault("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com/app"),
}
}
// ResolvePartnerID applies the same explicit-option, environment, and default
// precedence everywhere partner attribution is used. Keeping checkout creation
// and saved inference profiles on one resolver prevents them from attributing
// the same user to different partners.
func ResolvePartnerID(explicit string) string {
if value := strings.TrimSpace(explicit); value != "" {
return value
}
return envOrDefault("AIMLAPI_PARTNER_ID", DefaultPartnerID)
}
// WithResolvedPartnerHeader returns a copy with the effective partner ID set.
// Header matching is case-insensitive so an existing spelling is replaced rather
// than duplicated. Callers remain responsible for applying catalog attribution
// only to the canonical AIMLAPI endpoint.
func WithResolvedPartnerHeader(headers map[string]string) map[string]string {
resolved := make(map[string]string, len(headers)+1)
for key, value := range headers {
if strings.EqualFold(strings.TrimSpace(key), PartnerHeaderName) {
continue
}
resolved[key] = value
}
resolved[PartnerHeaderName] = ResolvePartnerID("")
return resolved
}
func BuildPartnerCheckoutReturnURLs(appBaseURL string, sessionToken string) (successURL string, cancelURL string) {
base := strings.TrimRight(strings.TrimSpace(appBaseURL), "/")
if base == "" {
// With no base there is nothing to anchor an absolute URL to. Return empty
// so the caller omits the return URLs entirely (client.Pay skips empty ones,
// leaving the backend's generic checkout) instead of emitting a broken
// relative "/checkout?...".
return "", ""
}
token := url.QueryEscape(sessionToken)
return base + "/checkout?checkout=success&partnerCheckout=1&sessionToken=" + token,
base + "/checkout?checkout=cancel&partnerCheckout=1&sessionToken=" + token
}
// BuildPartnerReturnURL returns the https page the browser is sent back to once
// the partner checkout or OAuth consent completes — the aimlapi web app the user
// opened the flow from. frontendBaseURL is normally endpoints.VerificationBaseURL
// (the front that hosts the login/consent page), so the return follows the same
// environment. AIMLAPI_RETURN_URL overrides it outright; an empty base falls back
// to DefaultReturnURL. It is deliberately NOT a custom scheme (zero://…): no
// browser can hand a custom scheme off without an OS-registered handler, which a
// CLI does not install.
func BuildPartnerReturnURL(frontendBaseURL string) string {
if override := strings.TrimSpace(os.Getenv("AIMLAPI_RETURN_URL")); override != "" {
return override
}
if base := strings.TrimRight(strings.TrimSpace(frontendBaseURL), "/"); base != "" {
return base
}
return DefaultReturnURL
}
func envOrDefault(name string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
-55
View File
@@ -1,55 +0,0 @@
package aimlapi
import "testing"
func TestResolveEndpointsDefaults(t *testing.T) {
t.Setenv("AIMLAPI_AUTH_URL", "")
t.Setenv("AIMLAPI_APP_URL", "")
t.Setenv("AIMLAPI_INFERENCE_URL", "")
t.Setenv("AIMLAPI_PAY_URL", "")
endpoints := ResolveEndpoints()
if endpoints.AuthBaseURL != "https://auth.aimlapi.com" ||
endpoints.AppBaseURL != "https://app.aimlapi.com" ||
endpoints.InferenceBaseURL != "https://api.aimlapi.com/v1" ||
endpoints.PayBaseURL != "https://pay.aimlapi.com" {
t.Fatalf("unexpected endpoints: %#v", endpoints)
}
}
func TestResolvedPartnerHeaderUsesEnvironmentOverrideWithoutDuplicates(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
headers := WithResolvedPartnerHeader(map[string]string{
"x-aimlapi-partner-id": "part_catalog",
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
})
if got := headers[PartnerHeaderName]; got != "part_override" {
t.Fatalf("partner header = %q, want part_override", got)
}
if _, ok := headers["x-aimlapi-partner-id"]; ok {
t.Fatalf("case-insensitive partner header was duplicated: %#v", headers)
}
if got := headers["X-AIMLAPI-Integration-Repo"]; got != "Gitlawb/zero" {
t.Fatalf("unrelated header changed: %#v", headers)
}
}
func TestBuildPartnerReturnURL(t *testing.T) {
t.Setenv("AIMLAPI_RETURN_URL", "")
// The frontend base (the web app the flow was opened from) is returned as-is,
// trailing slash trimmed — an https page the browser can actually land on.
if got := BuildPartnerReturnURL("https://aimlapi.com/app/"); got != "https://aimlapi.com/app" {
t.Fatalf("return URL = %q, want the frontend base", got)
}
// An empty base falls back to the https default, never a custom scheme.
if got := BuildPartnerReturnURL(""); got != DefaultReturnURL {
t.Fatalf("empty base return URL = %q, want %q", got, DefaultReturnURL)
}
t.Setenv("AIMLAPI_RETURN_URL", "https://example.test/done")
if got := BuildPartnerReturnURL("https://aimlapi.com/app"); got != "https://example.test/done" {
t.Fatalf("env override return URL = %q", got)
}
}
-42
View File
@@ -1,42 +0,0 @@
package aimlapi
// UI strings shared by the first-run and provider-wizard onboarding surfaces.
const (
// Path A — existing API key. Pick-path prompt + option labels.
MsgAPIKeyInputPrompt = "Enter your aimlapi.com key."
MsgAPIKeyInvalid = "API key is invalid. Please make sure you enter a valid aimlapi.com key."
MsgPickPathPrompt = "Do you have aimlapi.com key?"
MsgPickPathHaveKey = "I already have aimlapi.com key"
MsgPickPathHaveHint = "Proceed to paste the key"
MsgPickPathNewUser = "I am a new user"
MsgPickPathNewHint = "One click set up"
// Path B — email.
MsgEnterEmail = "Enter your email.\nTo access aimlapi.com dashboard."
MsgEmailInvalid = "Email format is incorrect."
MsgAccountActionInvalid = "aimlapi.com returned an unsupported account action. Please try again."
// %s = email
MsgCodeSent = "We sent a 6-digit code to %s.\nEnter it below to continue."
MsgCodeIncorrect = "Code you've entered is incorrect."
// Balance check.
MsgLowBalance = "Your aimlapi.com balance is running low.\nIt is recommended to top up your balance."
MsgEverythingRuns = "Everything is ready."
// Low-balance choices (spec wording).
MsgLowBalanceTopUp = "Sure, let's do that"
MsgLowBalanceSkip = "I'll skip topping up the balance for now"
// Top-up workflow.
MsgTopUpPrompt = "Add credits.\nEnter an amount (min $20)."
MsgAmountRequired = "Please enter a top-up amount."
// %s = checkout URL
MsgTopUpBrowserFallback = "If the browser did not open automatically please use this link to top up your account: %s"
MsgTopUpFailed = "Top up failed. Please try again."
MsgTopUpSuccess = "Top-up successful."
// Success — key delivered.
// %s = amount in dollars (e.g. "25")
MsgTopUpAddedFmt = "$%s has been added to your balance."
// %s = email
MsgSuccessMagicLink = "We've emailed you a magic link to %s. Use it to access your aimlapi.com account and review your usage and balance."
)
-26
View File
@@ -1,26 +0,0 @@
package aimlapi
import "testing"
func TestValidEmailRejectsIncompleteDomains(t *testing.T) {
tests := []struct {
value string
valid bool
}{
{value: "[email protected]", valid: true},
{value: "[email protected]", valid: true},
{value: "123@123.", valid: false},
{value: "123@123", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
{value: "[email protected]", valid: false},
}
for _, test := range tests {
t.Run(test.value, func(t *testing.T) {
if got := ValidEmail(test.value); got != test.valid {
t.Fatalf("ValidEmail(%q) = %v, want %v", test.value, got, test.valid)
}
})
}
}
-335
View File
@@ -1,335 +0,0 @@
package aimlapi
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// StreamTopUpOptions drives a session-based partner-checkout top-up for the
// interactive (TUI) onboarding. The caller already holds a user session —
// obtained from an email-code sign-in (Path B existing) or a
// passwordless new account (Path B sign-up) — so this skips authentication and
// runs create-session → pay → wait-for-payment, optionally exchanging the paid
// session into a fresh key (new accounts) versus keeping the caller's key.
type StreamTopUpOptions struct {
SessionToken string // user session bearer (required)
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
// InferenceBaseURL pins key-bound billing and the returned provider profile to
// the endpoint that validated the credential. Empty uses ResolveEndpoints.
InferenceBaseURL string
Method PaymentMethod
AutoTopUp bool // enroll the account in automatic top-up at checkout time
Exchange bool // exchange the paid session into a new key (new accounts)
Model string
PartnerID string
PartnerName string
NoOpen bool
// ResumeSessionToken, when set, resumes that partner-checkout session instead
// of opening a new one, so a retry after an ambiguous failure (a lost Pay/
// Exchange response) can never double-charge or strand an already-paid session.
ResumeSessionToken string
// PaymentSessionID is generated once per amount/auto-top-up intent and reused
// on retry so an ambiguous Pay response cannot create a second checkout.
PaymentSessionID string
HTTPClient *http.Client
OpenBrowser func(string) error
OnStatus func(Status, string)
// OnSession reports the partner-checkout session token the moment it exists (a
// fresh CreateSession, or the resumed one), so the caller can retain it and
// resume on a later failure. It fires with "" when the session is dead and the
// retained token should be dropped (a fresh run must start over).
OnSession func(sessionToken string)
}
// topupPhase is where StreamTopUp enters its create-checkout → poll → exchange
// pipeline. A fresh run starts at phasePay; a resume skips ahead to whatever the
// existing session's status makes safe.
type topupPhase int
const (
phasePay topupPhase = iota // (re-)open the Stripe checkout, then poll + exchange
phasePoll // checkout already open; poll for payment, never re-pay
phaseExchange // already paid; exchange only (recovers a lost key)
phaseWaitExchange // an exchange claim is in flight; poll, never call Exchange twice
)
// StreamTopUp performs the browser-checkout top-up and reports progress via
// OnStatus.
func StreamTopUp(ctx context.Context, options StreamTopUpOptions) (ProvisionedKey, error) {
endpoints := ResolveEndpoints()
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
endpoints.InferenceBaseURL = baseURL
}
client := NewClient(endpoints, options.HTTPClient)
partnerID := ResolvePartnerID(options.PartnerID)
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
method := options.Method
if method != PaymentMethodCrypto {
method = PaymentMethodCard
}
amount, err := ParseAmountUSD(options.AmountUSD)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(options.SessionToken) == "" {
return ProvisionedKey{}, fmt.Errorf("a session is required to top up")
}
if strings.TrimSpace(options.PaymentSessionID) == "" {
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
}
status(options.OnStatus, StatusCreatingSession, "")
sessionToken, phase, err := resolveTopupSession(ctx, client, options, partnerID, partnerName, endpoints)
if err != nil {
return ProvisionedKey{}, err
}
// Retain the live token so an ambiguous failure below can resume this session
// rather than open a second checkout on the next attempt.
reportSession(options.OnSession, sessionToken)
if phase <= phasePay {
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
status(options.OnStatus, StatusOpeningCheckout, "")
pay, err := client.Pay(ctx, options.SessionToken, sessionToken, amount, options.PaymentSessionID, method, successURL, cancelURL, options.AutoTopUp)
if err != nil {
return ProvisionedKey{}, err
}
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
if err != nil {
return ProvisionedKey{}, err
}
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
}
paidToken := sessionToken
if phase <= phasePoll {
status(options.OnStatus, StatusWaitingPayment, "")
paid, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession)
if err != nil {
return ProvisionedKey{}, err
}
paidToken = paid.SessionToken
if paid.Status == SessionStatusExchanging {
// Another/lost request claimed exchange while we were polling payment.
// Carry that fact into the provisioning branch so it follows the claim
// instead of issuing a second single-flight Exchange request.
phase = phaseWaitExchange
}
}
result := ProvisionedKey{
BaseURL: endpoints.InferenceBaseURL,
Model: firstNonEmpty(options.Model, DefaultModel),
}
if options.Exchange {
status(options.OnStatus, StatusProvisioningKey, "")
if phase == phaseWaitExchange {
return ProvisionedKey{}, pollUntilExchangeSettled(ctx, client, sessionToken, options.OnSession)
}
exchange, err := client.Exchange(ctx, options.SessionToken, paidToken)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(exchange.APIKey) == "" {
return ProvisionedKey{}, fmt.Errorf("aimlapi.com did not return an API key")
}
result.APIKey = exchange.APIKey
result.APIKeyID = exchange.APIKeyID
}
return result, nil
}
// resolveTopupSession returns the partner-checkout session token to drive and the
// phase to enter. A fresh run opens a new session at phasePay. A resume inspects
// the existing session and enters the earliest phase that cannot re-charge:
// - pending_auth → phasePay: payment never started, so (re-)Pay is safe.
// - pending_payment → phasePoll: a checkout is already open; poll, never re-Pay.
// - paid → phaseExchange: exchange the paid session once.
// - exchanging → phaseWaitExchange: another/lost request owns the atomic
// exchange claim; poll it, never issue a second Exchange request.
// - exchanged → terminal: the one-shot raw key is gone; point at dashboard
// recovery and keep the token so a re-run repeats that guidance, not a charge.
// - cancelled/expired/failed → terminal: drop the dead token so a re-run is fresh.
func resolveTopupSession(ctx context.Context, client *Client, options StreamTopUpOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
resume := strings.TrimSpace(options.ResumeSessionToken)
if resume == "" {
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
if err != nil {
return "", phasePay, err
}
return session.SessionToken, phasePay, nil
}
session, err := client.GetSession(ctx, resume)
if err != nil {
if isTerminalSessionAPIError(err) {
reportSession(options.OnSession, "")
}
return "", phasePay, err
}
switch session.Status {
case SessionStatusPendingAuth:
return resume, phasePay, nil
case SessionStatusPendingPayment:
return resume, phasePoll, nil
case SessionStatusPaid:
return resume, phaseExchange, nil
case SessionStatusExchanging:
return resume, phaseWaitExchange, nil
case SessionStatusExchanged:
return "", phasePay, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
default:
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
}
}
func reportSession(callback func(string), sessionToken string) {
if callback != nil {
callback(sessionToken)
}
}
// announceCheckout opens the hosted checkout in the browser (unless suppressed)
// and reports the URL either way, so the TUI can always show the direct link.
func announceCheckout(onStatus func(Status, string), open func(string) error, noOpen bool, checkoutURL string) {
if noOpen || open == nil {
status(onStatus, StatusOpeningCheckout, checkoutURL)
return
}
if err := open(checkoutURL); err != nil {
status(onStatus, StatusOpeningCheckout, "Open manually: "+checkoutURL)
return
}
status(onStatus, StatusOpeningCheckout, checkoutURL)
}
func validatedCheckoutURL(value string) (string, error) {
checkoutURL := strings.TrimSpace(value)
parsed, err := url.Parse(checkoutURL)
if err != nil || !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Hostname() == "" || parsed.User != nil {
return "", fmt.Errorf("payment provider did not return a valid HTTPS checkout URL")
}
return checkoutURL, nil
}
// StreamTopUpByKeyOptions drives a top-up funded with the API key that already
// owns the account (Path A pasted key, or AIMLAPI_API_KEY). Unlike StreamTopUp it
// needs no email session and never exchanges a key — the caller already holds one,
// so it only funds the wallet: create-session → pay-by-key → wait-for-payment.
type StreamTopUpByKeyOptions struct {
APIKey string // raw key that owns the account (required)
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
InferenceBaseURL string // validated key endpoint; empty uses ResolveEndpoints
AutoTopUp bool // enroll the account in automatic top-up at checkout time
PartnerID string
PartnerName string
NoOpen bool
// ResumeSessionToken resumes an existing partner-checkout session instead of
// opening a new one. PaymentSessionID is the idempotency handle carried across
// retries so a re-issued pay returns the same checkout, never a second charge.
ResumeSessionToken string
PaymentSessionID string // required; generate once via NewPaymentSessionID, reuse on retry
HTTPClient *http.Client
OpenBrowser func(string) error
OnStatus func(Status, string)
OnSession func(sessionToken string)
}
// StreamTopUpByKey funds the account behind APIKey via the hosted checkout and
// reports progress through OnStatus. It returns an empty ProvisionedKey: the
// pasted/env key is unchanged, only the balance grows.
func StreamTopUpByKey(ctx context.Context, options StreamTopUpByKeyOptions) (ProvisionedKey, error) {
endpoints := ResolveEndpoints()
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
endpoints.InferenceBaseURL = baseURL
}
client := NewClient(endpoints, options.HTTPClient)
partnerID := ResolvePartnerID(options.PartnerID)
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
amount, err := ParseAmountUSD(options.AmountUSD)
if err != nil {
return ProvisionedKey{}, err
}
if strings.TrimSpace(options.APIKey) == "" {
return ProvisionedKey{}, fmt.Errorf("an API key is required to top up")
}
if strings.TrimSpace(options.PaymentSessionID) == "" {
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
}
status(options.OnStatus, StatusCreatingSession, "")
sessionToken, phase, err := resolveByKeySession(ctx, client, options, partnerID, partnerName, endpoints)
if err != nil {
return ProvisionedKey{}, err
}
reportSession(options.OnSession, sessionToken)
if phase <= phasePay {
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
status(options.OnStatus, StatusOpeningCheckout, "")
pay, err := client.TopUpByKey(ctx, options.APIKey, TopUpByKeyRequest{
SessionToken: sessionToken,
AmountUSDMinor: amount,
PaymentSessionID: options.PaymentSessionID,
AutoTopUp: options.AutoTopUp,
SuccessURL: successURL,
CancelURL: cancelURL,
})
if err != nil {
return ProvisionedKey{}, err
}
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
if err != nil {
return ProvisionedKey{}, err
}
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
}
if phase <= phasePoll {
status(options.OnStatus, StatusWaitingPayment, "")
if _, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession); err != nil {
return ProvisionedKey{}, err
}
}
// No exchange: the account is funded and the caller keeps its existing key.
return ProvisionedKey{}, nil
}
// resolveByKeySession mirrors resolveTopupSession for the by-key flow. Because
// by-key never exchanges, a paid/exchanging/exchanged session all mean "already
// funded, done" (phaseExchange skips straight past pay + poll to the empty result).
func resolveByKeySession(ctx context.Context, client *Client, options StreamTopUpByKeyOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
resume := strings.TrimSpace(options.ResumeSessionToken)
if resume == "" {
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
if err != nil {
return "", phasePay, err
}
return session.SessionToken, phasePay, nil
}
session, err := client.GetSession(ctx, resume)
if err != nil {
if isTerminalSessionAPIError(err) {
reportSession(options.OnSession, "")
}
return "", phasePay, err
}
switch session.Status {
case SessionStatusPendingAuth:
return resume, phasePay, nil
case SessionStatusPendingPayment:
return resume, phasePoll, nil
case SessionStatusPaid, SessionStatusExchanging, SessionStatusExchanged:
return resume, phaseExchange, nil
default:
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
}
}
-457
View File
@@ -1,457 +0,0 @@
package aimlapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestStreamTopUpByKeyFundsWithoutExchange(t *testing.T) {
var topupCalls, exchangeCalls int
var topupBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
topupCalls++
_ = json.NewDecoder(r.Body).Decode(&topupBody)
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
exchangeCalls++
t.Errorf("by-key must never exchange a key")
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
t.Setenv("AIMLAPI_INFERENCE_URL", server.URL+"/v1")
var opened string
result, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
APIKey: "sk-user",
AmountUSD: "20",
AutoTopUp: true,
PaymentSessionID: "pay-1",
OpenBrowser: func(u string) error { opened = u; return nil },
})
if err != nil {
t.Fatalf("StreamTopUpByKey = %v", err)
}
if result.APIKey != "" {
t.Fatalf("by-key must not mint a key, got %q", result.APIKey)
}
if topupCalls != 1 || exchangeCalls != 0 {
t.Fatalf("topup=%d exchange=%d, want 1/0", topupCalls, exchangeCalls)
}
if opened != "https://pay/checkout" {
t.Fatalf("opened checkout = %q", opened)
}
if topupBody["autoTopUp"] != true || topupBody["paymentSessionId"] != "pay-1" {
t.Fatalf("topup body missing autoTopUp/paymentSessionId: %#v", topupBody)
}
}
func TestValidatedCheckoutURLRequiresAbsoluteHTTPS(t *testing.T) {
tests := []struct {
name string
value string
want string
wantErr bool
}{
{name: "https", value: " https://pay.example.test/checkout?id=1 ", want: "https://pay.example.test/checkout?id=1"},
{name: "empty", wantErr: true},
{name: "relative", value: "/checkout", wantErr: true},
{name: "http", value: "http://pay.example.test/checkout", wantErr: true},
{name: "file", value: "file:///tmp/checkout", wantErr: true},
{name: "custom protocol", value: "wallet://checkout/123", wantErr: true},
{name: "userinfo", value: "https://[email protected]/checkout", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := validatedCheckoutURL(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("validatedCheckoutURL(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("validatedCheckoutURL(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestStreamTopUpByKeyPinsValidatedInferenceEndpoint(t *testing.T) {
validatedCalls := 0
validated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
validatedCalls++
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
default:
t.Errorf("unexpected validated-endpoint request: %s %s", r.Method, r.URL.Path)
}
}))
defer validated.Close()
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("validated key leaked to process override: %s %s", r.Method, r.URL.Path)
}))
defer override.Close()
t.Setenv("AIMLAPI_APP_URL", validated.URL)
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
_, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
APIKey: "production-key",
AmountUSD: "20",
InferenceBaseURL: validated.URL,
PaymentSessionID: "payment-1",
OpenBrowser: func(string) error { return nil },
})
if err != nil {
t.Fatalf("StreamTopUpByKey() error = %v", err)
}
if validatedCalls != 1 {
t.Fatalf("validated endpoint top-up calls = %d, want 1", validatedCalls)
}
}
func TestStreamTopUpEmailRetrySendsStablePaymentSessionID(t *testing.T) {
getCalls := 0
var paymentSessionID string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
status := SessionStatusPendingAuth
if getCalls > 0 {
status = SessionStatusPaid
}
getCalls++
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_retry", Status: status})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
paymentSessionID, _ = body["paymentSessionId"].(string)
_ = json.NewEncoder(w).Encode(PayResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pcs_retry",
PaymentSessionID: "payment-stable",
AmountUSD: "20",
NoOpen: true,
})
if err != nil {
t.Fatalf("StreamTopUp() error = %v", err)
}
if paymentSessionID != "payment-stable" {
t.Fatalf("paymentSessionId = %q, want stable retry id", paymentSessionID)
}
}
// resumeServer is a partner-checkout backend fake that fails the test if the
// re-charging endpoints (create-session / pay) are ever hit, and serves a
// scripted GetSession status sequence so a resume can be driven deterministically.
type resumeServer struct {
t *testing.T
statuses []string // GetSession status per call, last value repeats
getCalls int
payCalls int
createCall int
exchanged int
}
func (rs *resumeServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
rs.createCall++
rs.t.Errorf("resume must not create a new session")
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
rs.payCalls++
rs.t.Errorf("resume must not re-pay (double charge)")
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
rs.exchanged++
_ = json.NewEncoder(w).Encode(ExchangeResult{APIKey: "sk-resumed", APIKeyID: "key_1"})
case r.Method == http.MethodGet:
status := rs.statuses[len(rs.statuses)-1]
if rs.getCalls < len(rs.statuses) {
status = rs.statuses[rs.getCalls]
}
rs.getCalls++
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pc_tok", Status: SessionStatus(status)})
default:
rs.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
}
func TestStreamTopUpResumePaidExchangesWithoutRecharge(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"paid"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
var seen string
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
OnSession: func(token string) { seen = token },
})
if err != nil {
t.Fatalf("StreamTopUp resume = %v", err)
}
if result.APIKey != "sk-resumed" {
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
}
if rs.exchanged != 1 {
t.Fatalf("exchange calls = %d, want 1", rs.exchanged)
}
if seen != "pc_tok" {
t.Fatalf("OnSession token = %q, want retained pc_tok", seen)
}
}
func TestStreamTopUpResumePendingPaymentPollsNeverRepays(t *testing.T) {
// First GetSession (resume resolution) sees the still-open checkout; the next
// (poll) sees it settle. Pay must never be called — the fake fails the test if
// it is.
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "paid"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err != nil {
t.Fatalf("StreamTopUp resume = %v", err)
}
if result.APIKey != "sk-resumed" {
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
}
if rs.payCalls != 0 {
t.Fatalf("pay calls = %d, want 0", rs.payCalls)
}
}
func TestStreamTopUpPollingDeadSessionClearsRetainedToken(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "expired"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
seen := []string{}
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
OnSession: func(token string) { seen = append(seen, token) },
})
if err == nil || !strings.Contains(err.Error(), "payment expired") {
t.Fatalf("err = %v, want expired payment error", err)
}
if len(seen) == 0 || seen[len(seen)-1] != "" {
t.Fatalf("OnSession events = %#v, want terminal clear", seen)
}
}
func TestPollUntilPaidTerminal4xxClearsRetainedToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session rejected", http.StatusNotFound)
}))
defer server.Close()
seen := "retained"
_, err := pollUntilPaid(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pc_missing", func(token string) {
seen = token
})
if err == nil {
t.Fatal("pollUntilPaid() error = nil, want terminal 4xx")
}
if seen != "" {
t.Fatalf("retained session = %q, want cleared", seen)
}
}
func TestPollUntilExchangeSettledClearsDeadSession(t *testing.T) {
for _, test := range []struct {
name string
statusCode int
status SessionStatus
}{
{name: "terminal API rejection", statusCode: http.StatusNotFound},
{name: "expired exchange claim", status: SessionStatusExpired},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if test.statusCode != 0 {
http.Error(w, "session rejected", test.statusCode)
return
}
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_dead", Status: test.status})
}))
defer server.Close()
seen := "retained"
err := pollUntilExchangeSettled(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pcs_dead", func(token string) {
seen = token
})
if err == nil {
t.Fatal("pollUntilExchangeSettled() error = nil")
}
if seen != "" {
t.Fatalf("retained session = %q, want cleared", seen)
}
})
}
}
func TestResumeHelpersClearOnlyRejectedSessions(t *testing.T) {
tests := []struct {
name string
status int
wantClear bool
}{
{name: "terminal 4xx", status: http.StatusNotFound, wantClear: true},
{name: "retryable 5xx", status: http.StatusServiceUnavailable, wantClear: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "session lookup failed", test.status)
}))
defer server.Close()
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
for _, flow := range []struct {
name string
resolve func(func(string)) error
}{
{
name: "session bearer",
resolve: func(onSession func(string)) error {
_, _, err := resolveTopupSession(context.Background(), client, StreamTopUpOptions{
ResumeSessionToken: "pc_rejected", OnSession: onSession,
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
return err
},
},
{
name: "api key",
resolve: func(onSession func(string)) error {
_, _, err := resolveByKeySession(context.Background(), client, StreamTopUpByKeyOptions{
ResumeSessionToken: "pc_rejected", OnSession: onSession,
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
return err
},
},
} {
t.Run(flow.name, func(t *testing.T) {
seen := "retained"
if err := flow.resolve(func(token string) { seen = token }); err == nil {
t.Fatal("resolve error = nil")
}
if cleared := seen == ""; cleared != test.wantClear {
t.Fatalf("session cleared = %v, want %v", cleared, test.wantClear)
}
})
}
})
}
}
func TestStreamTopUpResumeExchangedReturnsRecoveryError(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want an already-exchanged recovery error", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 (no second key)", rs.exchanged)
}
}
func TestStreamTopUpResumeExchangingPollsWithoutSecondExchange(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"exchanging", "exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want dashboard recovery guidance", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 while an exchange claim is in flight", rs.exchanged)
}
if rs.createCall != 0 || rs.payCalls != 0 {
t.Fatalf("resume created/paid a new checkout: create=%d pay=%d", rs.createCall, rs.payCalls)
}
}
func TestStreamTopUpPollObservesExchangingWithoutSecondExchange(t *testing.T) {
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "exchanging", "exchanged"}}
server := httptest.NewServer(rs.handler())
defer server.Close()
t.Setenv("AIMLAPI_APP_URL", server.URL)
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
SessionToken: "bearer",
ResumeSessionToken: "pc_tok",
PaymentSessionID: "payment-1",
AmountUSD: "20",
Exchange: true,
NoOpen: true,
})
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
t.Fatalf("err = %v, want dashboard recovery guidance", err)
}
if rs.exchanged != 0 {
t.Fatalf("exchange calls = %d, want 0 after poll observed an existing claim", rs.exchanged)
}
}
-186
View File
@@ -1,186 +0,0 @@
package aimlapi
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// Status is a progress phase reported by StreamTopUp through its OnStatus callback.
type Status string
const (
StatusCreatingSession Status = "creating-session"
StatusOpeningCheckout Status = "opening-checkout"
StatusWaitingPayment Status = "waiting-payment"
StatusProvisioningKey Status = "provisioning-key"
)
// ProvisionedKey is the outcome of a top-up: the (optionally minted) API key plus
// the inference base URL and model to write into the provider profile.
type ProvisionedKey struct {
APIKey string
APIKeyID string
BaseURL string
Model string
}
const (
pollInterval = 3 * time.Second
pollTimeout = 20 * time.Minute
)
// ParseAmountUSD parses a dollar string into USD minor units (cents), enforcing the
// min/max top-up bounds. An empty value yields the default amount.
func ParseAmountUSD(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultAmountUSDMinor, nil
}
dollars, err := strconv.ParseFloat(value, 64)
// NaN/±Inf parse without error and slip past the min/max comparisons below
// (every ordered comparison with NaN is false), so reject them explicitly
// before the cents conversion turns them into a garbage minor-unit amount.
if err != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0 {
return 0, fmt.Errorf("invalid amount %q; pass a positive USD amount", value)
}
minor := int(dollars*100 + 0.5)
if minor < MinAmountUSDMinor {
return 0, fmt.Errorf("minimum top-up is $%d", MinAmountUSDMinor/100)
}
if minor > MaxAmountUSDMinor {
return 0, fmt.Errorf("maximum top-up is $%d", MaxAmountUSDMinor/100)
}
return minor, nil
}
// NewPaymentSessionID returns a random UUIDv4-shaped idempotency id for a top-up.
// The caller generates it once per top-up intent and reuses it on retry so the
// backend (and Stripe) coalesce retries onto the same checkout instead of a
// second charge.
func NewPaymentSessionID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func pollUntilPaid(ctx context.Context, client *Client, sessionToken string, onSession func(string)) (PartnerCheckoutSession, error) {
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
session, err := client.GetSession(ctx, sessionToken)
if err != nil {
// Context cancellation/deadline is terminal (also covers an in-flight
// request cancelled mid-poll, whose transport error wraps ctx.Err()).
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return PartnerCheckoutSession{}, err
}
// A 4xx API response is terminal. Everything else — a 5xx or a transient
// transport error (non-APIError, e.g. a dropped connection during the
// wait) — is retried until the poll deadline.
if isTerminalSessionAPIError(err) {
reportSession(onSession, "")
return PartnerCheckoutSession{}, err
}
if err := sleepContext(ctx, pollInterval); err != nil {
return PartnerCheckoutSession{}, err
}
continue
}
switch session.Status {
case SessionStatusPaid, SessionStatusExchanging:
return session, nil
case SessionStatusExchanged:
return PartnerCheckoutSession{}, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
// The checkout cannot recover from these states. Drop the retained token
// (and the TUI's paired by-key idempotency ID) now so the very next retry
// creates a fresh payment intent.
reportSession(onSession, "")
return PartnerCheckoutSession{}, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
default:
if err := sleepContext(ctx, pollInterval); err != nil {
return PartnerCheckoutSession{}, err
}
}
}
return PartnerCheckoutSession{}, fmt.Errorf("timed out waiting for payment; re-run once the payment clears")
}
func isTerminalSessionAPIError(err error) bool {
var apiErr APIError
return errors.As(err, &apiErr) && apiErr.Status >= http.StatusBadRequest && apiErr.Status < http.StatusInternalServerError
}
// pollUntilExchangeSettled follows an exchange claim that was already in flight
// when a retry resumed the session. Exchange is single-flight and the raw key is
// returned only to the winning request, so issuing Exchange again is never safe.
// Once the claim completes, direct the user to the dashboard recovery path.
func pollUntilExchangeSettled(ctx context.Context, client *Client, sessionToken string, onSession func(string)) error {
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
session, err := client.GetSession(ctx, sessionToken)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
if isTerminalSessionAPIError(err) {
reportSession(onSession, "")
return err
}
if err := sleepContext(ctx, pollInterval); err != nil {
return err
}
continue
}
switch session.Status {
case SessionStatusExchanged:
return fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
case SessionStatusExchanging:
if err := sleepContext(ctx, pollInterval); err != nil {
return err
}
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
reportSession(onSession, "")
return fmt.Errorf("key provisioning %s; rotate the key from the aimlapi.com dashboard", session.Status)
default:
return fmt.Errorf("key provisioning returned to %s; re-run the top-up", session.Status)
}
}
return fmt.Errorf("timed out waiting for key provisioning; retry to check the same session")
}
func sleepContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func status(callback func(Status, string), value Status, detail string) {
if callback != nil {
callback(value, detail)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
-38
View File
@@ -1,38 +0,0 @@
package aimlapi
import (
"net/mail"
"strings"
)
// ValidEmail performs the lightweight validation used before starting the
// passwordless onboarding request.
func ValidEmail(value string) bool {
value = strings.TrimSpace(value)
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value {
return false
}
at := strings.LastIndexByte(value, '@')
if at <= 0 || at == len(value)-1 {
return false
}
domain := value[at+1:]
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
return false
}
dot := strings.LastIndexByte(domain, '.')
if dot <= 0 || dot == len(domain)-1 {
return false
}
tld := domain[dot+1:]
if len(tld) < 2 {
return false
}
for _, char := range tld {
if char < 'A' || char > 'Z' && char < 'a' || char > 'z' {
return false
}
}
return true
}
+5 -6
View File
@@ -766,12 +766,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
-77
View File
@@ -348,83 +348,6 @@ func TestRunProvidersAddWritesCatalogProfile(t *testing.T) {
}
}
func TestRunProvidersAddAimlapiWritesDefaultHeaders(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
exitCode := runWithDeps([]string{"providers", "add", "aimlapi"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
cfg := readFileConfig(t, configPath)
if len(cfg.Providers) != 1 {
t.Fatalf("providers = %#v, want one provider", cfg.Providers)
}
profile := cfg.Providers[0]
if profile.Name != "aimlapi" ||
profile.CatalogID != "aimlapi" ||
profile.BaseURL != "https://api.aimlapi.com/v1" ||
profile.Model != "anthropic/claude-sonnet-5" ||
profile.APIKeyEnv != "AIMLAPI_API_KEY" {
t.Fatalf("unexpected provider profile: %#v", profile)
}
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_62yQoGYDq4Yqnrj2R1iGrDNJ" ||
profile.CustomHeaders["X-AIMLAPI-Integration-Repo"] != "Gitlawb/zero" {
t.Fatalf("missing aimlapi.com default headers: %#v", profile.CustomHeaders)
}
}
func TestRunProvidersAddAimlapiMixedCaseHeaderOverride(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
// A differently-cased override must replace the catalog header in place, not
// leave both spellings behind to race when request construction canonicalizes.
exitCode := runWithDeps([]string{
"providers", "add", "aimlapi",
"--header", "x-aimlapi-partner-id=part_override",
}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
profile := readFileConfig(t, configPath).Providers[0]
if _, ok := profile.CustomHeaders["x-aimlapi-partner-id"]; ok {
t.Fatalf("lowercase override left a duplicate key: %#v", profile.CustomHeaders)
}
if profile.CustomHeaders["X-AIMLAPI-Partner-ID"] != "part_override" {
t.Fatalf("override did not win on the canonical key: %#v", profile.CustomHeaders)
}
}
func TestRunProvidersAddAimlapiOverrideDropsCatalogHeaders(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
exitCode := runWithDeps([]string{
"providers", "add", "aimlapi",
"--base-url", "https://staging.example/v1",
"--header", "X-Environment=staging",
}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
profile := readFileConfig(t, configPath).Providers[0]
if _, ok := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; ok {
t.Fatalf("partner attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
}
if _, ok := profile.CustomHeaders["X-AIMLAPI-Integration-Repo"]; ok {
t.Fatalf("integration attribution leaked to overridden endpoint: %#v", profile.CustomHeaders)
}
if profile.CustomHeaders["X-Environment"] != "staging" {
t.Fatalf("explicit user header was dropped: %#v", profile.CustomHeaders)
}
}
func TestRunProvidersAddWritesCustomHeaders(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
+5 -19
View File
@@ -853,28 +853,14 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: policy,
Store: store,
Backend: backend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
WorkspaceRoot: workspaceRoot,
Policy: policy,
Store: store,
Backend: backend,
Scope: scope,
}), nil
}
func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
keys := make([]string, 0, len(resolved.Providers)+1)
for _, profile := range resolved.Providers {
if key := strings.TrimSpace(profile.APIKeyEnv); key != "" {
keys = append(keys, key)
}
}
if key := strings.TrimSpace(resolved.Provider.APIKeyEnv); key != "" {
keys = append(keys, key)
}
return keys
}
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
// the default policy.
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
@@ -17,6 +17,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
defaultModel string
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "aimlapi", name: "AI/ML API", transport: "openai-compatible", defaultModel: "openai/gpt-5-chat"},
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
@@ -47,6 +48,21 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogAIMLAPIRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "aimlapi")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=AIMLAPI_API_KEY",
"setup: zero providers setup aimlapi --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected AIMLAPI catalog block to contain %q, got:\n%s", want, block)
}
}
}
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "longcat")
+3 -62
View File
@@ -3,10 +3,8 @@ package cli
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providerhealth"
@@ -426,39 +424,22 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
apiKeyEnv = descriptor.AuthEnvVars[0]
}
baseURL := firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL)
var catalogHeaders map[string]string
if sameProviderSetupBaseURL(baseURL, descriptor.DefaultBaseURL) {
catalogHeaders = descriptor.CustomHeaders
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
}
}
profile := config.ProviderProfile{
Name: name,
ProviderKind: providerKindForDescriptor(descriptor),
CatalogID: descriptor.ID,
BaseURL: baseURL,
BaseURL: firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL),
APIKeyEnv: apiKeyEnv,
APIFormat: firstAPIFormat(descriptor),
AuthHeader: strings.TrimSpace(options.authHeader),
AuthScheme: normalizeAuthScheme(options.authScheme),
AuthHeaderValue: strings.TrimSpace(options.authHeaderValue),
// Catalog attribution belongs only to the catalog endpoint. Explicit
// user headers still apply to an override/staging/proxy endpoint.
CustomHeaders: mergeProviderHeaders(catalogHeaders, options.customHeaders),
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
CustomHeaders: copyProviderHeaders(options.customHeaders),
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
}
return profile, nil
}
func sameProviderSetupBaseURL(left string, right string) bool {
return strings.EqualFold(
strings.TrimRight(strings.TrimSpace(left), "/"),
strings.TrimRight(strings.TrimSpace(right), "/"),
)
}
func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config.ProviderProfile, error) {
name = strings.TrimSpace(name)
if name == "" {
@@ -563,43 +544,3 @@ func copyProviderHeaders(headers map[string]string) map[string]string {
}
return copied
}
func mergeProviderHeaders(base map[string]string, overrides map[string]string) map[string]string {
merged := copyProviderHeaders(base)
if len(overrides) == 0 {
return merged
}
if merged == nil {
merged = map[string]string{}
}
for key, value := range overrides {
// HTTP header names are case-insensitive and request construction
// canonicalizes them (http.Header.Set), so an override that differs from a
// base key only in case must replace that key's value in place — otherwise
// both survive as distinct map entries and race non-deterministically at
// build time. Keep the existing spelling (the catalog's, which the resolver
// re-merges verbatim); only a genuinely new header is added as typed.
if existing, ok := headerKeyFold(merged, key); ok {
merged[existing] = value
continue
}
merged[key] = value
}
return merged
}
// headerKeyFold returns the key in headers whose canonical HTTP form matches key,
// so a case-insensitive override lands on the existing entry instead of adding a
// colliding duplicate.
func headerKeyFold(headers map[string]string, key string) (string, bool) {
if _, ok := headers[key]; ok {
return key, true
}
canonical := http.CanonicalHeaderKey(key)
for existing := range headers {
if http.CanonicalHeaderKey(existing) == canonical {
return existing, true
}
}
return "", false
}
@@ -1,23 +0,0 @@
package cli
import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestProviderSensitiveEnvKeys(t *testing.T) {
resolved := config.ResolvedConfig{
Providers: []config.ProviderProfile{
{Name: "custom", APIKeyEnv: " COMPANY_LLM_SECRET "},
{Name: "catalog", APIKeyEnv: "OPENAI_API_KEY"},
{Name: "inline"},
},
Provider: config.ProviderProfile{Name: "active", APIKeyEnv: "ACTIVE_PROVIDER_SECRET"},
}
want := []string{"COMPANY_LLM_SECRET", "OPENAI_API_KEY", "ACTIVE_PROVIDER_SECRET"}
if got := providerSensitiveEnvKeys(resolved); !reflect.DeepEqual(got, want) {
t.Fatalf("providerSensitiveEnvKeys() = %v, want %v", got, want)
}
}
-48
View File
@@ -200,54 +200,6 @@ func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
}
}
func TestSaveSetupProviderAimlapiEnvReferenceAndOverrideHeaders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
}, tui.SetupSelection{
CatalogID: "aimlapi",
BaseURL: "https://staging.example/v1",
Model: "anthropic/claude-sonnet-5",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.APIKey != "" || result.Provider.APIKeyEnv != "AIMLAPI_API_KEY" {
t.Fatalf("runtime profile did not retain AIMLAPI_API_KEY reference: %+v", result.Provider)
}
if len(result.Provider.CustomHeaders) != 0 {
t.Fatalf("catalog headers leaked to first-run override: %#v", result.Provider.CustomHeaders)
}
persisted := readFileConfig(t, configPath).Providers[0]
if persisted.APIKey != "" || persisted.APIKeyEnv != "AIMLAPI_API_KEY" || persisted.APIKeyStored {
t.Fatalf("persisted credential source changed: %+v", persisted)
}
if len(persisted.CustomHeaders) != 0 {
t.Fatalf("persisted override contains catalog headers: %#v", persisted.CustomHeaders)
}
}
func TestSaveSetupProviderAimlapiUsesResolvedPartnerOverride(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
}, tui.SetupSelection{
CatalogID: "aimlapi",
BaseURL: "https://api.aimlapi.com/v1",
Model: "anthropic/claude-sonnet-5",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if got := result.Provider.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
t.Fatalf("runtime partner header = %q, want part_override", got)
}
if got := readFileConfig(t, configPath).Providers[0].CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
t.Fatalf("persisted partner header = %q, want part_override", got)
}
}
func TestSaveSetupProviderStoresCustomEndpointSelection(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
+6 -32
View File
@@ -6,7 +6,7 @@ import (
"strings"
)
func mergeMCPConfig(dst *MCPConfig, src MCPConfig, canReenable bool) {
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
}
@@ -14,14 +14,10 @@ func mergeMCPConfig(dst *MCPConfig, src MCPConfig, canReenable bool) {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server, canReenable)
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
}
}
// mergeProjectMCPConfig merges a project-scoped (lower-trust) MCP config into
// dst. It never re-enables a server the user explicitly disabled or disabled a
// server the user explicitly enabled: it merges with canReenable=false so the
// trust-boundary guard in mergeMCPServer keeps the user's decision authoritative.
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
if len(src.Servers) == 0 {
return nil
@@ -31,7 +27,7 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
}
for name, server := range src.Servers {
base := dst.Servers[name]
candidate := mergeMCPServer(base, server, false)
candidate := mergeMCPServer(base, server)
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
}
@@ -41,11 +37,7 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
return nil
}
// mergeMCPServer merges a later config layer's MCP server into the base. The
// canReenable flag marks the CLI-override scope, the only layer that is allowed
// to lift a sticky user-level disable/enable decision (see the disabled-handling
// block).
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool) MCPServerConfig {
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
if strings.TrimSpace(next.Type) != "" {
base.Type = next.Type
}
@@ -70,26 +62,8 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
if next.OAuth != nil {
base.OAuth = next.OAuth
}
// Capture the higher-trust scope's prior decision before folding in
// next.disabledSet. The trust boundary must be evaluated against the state
// the higher-trust layer actually left behind, not against next's own flag.
baseDisabledSet := base.disabledSet
baseDisabled := base.Disabled
if next.disabledSet {
base.disabledSet = true
}
if next.disabledSet || next.Disabled {
// Trust boundary: once a higher-trust scope has explicitly set the
// disabled flag, a lower-trust layer (project config) cannot override
// that decision in either direction — the user's choice (whether to
// enable or disable a server) wins over the repo. This blocks a repo
// from re-enabling a server the user disabled, and from disabling a
// server the user explicitly enabled. The only layer allowed to
// override an explicit higher-scope decision is the CLI override scope
// (canReenable=true).
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
base.Disabled = next.Disabled
}
base.Disabled = next.Disabled
}
if next.configured {
base.configured = true
@@ -101,7 +75,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
}
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next, true)) {
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
return true
}
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
+4 -47
View File
@@ -9,7 +9,6 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providercatalog"
@@ -171,10 +170,7 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
if err != nil {
return MCPConfig{}, err
}
// User config is higher-trust than project config: it may re-enable a
// server the user disabled (a user-level disable is sticky, but the user
// scope itself may lift it).
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
@@ -188,7 +184,7 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
return MCPConfig{}, err
}
}
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP, true)
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
return cfg.MCP, nil
}
@@ -215,7 +211,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
for _, provider := range src.Providers {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP, true)
mergeMCPConfig(&dst.MCP, src.MCP)
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
@@ -713,7 +709,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if hasProviderFields(overrides.Provider) {
mergeProvider(cfg, overrides.Provider)
}
mergeMCPConfig(&cfg.MCP, overrides.MCP, true)
mergeMCPConfig(&cfg.MCP, overrides.MCP)
}
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
@@ -1043,45 +1039,6 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
}
canonicalCatalogEndpoint := !explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
if len(descriptor.CustomHeaders) > 0 && canonicalCatalogEndpoint {
catalogHeaders := descriptor.CustomHeaders
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
}
merged := copyStringMap(catalogHeaders)
for key, value := range profile.CustomHeaders {
// Header names are case-insensitive. Preserve the catalog spelling while
// replacing its value so request construction cannot see two colliding
// map entries whose eventual winner depends on iteration order.
existingKey := ""
for candidate := range merged {
if strings.EqualFold(candidate, key) {
existingKey = candidate
break
}
}
if existingKey != "" {
merged[existingKey] = value
continue
}
merged[key] = value
}
profile.CustomHeaders = merged
} else if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") && !canonicalCatalogEndpoint {
// AIMLAPI attribution is owned by the catalog endpoint. A profile can retain
// those generated headers after its base URL is edited; strip their names
// before sending requests to an arbitrary staging/proxy host while preserving
// unrelated headers explicitly supplied by the user.
for profileKey := range profile.CustomHeaders {
for catalogKey := range descriptor.CustomHeaders {
if strings.EqualFold(profileKey, catalogKey) {
delete(profile.CustomHeaders, profileKey)
break
}
}
}
}
}
func sameBaseURL(left string, right string) bool {
+3 -190
View File
@@ -9,7 +9,6 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
)
func TestResolveAppliesLayerPrecedence(t *testing.T) {
@@ -555,7 +554,7 @@ func TestResolveReplacesMCPServerOverlayCollections(t *testing.T) {
}
}
func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
func TestResolveMCPServerLayersCanClearAndReenable(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
@@ -589,11 +588,9 @@ func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
}
docs := resolved.MCP.Servers["docs"]
// A user-level disable is sticky: project config must not re-enable it.
if !docs.Disabled {
t.Fatal("docs.Disabled = false, want user-level disable to remain sticky")
if docs.Disabled {
t.Fatalf("docs.Disabled = true, want project layer to re-enable")
}
// Non-disable fields from the project layer still merge normally.
if len(docs.Args) != 0 {
t.Fatalf("docs.Args = %#v, want project layer to clear inherited args", docs.Args)
}
@@ -602,125 +599,6 @@ func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
}
}
func TestMergeMCPStickyDisableReenable(t *testing.T) {
disabled := MCPServerConfig{Disabled: true, disabledSet: true}
// A lower-trust (project) layer must not lift a sticky user disable.
project := MCPServerConfig{Disabled: false, disabledSet: true}
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
t.Fatal("project layer re-enabled a sticky user disable")
}
// The user scope (explicit mcp enable command / CLI override) may re-enable.
override := MCPServerConfig{Disabled: false, disabledSet: true}
if got := mergeMCPServer(disabled, override, true); got.Disabled {
t.Fatal("user-scope override must be able to re-enable")
}
// A user-scope disable stacked on a project attempt still sticks.
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
t.Fatal("sticky disable must survive a project re-enable attempt")
}
// Baseline: a project layer may disable a default-enabled server when no
// higher-trust scope has explicitly set disabled (the trust boundary must
// not block legitimate project disables). Regression guard for the
// disabledSet-before-check ordering bug.
defaultEnabled := MCPServerConfig{Disabled: false, disabledSet: false}
projectDisable := MCPServerConfig{Disabled: true, disabledSet: true}
if got := mergeMCPServer(defaultEnabled, projectDisable, false); !got.Disabled {
t.Fatal("project layer should be able to disable a default-enabled server")
}
// And an empty project layer must not flip an unconfigured server to disabled.
if got := mergeMCPServer(MCPServerConfig{}, MCPServerConfig{}, false); got.Disabled {
t.Fatal("an empty project layer must not disable an unconfigured server")
}
}
// TestResolveMCPCannotReenableUserDisabled exercises the trust boundary at the
// actual ResolveMCP entry point: a project config (project scope) must not lift
// a disable the user set in their higher-trust user config.
func TestResolveMCPCannotReenableUserDisabled(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"disabled": true
}
}
}
}`)
// Project config tries to re-enable it.
projectPath := writeConfig(t, `{
"mcpServers": {
"docs": {
"disabled": false
}
}
}`)
resolved, err := ResolveMCP(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
})
if err != nil {
t.Fatalf("ResolveMCP() error = %v", err)
}
docs, ok := resolved.Servers["docs"]
if !ok {
t.Fatal("docs server missing from resolved config")
}
if !docs.Disabled {
t.Fatalf("docs.Disabled = false, want user-level disable to remain sticky across project re-enable")
}
}
// TestResolveMCPUserLiftsProjectDisabled asserts the reverse direction is open:
// a user config (higher-trust scope) re-enabling a server the project config
// disabled must be permitted, since user > project in the trust hierarchy.
func TestResolveMCPUserLiftsProjectDisabled(t *testing.T) {
// Project config disables the server.
projectPath := writeConfig(t, `{
"mcpServers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"disabled": true
}
}
}`)
// User config re-enables it.
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"disabled": false
}
}
}
}`)
resolved, err := ResolveMCP(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
})
if err != nil {
t.Fatalf("ResolveMCP() error = %v", err)
}
docs, ok := resolved.Servers["docs"]
if !ok {
t.Fatal("docs server missing from resolved config")
}
if docs.Disabled {
t.Fatalf("docs.Disabled = true, want user scope to lift the project-level disable")
}
}
func TestResolveRejectsDuplicateMCPRootAliases(t *testing.T) {
path := writeConfig(t, `{
"mcpServers": {
@@ -1496,71 +1374,6 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
}
}
func TestApplyCatalogDescriptorFoldsCustomHeadersCaseInsensitively(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "part_env")
descriptor, err := providercatalog.Require("aimlapi")
if err != nil {
t.Fatal(err)
}
profile := ProviderProfile{
BaseURL: descriptor.DefaultBaseURL,
CustomHeaders: map[string]string{
"x-aimlapi-partner-id": "part_override",
},
}
applyCatalogDescriptor(&profile, descriptor, true)
if got := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_override" {
t.Fatalf("canonical header value = %q, want override", got)
}
if _, duplicate := profile.CustomHeaders["x-aimlapi-partner-id"]; duplicate {
t.Fatalf("case-colliding header survived merge: %#v", profile.CustomHeaders)
}
}
func TestApplyCatalogDescriptorUsesAimlapiPartnerEnvironmentOverride(t *testing.T) {
t.Setenv("AIMLAPI_PARTNER_ID", "part_env")
descriptor, err := providercatalog.Require("aimlapi")
if err != nil {
t.Fatal(err)
}
profile := ProviderProfile{BaseURL: descriptor.DefaultBaseURL}
applyCatalogDescriptor(&profile, descriptor, true)
if got := profile.CustomHeaders["X-AIMLAPI-Partner-ID"]; got != "part_env" {
t.Fatalf("partner header = %q, want part_env", got)
}
}
func TestApplyCatalogDescriptorStripsAimlapiAttributionFromRetargetedProfile(t *testing.T) {
descriptor, err := providercatalog.Require("aimlapi")
if err != nil {
t.Fatal(err)
}
profile := ProviderProfile{
BaseURL: "https://proxy.example.test/v1",
CustomHeaders: map[string]string{
"x-aimlapi-partner-id": "persisted-partner",
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
"X-AIMLAPI-Integration-Version": "zero",
"X-Environment": "staging",
},
}
applyCatalogDescriptor(&profile, descriptor, true)
for key := range profile.CustomHeaders {
if strings.HasPrefix(strings.ToLower(key), "x-aimlapi-") {
t.Fatalf("catalog attribution survived retargeting: %#v", profile.CustomHeaders)
}
}
if profile.CustomHeaders["X-Environment"] != "staging" {
t.Fatalf("user header was removed: %#v", profile.CustomHeaders)
}
}
func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
+7 -39
View File
@@ -34,7 +34,7 @@ const (
var ErrUnknownProvider = errors.New("unknown provider")
// AIMLAPIID is the provider catalog identifier for the aimlapi.com preset.
// AIMLAPIID is the provider catalog identifier for the AI/ML API preset.
const AIMLAPIID = "aimlapi"
type Descriptor struct {
@@ -44,7 +44,6 @@ type Descriptor struct {
DefaultBaseURL string
DefaultModel string
AuthEnvVars []string
CustomHeaders map[string]string
RequiresAuth bool
UsesAmbientAuth bool
Public bool
@@ -71,9 +70,9 @@ type Descriptor struct {
// headless / SSH use) in addition to the browser flow.
OAuthDeviceFlow bool
// Recommended marks a provider surfaced at the top and badged in
// catalog-ordered lists and pickers. The recommended descriptors are the first
// entries in the catalog, in order.
// Recommended marks the provider that should be surfaced first and badged
// (★ … (recommended)) in every catalog-ordered list and picker. At most one
// descriptor should set this.
Recommended bool
}
@@ -104,11 +103,6 @@ var descriptors = []Descriptor{
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
// with a Bearer ogw_live_… key; listed first and badged in every picker.
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
// recommended (second, right after the OpenGateway default). The built-in TUI
// onboarding can register/top up an account and save the issued key; the partner
// headers attribute usage for the rebate.
recommended(aimlapi()),
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
@@ -116,6 +110,7 @@ var descriptors = []Descriptor{
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
openAICompat(AIMLAPIID, "AI/ML API", "https://api.aimlapi.com/v1", "openai/gpt-5-chat", []string{"AIMLAPI_API_KEY"}, "aiml api", "ai/ml api"),
// Hugging Face Inference Providers — OpenAI-compatible router at
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
// requires a one-time app registration at huggingface.co/settings/applications/new
@@ -307,21 +302,8 @@ func google(id string, name string, baseURL string, model string, env []string,
}
}
func aimlapi() Descriptor {
descriptor := openAICompat(AIMLAPIID, "aimlapi.com", "https://api.aimlapi.com/v1", "anthropic/claude-sonnet-5", []string{"AIMLAPI_API_KEY"}, "aimlapi", "aiml api", "ai/ml api", "ai ml api")
descriptor.CustomHeaders = map[string]string{
"X-AIMLAPI-Partner-ID": "part_62yQoGYDq4Yqnrj2R1iGrDNJ",
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
"X-AIMLAPI-Integration-Version": "zero",
}
// Onboarding is key-based only: the TUI sub-flow takes an existing key or an
// email top-up (internal/aimlapi Paths A/B). Not flagged
// OAuth-capable, so it does not appear in the wizard's "Sign in with OAuth" list.
return descriptor
}
// recommended marks a descriptor as a recommended default so list/picker
// surfaces sort it to the top and render a provider-specific badge.
// recommended marks a descriptor as the recommended default so list/picker
// surfaces sort it first and render the ★ … (recommended) badge.
func recommended(descriptor Descriptor) Descriptor {
descriptor.Recommended = true
return descriptor
@@ -402,19 +384,5 @@ func cloneDescriptor(descriptor Descriptor) Descriptor {
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
if descriptor.CustomHeaders != nil {
descriptor.CustomHeaders = copyStringMap(descriptor.CustomHeaders)
}
return descriptor
}
func copyStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
copied[key] = value
}
return copied
}
+17 -23
View File
@@ -9,7 +9,6 @@ import (
var expectedCatalogIDs = []string{
"gitlawb-opengateway",
"aimlapi",
"openai",
"anthropic",
"google",
@@ -17,6 +16,7 @@ var expectedCatalogIDs = []string{
"ollama",
"lmstudio",
"openrouter",
"aimlapi",
"huggingface",
"chatgpt",
"groq",
@@ -69,31 +69,25 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
}
}
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
descriptors := All()
// The recommended providers are badged and pinned to the top of the catalog,
// in this order (OpenGateway remains the default; aimlapi.com is also badged).
wantTop := []string{"gitlawb-opengateway", "aimlapi"}
if len(descriptors) < len(wantTop) {
t.Fatalf("All() returned %d descriptors, want at least %d", len(descriptors), len(wantTop))
if len(descriptors) == 0 {
t.Fatal("All() returned no descriptors")
}
for index, id := range wantTop {
if descriptors[index].ID != id {
t.Fatalf("descriptors[%d] = %q, want %q", index, descriptors[index].ID, id)
}
if !descriptors[index].Recommended {
t.Fatalf("descriptors[%d] (%q) should be recommended", index, id)
}
if !descriptors[0].Recommended {
t.Fatalf("All()[0] = %q, want it to be the recommended provider", descriptors[0].ID)
}
if descriptors[0].ID != "gitlawb-opengateway" {
t.Fatalf("recommended provider = %q, want %q", descriptors[0].ID, "gitlawb-opengateway")
}
// Exactly those are recommended, and they are contiguous at the top.
recommended := 0
for _, descriptor := range descriptors {
if descriptor.Recommended {
recommended++
}
}
if recommended != len(wantTop) {
t.Fatalf("recommended descriptor count = %d, want %d", recommended, len(wantTop))
if recommended != 1 {
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
}
}
@@ -115,14 +109,14 @@ func TestAIMLAPIDescriptor(t *testing.T) {
if err != nil {
t.Fatalf("Require(aimlapi) error = %v", err)
}
if descriptor.Name != "aimlapi.com" {
t.Fatalf("Name = %q, want aimlapi.com", descriptor.Name)
if descriptor.Name != "AI/ML API" {
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
t.Fatalf("DefaultBaseURL = %q, want aimlapi.com endpoint", descriptor.DefaultBaseURL)
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "anthropic/claude-sonnet-5" {
t.Fatalf("DefaultModel = %q, want anthropic/claude-sonnet-5", descriptor.DefaultModel)
if descriptor.DefaultModel != "openai/gpt-5-chat" {
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
@@ -322,7 +316,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
-11
View File
@@ -61,17 +61,6 @@ var zaiCuratedModels = []Model{
}
var curatedModels = map[string][]Model{
// aimlapi.com aggregates every upstream provider behind one OpenAI-compatible
// endpoint, so the curated defaults are a cross-provider "best of" flagship
// list. The live /v1/models discovery (when a key is present) replaces this;
// these are the offline/pre-key fallback and the picker's default order.
"aimlapi": {
{ID: "anthropic/claude-sonnet-5", Description: "catalog default (Anthropic Claude Sonnet 5)"},
{ID: "google/gemini-3.5-flash", Description: "Google Gemini Flash 3.5"},
{ID: "openai/gpt-5.5-2026-04-23", Description: "OpenAI GPT-5.5"},
{ID: "qwen/qwen-3.7-max", Description: "Qwen 3.7 Max"},
{ID: "deepseek/deepseek-v4-pro", Description: "DeepSeek V4"},
},
"ollama-cloud": {
{ID: "qwen3-coder:480b", Description: "catalog default"},
{ID: "gpt-oss:120b", Description: "agentic coding model"},
@@ -33,11 +33,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
},
{
provider: "aimlapi",
want: []string{"anthropic/claude-sonnet-5", "google/gemini-3.5-flash", "openai/gpt-5.5-2026-04-23", "qwen/qwen-3.7-max", "deepseek/deepseek-v4-pro"},
notWant: []string{"gpt-4o", "openai/gpt-4o", "anthropic/claude-opus-4.8", "x-ai/grok-4-5"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
+18 -23
View File
@@ -14,22 +14,18 @@ type EngineOptions struct {
Store *GrantStore
Backend Backend
Scope *Scope
// SensitiveEnvKeys adds config-derived credential variable names to the
// catalog and namespace secrets scrubbed from sandboxed commands.
SensitiveEnvKeys []string
}
type Engine struct {
workspaceRoot string
policy Policy
store *GrantStore
backend Backend
scope *Scope
sensitiveEnvKeys []string
sessionGrants *memoryGrantSet
sessionProfiles *permissionProfileGrantSet
turnProfiles *permissionProfileGrantSet
commandPrefixes *commandPrefixGrantSet
workspaceRoot string
policy Policy
store *GrantStore
backend Backend
scope *Scope
sessionGrants *memoryGrantSet
sessionProfiles *permissionProfileGrantSet
turnProfiles *permissionProfileGrantSet
commandPrefixes *commandPrefixGrantSet
}
func NewEngine(options EngineOptions) *Engine {
@@ -53,16 +49,15 @@ func NewEngine(options EngineOptions) *Engine {
scope = newScopeBestEffort(workspaceRoot)
}
return &Engine{
workspaceRoot: workspaceRoot,
policy: policy,
store: options.Store,
backend: options.Backend,
scope: scope,
sensitiveEnvKeys: normalizeSensitiveEnvKeys(options.SensitiveEnvKeys),
sessionGrants: newMemoryGrantSet(),
sessionProfiles: newPermissionProfileGrantSet(),
turnProfiles: newPermissionProfileGrantSet(),
commandPrefixes: newCommandPrefixGrantSet(),
workspaceRoot: workspaceRoot,
policy: policy,
store: options.Store,
backend: options.Backend,
scope: scope,
sessionGrants: newMemoryGrantSet(),
sessionProfiles: newPermissionProfileGrantSet(),
turnProfiles: newPermissionProfileGrantSet(),
commandPrefixes: newCommandPrefixGrantSet(),
}
}
+6 -66
View File
@@ -29,9 +29,6 @@ type CommandSpec struct {
Args []string
Dir string
Env []string
// sensitiveEnvKeys is populated by Engine from config-derived credential
// variable names and carried through SandboxManager to the platform plan.
sensitiveEnvKeys []string
}
type CommandPlan struct {
@@ -125,7 +122,6 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
return CommandPlan{}, errors.New("sandbox command name is required")
}
spec.Dir = commandDir
spec.sensitiveEnvKeys = engine.sensitiveEnvKeys
backend := engine.backend
if backend.Name == "" {
@@ -211,7 +207,7 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap, "")
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
@@ -257,27 +253,10 @@ func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspa
Name: spec.Name,
Args: cloneStrings(spec.Args),
Dir: spec.Dir,
Env: directCommandEnv(spec),
Env: cloneStrings(spec.Env),
}
}
// directCommandEnv scrubs sensitive credentials from the environment for a
// direct (unwrapped) command plan: the platform sandbox backend is
// unavailable, disabled, or not required, so this is the actual environment
// the child process inherits. Without scrubbing here, config-derived
// apiKeyEnv and dynamic OAuth client-secret variables would leak into
// commands that fall back to this path (e.g. EnforcementDegraded).
func directCommandEnv(spec CommandSpec) []string {
env := cloneStrings(spec.Env)
if spec.Env == nil {
// Match the wrapped-plan behavior: an unset spec.Env means "inherit the
// caller's environment," so scrub a snapshot of it rather than passing
// nil through to exec.Cmd, which would skip scrubbing entirely.
env = os.Environ()
}
return scrubSensitiveEnv(env, spec.sensitiveEnvKeys...)
}
func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, string, error) {
workspaceRoot := strings.TrimSpace(engine.workspaceRoot)
if workspaceRoot == "" {
@@ -339,7 +318,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
if envBackend == "" {
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend, "")
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
@@ -398,10 +377,6 @@ func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string
}
func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommandWithSensitiveEnv(specEnv, policy, backend, workspaceRoot, nil)
}
func sandboxEnvironmentForCommandWithSensitiveEnv(specEnv []string, policy Policy, backend BackendName, workspaceRoot string, sensitiveEnvKeys []string) []string {
env := cloneStrings(specEnv)
if specEnv == nil {
// Preserve the caller environment for sandboxed commands. The sandbox
@@ -409,7 +384,7 @@ func sandboxEnvironmentForCommandWithSensitiveEnv(specEnv []string, policy Polic
// command env values still replace inherited values below.
env = os.Environ()
}
env = scrubSensitiveEnv(env, sensitiveEnvKeys...)
env = scrubSensitiveEnv(env)
pathValue := envListValue(env, "PATH", defaultPath())
if runtime.GOOS == "darwin" {
// Preserve standard user tool locations so a bare `python3`/`node`
@@ -992,7 +967,7 @@ func regexpQuoteMeta(value string) string {
return replacer.Replace(value)
}
func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
func scrubSensitiveEnv(env []string) []string {
// Secrets not covered by the provider catalog: cloud/VCS credentials and
// providers Zero talks to through generic OpenAI-compatible endpoints.
sensitiveKeys := []string{
@@ -1022,7 +997,6 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
sensitiveKeys = append(sensitiveKeys, key)
}
}
sensitiveKeys = append(sensitiveKeys, normalizeSensitiveEnvKeys(additionalKeys)...)
out := make([]string, 0, len(env))
for _, kv := range env {
@@ -1031,7 +1005,7 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
out = append(out, kv)
continue
}
drop := isDynamicSensitiveEnvKey(key)
drop := false
for _, sensitive := range sensitiveKeys {
if strings.EqualFold(key, sensitive) {
drop = true
@@ -1044,37 +1018,3 @@ func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
}
return out
}
func normalizeSensitiveEnvKeys(keys []string) []string {
out := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
// A misconfigured value like "COMPANY_LLM_SECRET=..." would never
// match a real env key during scrubbing; keep only the name part.
if name, _, found := strings.Cut(key, "="); found {
key = strings.TrimSpace(name)
}
folded := strings.ToUpper(key)
if key == "" {
continue
}
if _, ok := seen[folded]; ok {
continue
}
seen[folded] = struct{}{}
out = append(out, key)
}
return out
}
func isDynamicSensitiveEnvKey(key string) bool {
const (
prefix = "ZERO_OAUTH_"
suffix = "_CLIENT_SECRET"
)
key = strings.ToUpper(strings.TrimSpace(key))
return strings.HasPrefix(key, prefix) &&
strings.HasSuffix(key, suffix) &&
len(key) > len(prefix)+len(suffix)
}
+4 -81
View File
@@ -122,47 +122,6 @@ func TestBuildCommandPlanDegradesUnavailableFallback(t *testing.T) {
}
}
// TestBuildCommandPlanDegradedFallbackScrubsInheritedEnv covers the
// EnforcementDegraded fallback path: when the native backend is
// unavailable, BuildCommandPlan falls back to a direct (unwrapped) plan
// whose spec.Env is nil, so exec.Cmd would otherwise inherit the caller's
// environment — including configured and dynamically named credentials —
// unscrubbed.
func TestBuildCommandPlanDegradedFallbackScrubsInheritedEnv(t *testing.T) {
t.Setenv("COMPANY_LLM_SECRET", "custom-secret")
t.Setenv("ZERO_OAUTH_ACME_CLIENT_SECRET", "oauth-secret")
t.Setenv("SAFE_VAR", "hello")
root := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: root,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Message: "native sandbox unavailable"},
SensitiveEnvKeys: []string{"COMPANY_LLM_SECRET"},
})
plan, err := engine.BuildCommandPlan(CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "pwd"},
Dir: root,
})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded {
t.Fatalf("plan = %#v, want degraded direct plan", plan)
}
for _, entry := range plan.Env {
key, _, _ := strings.Cut(entry, "=")
if strings.EqualFold(key, "COMPANY_LLM_SECRET") || strings.EqualFold(key, "ZERO_OAUTH_ACME_CLIENT_SECRET") {
t.Fatalf("degraded plan.Env retained sensitive key %q: %v", key, plan.Env)
}
}
if got := envListValue(plan.Env, "SAFE_VAR", ""); got != "hello" {
t.Fatalf("SAFE_VAR = %q, want hello", got)
}
}
func TestBuildCommandPlanRejectsOutsideDirectory(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{
@@ -694,19 +653,14 @@ func TestScrubSensitiveEnv(t *testing.T) {
"XAI_API_KEY=xai-12345",
"HUGGINGFACE_API_KEY=hf_12345",
"GOOGLE_APPLICATION_CREDENTIALS=/home/user/sa-key.json",
"COMPANY_LLM_SECRET=custom-secret",
"ZERO_OAUTH_MY_SVC_CLIENT_SECRET=oauth-secret",
"zero_oauth_second_client_secret=case-insensitive-secret",
"ZERO_OAUTH_CLIENT_SECRET=not-a-provider-secret",
"AWS_PROFILE=staging",
"SAFE_VAR=hello",
}
scrubbed := scrubSensitiveEnv(inputEnv, " COMPANY_LLM_SECRET ", "company_llm_secret", "GITHUB_TOKEN=ghp_pasted-assignment", "=", "")
scrubbed := scrubSensitiveEnv(inputEnv)
expected := map[string]string{
"PATH": "/usr/bin",
"SAFE_VAR": "hello",
"AWS_PROFILE": "staging",
"ZERO_OAUTH_CLIENT_SECRET": "not-a-provider-secret",
"PATH": "/usr/bin",
"SAFE_VAR": "hello",
"AWS_PROFILE": "staging",
}
actual := make(map[string]string, len(scrubbed))
for _, entry := range scrubbed {
@@ -720,34 +674,3 @@ func TestScrubSensitiveEnv(t *testing.T) {
t.Errorf("scrubSensitiveEnv() = %v, want %v", actual, expected)
}
}
func TestEngineScrubsConfiguredSensitiveEnvKeys(t *testing.T) {
workspace := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"},
SensitiveEnvKeys: []string{"COMPANY_LLM_SECRET"},
})
plan, err := engine.BuildCommandPlan(CommandSpec{
Name: "true",
Env: []string{
"PATH=/usr/bin",
"COMPANY_LLM_SECRET=custom-secret",
"ZERO_OAUTH_CUSTOM_CLIENT_SECRET=oauth-secret",
"SAFE_VAR=hello",
},
})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
for _, entry := range plan.Env {
key, _, _ := strings.Cut(entry, "=")
if strings.EqualFold(key, "COMPANY_LLM_SECRET") || strings.EqualFold(key, "ZERO_OAUTH_CUSTOM_CLIENT_SECRET") {
t.Fatalf("plan.Env retained sensitive key %q: %v", key, plan.Env)
}
}
if got := envListValue(plan.Env, "SAFE_VAR", ""); got != "hello" {
t.Fatalf("SAFE_VAR = %q, want hello", got)
}
}
+1 -1
View File
@@ -326,7 +326,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
if err != nil {
return CommandPlan{}, err
}
childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys)
childEnv := sandboxEnvironmentForCommand(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot)
// The unelevated enforcement tier maps to the runner's unelevated level: same
// restricted token, but the runner applies the workspace ACLs itself instead
// of requiring the elevated setup marker.
-23
View File
@@ -94,29 +94,6 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
return err
}
}
// Prefix fingerprints are emitted after counters in insertion (turn)
// order. The order is the order EmitPrefixHash was called, which is the
// order the agent loop computed each turn's fingerprint, which is the
// order a downstream consumer needs to correlate a prefix_hash event
// with the cached_input_tokens counter for that turn. Sorting by
// complete_prefix hash would destroy that correlation, so we do not
// sort. The slice is already a deep copy from Finish (see
// Recorder.Finish) so it is safe to range over without copying.
for _, p := range t.PrefixHashes {
if err := enc.Encode(map[string]any{
"type": "prefix_hash",
"base_instructions": p.BaseInstructionsHash,
"confirmation_policy": p.ConfirmationPolicyHash,
"project_context": p.ProjectContextHash,
"skills": p.SkillsHash,
"tools": p.ToolsHash,
"schema": p.SchemaHash,
"complete_prefix": p.CompletePrefixHash,
}); err != nil {
return err
}
}
return nil
}
+2 -30
View File
@@ -97,25 +97,6 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
}
name, _ := obj["name"].(string)
t.Counters = append(t.Counters, Counter{Name: name, Value: parseInt64(obj["value"])})
case "prefix_hash":
if !sawTraceHeader {
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
}
// Round-trip the seven prefix-hash fields. Missing fields are
// accepted as empty strings (a partially-written trace from a
// crashed emitter must not fatal a parse). The decoder tolerates
// any shape the encoder produced and the seven keys are the
// contract — adding a new field is non-breaking; renaming one
// requires a schema version bump.
t.PrefixHashes = append(t.PrefixHashes, PrefixHash{
BaseInstructionsHash: stringField(obj, "base_instructions"),
ConfirmationPolicyHash: stringField(obj, "confirmation_policy"),
ProjectContextHash: stringField(obj, "project_context"),
SkillsHash: stringField(obj, "skills"),
ToolsHash: stringField(obj, "tools"),
SchemaHash: stringField(obj, "schema"),
CompletePrefixHash: stringField(obj, "complete_prefix"),
})
default:
// Unknown event type: tolerate (forward-compat) but only after a
// header has been seen.
@@ -136,21 +117,12 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
if !sawTraceHeader {
return nil, errors.New("parse trace: non-empty input had no type:trace header")
}
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 {
return nil, errors.New("parse trace: header present but no spans, counters, or prefix hashes recovered (corrupt or truncated)")
if len(t.Spans) == 0 && len(t.Counters) == 0 {
return nil, errors.New("parse trace: header present but no spans or counters recovered (corrupt or truncated)")
}
return t, nil
}
// stringField returns obj[key] as a string, or "" if the key is missing or
// the value is not a string. JSON-marshaled trace events always emit
// string fields as JSON strings, so the type assertion is the right
// narrowing; a missing key produces an empty hash, which is what the
// encoder would have produced for the absent value.
func stringField(obj map[string]any, key string) string {
s, _ := obj[key].(string)
return s
}
func parseTime(v any) time.Time {
s, _ := v.(string)
if s == "" {
-17
View File
@@ -172,22 +172,6 @@ func (r *Recorder) StampFirstUsefulAction() {
r.tr.FirstUsefulActionAt = time.Now()
}
// EmitPrefixHash records one prompt-prefix fingerprint on the trace. Multiple
// calls are allowed within a run (one per turn, typically) and accumulate in
// order. The first call after Finish is a no-op; later calls are also no-ops
// because the trace has been sealed.
func (r *Recorder) EmitPrefixHash(p PrefixHash) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.finished {
return
}
r.tr.PrefixHashes = append(r.tr.PrefixHashes, p)
}
// Finish stamps CompletedAt, derives each span's parent (by interval
// containment) and exclusive time, and returns a snapshot of the trace. Calling
// Finish more than once returns the same snapshot.
@@ -206,7 +190,6 @@ func (r *Recorder) Finish() *TurnTrace {
// Copy the slices so callers cannot mutate the recorder's state.
snap.Spans = append([]Span(nil), r.tr.Spans...)
snap.Counters = append([]Counter(nil), r.tr.Counters...)
snap.PrefixHashes = append([]PrefixHash(nil), r.tr.PrefixHashes...)
return &snap
}
+10 -47
View File
@@ -82,52 +82,16 @@ type Counter struct {
// TurnTrace is the finished record for one agent.Run. It is the value
// emitters serialize; it is not mutated after Finish returns a snapshot.
type TurnTrace struct {
SessionID string `json:"session_id"`
RunID string `json:"run_id"`
Profile string `json:"profile,omitempty"`
StartedAt time.Time `json:"started_at"`
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
CompletedAt time.Time `json:"completed_at"`
Spans []Span `json:"spans"`
Counters []Counter `json:"counters"`
PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"`
}
// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run.
// The seven fields decompose the cacheable part of the request so a downstream
// observer can detect which sub-component drifted turn-over-turn:
// base_instructions (the embedded core system prompt), confirmation_policy,
// project_context (AGENTS.md / ZERO.md chain), skills, tools, schema (tool
// JSON schemas), and complete_prefix (SHA-256 of the canonical concatenation
// of the other six). All hashes are hex-encoded SHA-256.
//
// Scope: the fingerprint covers 4 of 11 sections of buildSystemPrompt. The
// seven sections NOT covered — modelPromptAddendum, sessionRuntimeContext,
// approvedCommandPrefixContext, workspaceSeedContext, userGuidelines,
// specialistDelegationContext, responseStyleContext — fire only for
// non-default Options. For default Options (the common case), the four
// captured substrings are the full prompt and the fingerprint is accurate.
//
// A run with a stable CompletePrefix across turns means the four captured
// sub-components are byte-identical. A run where CompletePrefix changes
// names the captured sub-component that drifted, but does NOT rule out
// drift in the seven uncaptured sections. modelPromptAddendum in particular
// changes on a model switch (a model_switches counter the trace already
// emits), so a consumer correlating CompletePrefix stability with
// cached_input_tokens must cross-check the model_switches counter to
// disambiguate "no drift" from "drift in an uncaptured section." The fields
// are emitted as a "prefix_hash" event in the NDJSON trace (see
// WriteNDJSON).
type PrefixHash struct {
BaseInstructionsHash string `json:"base_instructions"`
ConfirmationPolicyHash string `json:"confirmation_policy"`
ProjectContextHash string `json:"project_context"`
SkillsHash string `json:"skills"`
ToolsHash string `json:"tools"`
SchemaHash string `json:"schema"`
CompletePrefixHash string `json:"complete_prefix"`
SessionID string `json:"session_id"`
RunID string `json:"run_id"`
Profile string `json:"profile,omitempty"`
StartedAt time.Time `json:"started_at"`
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
CompletedAt time.Time `json:"completed_at"`
Spans []Span `json:"spans"`
Counters []Counter `json:"counters"`
}
// WallDuration is the total traced wall time of the run.
@@ -265,6 +229,5 @@ func OptionalEventKeys() []string {
"counter:" + CounterAcceptanceChecks,
"counter:" + CounterPollingTurn,
"counter:" + CounterModelSwitches,
"event:prefix_hash",
}
}
+2 -39
View File
@@ -370,27 +370,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
r.Counter(CounterModelRequests, 3)
r.Counter(CounterToolCalls, 7)
r.StampFirstToken()
// Two prefix_hash events so the round-trip test covers the third
// event type. Insertion order is preserved by the parser (no sort
// is applied on read or write).
r.EmitPrefixHash(PrefixHash{
BaseInstructionsHash: "b1",
ConfirmationPolicyHash: "c1",
ProjectContextHash: "p1",
SkillsHash: "s1",
ToolsHash: "t1",
SchemaHash: "x1",
CompletePrefixHash: "complete1",
})
r.EmitPrefixHash(PrefixHash{
BaseInstructionsHash: "b2",
ConfirmationPolicyHash: "c2",
ProjectContextHash: "p2",
SkillsHash: "s2",
ToolsHash: "t2",
SchemaHash: "x2",
CompletePrefixHash: "complete2",
})
original := r.Finish()
var buf bytes.Buffer
@@ -419,20 +398,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
if parsed.FirstTokenAt.IsZero() {
t.Fatal("first_token_at lost in round-trip")
}
// prefix_hash round-trip: two events, in insertion order, with all
// seven sub-hash fields preserved exactly.
if len(parsed.PrefixHashes) != 2 {
t.Fatalf("expected 2 prefix_hash events after round-trip, got %d", len(parsed.PrefixHashes))
}
if parsed.PrefixHashes[0].CompletePrefixHash != "complete1" || parsed.PrefixHashes[1].CompletePrefixHash != "complete2" {
t.Fatalf("prefix_hash insertion order lost: got %+v want [complete1, complete2]", parsed.PrefixHashes)
}
if parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" {
t.Fatalf("prefix_hash sub-hashes lost on first event: got %+v", parsed.PrefixHashes[0])
}
if parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
t.Fatalf("prefix_hash sub-hashes lost on second event: got %+v", parsed.PrefixHashes[1])
}
}
func TestReadNDJSONRejectsNonTrace(t *testing.T) {
@@ -446,12 +411,10 @@ func TestReadNDJSONRejectsNonTrace(t *testing.T) {
}
func TestReadNDJSONRejectsHeaderOnly(t *testing.T) {
// A header with no recoverable spans, counters, or prefix hashes is
// corrupt/truncated, not empty. prefix_hash is now a third valid
// event type, so a header with only prefix_hash events is accepted.
// A header with no recoverable spans/counters is corrupt/truncated, not empty.
header := `{"type":"trace","name":"run","session_id":"s","run_id":"r"}` + "\n"
if _, err := ReadNDJSON(strings.NewReader(header)); err == nil {
t.Fatal("expected error for a trace header with no spans, counters, or prefix hashes")
t.Fatal("expected error for a trace header with no spans or counters")
}
}
File diff suppressed because it is too large. Load diff
-8
View File
@@ -63,14 +63,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// do it once here, before any early return, so both the bracketed-paste
// and right-click-paste paths are covered uniformly.
m = m.disarmCancelConfirmation()
// Same immediate-input transition as the keypress hook: text entering the
// composer must land the caret in its solid-while-typing state right away
// and refresh the typing timestamp. Without this, a paste right after the
// blink phase hid the caret leaves the newly populated composer caret-less
// until the next 530ms tick, and the stale timestamp makes that tick
// toggle instead of going solid.
m.lastCharTime = m.now()
m.composerCursorVisible = true
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
+1 -4
View File
@@ -39,8 +39,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
{"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"},
{"Ctrl+X ?", "list every Ctrl+X slash shortcut"},
{"?", "show this help (on an empty input)"},
},
},
@@ -49,7 +47,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel"},
},
},
{
@@ -57,7 +55,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
bindings: []keybinding{
{"PgUp / PgDn", "scroll the transcript by a page"},
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
+3 -6
View File
@@ -79,7 +79,6 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
"Ctrl+T", "cycle reasoning effort",
"Shift+Tab", "Ctrl+P", "Ctrl+O",
"drill into its sub-session",
"Ctrl+X then letter", "/model",
keybindingHelpFooter,
} {
if !strings.Contains(view, want) {
@@ -225,9 +224,7 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
m.height = 40
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
m.keyBindings.toggleMouse = parseBinding("ctrl+m")
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
// reaches configurable global bindings.
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
m.keyBindings.toggleSidebar = parseBinding("ctrl+n")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
}
@@ -248,10 +245,10 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
}
initialSidebar := next.sidebarHidden
updated, _ = next.Update(testKeyCtrl('y'))
updated, _ = next.Update(testKeyCtrl('n'))
next = updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
t.Fatal("remapped Ctrl+N toggleSidebar binding should still fire with a non-empty composer")
}
if next.composerValue() != "hello" {
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
-170
View File
@@ -1,170 +0,0 @@
package tui
import (
"time"
"unicode"
tea "charm.land/bubbletea/v2"
)
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
// cancels itself. Short enough to not feel sticky; long enough to find the
// second key without racing.
const leaderTimeout = 2 * time.Second
// leaderCommandByKey maps the second key of a Ctrl+X chord to a builtin slash
// command. Case-sensitive so m/M and p/P stay distinct. Only bare commands (no
// args) — same as typing the slash and pressing Enter with an empty argument.
var leaderCommandByKey = map[rune]string{
'm': "/model",
'p': "/provider",
'P': "/plan",
'M': "/stt-model",
'v': "/voice",
'c': "/clear",
'C': "/context",
's': "/stop",
'i': "/image",
'r': "/resume",
'u': "/rewind",
't': "/tools",
'R': "/retry",
}
// leaderExpiredMsg clears leader-pending when the follow-up window elapses.
// seq must match m.leaderSeq or the tick is stale (a later Ctrl+X re-armed).
type leaderExpiredMsg struct {
seq int
}
func (m model) canArmLeader() bool {
return m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active && !m.helpOverlay && !m.leaderHelpOverlay
}
func (m model) armLeader() (model, tea.Cmd) {
m.leaderPending = true
m.leaderSeq++
seq := m.leaderSeq
return m, tea.Tick(leaderTimeout, func(time.Time) tea.Msg {
return leaderExpiredMsg{seq: seq}
})
}
func (m model) clearLeader() model {
m.leaderPending = false
// Bump seq so an in-flight timeout tick becomes a no-op.
m.leaderSeq++
return m
}
// handleLeaderKey consumes the key while leader-pending is armed. Never inserts
// into the composer; either runs a mapped slash command, opens the chord map
// (?), or cancels the chord. Ctrl+C is handled before this is called so
// exit/cancel still works.
func (m model) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Second Ctrl+X cancels (does not re-arm).
if keyCtrl(msg, 'x') {
return m.clearLeader(), nil
}
if keyIs(msg, tea.KeyEsc) {
return m.clearLeader(), nil
}
// Ctrl+X ? opens the full leader-chord map (discoverability for every wired
// slash shortcut). Not a letter binding, so handle before leaderSecondKey.
if keyText(msg) == "?" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl) {
m = m.clearLeader()
m.leaderHelpOverlay = true
return m, nil
}
key, ok := leaderSecondKey(msg)
if !ok {
return m.clearLeader(), nil
}
slash, mapped := leaderCommandByKey[key]
if !mapped {
return m.clearLeader(), nil
}
m = m.clearLeader()
return m.executeSlash(slash)
}
// leaderHelpBindings is the stable, display-ordered table of every wired
// Ctrl+X chord. Keep in sync with leaderCommandByKey.
func leaderHelpBindings() []keybinding {
return []keybinding{
{"Ctrl+X m", "open /model"},
{"Ctrl+X p", "open /provider"},
{"Ctrl+X P", "run /plan"},
{"Ctrl+X M", "open /stt-model"},
{"Ctrl+X v", "toggle /voice"},
{"Ctrl+X c", "run /clear"},
{"Ctrl+X C", "run /context"},
{"Ctrl+X s", "run /stop"},
{"Ctrl+X i", "run /image"},
{"Ctrl+X r", "open /resume"},
{"Ctrl+X u", "run /rewind"},
{"Ctrl+X t", "run /tools"},
{"Ctrl+X R", "run /retry"},
{"Ctrl+X ?", "show this list"},
}
}
const leaderHelpFooter = "? or Esc to close \u00b7 letter after Ctrl+X runs the command"
// renderLeaderHelpLines builds the body of the Ctrl+X ? overlay — same shape as
// renderKeybindingHelpLines (group title + key rows + footer) so the two modals
// share layout and column alignment.
func renderLeaderHelpLines(innerWidth int) []string {
groups := []keybindingGroup{{
title: "Slash commands",
bindings: leaderHelpBindings(),
}}
keyColumn := keybindingKeyColumnWidth(groups)
lines := make([]string, 0, 32)
for index, group := range groups {
if index > 0 {
lines = append(lines, "")
}
lines = append(lines, zeroTheme.accent.Render(group.title))
for _, binding := range group.bindings {
lines = append(lines, formatKeybindingLine(binding, keyColumn, innerWidth))
}
}
lines = append(lines, "")
lines = append(lines, zeroTheme.faint.Render(leaderHelpFooter))
return lines
}
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
// general ? keyboard-shortcut overlay: same width helper, border style
// (zeroTheme.line), panel fill, and centered block.
func (m model) renderLeaderHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := renderLeaderHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
return centerRenderedBlock(block, width)
}
// leaderSecondKey extracts the letter for a leader follow-up. Prefers printable
// text ("m" / "M"); falls back to code+Shift so test harnesses and terminals
// that report only ModShift still work.
func leaderSecondKey(msg tea.KeyMsg) (rune, bool) {
if keyHasMod(msg, tea.ModCtrl) || keyAlt(msg) {
return 0, false
}
text := keyText(msg)
if runes := []rune(text); len(runes) == 1 && unicode.IsLetter(runes[0]) {
return runes[0], true
}
code := keyCode(msg)
if code >= 'a' && code <= 'z' {
if keyShift(msg) {
return code - ('a' - 'A'), true
}
return code, true
}
if code >= 'A' && code <= 'Z' {
return code, true
}
return 0, false
}
-338
View File
@@ -1,338 +0,0 @@
package tui
import (
"context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
func TestLeaderArmsOnCtrlX(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
updated, cmd := m.Update(testKeyCtrl('x'))
next := updated.(model)
if !next.leaderPending {
t.Fatal("Ctrl+X should arm leader-pending")
}
if next.composerValue() != "" {
t.Fatalf("Ctrl+X must not type into the composer, got %q", next.composerValue())
}
if cmd == nil {
t.Fatal("arming leader should schedule a timeout tick")
}
}
func TestLeaderModelOpensPicker(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
})
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKeyText("m"))
next := updated.(model)
if next.leaderPending {
t.Fatal("mapped second key should clear leader-pending")
}
if next.picker == nil || next.picker.kind != pickerModel {
t.Fatalf("Ctrl+X m should open the model picker, picker=%#v", next.picker)
}
if next.composerValue() != "" {
t.Fatalf("leader chord must not leave /model in the composer, got %q", next.composerValue())
}
}
func TestLeaderPreservesComposerDraft(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
})
m = typeRunes(t, m, "draft text")
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKeyText("m"))
next := updated.(model)
if got := next.composerValue(); got != "draft text" {
t.Fatalf("leader /model must preserve composer draft, got %q", got)
}
if next.picker == nil || next.picker.kind != pickerModel {
t.Fatal("expected model picker after Ctrl+X m")
}
}
func TestLeaderTimeoutClears(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
if !m.leaderPending {
t.Fatal("expected leader pending after Ctrl+X")
}
seq := m.leaderSeq
updated, _ = m.Update(leaderExpiredMsg{seq: seq})
next := updated.(model)
if next.leaderPending {
t.Fatal("matching timeout tick should clear leader-pending")
}
// Stale tick must not clear a re-armed leader.
updated, _ = next.Update(testKeyCtrl('x'))
next = updated.(model)
updated, _ = next.Update(leaderExpiredMsg{seq: seq})
next = updated.(model)
if !next.leaderPending {
t.Fatal("stale timeout must not clear a re-armed leader")
}
}
func TestLeaderEscCancels(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m = typeRunes(t, m, "keep me")
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if next.leaderPending {
t.Fatal("Esc should clear leader-pending")
}
if got := next.composerValue(); got != "keep me" {
t.Fatalf("Esc cancel must not clear composer, got %q", got)
}
}
func TestLeaderSecondCtrlXCancels(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKeyCtrl('x'))
next := updated.(model)
if next.leaderPending {
t.Fatal("second Ctrl+X should cancel, not re-arm")
}
}
func TestLeaderUnknownKeyClearsWithoutCommand(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
before := len(m.transcript)
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKeyText("z"))
next := updated.(model)
if next.leaderPending {
t.Fatal("unknown second key should clear leader-pending")
}
if next.picker != nil {
t.Fatal("unknown second key must not open a picker")
}
if next.composerValue() != "" {
t.Fatalf("unknown key must not type into composer, got %q", next.composerValue())
}
if len(next.transcript) != before {
t.Fatalf("unknown key should not append transcript rows, before=%d after=%d", before, len(next.transcript))
}
}
func TestLeaderDoesNotInsertText(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKeyText("q"))
next := updated.(model)
if next.composerValue() != "" {
t.Fatalf("follow-up key must never appear in composer, got %q", next.composerValue())
}
}
func TestLeaderIgnoredWhenPickerOpen(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
})
// Open model picker the normal way.
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
if m.picker == nil {
t.Fatal("setup: expected /model to open a picker")
}
updated, _ = m.Update(testKeyCtrl('x'))
next := updated.(model)
if next.leaderPending {
t.Fatal("Ctrl+X must not arm leader while a picker is open")
}
// Printable 'm' should filter the picker, not dispatch another /model.
updated, _ = next.Update(testKeyText("m"))
next = updated.(model)
if next.picker == nil {
t.Fatal("picker should still be open")
}
if next.leaderPending {
t.Fatal("picker keystrokes must not arm leader")
}
}
func TestLeaderToolsRunsSlash(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKeyText("t"))
next := updated.(model)
if next.leaderPending {
t.Fatal("expected leader cleared after Ctrl+X t")
}
if !transcriptContains(next.transcript, "Tools") && !transcriptContains(next.transcript, "tool") {
// toolsText content varies; at least something was appended.
if len(next.transcript) == 0 {
t.Fatal("Ctrl+X t should run /tools and append output")
}
}
}
func TestLeaderHelpOverlayListsChords(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
groups := m.buildKeybindingGroups()
var found bool
for _, g := range groups {
for _, b := range g.bindings {
if strings.Contains(b.keys, "Ctrl+X") && strings.Contains(b.desc, "/model") {
found = true
}
}
}
if !found {
t.Fatal("buildKeybindingGroups must document Ctrl+X slash-command chords")
}
}
func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
m.height = 50
m.altScreen = true
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
if !m.leaderPending {
t.Fatal("setup: Ctrl+X should arm leader")
}
updated, _ = m.Update(testKeyText("?"))
next := updated.(model)
if next.leaderPending {
t.Fatal("Ctrl+X ? should clear leader-pending")
}
if !next.leaderHelpOverlay {
t.Fatal("Ctrl+X ? should open the leader chord map")
}
if next.composerValue() != "" {
t.Fatalf("? must not type into composer, got %q", next.composerValue())
}
view := plainRender(t, next.View())
for _, want := range []string{
"Ctrl+X Shortcuts",
"Slash commands",
"Ctrl+X m", "open /model",
"Ctrl+X p", "open /provider",
"Ctrl+X R", "run /retry",
"╭", "╰", "│", // same box border chrome as the ? keyboard-shortcut modal
} {
if !strings.Contains(view, want) {
t.Fatalf("leader help missing %q, got:\n%s", want, view)
}
}
}
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
// Both overlays must use the same frame builder (titled box + panel fill).
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
help := m.renderKeybindingHelpOverlay(100)
leader := m.renderLeaderHelpOverlay(100)
for _, block := range []struct {
name string
s string
}{
{"help", help},
{"leader", leader},
} {
plain := ansiPattern.ReplaceAllString(block.s, "")
for _, want := range []string{"╭", "╰", "│", "╮", "╯"} {
if !strings.Contains(plain, want) {
t.Fatalf("%s overlay missing border %q:\n%s", block.name, want, plain)
}
}
}
if !strings.Contains(ansiPattern.ReplaceAllString(help, ""), "Keyboard Shortcuts") {
t.Fatal("help overlay should keep Keyboard Shortcuts title")
}
if !strings.Contains(ansiPattern.ReplaceAllString(leader, ""), "Ctrl+X Shortcuts") {
t.Fatal("leader overlay should use Ctrl+X Shortcuts title in the border")
}
}
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.leaderHelpOverlay = true
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if next.leaderHelpOverlay {
t.Fatal("Esc should close the leader help overlay")
}
}
func TestLeaderHelpBindingsCoverEveryMapEntry(t *testing.T) {
listed := map[string]bool{}
for _, b := range leaderHelpBindings() {
// "Ctrl+X m" → last field is the letter (skip the "?" meta row for map check).
parts := strings.Fields(b.keys)
if len(parts) != 2 || parts[0] != "Ctrl+X" {
continue
}
letter := parts[1]
if letter == "?" {
continue
}
runes := []rune(letter)
if len(runes) != 1 {
t.Fatalf("unexpected key label %q", b.keys)
}
listed[string(runes[0])] = true
if _, ok := leaderCommandByKey[runes[0]]; !ok {
t.Fatalf("help lists %q but leaderCommandByKey has no entry", b.keys)
}
}
for key := range leaderCommandByKey {
if !listed[string(key)] {
t.Fatalf("leaderCommandByKey has %q but help table omits it", string(key))
}
}
}
func TestLeaderSecondKeyShiftCapital(t *testing.T) {
// Shift+p should map to /plan (capital P), not /provider.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
updated, _ := m.Update(testKeyCtrl('x'))
m = updated.(model)
updated, _ = m.Update(testKeyShift('p'))
next := updated.(model)
if next.leaderPending {
t.Fatal("Shift+p should resolve the leader chord")
}
if next.picker != nil {
t.Fatal("/plan should not open a picker")
}
if !transcriptContains(next.transcript, "Plan") && !transcriptContains(next.transcript, "plan") {
// planText wording may vary; require some transcript growth from /plan.
if len(next.transcript) == 0 {
t.Fatal("Ctrl+X P should run /plan")
}
}
}
func TestLeaderSecondKeyFromTextCapital(t *testing.T) {
key, ok := leaderSecondKey(testKeyText("M"))
if !ok || key != 'M' {
t.Fatalf("leaderSecondKey(M) = %q,%v, want M,true", key, ok)
}
key, ok = leaderSecondKey(testKeyText("m"))
if !ok || key != 'm' {
t.Fatalf("leaderSecondKey(m) = %q,%v, want m,true", key, ok)
}
}
-62
View File
@@ -1,62 +0,0 @@
package tui
import tea "charm.land/bubbletea/v2"
// moveModalSelection moves the highlight on an open selection surface by delta
// (−1 previous / +1 next), matching arrow-key behavior for that surface.
// handled is false when no list/selector modal owns the keys (caller may fall
// through to plan toggle, composer, etc.).
//
// Surfaces: permission options, ask_user option lists, provider/MCP wizards and
// managers, command pickers, slash/file autocomplete. Does not scroll the
// transcript or drive composer history — those stay arrow-only.
func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
if delta == 0 {
return m, nil, false
}
if m.pendingPermission != nil {
return m.movePermissionCursor(delta), nil, true
}
if m.pendingAskUser != nil {
// moveAskUserCursor is a no-op in free-text mode (parity with arrows).
return m.moveAskUserCursor(delta), nil, true
}
if m.providerWizard != nil {
m.burstCount = 0
next, cmd := m.handleProviderWizardKey(syntheticArrowKey(delta))
return next, cmd, true
}
if m.mcpAddWizard != nil {
m.burstCount = 0
next, cmd := m.handleMCPAddWizardKey(syntheticArrowKey(delta))
return next, cmd, true
}
if m.mcpManager != nil {
m.burstCount = 0
next, cmd := m.handleMCPManagerKey(syntheticArrowKey(delta))
return next, cmd, true
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil, true
}
m.pickerMoved(delta)
return m, nil, true
}
if m.suggestionsActive() {
m.moveSuggestion(delta)
return m, nil, true
}
return m, nil, false
}
// syntheticArrowKey builds a KeyUp (delta < 0) or KeyDown (delta > 0) message so
// wizard/manager handlers that only match arrow keys can share one code path
// with Ctrl+P / Ctrl+N.
func syntheticArrowKey(delta int) tea.KeyPressMsg {
code := tea.KeyDown
if delta < 0 {
code = tea.KeyUp
}
return tea.KeyPressMsg(tea.Key{Code: code})
}
-189
View File
@@ -1,189 +0,0 @@
package tui
import (
"context"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
)
func TestCtrlPNMovesModelPicker(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
Provider: &fakeProvider{},
})
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
if m.picker == nil {
t.Fatal("setup: expected model picker")
}
if len(m.picker.items) < 2 {
t.Skip("model picker has fewer than 2 items; cannot assert movement")
}
m.picker.selected = 0
updated, _ = m.Update(testKey(tea.KeyDown))
viaArrow := updated.(model).picker.selected
m.picker.selected = 0
updated, _ = m.Update(testKeyCtrl('n'))
m = updated.(model)
if m.picker == nil {
t.Fatal("picker should stay open")
}
if m.picker.selected != viaArrow {
t.Fatalf("Ctrl+N selection = %d, KeyDown selection = %d", m.picker.selected, viaArrow)
}
updated, _ = m.Update(testKeyCtrl('p'))
m = updated.(model)
if m.picker.selected != 0 {
t.Fatalf("Ctrl+P should return to 0, got %d", m.picker.selected)
}
}
func TestCtrlPNMovesSuggestions(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/s")
if !m.suggestionsActive() || len(m.suggestions) < 2 {
t.Fatalf("setup: need multiple suggestions, got %d", len(m.suggestions))
}
updated, _ := m.Update(testKeyCtrl('n'))
m = updated.(model)
if m.suggestionIdx != 1 {
t.Fatalf("Ctrl+N should select index 1, got %d", m.suggestionIdx)
}
if got := m.composerValue(); got != "/s" {
t.Fatalf("Ctrl+N must not insert text, composer = %q", got)
}
updated, _ = m.Update(testKeyCtrl('p'))
m = updated.(model)
if m.suggestionIdx != 0 {
t.Fatalf("Ctrl+P should return to index 0, got %d", m.suggestionIdx)
}
}
func TestCtrlPNMovesPermissionOptions(t *testing.T) {
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
if m.pendingPermission == nil {
t.Fatal("setup: expected permission prompt")
}
start := m.pendingPermission.cursor
updated, _ := m.Update(testKeyCtrl('n'))
m = updated.(model)
if m.pendingPermission == nil {
t.Fatal("permission prompt should stay open")
}
if m.pendingPermission.cursor == start {
// Wrap or multi-option: try another N if single step no-op is impossible.
if len(permissionOptions(m.pendingPermission.request)) > 1 {
t.Fatalf("Ctrl+N should move permission cursor from %d", start)
}
}
mid := m.pendingPermission.cursor
updated, _ = m.Update(testKeyCtrl('p'))
m = updated.(model)
if m.pendingPermission.cursor != start && mid != start {
if m.pendingPermission.cursor == mid {
t.Fatalf("Ctrl+P should move permission cursor from %d", mid)
}
}
}
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
}
if m.plan.isEmpty() {
t.Fatal("setup: plan should be non-empty")
}
initial := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('p'))
next := updated.(model)
if next.plan.expanded == initial {
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
}
}
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
Provider: &fakeProvider{},
})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: true,
}
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
if m.picker == nil {
t.Fatal("setup: expected picker")
}
updated, _ = m.Update(testKeyCtrl('p'))
next := updated.(model)
if !next.plan.expanded {
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
}
if next.picker == nil {
t.Fatal("picker should remain open")
}
}
func TestCtrlNNoOpWithoutModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: false,
}
before := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('n'))
next := updated.(model)
if next.picker != nil || next.suggestionsActive() {
t.Fatal("Ctrl+N idle must not open a modal")
}
if next.plan.expanded != before {
t.Fatal("Ctrl+N idle must not toggle the plan panel")
}
if next.composerValue() != "" {
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
}
}
func TestCtrlPNThemePickerPreview(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.input.SetValue("/theme")
updated, _ := m.handleSubmit()
m = updated.(model)
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("setup: expected theme picker, got %#v", m.picker)
}
// Move with Ctrl+N — should not error; preview path is inside pickerMoved.
start := m.picker.selected
updated, _ = m.Update(testKeyCtrl('n'))
m = updated.(model)
if m.picker == nil {
t.Fatal("theme picker should stay open")
}
if len(m.picker.items) > 1 && m.picker.selected == start {
t.Fatalf("Ctrl+N should advance theme selection from %d", start)
}
}
func TestMoveModalSelectionMatchesArrowsOnSuggestions(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/")
if len(m.suggestions) < 2 {
t.Fatalf("need suggestions, got %d", len(m.suggestions))
}
m.suggestionIdx = 0
viaArrow, _ := m.Update(testKey(tea.KeyDown))
m.suggestionIdx = 0
viaCtrl, _ := m.Update(testKeyCtrl('n'))
if viaArrow.(model).suggestionIdx != viaCtrl.(model).suggestionIdx {
t.Fatalf("arrow idx=%d ctrl idx=%d", viaArrow.(model).suggestionIdx, viaCtrl.(model).suggestionIdx)
}
}
+131 -180
View File
@@ -145,19 +145,13 @@ type model struct {
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
// /context can show a rolling average turn latency (the "is it slow?" signal).
// Reset by /new.
turnLatencySum time.Duration
turnLatencyCount int
turnTTFTSum time.Duration
turnTTFTCount int
transcript []transcriptRow
transcriptDetailed bool
helpOverlay bool // the `?` keyboard-shortcut overlay is open
// leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord.
leaderHelpOverlay bool
// leaderPending is true after Ctrl+X until a second key, Esc, or timeout
// resolves the chord (see leader.go). leaderSeq invalidates a stale tick.
leaderPending bool
leaderSeq int
turnLatencySum time.Duration
turnLatencyCount int
turnTTFTSum time.Duration
turnTTFTCount int
transcript []transcriptRow
transcriptDetailed bool
helpOverlay bool // the `?` keyboard-shortcut overlay is open
transcriptBodyHeights *transcriptBodyHeightCache
input textinput.Model
composer composerState
@@ -205,13 +199,8 @@ type model struct {
// lastKeyTime tracks every keypress timestamp for burst calculation.
lastKeyTime time.Time
// burstCount counts consecutive keypresses within 100ms (paste mode).
burstCount int
// terminalFocused tracks whether the terminal window currently has focus, per
// tea.FocusMsg/tea.BlurMsg. Defaults to true since many terminals/multiplexers
// never send focus events at all, and defaulting to "unfocused" would wrongly
// hide the cursor for those users.
terminalFocused bool
queuedMessage string
burstCount int
queuedMessage string
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
@@ -781,7 +770,6 @@ func newModel(ctx context.Context, options Options) model {
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
composerCursorVisible: true,
terminalFocused: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
@@ -893,11 +881,6 @@ const (
// composerCursorBlinkInterval is the on/off period of the composer text cursor.
const composerCursorBlinkInterval = 530 * time.Millisecond
// composerTypingIdleThreshold is how long a typing pause must last before the
// cursor resumes blinking; comfortably above normal inter-keystroke gaps
// (~150-300ms) so it won't flicker mid-sentence.
const composerTypingIdleThreshold = 500 * time.Millisecond
// composerBlinkMsg toggles the composer cursor's visibility each tick. The custom
// composer render draws its own cursor (not textinput's), so it drives its own
// blink rather than relying on textinput.Blink.
@@ -987,9 +970,6 @@ func (m model) noBlockingModal() bool {
}
func (m model) quit() (tea.Model, tea.Cmd) {
if m.providerWizard != nil {
m.providerWizard.resetAimlapiOnboard()
}
m.stopPRWatcher()
m.stopAllBackgroundTerminalSessions()
m.shutdownLSPManager()
@@ -1094,14 +1074,7 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case composerBlinkMsg:
switch {
case !m.terminalFocused:
m.composerCursorVisible = false // hidden while unfocused
case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold:
m.composerCursorVisible = true // solid while actively typing
default:
m.composerCursorVisible = !m.composerCursorVisible // idle + focused: blink as before
}
m.composerCursorVisible = !m.composerCursorVisible
return m, composerBlinkCmd()
case tea.BackgroundColorMsg:
// Terminal background-color reply (from Init's RequestBackgroundColor). In
@@ -1145,11 +1118,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cancelConfirmActive = false
}
return m, nil
case leaderExpiredMsg:
if msg.seq == m.leaderSeq {
m.leaderPending = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
@@ -1158,10 +1126,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
case providerWizardOAuthMsg:
return m.applyProviderWizardOAuth(msg)
case aimlapiOnboardMsg:
return m.applyAimlapiOnboard(msg)
case aimlapiExistingBalanceMsg:
return m.applyExistingAimlapiBalance(msg)
case providerWizardDeviceCodeMsg:
return m.applyProviderWizardDeviceCode(msg)
case providerManagerCredsMsg:
@@ -1239,11 +1203,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if !keyIs(msg, tea.KeyEnter) {
m.lastCharTime = now
}
// Enter the solid-while-typing state right away: only composerBlinkMsg
// evaluates the typing threshold, so if the blink phase had just hidden
// the caret, the typed character would render caret-less for up to a
// full tick before the timer catches up.
m.composerCursorVisible = true
if m.setup.visible {
return m.handleSetupKey(msg)
}
@@ -1274,44 +1233,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.burstCount = 0
return m, nil
}
// Ctrl+X ? leader-chord map: same dismiss keys as the general help overlay.
if m.leaderHelpOverlay {
if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') {
m.leaderHelpOverlay = false
}
m.burstCount = 0
return m, nil
}
if keyCtrl(msg, 'c') {
if m.leaderPending {
m = m.clearLeader()
}
return m.handleCtrlC()
}
if m.leaderPending {
// Leader owns every keystroke until resolved; never type into the composer.
return m.handleLeaderKey(msg)
}
if keyCtrl(msg, 'x') && m.canArmLeader() {
return m.armLeader()
}
// Emacs Ctrl+P / Ctrl+N move selection in open menus. Runs before the
// switch so menus win over global Ctrl+P (plan toggle). Idle Ctrl+P
// falls through to that binding; idle Ctrl+N is a reserved no-op so it
// never reaches remapped configurable bindings (e.g. toggleSidebar).
if !m.transcriptDetailed && (keyCtrl(msg, 'p') || keyCtrl(msg, 'n')) {
delta := 1
if keyCtrl(msg, 'p') {
delta = -1
}
if next, cmd, ok := m.moveModalSelection(delta); ok {
return next, cmd
}
if keyCtrl(msg, 'n') {
return m, nil
}
}
switch {
case keyCtrl(msg, 'c'):
return m.handleCtrlC()
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
return m.toggleDetailedTranscript(), nil
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
@@ -1573,7 +1497,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
// Modal selection is handled before this switch so menus win over toggle.
if m.noBlockingModal() && !m.plan.isEmpty() {
m.plan.expanded = !m.plan.expanded
return m, nil
@@ -1671,14 +1594,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.transcriptDetailed {
return m, nil
}
// Suggestions keep Shift+arrows for the composer path (unchanged); plain
// ↑/↓ and Ctrl+P/N still move the palette via moveModalSelection.
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(-1), nil
}
if m.providerWizard != nil {
m.burstCount = 0
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.burstCount = 0
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
m.burstCount = 0
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
return m, nil
}
if m.suggestionsActive() {
break
}
if next, cmd, ok := m.moveModalSelection(-1); ok {
return next, cmd
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
@@ -1690,12 +1633,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(1), nil
}
if m.providerWizard != nil {
m.burstCount = 0
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.burstCount = 0
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
m.burstCount = 0
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
return m, nil
}
if m.suggestionsActive() {
break
}
if next, cmd, ok := m.moveModalSelection(1); ok {
return next, cmd
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
@@ -1706,8 +1671,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.clearHover()
return m.scrollChat(-1), nil
}
if next, cmd, ok := m.moveModalSelection(1); ok {
return next, cmd
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(1), nil
}
if m.providerWizard != nil {
m.burstCount = 0
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.burstCount = 0
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
m.burstCount = 0
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
return m, nil
}
if m.suggestionsActive() {
m.moveSuggestion(1)
return m, nil
}
if next, ok := m.moveComposerVisualCursor(1); ok {
return next, nil
@@ -1726,8 +1717,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.clearHover()
return m.scrollChat(1), nil
}
if next, cmd, ok := m.moveModalSelection(-1); ok {
return next, cmd
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(-1), nil
}
if m.providerWizard != nil {
m.burstCount = 0
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.burstCount = 0
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
m.burstCount = 0
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
return m, nil
}
if m.suggestionsActive() {
m.moveSuggestion(-1)
return m, nil
}
if next, ok := m.moveComposerVisualCursor(-1); ok {
return next, nil
@@ -1853,19 +1870,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.recomputeSuggestions()
return m, cmd
case tea.FocusMsg:
m.terminalFocused = true
// Sync the caret with focus immediately: leaving it to the next
// composerBlinkMsg tick can keep it hidden for up to a tick after the
// terminal regains focus (and, on blur below, leave it visible in an
// unfocused terminal for the same window).
m.composerCursorVisible = true
if m.notifier != nil {
m.notifier.SetFocused(true)
}
return m, nil
case tea.BlurMsg:
m.terminalFocused = false
m.composerCursorVisible = false
if m.notifier != nil {
m.notifier.SetFocused(false)
}
@@ -1943,21 +1952,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// advancing even when no run is pending, so the tick loop stays alive while
// sidebarHasAgents() holds (and stops the moment the agents/sidebar clear).
if !m.pending && !m.compactInFlight && !m.doctorInFlight {
// The tick also keeps advancing while the aimlapi.com onboarding sub-flow is
// busy (its progress screen is spinner-only), even though no agent run is in
// flight, so its shared MiniDot spinner keeps animating.
//
// Return the FPS-throttled tick that Update hands back — NOT m.spinner.Tick,
// which fires immediately and busy-loops the frame at event-loop speed. That
// makes the glyph spin far too fast (and burns CPU) on screens that sit here
// for a while, e.g. the aimlapi checkout wait. The active-run path below
// already does this; this keeps idle animation at the same cadence.
if (m.sidebarHasAgents() || m.aimlapiOnboardAnimating()) && !m.reducedMotion {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
if m.sidebarHasAgents() && !m.reducedMotion {
m.spinner, _ = m.spinner.Update(msg)
m.spinnerPhase++
m.spinnerTicking = true
return m, cmd
return m, m.spinner.Tick
}
m.spinnerTicking = false
return m, nil
@@ -2520,9 +2519,9 @@ func (m model) View() tea.View {
var content string
if m.setup.visible {
content = m.setupView(chatWidth(m.width))
} else if m.helpOverlay || m.leaderHelpOverlay || !m.transcriptDetailed {
// When helpOverlay / leaderHelpOverlay is active the panel is composited into
// the normal transcript view as a true overlay (scrim + vertical centering), matching
} else if m.helpOverlay || !m.transcriptDetailed {
// When helpOverlay is active the help panel is composited into the normal
// transcript view as a true overlay (scrim + vertical centering), matching
// how the suggestion picker / provider wizard / pickers are drawn.
content = m.transcriptView()
} else {
@@ -2542,11 +2541,7 @@ func (m model) View() tea.View {
if m.altScreen {
view.BackgroundColor = zeroTheme.bgPanel
}
// Always requested, independent of the notifier: the composer cursor's
// focus/blink behavior (composerBlinkMsg above) needs tea.FocusMsg/BlurMsg
// regardless of notification config. A standard, widely supported DEC
// private mode (CSI ?1004h) that unsupported terminals silently ignore.
view.ReportFocus = true
view.ReportFocus = m.notifier != nil
// Voice mode's Space-hold gesture needs key-release events (Kitty protocol).
// Request them only while voice mode is on — the renderer re-sends the request
// only when the value changes, so gating this costs nothing (§10).
@@ -2618,10 +2613,6 @@ func (m model) transcriptView() string {
if m.helpOverlay {
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
}
leaderHelpOverlayContent := ""
if m.leaderHelpOverlay {
leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width)
}
suggestionOverlay := m.suggestionOverlay(width)
providerOverlay := m.providerWizardOverlay(width)
@@ -2635,8 +2626,6 @@ func (m model) transcriptView() string {
viewportOverlay = sttKeyOverlay
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case leaderHelpOverlayContent != "":
viewportOverlay = leaderHelpOverlayContent
case providerOverlay != "":
viewportOverlay = providerOverlay
case mcpAddOverlay != "":
@@ -2781,11 +2770,6 @@ func (m model) footerView(width int) string {
// full-screen transcript, or under any modal/overlay so it never competes for
// attention. Width-tiered so a narrow terminal only shows the essential pointer.
func (m model) composerIdleHint() string {
// Leader-pending is always shown (even mid-type) so the user knows the next
// key is a chord, not composer input.
if m.leaderPending {
return zeroTheme.faint.Render("Ctrl+X — await shortcut (m model · p provider · ? list · Esc cancel)")
}
// Managed (alt-screen) mode only: inline mode prints to native scrollback where
// this footer row isn't a stable surface. Hidden while typing, during a run, in
// the full-screen transcript, or under any modal/overlay.
@@ -2804,9 +2788,9 @@ func (m model) composerIdleHint() string {
case tierNarrow:
hint = "? shortcuts"
case tierMedium:
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey)
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
default:
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
}
return zeroTheme.faint.Render(hint)
}
@@ -3491,7 +3475,7 @@ func (m model) composerLine(width int) string {
}
if argumentHint != "" {
input.SetWidth(0)
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint, m.composerCursorVisible), width)
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint), width)
}
previews := validComposerPastePreviews(state, m.composerPastePreviews)
displayState := composerDisplayStateForPastePreviews(state, previews)
@@ -3767,23 +3751,16 @@ func composerCursorForVisualColumn(state composerState, segment composerVisualLi
return segment.end
}
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string, cursorVisible bool) string {
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string) string {
hintRunes := []rune(argumentHint)
if len(hintRunes) == 0 {
return input.View()
}
displayValue := strings.TrimRightFunc(input.Value(), unicode.IsSpace)
// This alternate composer path must follow the same caret contract as
// renderComposerInput: hidden while the terminal is unfocused and blinking
// per composerCursorVisible, not a permanently painted cursor cell.
cursor := zeroTheme.faint.Render(string(hintRunes[0]))
if cursorVisible {
cursor = composerCursor(cursor)
}
return zeroTheme.userPrompt.Render(input.Prompt) +
zeroTheme.ink.Inline(true).Render(displayValue) +
zeroTheme.faint.Render(" ") +
cursor +
composerCursor(zeroTheme.faint.Render(string(hintRunes[0]))) +
zeroTheme.faint.Render(string(hintRunes[1:]))
}
@@ -4092,12 +4069,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.chatScrollOffset = 0
}
return m.dispatchCommand(command)
}
// dispatchCommand runs a parsed slash/prompt command after submit/leader
// preamble (history, composer clear, leave-prompt disarm) has already run.
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
switch command.kind {
case commandEmpty:
return m, nil
@@ -4472,23 +4443,6 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
}
}
// executeSlash runs a builtin slash command as if the user typed it and pressed
// Enter, without clearing the composer draft. Used by Ctrl+X leader chords so a
// mid-type prompt is preserved while /model (etc.) still opens.
func (m model) executeSlash(input string) (tea.Model, tea.Cmd) {
command := parseCommand(input)
if command.kind == commandEmpty || command.kind == commandPrompt {
return m, nil
}
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
m.loopLeavePrompt = commandEmpty
}
m.rememberInput(input)
m.clearSuggestions()
m.chatScrollOffset = 0
return m.dispatchCommand(command)
}
// launchPrompt starts a normal agent turn from text already accepted by the
// composer. Queued prompts use this path too, so session and image behavior
// stays identical to immediate submissions.
@@ -4608,10 +4562,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
// loop is already alive, when reduced motion is set, or when there is nothing to
// animate, so an idle plain session schedules no timer.
func (m *model) ensureSpinnerTick() tea.Cmd {
if m.spinnerTicking || m.reducedMotion {
return nil
}
if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() {
if m.spinnerTicking || m.reducedMotion || !m.sidebarHasAgents() {
return nil
}
m.spinnerTicking = true
-196
View File
@@ -11,7 +11,6 @@ import (
"time"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
@@ -1965,35 +1964,6 @@ func TestCtrlCRequiresSecondPressToExit(t *testing.T) {
}
}
func TestConfirmedCtrlCCancelsProviderAimlapiOnboarding(t *testing.T) {
cancelled := false
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
m.providerWizard = &providerWizardState{
step: providerWizardStepAimlapi,
aimlapi: &aimlapiOnboardState{
topupCancel: func() { cancelled = true },
},
}
updated, _ := m.Update(testKeyCtrl('c'))
next := updated.(model)
if cancelled {
t.Fatal("first Ctrl+C should only arm exit confirmation")
}
updated, cmd := next.Update(testKeyCtrl('c'))
next = updated.(model)
if !cancelled {
t.Fatal("confirmed Ctrl+C did not cancel AIMLAPI onboarding")
}
if next.providerWizard.aimlapi != nil {
t.Fatal("confirmed Ctrl+C retained AIMLAPI onboarding state")
}
if cmd == nil {
t.Fatal("confirmed Ctrl+C should return quit command")
}
}
func TestCtrlCExitConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
@@ -2677,172 +2647,6 @@ func TestModelNotifierFocusAndCompletion(t *testing.T) {
}
}
func TestComposerBlinkStaysSolidWhileTyping(t *testing.T) {
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
now := base
m := model{
now: func() time.Time { return now },
terminalFocused: true,
lastCharTime: base,
composerCursorVisible: true,
}
// Each iteration simulates a keystroke (refreshing lastCharTime) followed by
// a blink tick within the typing-idle threshold: cursor must stay solid
// rather than toggling off, however many ticks land.
for i := 0; i < 3; i++ {
now = now.Add(200 * time.Millisecond)
m.lastCharTime = now
updated, _ := m.Update(composerBlinkMsg{})
m = updated.(model)
if !m.composerCursorVisible {
t.Fatalf("tick %d: expected cursor to stay visible while typing, got hidden", i)
}
}
}
func TestComposerBlinkHiddenWhileUnfocused(t *testing.T) {
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
m := model{
now: func() time.Time { return base },
lastCharTime: base.Add(-time.Hour), // long idle, irrelevant while unfocused
composerCursorVisible: true,
}
updated, _ := m.Update(tea.BlurMsg{})
m = updated.(model)
for i := 0; i < 3; i++ {
updated, _ = m.Update(composerBlinkMsg{})
m = updated.(model)
if m.composerCursorVisible {
t.Fatalf("tick %d: expected cursor to stay hidden while unfocused, got visible", i)
}
}
}
func TestComposerBlinkResumesAfterRefocusAndIdle(t *testing.T) {
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
m := model{
now: func() time.Time { return base },
lastCharTime: base.Add(-time.Hour), // stale: well past the idle threshold
composerCursorVisible: true,
}
updated, _ := m.Update(tea.BlurMsg{})
m = updated.(model)
updated, _ = m.Update(tea.FocusMsg{})
m = updated.(model)
if !m.terminalFocused {
t.Fatal("expected terminalFocused to be true after FocusMsg")
}
updated, _ = m.Update(composerBlinkMsg{})
m = updated.(model)
first := m.composerCursorVisible
updated, _ = m.Update(composerBlinkMsg{})
m = updated.(model)
second := m.composerCursorVisible
if first == second {
t.Fatalf("expected blink to toggle once idle+focused, got %v then %v", first, second)
}
}
func TestComposerBlinkTogglesWhenIdleAndFocused(t *testing.T) {
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
m := model{
now: func() time.Time { return base },
terminalFocused: true,
lastCharTime: base.Add(-time.Hour),
composerCursorVisible: true,
}
updated, _ := m.Update(composerBlinkMsg{})
m = updated.(model)
if m.composerCursorVisible {
t.Fatal("expected cursor to toggle off on first idle+focused tick")
}
updated, _ = m.Update(composerBlinkMsg{})
m = updated.(model)
if !m.composerCursorVisible {
t.Fatal("expected cursor to toggle back on on second idle+focused tick")
}
}
func TestComposerCursorShowsImmediatelyOnKeypress(t *testing.T) {
// The blink phase may have just hidden the caret when the user starts
// typing; the typed character must render with a caret immediately, not
// after the next composerBlinkMsg tick evaluates the typing threshold.
m := newModel(context.Background(), Options{})
m.terminalFocused = true
m.composerCursorVisible = false
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'a', Text: "a"}))
m = updated.(model)
if !m.composerCursorVisible {
t.Fatal("expected caret visible immediately after a keypress, before any blink tick")
}
}
func TestComposerCursorSyncsImmediatelyOnFocusChange(t *testing.T) {
// Focus transitions must apply the caret contract synchronously: a blur
// with no blink tick yet must not leave a visible caret in an unfocused
// terminal, and a refocus must not leave the caret hidden for a tick.
m := model{composerCursorVisible: true, terminalFocused: true}
updated, _ := m.Update(tea.BlurMsg{})
m = updated.(model)
if m.composerCursorVisible {
t.Fatal("expected caret hidden immediately after BlurMsg, before any blink tick")
}
updated, _ = m.Update(tea.FocusMsg{})
m = updated.(model)
if !m.composerCursorVisible {
t.Fatal("expected caret visible immediately after FocusMsg, before any blink tick")
}
}
func TestComposerCursorShowsImmediatelyOnPaste(t *testing.T) {
// A paste is the same immediate-input transition as a keypress: if the
// blink phase had just hidden the caret, the freshly pasted composer must
// render with a solid caret right away, and the typing timestamp must be
// refreshed so the next blink tick holds solid instead of toggling off a
// stale idle state.
m := newModel(context.Background(), Options{})
m.terminalFocused = true
m.composerCursorVisible = false
m.lastCharTime = time.Now().Add(-time.Hour) // stale: well past the idle threshold
updated, _ := m.Update(tea.PasteMsg{Content: "pasted text"})
m = updated.(model)
if !m.composerCursorVisible {
t.Fatal("expected caret visible immediately after a paste, before any blink tick")
}
if time.Since(m.lastCharTime) > time.Minute {
t.Fatalf("expected the paste to refresh lastCharTime, still %v old", time.Since(m.lastCharTime))
}
}
func TestCommandArgumentHintFollowsCursorVisibility(t *testing.T) {
// The argument-hint composer line is an alternate render path that used to
// paint its caret cell unconditionally, ignoring focus and blink state.
input := textinput.New()
input.SetValue("/rewind ")
visible := commandArgumentHintComposerLine(input, "hint", true)
hidden := commandArgumentHintComposerLine(input, "hint", false)
if visible == hidden {
t.Fatal("expected the rendered hint line to differ between visible and hidden caret states")
}
if want := composerCursor(zeroTheme.faint.Render("h")); !strings.Contains(visible, want) {
t.Fatalf("expected visible-caret render to contain the styled cursor cell, got %q", visible)
}
if got := hidden; strings.Contains(got, composerCursor(zeroTheme.faint.Render("h"))) {
t.Fatalf("expected hidden-caret render to drop the styled cursor cell, got %q", got)
}
}
func TestScrimViewportLine(t *testing.T) {
// Blank lines are left untouched (no scrim).
if got := scrimViewportLine(" ", 10); got != " " {
+5 -142
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"fmt"
"os"
"strings"
"time"
"unicode"
@@ -12,7 +11,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/browser"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
@@ -31,9 +29,6 @@ const (
setupStageEndpoint
setupStageName
setupStageCredentials
// aimlapi.com onboarding sub-flow, reached when the selected
// provider is aimlapi. Delegated to the shared aimlapiOnboardState.
setupStageAimlapi
setupStageModel
setupStageSafety
setupStageReady
@@ -67,25 +62,6 @@ type setupState struct {
modelErr string
modelSrc string
modelGen uint64
// aimlapi holds the shared aimlapi.com onboarding sub-flow while
// setup is on setupStageAimlapi.
aimlapi *aimlapiOnboardState
}
// aimlapiTopupEvent is one live update from a running partner checkout, streamed
// over a channel so the TUI can show progress (and the direct checkout link) as
// it happens instead of blocking on a single result. Mirrors openclaude's
// onStatus-driven progress screen.
type aimlapiTopupEvent struct {
detail string // for opening-checkout this is the direct checkout URL
done bool
result aimlapi.ProvisionedKey
err error
// session carries the live partner-checkout session token (empty string means
// "drop the retained token"); hasSession distinguishes it from a status update.
session string
hasSession bool
}
// setupOAuthMsg carries the result of a first-run browser OAuth login.
@@ -161,16 +137,6 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, nil
}
// The aimlapi.com sub-flow owns all of its keys; Ctrl+C still quits.
if m.setup.stage == setupStageAimlapi {
if keyCtrl(msg, 'c') {
if m.setup.aimlapi != nil {
m.setup.aimlapi.cancelStream()
}
return m, tea.Quit
}
return m.handleSetupAimlapiKey(msg)
}
if m.setupEndpointInputActive() {
return m.handleSetupEndpointKey(msg)
}
@@ -217,7 +183,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.advanceSetup()
}
return m, nil
case keyIs(msg, tea.KeyUp) || keyCtrl(msg, 'p'):
case keyIs(msg, tea.KeyUp):
if m.setup.stage == setupStageMethod {
m.moveSetupMethod(-1)
} else if m.setup.stage == setupStageProvider {
@@ -226,7 +192,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveSetupModel(-1)
}
return m, nil
case keyIs(msg, tea.KeyDown) || keyCtrl(msg, 'n'):
case keyIs(msg, tea.KeyDown):
if m.setup.stage == setupStageMethod {
m.moveSetupMethod(1)
} else if m.setup.stage == setupStageProvider {
@@ -303,10 +269,6 @@ func (m model) handleSetupPaste(msg tea.PasteMsg) (tea.Model, tea.Cmd) {
m.appendSetupBaseURL([]rune(msg.Content))
case m.setupNameInputActive():
m.appendSetupName([]rune(msg.Content))
case m.setup.stage == setupStageAimlapi:
if m.setup.aimlapi != nil {
m.setup.aimlapi.appendInput(msg.Content)
}
case m.setupCredentialInputActive():
previousAPIKey := m.setup.apiKey.Value()
var cmd tea.Cmd
@@ -459,21 +421,10 @@ func setupContentTop(height int, contentLines int, hasError bool) int {
func (m model) setupStages() []setupStage {
stages := []setupStage{setupStageWelcome, setupStageMethod}
if m.setup.oauthMode {
// OAuth path skips endpoint/name/credentials because login provides the credential.
// OAuth path skips endpoint/name/credentials (the login provides the credential).
return append(stages, setupStageProvider, setupStageModel, setupStageSafety, setupStageReady)
}
stages = append(stages, setupStageProvider)
if setupProviderIsAimlapi(m.setupProvider()) {
// Preserve the catalog's normal env-credential path in first-run setup.
// A ready AIMLAPI_API_KEY needs no guided identity flow: model discovery
// resolves it at runtime and saveSetupProvider persists APIKeyEnv, not the
// materialized secret. /provider owns the richer balance/top-up preflight.
if setupAimlapiEnvKey() != "" {
return append(stages, setupStageModel, setupStageSafety, setupStageReady)
}
stages = append(stages, setupStageAimlapi, setupStageModel, setupStageSafety, setupStageReady)
return stages
}
if setupProviderNeedsEndpoint(m.setupProvider()) {
stages = append(stages, setupStageEndpoint, setupStageName)
}
@@ -481,14 +432,6 @@ func (m model) setupStages() []setupStage {
return stages
}
func setupAimlapiEnvKey() string {
return strings.TrimSpace(os.Getenv("AIMLAPI_API_KEY"))
}
func setupProviderIsAimlapi(option SetupProviderOption) bool {
return strings.EqualFold(strings.TrimSpace(option.ID), "aimlapi")
}
// setupReturnToMethod resets the OAuth selection when navigating back to the
// connect-method chooser.
func (m model) setupReturnToMethod() model {
@@ -664,54 +607,6 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
return m, cmd
}
// handleSetupAimlapiKey delegates a key press to first-run setup's aimlapi.com
// sub-flow and reacts to its outcome.
func (m model) handleSetupAimlapiKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.setup.aimlapi == nil {
return m, nil
}
cmd, outcome := m.setup.aimlapi.handleKey(msg)
return m.resolveSetupAimlapi(cmd, outcome)
}
// applySetupAimlapiOnboard routes an async aimlapi.com onboarding result into
// first-run setup's sub-flow and reacts to its outcome.
func (m model) applySetupAimlapiOnboard(msg aimlapiOnboardMsg) (tea.Model, tea.Cmd) {
if m.setup.aimlapi == nil || m.setup.aimlapi != msg.state {
return m, nil
}
cmd, outcome := m.setup.aimlapi.apply(msg)
return m.resolveSetupAimlapi(cmd, outcome)
}
// resolveSetupAimlapi lands the sub-flow outcome on first-run setup: a terminal
// key moves the issued key into the setup and jumps to model selection; a cancel
// backs out to the provider list.
func (m model) resolveSetupAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) (tea.Model, tea.Cmd) {
switch outcome {
case aimlapiDone:
m.setup.apiKey.SetValue(m.setup.aimlapi.apiKey)
m.setup.baseURL = m.setup.aimlapi.baseURL
m.setup.aimlapi = nil
m.setup.err = ""
m.setup.stage = setupStageModel
m.resetSetupModels()
m.setup.modelErr = ""
m.setup.modelGen++
discoverCmd := m.setupModelDiscoveryCmd(m.setup.modelGen)
m.setup.modelLoad = discoverCmd != nil
return m, discoverCmd
case aimlapiCancel:
m.setup.aimlapi = nil
m.setup.err = ""
m.setup.stage = setupStageProvider
return m, nil
}
// Keep the shared spinner tick alive whenever the sub-flow just entered a busy
// or progress state, so its animated spinner advances during onboarding.
return m, tea.Batch(cmd, m.ensureSpinnerTick())
}
func (m model) nextSetupStage() setupStage {
stages := m.setupStages()
for index, stage := range stages {
@@ -726,14 +621,6 @@ func (m model) nextSetupStage() setupStage {
}
func (m model) previousSetupStage() setupStage {
// The aimlapi.com connect flow is terminal: once it has issued a key, its
// transient state is cleared. Going back from model selection or Safety can
// otherwise strand the user on an empty intermediate screen. Return to the
// provider list so choosing aimlapi.com again starts a fresh connect flow.
if (m.setup.stage == setupStageModel || m.setup.stage == setupStageSafety) &&
setupProviderIsAimlapi(m.setupProvider()) {
return setupStageProvider
}
stages := m.setupStages()
for index, stage := range stages {
if stage == m.setup.stage {
@@ -812,9 +699,6 @@ func (m model) advanceSetup() (tea.Model, tea.Cmd) {
}
m.setup.stage = m.nextSetupStage()
m.setup.err = ""
if m.setup.stage == setupStageAimlapi && m.setup.aimlapi == nil {
m.setup.aimlapi = newAimlapiOnboard(browser.OpenURL)
}
if m.setup.stage == setupStageModel {
m.resetSetupModels()
m.setup.modelErr = ""
@@ -951,7 +835,7 @@ func (m model) setupModelDiscoveryCmd(gen uint64) tea.Cmd {
apiKey = resolved
}
}
profile := providerWizardDiscoveryProfile(provider, apiKey, m.setupBaseURL(option))
profile := providerWizardDiscoveryProfile(provider, apiKey)
secrets := append(append([]string{}, baseSecrets...), apiKey, profile.APIKey)
models, err := discover(ctx, profile)
return setupModelsDiscoveredMsg{providerID: providerID, gen: gen, redactionSecrets: secrets, models: models, err: err}
@@ -1215,16 +1099,6 @@ func (m model) setupCredentialAPIKey(option SetupProviderOption) string {
}
func (m model) setupBaseURL(option SetupProviderOption) string {
// aimlapi.com never shows the endpoint field, but its onboarding resolves the
// inference endpoint (honoring an AIMLAPI_INFERENCE_URL override) onto
// m.setup.baseURL. Persist that so an override reaches the saved profile instead
// of being replaced by the catalog default at save time.
if providerWizardIsAimlapi(setupProviderDescriptor(option)) {
if baseURL := strings.TrimSpace(m.setup.baseURL); baseURL != "" {
return baseURL
}
return strings.TrimSpace(aimlapi.ResolveEndpoints().InferenceBaseURL)
}
if !setupProviderNeedsEndpoint(option) {
return ""
}
@@ -1352,11 +1226,6 @@ func (m model) setupStageLines(width int, height int) []string {
return m.setupNameLines(width)
case setupStageCredentials:
return m.setupCredentialLines(width)
case setupStageAimlapi:
if m.setup.aimlapi != nil {
return m.setup.aimlapi.view(width, m.spinnerGlyph())
}
return nil
case setupStageModel:
return m.setupModelLines(width, height)
case setupStageSafety:
@@ -1737,11 +1606,7 @@ func setupProviderBlockWidth(terminalWidth int, providers []SetupProviderOption)
available := maxInt(24, minInt(terminalWidth-8, 44))
target := maxInt(lipgloss.Width(" 2/6"), lipgloss.Width(" Choose a provider"))
for _, provider := range providers {
label := displayValue(provider.Name, provider.ID)
if provider.Recommended {
label = "★ " + label + " (recommended)"
}
target = maxInt(target, 2+lipgloss.Width(label))
target = maxInt(target, 2+lipgloss.Width(displayValue(provider.Name, provider.ID)))
}
target = maxInt(target, 32)
return minInt(target, available)
@@ -1829,8 +1694,6 @@ func (m model) setupFooter() string {
return zeroTheme.faint.Render("paste key optional ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back")
}
return zeroTheme.accent.Render("Space") + zeroTheme.faint.Render(" to continue")
case setupStageAimlapi:
return zeroTheme.faint.Render("type/↑↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back Esc cancel")
case setupStageProvider:
if m.setup.oauthMode && m.setupProviderDescriptor().OAuthDeviceFlow {
return zeroTheme.faint.Render("↑/↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" sign in ") + zeroTheme.accent.Render("d") + zeroTheme.faint.Render(" device code q quit")
-607
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"errors"
"net/http"
"os"
"strings"
"testing"
@@ -11,7 +10,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
@@ -51,611 +49,6 @@ func TestSetupMethodOptionsDropsOAuthWithoutOAuthProviders(t *testing.T) {
}
}
func TestAimlapiCheckoutLinkWrapsWithoutTruncation(t *testing.T) {
link := "https://checkout.stripe.com/c/pay/cs_test_abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789#fidkdWxOYHwnPyd1blpxYHZxWjA0"
lines := aimlapiLinkLines(link, 32)
if len(lines) < 2 {
t.Fatalf("expected long checkout link to wrap, got %d line(s): %#v", len(lines), lines)
}
plain := plainRender(t, strings.Join(lines, "\n"))
for index, line := range strings.Split(plain, "\n") {
if strings.Contains(line, "://") {
t.Fatalf("line %d still exposes an auto-detectable URL scheme: %q", index, line)
}
}
if strings.Contains(plain, "…") {
t.Fatalf("wrapped checkout link should not be truncated:\n%s", plain)
}
var joined strings.Builder
for _, line := range strings.Split(plain, "\n") {
joined.WriteString(strings.TrimSpace(line))
}
if got := joined.String(); got != link {
t.Fatalf("wrapped checkout link changed:\ngot %q\nwant %q", got, link)
}
for index, line := range strings.Split(plain, "\n") {
if got := lipgloss.Width(line); got > 32 {
t.Fatalf("line %d width = %d, want <= 32: %q", index, got, line)
}
}
}
func TestAimlapiCheckoutLinkReplacesProgressSpinner(t *testing.T) {
link := "https://checkout.stripe.com/c/pay/cs_test_abcdefghijklmnopqrstuvwxyz0123456789"
state := &aimlapiOnboardState{
step: aimlapiStepProgress,
detail: link,
}
view := plainRender(t, strings.Join(state.viewProgress(40, "spin"), "\n"))
assertContains(t, view, "Opening checkout in browser...")
assertContains(t, view, "https:")
assertContains(t, view, "//checkout.stripe.com")
assertNotContains(t, view, "spin")
}
func TestAimlapiEmailInputRejectsIncompleteDomain(t *testing.T) {
state := &aimlapiOnboardState{
step: aimlapiStepEmailInput,
email: "123@123.",
}
cmd, outcome := state.handleKey(testKey(tea.KeyEnter))
if cmd != nil {
t.Fatal("invalid email should not start an account request")
}
if outcome != aimlapiContinue || state.busy {
t.Fatalf("invalid email returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
}
if state.errText != aimlapi.MsgEmailInvalid {
t.Fatalf("error = %q, want %q", state.errText, aimlapi.MsgEmailInvalid)
}
}
func TestAimlapiEverythingReadyBackReturnsToProviderList(t *testing.T) {
m := newModel(context.Background(), Options{Setup: SetupOptions{
Visible: true,
Providers: []SetupProviderOption{
{ID: "openai", Name: "OpenAI"},
{ID: "aimlapi", Name: "aimlapi.com"},
},
}})
m.setup.selected = 1
m.setup.stage = setupStageAimlapi
m.setup.aimlapi = &aimlapiOnboardState{
step: aimlapiStepDone,
successLines: []string{aimlapi.MsgEverythingRuns},
}
updated, cmd := m.Update(testKey(tea.KeyLeft))
next := updated.(model)
if cmd != nil {
t.Fatal("back from Everything is ready should not start a command")
}
if next.setup.stage != setupStageProvider {
t.Fatalf("stage = %v, want provider list", next.setup.stage)
}
if next.setup.aimlapi != nil {
t.Fatal("completed AIMLAPI sub-flow should be cleared when returning to providers")
}
if got := next.setupProvider().ID; got != "aimlapi" {
t.Fatalf("selected provider = %q, want aimlapi", got)
}
}
func TestSetupAimlapiCtrlCCancelsInFlightCheckout(t *testing.T) {
cancelled := false
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
m.setup.stage = setupStageAimlapi
m.setup.aimlapi = &aimlapiOnboardState{
step: aimlapiStepProgress,
topupCancel: func() { cancelled = true },
}
_, cmd := m.Update(testKeyCtrl('c'))
if !cancelled {
t.Fatal("Ctrl+C did not cancel the in-flight AIMLAPI checkout")
}
if cmd == nil {
t.Fatal("Ctrl+C should still quit after cancelling the checkout")
}
}
func TestSetupAimlapiEnvKeyUsesResolvedInferenceEndpoint(t *testing.T) {
t.Setenv("AIMLAPI_API_KEY", "env-secret")
t.Setenv("AIMLAPI_INFERENCE_URL", "https://staging.example.test/v1")
m := newModel(context.Background(), Options{Setup: SetupOptions{
Visible: true,
Providers: []SetupProviderOption{{
ID: "aimlapi", Name: "aimlapi.com", EnvVar: "AIMLAPI_API_KEY", RequiresAuth: true,
}},
}})
if got := m.setupBaseURL(m.setupProvider()); got != "https://staging.example.test/v1" {
t.Fatalf("setup base URL = %q, want resolved override", got)
}
}
func TestAimlapiKeyInputUsesAutomaticVerificationHint(t *testing.T) {
state := &aimlapiOnboardState{step: aimlapiStepKeyInput}
view := plainRender(t, strings.Join(state.view(64, ""), "\n"))
assertContains(t, view, "Your API key will be hidden and verified automatically.")
assertNotContains(t, view, "Pasted keys are hidden")
}
func TestAimlapiPastedKeyStartsBalanceValidation(t *testing.T) {
state := &aimlapiOnboardState{step: aimlapiStepKeyInput, apiKey: " key_test "}
cmd, outcome := state.handleKey(testKey(tea.KeyEnter))
if cmd == nil {
t.Fatal("pasted key should start balance validation")
}
if outcome != aimlapiContinue || !state.busy {
t.Fatalf("validation returned outcome=%v busy=%v, want continue/true", outcome, state.busy)
}
}
func TestAimlapiOnboardingErrorsStripBodiesAndRedactActiveSecrets(t *testing.T) {
const (
apiKey = "sk-super-secret"
code = "654321"
bearer = "session-super-secret"
body = "server echoed sk-super-secret 654321 session-super-secret"
)
newState := func(step aimlapiOnboardStep) *aimlapiOnboardState {
return &aimlapiOnboardState{step: step, apiKey: apiKey, code: code, sessionToken: bearer, busy: true}
}
err := aimlapi.APIError{Message: "onboarding request failed", Status: http.StatusInternalServerError, Body: body}
tests := []struct {
name string
state *aimlapiOnboardState
apply func(*aimlapiOnboardState)
}{
{name: "key validation", state: newState(aimlapiStepKeyInput), apply: func(s *aimlapiOnboardState) { s.applyKeyValidation(aimlapiOnboardMsg{err: err}) }},
{name: "account check", state: newState(aimlapiStepEmailInput), apply: func(s *aimlapiOnboardState) { s.applyCheck(aimlapiOnboardMsg{err: err}) }},
{name: "code verification", state: newState(aimlapiStepCodeInput), apply: func(s *aimlapiOnboardState) { s.applyToken(aimlapiOnboardMsg{err: err}) }},
{name: "key mint", state: newState(aimlapiStepEmailInput), apply: func(s *aimlapiOnboardState) { s.applyKey(aimlapiOnboardMsg{err: err}) }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.apply(test.state)
if strings.Contains(test.state.errText, body) || strings.Contains(test.state.errText, apiKey) ||
strings.Contains(test.state.errText, code) || strings.Contains(test.state.errText, bearer) {
t.Fatalf("credential leaked in error: %q", test.state.errText)
}
if !strings.Contains(test.state.errText, "HTTP 500") {
t.Fatalf("safe status missing from error: %q", test.state.errText)
}
})
}
}
func TestAimlapiPastedKeyRejectsUnauthorized(t *testing.T) {
state := &aimlapiOnboardState{
step: aimlapiStepKeyInput,
apiKey: "bad-key",
busy: true,
}
_, outcome := state.apply(aimlapiOnboardMsg{
state: state,
kind: aimlapiMsgKeyValidation,
err: aimlapi.APIError{Status: http.StatusUnauthorized},
})
if outcome != aimlapiContinue || state.busy {
t.Fatalf("invalid key returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
}
if state.step != aimlapiStepKeyInput || state.apiKey != "" {
t.Fatalf("invalid key left step=%v key=%q, want key input with cleared key", state.step, state.apiKey)
}
if state.errText != aimlapi.MsgAPIKeyInvalid {
t.Fatalf("error = %q, want %q", state.errText, aimlapi.MsgAPIKeyInvalid)
}
}
func TestAimlapiVerifyCodeLabelsOnlyBadRequestAsIncorrect(t *testing.T) {
tests := []struct {
name string
err error
wantIncorrect bool
}{
{name: "invalid code", err: aimlapi.APIError{Status: http.StatusBadRequest}, wantIncorrect: true},
{name: "throttled", err: aimlapi.APIError{Status: http.StatusTooManyRequests}},
{name: "server failure", err: aimlapi.APIError{Status: http.StatusInternalServerError}},
{name: "network failure", err: errors.New("connection reset")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state := &aimlapiOnboardState{step: aimlapiStepCodeInput, code: "123456", busy: true}
state.applyToken(aimlapiOnboardMsg{err: tt.err})
if got := state.errText == aimlapi.MsgCodeIncorrect; got != tt.wantIncorrect {
t.Fatalf("incorrect-code label = %v, error text %q", got, state.errText)
}
if state.step != aimlapiStepCodeInput || state.code != "123456" {
t.Fatalf("verification retry lost its screen/code: step=%v code=%q", state.step, state.code)
}
})
}
}
func TestAimlapiPastedKeyValidationCompletesForValidKey(t *testing.T) {
state := &aimlapiOnboardState{
step: aimlapiStepKeyInput,
apiKey: " key_test ",
busy: true,
}
_, outcome := state.apply(aimlapiOnboardMsg{
state: state,
kind: aimlapiMsgKeyValidation,
})
if outcome != aimlapiContinue || state.busy {
t.Fatalf("valid key returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
}
if state.step != aimlapiStepDone || state.apiKey != "key_test" {
t.Fatalf("valid key left step=%v key=%q, want done with trimmed key", state.step, state.apiKey)
}
if len(state.successLines) != 1 || state.successLines[0] != aimlapi.MsgEverythingRuns {
t.Fatalf("success lines = %#v", state.successLines)
}
}
func TestAimlapiKeyBalanceLowBalanceOffersTopUp(t *testing.T) {
state := &aimlapiOnboardState{
step: aimlapiStepProgress,
apiKey: "key_minted",
busy: true,
}
_, outcome := state.apply(aimlapiOnboardMsg{
state: state,
kind: aimlapiMsgKeyBalance,
balance: aimlapi.BalanceResult{LowBalance: true},
})
if outcome != aimlapiContinue || state.busy {
t.Fatalf("low balance returned outcome=%v busy=%v, want continue/false", outcome, state.busy)
}
if state.step != aimlapiStepLowBalance || state.lowCursor != 0 {
t.Fatalf("low balance left step=%v cursor=%d, want low-balance prompt with cursor 0", state.step, state.lowCursor)
}
}
func TestAimlapiTopUpFailureRetainsSessionForRetry(t *testing.T) {
state := &aimlapiOnboardState{step: aimlapiStepProgress, paymentSessionID: "payment-live"}
state.applyTopup(aimlapiOnboardMsg{
topupOK: true,
topup: aimlapiTopupEvent{session: "pcs_resume", hasSession: true},
})
state.applyTopup(aimlapiOnboardMsg{
topupOK: true,
topup: aimlapiTopupEvent{done: true, err: errors.New("lost response")},
})
if state.resumeSessionToken != "pcs_resume" {
t.Fatalf("resume token = %q, want retained session", state.resumeSessionToken)
}
if state.paymentSessionID != "payment-live" {
t.Fatalf("payment session id = %q, want retained live id", state.paymentSessionID)
}
if state.step != aimlapiStepAmountInput {
t.Fatalf("step = %v, want retryable amount input", state.step)
}
}
func TestAimlapiRejectsUnknownAccountAction(t *testing.T) {
state := &aimlapiOnboardState{step: aimlapiStepProgress, email: "[email protected]", busy: true}
cmd, outcome := state.applyCheck(aimlapiOnboardMsg{check: aimlapi.CheckResult{Action: "future-action"}})
if cmd != nil || outcome != aimlapiContinue {
t.Fatalf("unknown action returned cmd=%v outcome=%v", cmd != nil, outcome)
}
if state.step != aimlapiStepEmailInput || state.newAccount || state.busy {
t.Fatalf("unknown action state = step %v new=%v busy=%v", state.step, state.newAccount, state.busy)
}
if state.errText != aimlapi.MsgAccountActionInvalid {
t.Fatalf("error = %q", state.errText)
}
}
func TestAimlapiChangedTopUpIntentDropsRetainedCheckout(t *testing.T) {
tests := []struct {
name string
amount int
autoTopUp bool
wantDrop bool
}{
{name: "same intent", amount: 2500, autoTopUp: true, wantDrop: false},
{name: "changed amount", amount: 5000, autoTopUp: true, wantDrop: true},
{name: "changed auto top-up", amount: 2500, autoTopUp: false, wantDrop: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
state := &aimlapiOnboardState{
resumeSessionToken: "pcs_old",
paymentSessionID: "payment-old",
paymentAmountUSDMinor: 2500,
paymentAutoTopUp: true,
autoTopUp: test.autoTopUp,
}
state.prepareTopUpIntent(test.amount)
dropped := state.resumeSessionToken == "" && state.paymentSessionID == ""
if dropped != test.wantDrop {
t.Fatalf("checkout dropped = %v, want %v", dropped, test.wantDrop)
}
})
}
}
func TestAimlapiTopUpSuccessShowsChargedCentAmount(t *testing.T) {
state := &aimlapiOnboardState{amount: "20.999", paymentAmountUSDMinor: 2100}
lines := state.topUpSuccessLines()
if got := strings.Join(lines, "\n"); !strings.Contains(got, "$21.00 has been added") || strings.Contains(got, "20.999") {
t.Fatalf("success lines do not show normalized charge: %q", got)
}
}
func TestAimlapiTerminalTopUpDropsByKeyIdempotencyID(t *testing.T) {
state := &aimlapiOnboardState{
step: aimlapiStepProgress,
resumeSessionToken: "pcs_old",
paymentSessionID: "payment-old",
}
state.applyTopup(aimlapiOnboardMsg{
topupOK: true,
topup: aimlapiTopupEvent{
done: true, err: errors.New("checkout expired"), session: "", hasSession: true,
},
})
if state.resumeSessionToken != "" || state.paymentSessionID != "" {
t.Fatalf("terminal checkout retained session ids: resume=%q payment=%q", state.resumeSessionToken, state.paymentSessionID)
}
if state.step != aimlapiStepAmountInput {
t.Fatalf("step = %v, want retryable amount input", state.step)
}
}
func TestAimlapiOnboardingKeepsSharedTickAlive(t *testing.T) {
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
m.reducedMotion = false
m.setup.stage = setupStageAimlapi
m.setup.aimlapi = &aimlapiOnboardState{step: aimlapiStepProgress}
// No agent run is in flight, but the aimlapi onboarding sub-flow must keep the
// shared tick loop alive so its spinner-only progress screen keeps re-rendering.
updated, cmd := m.Update(m.spinner.Tick())
next := updated.(model)
if cmd == nil || !next.spinnerTicking {
t.Fatal("aimlapi onboarding should keep the shared spinner tick loop alive")
}
}
func TestAimlapiAmountInputIgnoresLeft(t *testing.T) {
for _, newAccount := range []bool{false, true} {
t.Run(map[bool]string{false: "existing account", true: "new account"}[newAccount], func(t *testing.T) {
state := &aimlapiOnboardState{
step: aimlapiStepAmountInput,
newAccount: newAccount,
errText: "keep this error",
}
cmd, outcome := state.handleKey(testKey(tea.KeyLeft))
if cmd != nil {
t.Fatal("left on the top-up amount screen should not start a command")
}
if outcome != aimlapiContinue {
t.Fatalf("outcome = %v, want aimlapiContinue", outcome)
}
if state.step != aimlapiStepAmountInput {
t.Fatalf("step = %v, want amount input", state.step)
}
if state.amountField != 0 || state.errText != "keep this error" {
t.Fatalf("left changed amount-screen state: field=%d error=%q", state.amountField, state.errText)
}
})
}
}
func TestAimlapiAutoTopUpToggleUsesBothArrowKeys(t *testing.T) {
for _, test := range []struct {
name string
key rune
}{
{name: "left", key: tea.KeyLeft},
{name: "right", key: tea.KeyRight},
} {
t.Run(test.name, func(t *testing.T) {
state := &aimlapiOnboardState{
step: aimlapiStepAmountInput,
amountField: 1,
autoTopUp: true,
}
cmd, outcome := state.handleKey(testKey(test.key))
if cmd != nil || outcome != aimlapiContinue {
t.Fatalf("arrow returned cmd=%v outcome=%v, want nil/continue", cmd != nil, outcome)
}
if state.autoTopUp {
t.Fatalf("%s did not toggle auto top-up", test.name)
}
if state.step != aimlapiStepAmountInput {
t.Fatalf("step = %v, want amount input", state.step)
}
})
}
}
func TestAimlapiAutoTopUpRendersOnOff(t *testing.T) {
idle := aimlapiToggleLine("auto top up > ", true, false)
view := plainRender(t, idle)
assertContains(t, view, "on/off")
assertNotContains(t, view, "yes/no")
if !strings.Contains(idle, zeroTheme.ink.Render("on")) ||
!strings.Contains(idle, zeroTheme.faint.Render("off")) {
t.Fatalf("idle toggle should render selected on white and unselected off faint: %q", idle)
}
focused := aimlapiToggleLine("auto top up > ", false, true)
if !strings.Contains(focused, zeroTheme.accent.Render("auto top up > ")) ||
!strings.Contains(focused, zeroTheme.faint.Render("on")) ||
!strings.Contains(focused, zeroTheme.ink.Render("off")) {
t.Fatalf("focused toggle should highlight its prompt, selected off, and dim unselected on: %q", focused)
}
}
func TestAimlapiPickPathShowsNewUserFirstAndRoutesSelections(t *testing.T) {
state := &aimlapiOnboardState{step: aimlapiStepPickPath}
view := plainRender(t, strings.Join(state.viewPickPath(64), "\n"))
newUserIndex := strings.Index(view, aimlapi.MsgPickPathNewUser)
haveKeyIndex := strings.Index(view, aimlapi.MsgPickPathHaveKey)
if newUserIndex < 0 || haveKeyIndex < 0 || newUserIndex >= haveKeyIndex {
t.Fatalf("new-user option should precede have-key option:\n%s", view)
}
state.pathCursor = 0
state.handlePickPathKey(testKey(tea.KeyEnter))
if state.step != aimlapiStepEmailInput {
t.Fatalf("first option routes to %v, want email input", state.step)
}
state.step = aimlapiStepPickPath
state.pathCursor = 1
state.handlePickPathKey(testKey(tea.KeyEnter))
if state.step != aimlapiStepKeyInput {
t.Fatalf("second option routes to %v, want key input", state.step)
}
}
func TestFirstRunAimlapiEnvSkipsGuidedOnboarding(t *testing.T) {
t.Setenv("AIMLAPI_API_KEY", "env-runtime-secret")
m := newModel(context.Background(), Options{Setup: SetupOptions{
Visible: true,
Providers: []SetupProviderOption{{
ID: "aimlapi", Name: "aimlapi.com", DefaultModel: "anthropic/claude-sonnet-5",
}},
}})
m.setup.stage = setupStageProvider
m.setup.selected = 0
updated, cmd := m.advanceSetup()
next := updated.(model)
if next.setup.stage != setupStageModel {
t.Fatalf("stage = %v, want model selection", next.setup.stage)
}
if next.setup.aimlapi != nil {
t.Fatal("env credential should not create the guided onboarding state")
}
if cmd == nil {
t.Fatal("env credential should start authenticated model discovery")
}
}
func TestAimlapiWizardDoesNotFilterDiscoveredModelsByDefault(t *testing.T) {
m := newModel(context.Background(), Options{})
m.providerWizard = m.newProviderWizard()
m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "aimlapi")
m.providerWizard.aimlapi = &aimlapiOnboardState{
apiKey: "sk-issued",
model: "anthropic/claude-sonnet-5",
}
next, cmd := m.resolveProviderWizardAimlapi(nil, aimlapiDone)
if next.providerWizard.step != providerWizardStepModel || cmd == nil {
t.Fatalf("AIMLAPI completion should enter model discovery: step=%v cmd=%v", next.providerWizard.step, cmd != nil)
}
if next.providerWizard.modelSearch != "" {
t.Fatalf("model search = %q, want empty so the full discovered list is visible", next.providerWizard.modelSearch)
}
}
func TestAimlapiAmountDollarUsesInkStyle(t *testing.T) {
line := aimlapiAmountInputLine("", "25", 40, true)
if !strings.Contains(line, zeroTheme.ink.Render("$")) {
t.Fatalf("amount dollar is not rendered with ink style: %q", line)
}
idle := aimlapiAmountInputLine("25", "25", 40, false)
if !strings.Contains(idle, zeroTheme.ink.Render("amount > ")) ||
strings.Contains(idle, zeroTheme.accent.Render("amount > ")) {
t.Fatalf("unfocused amount prompt should be white: %q", idle)
}
}
func TestAimlapiSafetyBackReturnsToProviderList(t *testing.T) {
for _, test := range []struct {
name string
key rune
}{
{name: "left", key: tea.KeyLeft},
{name: "escape", key: tea.KeyEsc},
} {
t.Run(test.name, func(t *testing.T) {
m := newModel(context.Background(), Options{Setup: SetupOptions{
Visible: true,
Providers: []SetupProviderOption{
{ID: "openai", Name: "OpenAI"},
{ID: "aimlapi", Name: "aimlapi.com"},
},
}})
m.setup.selected = 1
m.setup.stage = setupStageSafety
m.setup.aimlapi = nil // Cleared after the AIMLAPI connect flow completes.
updated, cmd := m.Update(testKey(test.key))
next := updated.(model)
if cmd != nil {
t.Fatal("back from Safety should not start a command")
}
if next.setup.stage != setupStageProvider {
t.Fatalf("stage = %v, want provider list", next.setup.stage)
}
if got := next.setupProvider().ID; got != "aimlapi" {
t.Fatalf("selected provider = %q, want aimlapi", got)
}
})
}
}
func TestAimlapiModelBackReturnsToProviderList(t *testing.T) {
for _, test := range []struct {
name string
key rune
}{
{name: "left", key: tea.KeyLeft},
{name: "escape", key: tea.KeyEsc},
} {
t.Run(test.name, func(t *testing.T) {
m := newModel(context.Background(), Options{Setup: SetupOptions{
Visible: true,
Providers: []SetupProviderOption{
{ID: "openai", Name: "OpenAI"},
{ID: "aimlapi", Name: "aimlapi.com"},
},
}})
m.setup.selected = 1
m.setup.stage = setupStageModel
m.setup.aimlapi = nil // Cleared once AIMLAPI has issued the key.
updated, cmd := m.Update(testKey(test.key))
next := updated.(model)
if cmd != nil {
t.Fatal("back from AIMLAPI model selection should not start a command")
}
if next.setup.stage != setupStageProvider {
t.Fatalf("stage = %v, want provider list", next.setup.stage)
}
if got := next.setupProvider().ID; got != "aimlapi" {
t.Fatalf("selected provider = %q, want aimlapi", got)
}
})
}
}
func TestSetupCredentialSummaryOAuthToken(t *testing.T) {
m := newModel(context.Background(), Options{Setup: SetupOptions{Visible: true}})
m.setup.oauthMode = true
+1 -1
View File
@@ -420,7 +420,7 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
k = resolved
}
}
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k, authed.BaseURL))
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k))
return modelPickerModelsDiscoveredMsg{providerID: providerID, models: models, err: err}
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ func TestOpenProviderManagerListsSavedProviders(t *testing.T) {
if len(m.providerWizard.manageRows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(m.providerWizard.manageRows))
}
rendered := m.providerWizard.render(m.width, m.spinnerGlyph())
rendered := m.providerWizard.render(m.width)
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
if !strings.Contains(rendered, want) {
t.Fatalf("manager render missing %q:\n%s", want, rendered)
+11 -245
View File
@@ -2,7 +2,6 @@ package tui
import (
"context"
"maps"
"net"
"net/http"
"net/url"
@@ -15,7 +14,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/browser"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
@@ -76,52 +74,6 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.
return m, m.providerModelDiscoveryCmd()
}
// applyProviderWizardAimlapiOnboard routes an async aimlapi.com onboarding result
// into the wizard's shared sub-flow and reacts to its outcome.
func (m model) applyProviderWizardAimlapiOnboard(msg aimlapiOnboardMsg) (model, tea.Cmd) {
wizard := m.providerWizard
if wizard == nil || wizard.aimlapi == nil || wizard.aimlapi != msg.state {
return m, nil
}
cmd, outcome := wizard.aimlapi.apply(msg)
return m.resolveProviderWizardAimlapi(cmd, outcome)
}
// handleProviderWizardAimlapiKey delegates a key press to the wizard's aimlapi.com
// sub-flow and reacts to its outcome.
func (m model) handleProviderWizardAimlapiKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
if wizard == nil || wizard.aimlapi == nil {
return m, nil
}
cmd, outcome := wizard.aimlapi.handleKey(msg)
return m.resolveProviderWizardAimlapi(cmd, outcome)
}
// resolveProviderWizardAimlapi lands the sub-flow's outcome on the wizard: on a
// terminal key it moves the freshly acquired key into the wizard and advances to
// model selection (reusing the standard finalize); on cancel it backs out.
func (m model) resolveProviderWizardAimlapi(cmd tea.Cmd, outcome aimlapiOutcome) (model, tea.Cmd) {
wizard := m.providerWizard
switch outcome {
case aimlapiDone:
wizard.apiKey = wizard.aimlapi.apiKey
wizard.baseURL = wizard.aimlapi.baseURL
wizard.aimlapi = nil
wizard.err = ""
wizard.step = providerWizardStepModel
return m, m.providerModelDiscoveryCmd()
case aimlapiCancel:
wizard.aimlapi = nil
wizard.err = ""
wizard.step = providerWizardStepProvider
return m, nil
}
// Keep the shared spinner tick alive whenever the sub-flow just entered a busy
// or progress state, so its animated spinner advances during onboarding.
return m, tea.Batch(cmd, m.ensureSpinnerTick())
}
// applyProviderWizardDeviceCode handles phase 1 of device-code login: show the
// user_code + verification URI, then kick off phase 2 (the token poll). On error
// the redacted message is surfaced and the login is abandoned.
@@ -348,8 +300,6 @@ const (
providerWizardStepManageKey
providerWizardStepEndpoint
providerWizardStepName
providerWizardStepAimlapiConfigured
providerWizardStepAimlapi
providerWizardStepCredential
providerWizardStepModel
providerWizardStepDone
@@ -421,27 +371,14 @@ type providerWizardState struct {
// Manage-key step: shown when the selected provider already has a stored key.
manageKeyCursor int
manageProviderName string
// aimlapiExistingProfile is the persisted credential source selected by the
// "Use existing configuration" preflight. aimlapiRuntimeKey is resolved only
// for balance/top-up/model requests; it must never replace APIKeyEnv or an
// APIKeyStored marker when the profile is saved again.
aimlapiConfiguredCursor int
aimlapiExistingProfile config.ProviderProfile
aimlapiRuntimeKey string
aimlapiExistingBusy bool
aimlapiExistingGen int
aimlapiExistingCancel context.CancelFunc
oauthMode bool
oauthPending bool
oauthAttemptID int
oauthErr string
oauthMode bool
oauthPending bool
oauthAttemptID int
oauthErr string
// Device-code login (RFC 8628) state while an OAuth login is in flight.
oauthDevice bool
deviceUserCode string
deviceVerificationURI string
// aimlapi holds the shared aimlapi.com onboarding sub-flow while
// the wizard is on providerWizardStepAimlapi.
aimlapi *aimlapiOnboardState
// Manager state (provider_manager.go): the list-first /provider surface.
manage bool
manageRows []providerManagerRow
@@ -516,34 +453,6 @@ func (wizard *providerWizardState) oauthResultMatches(providerID string, attempt
return wizard.currentProvider().ID == providerID && wizard.oauthAttemptID == attemptID
}
// resetAimlapiOnboard drops any in-flight aimlapi.com sub-flow (cancelling a
// running top-up stream) when the selected provider changes.
func (wizard *providerWizardState) resetAimlapiOnboard() {
if wizard.aimlapi != nil {
wizard.aimlapi.cancelStream()
wizard.aimlapi = nil
}
wizard.clearAimlapiExisting()
}
// enterAimlapi starts the shared aimlapi.com onboarding sub-flow.
func (wizard *providerWizardState) enterAimlapi() {
wizard.clearAimlapiExisting()
wizard.aimlapi = newAimlapiOnboard(browser.OpenURL)
wizard.step = providerWizardStepAimlapi
}
func (wizard *providerWizardState) clearAimlapiExisting() {
if wizard.aimlapiExistingCancel != nil {
wizard.aimlapiExistingCancel()
wizard.aimlapiExistingCancel = nil
}
wizard.aimlapiExistingProfile = config.ProviderProfile{}
wizard.aimlapiRuntimeKey = ""
wizard.aimlapiExistingBusy = false
wizard.aimlapiConfiguredCursor = 0
}
func (wizard *providerWizardState) currentModel() providerWizardModel {
if wizard == nil {
return providerWizardModel{}
@@ -592,7 +501,6 @@ func (wizard *providerWizardState) move(delta int) {
wizard.modelLoadError = ""
wizard.oauthPending = false
wizard.oauthErr = ""
wizard.resetAimlapiOnboard()
wizard.refreshModels()
case providerWizardStepModel:
wizard.refreshModels()
@@ -648,8 +556,6 @@ func (wizard *providerWizardState) advance() {
wizard.err = ""
if providerWizardNeedsEndpoint(wizard.currentProvider()) {
wizard.step = providerWizardStepEndpoint
} else if providerWizardIsAimlapi(wizard.currentProvider()) {
wizard.enterAimlapi()
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
wizard.step = providerWizardStepCredential
} else {
@@ -664,9 +570,7 @@ func (wizard *providerWizardState) advance() {
wizard.step = providerWizardStepName
case providerWizardStepName:
wizard.err = ""
if providerWizardIsAimlapi(wizard.currentProvider()) {
wizard.enterAimlapi()
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
if providerWizardNeedsCredential(wizard.currentProvider()) {
wizard.step = providerWizardStepCredential
} else {
wizard.step = providerWizardStepModel
@@ -714,15 +618,6 @@ func (wizard *providerWizardState) retreat() {
wizard.providerSearch = ""
case providerWizardStepName:
wizard.step = providerWizardStepEndpoint
case providerWizardStepAimlapi:
wizard.resetAimlapiOnboard()
if providerWizardNeedsProfileName(wizard.currentProvider()) {
wizard.step = providerWizardStepName
} else if providerWizardNeedsEndpoint(wizard.currentProvider()) {
wizard.step = providerWizardStepEndpoint
} else {
wizard.step = providerWizardStepProvider
}
case providerWizardStepCredential:
if providerWizardNeedsProfileName(wizard.currentProvider()) {
wizard.step = providerWizardStepName
@@ -732,13 +627,7 @@ func (wizard *providerWizardState) retreat() {
wizard.step = providerWizardStepProvider
}
case providerWizardStepModel:
if providerWizardIsAimlapi(wizard.currentProvider()) {
if aimlapiProfile(wizard.aimlapiExistingProfile) {
wizard.step = providerWizardStepAimlapiConfigured
} else {
wizard.step = providerWizardStepProvider
}
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
if providerWizardNeedsCredential(wizard.currentProvider()) {
wizard.step = providerWizardStepCredential
} else if providerWizardNeedsEndpoint(wizard.currentProvider()) {
wizard.step = providerWizardStepEndpoint
@@ -788,10 +677,6 @@ func providerWizardNeedsCredential(provider providercatalog.Descriptor) bool {
return provider.RequiresAuth && !provider.Local && len(provider.AuthEnvVars) > 0
}
func providerWizardIsAimlapi(provider providercatalog.Descriptor) bool {
return strings.EqualFold(strings.TrimSpace(provider.ID), "aimlapi")
}
func providerWizardNeedsEndpoint(provider providercatalog.Descriptor) bool {
switch provider.ID {
case "custom-openai-compatible", "custom-anthropic-compatible":
@@ -828,10 +713,6 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard.oauthDevice = false
wizard.deviceUserCode = ""
wizard.deviceVerificationURI = ""
// This branch preempts the aimlapi.com key delegation below, so cancel the
// embedded sub-flow here too — otherwise its in-flight CreateKey/checkout
// keeps running against a background context and mints a key we discard.
wizard.resetAimlapiOnboard()
wizard.err = ""
wizard.oauthErr = ""
wizard.step = providerWizardStepManage
@@ -931,34 +812,6 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
return m.advanceProviderWizard()
}
}
if m.providerWizard.step == providerWizardStepAimlapi {
return m.handleProviderWizardAimlapiKey(msg)
}
if m.providerWizard.step == providerWizardStepAimlapiConfigured {
if m.providerWizard.aimlapiExistingBusy {
if keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft) {
m.providerWizard.aimlapiExistingGen++
m.providerWizard.clearAimlapiExisting()
m.providerWizard.step = providerWizardStepProvider
}
return m, nil
}
switch {
case keyIs(msg, tea.KeyEsc):
m.providerWizard = nil
case keyIs(msg, tea.KeyLeft):
m.providerWizard.clearAimlapiExisting()
m.providerWizard.step = providerWizardStepProvider
case keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
m.providerWizard.aimlapiConfiguredCursor = (m.providerWizard.aimlapiConfiguredCursor + 1) % 2
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
if m.providerWizard.aimlapiConfiguredCursor == 0 {
return m.checkExistingAimlapiBalance()
}
m.providerWizard.enterAimlapi()
}
return m, nil
}
if m.providerWizard.step == providerWizardStepCredential {
switch {
case keyIs(msg, tea.KeyEsc):
@@ -1040,10 +893,6 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
m.providerWizard.appendBaseURL([]rune(content))
case providerWizardStepName:
m.providerWizard.appendProfileName([]rune(content))
case providerWizardStepAimlapi:
if m.providerWizard.aimlapi != nil {
m.providerWizard.aimlapi.appendInput(content)
}
case providerWizardStepCredential:
m.providerWizard.appendAPIKey([]rune(content))
case providerWizardStepModel:
@@ -1233,18 +1082,6 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
modelChoice := wizard.currentModel()
profile := providerWizardProfile(provider, modelChoice.ID, wizard.apiKey, wizard.baseURL, wizard.profileName)
runtimeProfile := providerWizardRuntimeProfile(profile)
usingExistingAimlapi := aimlapiProfile(wizard.aimlapiExistingProfile)
preserveExistingCredentialReference := false
if usingExistingAimlapi {
// Preserve the configured credential source exactly. The resolved key is
// runtime-only and must not turn APIKeyEnv or APIKeyStored into a captured
// secret merely because the user changed models or topped up.
profile = wizard.aimlapiExistingProfile
profile.Model = modelChoice.ID
runtimeProfile = profile
runtimeProfile.APIKey = wizard.aimlapiRuntimeKey
preserveExistingCredentialReference = strings.TrimSpace(profile.APIKeyEnv) != "" || profile.APIKeyStored
}
// Build and persist into LOCALS first, committing live state only once BOTH
// succeed. A persist failure (read-only config, disk full) must not leave the
@@ -1265,9 +1102,7 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
// store before persisting, so config.json never holds the cleartext. The
// provider was already built above from runtimeProfile, which has the key.
secret := profile.APIKey
if !preserveExistingCredentialReference {
profile = config.SecureProviderProfile(profile, m.userConfigPath)
}
profile = config.SecureProviderProfile(profile, m.userConfigPath)
if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil {
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}})
return m, nil // nothing committed to live state yet
@@ -1358,12 +1193,10 @@ func (m model) providerWizardOverlay(width int) string {
if m.providerWizard == nil {
return ""
}
return m.providerWizard.render(width, m.spinnerGlyph())
return m.providerWizard.render(width)
}
// render's spinner glyph is used only by the embedded aimlapi.com onboarding
// sub-view; it is the shared global run spinner (spinnerGlyph()), same as everywhere.
func (wizard *providerWizardState) render(width int, spinner string) string {
func (wizard *providerWizardState) render(width int) string {
if wizard == nil {
return ""
}
@@ -1400,12 +1233,6 @@ func (wizard *providerWizardState) render(width int, spinner string) string {
lines = append(lines, wizard.renderEndpointStep(innerWidth)...)
case providerWizardStepName:
lines = append(lines, wizard.renderNameStep(innerWidth)...)
case providerWizardStepAimlapiConfigured:
lines = append(lines, wizard.renderAimlapiConfiguredStep(innerWidth, spinner)...)
case providerWizardStepAimlapi:
if wizard.aimlapi != nil {
lines = append(lines, wizard.aimlapi.view(innerWidth, spinner)...)
}
case providerWizardStepCredential:
lines = append(lines, wizard.renderCredentialStep(innerWidth)...)
case providerWizardStepModel:
@@ -1479,10 +1306,6 @@ func (wizard *providerWizardState) footerText() string {
return "Enter continue ← back Esc close"
case providerWizardStepName:
return "Enter/→ continue ← back Esc close"
case providerWizardStepAimlapiConfigured:
return "↑/↓ move Enter/→ select ← back Esc close"
case providerWizardStepAimlapi:
return "type/↑↓ choose Enter/→ continue ← back Esc close"
case providerWizardStepModel:
if canRight {
return "↑/↓ move Enter/→ continue ← back Esc close"
@@ -1506,7 +1329,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
switch step {
case providerWizardStepProvider:
target = providerWizardProviderWidth
case providerWizardStepModel, providerWizardStepManage, providerWizardStepAimlapi, providerWizardStepAimlapiConfigured:
case providerWizardStepModel, providerWizardStepManage:
target = providerWizardModelWidth
}
target = minInt(target, width)
@@ -1544,16 +1367,6 @@ func providerWizardStepLine(wizard *providerWizardState) string {
stepLabel{providerWizardStepModel, "3 model"},
stepLabel{providerWizardStepDone, "4 ready"},
)
case providerWizardIsAimlapi(wizard.currentProvider()):
connectStep := providerWizardStepAimlapi
if aimlapiProfile(wizard.aimlapiExistingProfile) {
connectStep = providerWizardStepAimlapiConfigured
}
steps = append(steps,
stepLabel{connectStep, "3 connect"},
stepLabel{providerWizardStepModel, "4 model"},
stepLabel{providerWizardStepDone, "5 ready"},
)
case providerWizardNeedsEndpoint(wizard.currentProvider()):
steps = append(steps,
stepLabel{providerWizardStepEndpoint, "3 endpoint"},
@@ -1598,32 +1411,6 @@ func (wizard *providerWizardState) renderMethodStep(width int) []string {
return lines
}
func (wizard *providerWizardState) renderAimlapiConfiguredStep(width int, spinner string) []string {
lines := []string{zeroTheme.accent.Render("aimlapi.com is already configured")}
if wizard.aimlapiExistingBusy {
glyph := strings.TrimSpace(spinner)
if glyph == "" {
glyph = "•"
}
return append(lines, "", zeroTheme.faint.Render(glyph+" checking balance"))
}
options := []struct{ label, hint string }{
{"Use existing configuration", "Continue with your saved API key"},
{"Configure again", "Set up a new key or switch accounts"},
}
for index, option := range options {
marker := " "
style := zeroTheme.ink
if index == wizard.aimlapiConfiguredCursor {
marker = "❯ "
style = zeroTheme.accent.Bold(true)
}
lines = append(lines, marker+style.Render(option.label))
lines = append(lines, " "+zeroTheme.faint.Render(option.hint))
}
return lines
}
// renderManageKeyStep shows keep/replace/remove for a provider that already has a
// key in the encrypted credential store.
func (wizard *providerWizardState) renderManageKeyStep(width int) []string {
@@ -2037,25 +1824,14 @@ func maskedProviderWizardKey(value string) string {
}
func providerWizardProfile(provider providercatalog.Descriptor, model string, apiKey string, baseURL string, profileName string) config.ProviderProfile {
resolvedBaseURL := firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL)
profile := config.ProviderProfile{
Name: providerWizardDisplayName(provider, baseURL, profileName),
ProviderKind: providerWizardProviderKind(provider),
CatalogID: provider.ID,
BaseURL: resolvedBaseURL,
BaseURL: firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL),
APIFormat: providerWizardAPIFormat(provider),
Model: firstProviderDisplayValue(model, provider.DefaultModel),
}
// Catalog custom headers (e.g. aimlapi.com's partner attribution) belong to the
// catalog endpoint: the resolver only applies them when the base URL is the
// default, so a profile built against a staging/proxy/custom override must not
// bake them in either — otherwise attribution leaks to an arbitrary host.
if sameProviderBaseURL(resolvedBaseURL, provider.DefaultBaseURL) {
profile.CustomHeaders = maps.Clone(provider.CustomHeaders)
if providerWizardIsAimlapi(provider) {
profile.CustomHeaders = aimlapi.WithResolvedPartnerHeader(profile.CustomHeaders)
}
}
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
profile.APIKey = apiKey
} else if env := firstProviderDisplayValue(provider.AuthEnvVars...); provider.RequiresAuth && env != "" && !provider.Custom {
@@ -2069,16 +1845,6 @@ func providerWizardProfile(provider providercatalog.Descriptor, model string, ap
return profile
}
// sameProviderBaseURL reports whether two base URLs address the same endpoint,
// normalizing case and a trailing slash — mirroring the resolver's sameBaseURL so
// the wizard bakes catalog headers on exactly the endpoints the resolver would.
func sameProviderBaseURL(left string, right string) bool {
return strings.EqualFold(
strings.TrimRight(strings.TrimSpace(left), "/"),
strings.TrimRight(strings.TrimSpace(right), "/"),
)
}
func providerWizardEndpointError(value string) string {
value = strings.TrimSpace(value)
if value == "" {
+3 -153
View File
@@ -3,14 +3,11 @@ package tui
import (
"context"
"os"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/browser"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
@@ -27,13 +24,6 @@ type providerModelsDiscoveredMsg struct {
secrets []string
}
type aimlapiExistingBalanceMsg struct {
wizard *providerWizardState
gen int
balance aimlapi.BalanceResult
err error
}
func (m model) advanceProviderWizard() (model, tea.Cmd) {
if m.providerWizard == nil {
return m, nil
@@ -53,18 +43,6 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
// A non-OAuth provider that already has a key in the credential store: offer
// keep/replace/remove before re-entering credentials.
if m.providerWizard.step == providerWizardStepProvider && !m.providerWizard.oauthMode {
if providerWizardIsAimlapi(m.providerWizard.currentProvider()) {
if profile, runtimeKey, ok := m.existingAimlapiConfiguration(); ok {
m.providerWizard.aimlapiExistingProfile = profile
m.providerWizard.aimlapiRuntimeKey = runtimeKey
m.providerWizard.aimlapiConfiguredCursor = 0
m.providerWizard.err = ""
m.providerWizard.step = providerWizardStepAimlapiConfigured
return m, nil
}
m.providerWizard.enterAimlapi()
return m, nil
}
if name, ok := m.wizardProviderStoredKey(m.providerWizard.currentProvider()); ok {
// Generic/custom providers (custom-openai-compatible etc.) all share
// the same CatalogID — matching on CatalogID would block creating a
@@ -89,133 +67,6 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
return m, nil
}
// existingAimlapiConfiguration returns a usable persisted credential source and
// its current runtime key. The active aimlapi profile wins; otherwise the first
// usable saved profile is used. A bare AIMLAPI_API_KEY is represented as an env
// profile so choosing it never snapshots the secret into the credential store.
func (m model) existingAimlapiConfiguration() (config.ProviderProfile, string, bool) {
descriptor, descriptorErr := providercatalog.Require("aimlapi")
profiles := append([]config.ProviderProfile(nil), m.savedProviders...)
activeName := strings.TrimSpace(m.providerProfile.Name)
if activeName != "" {
for index, profile := range profiles {
if strings.EqualFold(strings.TrimSpace(profile.Name), activeName) && aimlapiProfile(profile) {
profiles[0], profiles[index] = profiles[index], profiles[0]
break
}
}
}
var store config.APIKeyGetter
if strings.TrimSpace(m.userConfigPath) != "" {
store, _ = config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath))
}
for _, persisted := range profiles {
if !aimlapiProfile(persisted) {
continue
}
// The guided balance/top-up client talks to AIMLAPI's production account
// API. Never send a credential saved for a proxy, staging service, or other
// custom inference endpoint to that API. Such profiles remain available to
// the normal provider runtime; they are simply ineligible for this preflight.
if descriptorErr != nil || (strings.TrimSpace(persisted.BaseURL) != "" && !sameProviderBaseURL(persisted.BaseURL, descriptor.DefaultBaseURL)) {
continue
}
resolved := persisted
if strings.TrimSpace(resolved.APIKey) == "" && strings.TrimSpace(resolved.APIKeyEnv) != "" {
resolved.APIKey = strings.TrimSpace(os.Getenv(resolved.APIKeyEnv))
}
resolved = config.ApplyStoredAPIKey(resolved, store)
if key := strings.TrimSpace(resolved.APIKey); key != "" {
// ResolvedConfig materializes APIKeyEnv into APIKey for runtime use.
// Strip that materialized value (and any loaded stored key) from the
// persisted copy so finalization retains the reference, not the secret.
if strings.TrimSpace(persisted.APIKeyEnv) != "" || persisted.APIKeyStored {
persisted.APIKey = ""
}
return persisted, key, true
}
}
if key := strings.TrimSpace(os.Getenv("AIMLAPI_API_KEY")); key != "" {
if descriptorErr == nil {
profile := providerWizardProfile(
descriptor,
descriptor.DefaultModel,
"",
aimlapi.ResolveEndpoints().InferenceBaseURL,
"",
)
return profile, key, true
}
}
return config.ProviderProfile{}, "", false
}
func aimlapiProfile(profile config.ProviderProfile) bool {
return strings.EqualFold(strings.TrimSpace(profile.CatalogID), "aimlapi") ||
strings.EqualFold(strings.TrimSpace(profile.Name), "aimlapi")
}
func (m model) checkExistingAimlapiBalance() (model, tea.Cmd) {
wizard := m.providerWizard
if wizard == nil || wizard.step != providerWizardStepAimlapiConfigured || wizard.aimlapiExistingBusy {
return m, nil
}
wizard.aimlapiExistingBusy = true
wizard.err = ""
wizard.aimlapiExistingGen++
gen := wizard.aimlapiExistingGen
key := wizard.aimlapiRuntimeKey
endpoints := aimlapi.ResolveEndpoints()
if baseURL := strings.TrimSpace(wizard.aimlapiExistingProfile.BaseURL); baseURL != "" {
endpoints.InferenceBaseURL = baseURL
} else if descriptor, ok := providercatalog.Get("aimlapi"); ok {
// An empty stored BaseURL means the catalog endpoint. Pin it explicitly so
// a process-wide inference override cannot redirect this saved key.
endpoints.InferenceBaseURL = descriptor.DefaultBaseURL
}
wizard.baseURL = endpoints.InferenceBaseURL
ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second)
wizard.aimlapiExistingCancel = cancel
cmd := func() tea.Msg {
balance, err := aimlapi.NewClient(endpoints, nil).GetBalance(ctx, key)
return aimlapiExistingBalanceMsg{wizard: wizard, gen: gen, balance: balance, err: err}
}
return m, tea.Batch(cmd, m.ensureSpinnerTick())
}
func (m model) applyExistingAimlapiBalance(msg aimlapiExistingBalanceMsg) (model, tea.Cmd) {
wizard := m.providerWizard
if wizard == nil || wizard != msg.wizard || wizard.step != providerWizardStepAimlapiConfigured || msg.gen != wizard.aimlapiExistingGen {
return m, nil
}
if wizard.aimlapiExistingCancel != nil {
wizard.aimlapiExistingCancel()
wizard.aimlapiExistingCancel = nil
}
wizard.aimlapiExistingBusy = false
if msg.err != nil {
if aimlapiIsUnauthorized(msg.err) {
wizard.err = "The existing AIMLAPI API key is invalid. Choose Configure again."
} else {
wizard.err = redaction.ErrorMessage(msg.err, redaction.Options{ExtraSecretValues: []string{wizard.aimlapiRuntimeKey}})
}
return m, nil
}
wizard.apiKey = wizard.aimlapiRuntimeKey
if msg.balance.LowBalance {
state := newAimlapiOnboard(browser.OpenURL)
state.apiKey = wizard.aimlapiRuntimeKey
state.baseURL = wizard.baseURL
state.byKey = true
state.step = aimlapiStepLowBalance
wizard.aimlapi = state
wizard.step = providerWizardStepAimlapi
return m, nil
}
wizard.step = providerWizardStepModel
return m, m.providerModelDiscoveryCmd()
}
func (m model) providerModelDiscoveryCmd() tea.Cmd {
wizard := m.providerWizard
if wizard == nil {
@@ -245,7 +96,6 @@ func (m model) providerModelDiscoveryCmd() tea.Cmd {
wizard.discoveryToken++
token := wizard.discoveryToken
providerID := provider.ID
baseURL := wizard.baseURL
return func() tea.Msg {
ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second)
defer cancel()
@@ -255,7 +105,7 @@ func (m model) providerModelDiscoveryCmd() tea.Cmd {
apiKey = resolved
}
}
profile := providerWizardDiscoveryProfile(provider, apiKey, baseURL)
profile := providerWizardDiscoveryProfile(provider, apiKey)
models, err := discover(ctx, profile)
return providerModelsDiscoveredMsg{providerID: providerID, token: token, models: models, err: err, secrets: []string{apiKey, profile.APIKey}}
}
@@ -315,8 +165,8 @@ func providerWizardModelsSource(models []providermodeldiscovery.Model) string {
return ""
}
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string, baseURL string) config.ProviderProfile {
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, baseURL, "")
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string) config.ProviderProfile {
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, "", "")
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
}
+1 -297
View File
@@ -5,18 +5,13 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/aimlapi"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
@@ -58,7 +53,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) {
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
listView := plainRender(t, next.View())
for _, want := range []string{"Choose provider", "aimlapi.com", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
for _, want := range []string{"Choose provider", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
assertContains(t, listView, want)
}
}
@@ -1172,39 +1167,6 @@ func TestProviderWizardManageKeyReplaceAndKeep(t *testing.T) {
}
}
func TestProviderManagerEscCancelsEmbeddedAimlapiFlow(t *testing.T) {
opCtx, opCancel := context.WithCancel(context.Background())
topupCtx, topupCancel := context.WithCancel(context.Background())
state := &aimlapiOnboardState{
step: aimlapiStepProgress,
opCancel: opCancel,
topupCancel: topupCancel,
}
m := model{providerWizard: &providerWizardState{
manage: true,
step: providerWizardStepAimlapi,
aimlapi: state,
}}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEsc))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("manager escape did not return to provider list: %+v", next.providerWizard)
}
if next.providerWizard.aimlapi != nil {
t.Fatal("manager escape retained embedded aimlapi state")
}
select {
case <-opCtx.Done():
default:
t.Fatal("pending account/key operation was not cancelled")
}
select {
case <-topupCtx.Done():
default:
t.Fatal("pending top-up stream was not cancelled")
}
}
func TestAdvanceProviderWizardCustomSkipsManageKey(t *testing.T) {
tests := []struct {
name string
@@ -1270,264 +1232,6 @@ func TestAdvanceProviderWizardNamedShowsManageKey(t *testing.T) {
}
}
func TestExistingAimlapiConfigurationUsesEnvWithoutCapturingLiteral(t *testing.T) {
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
m := model{savedProviders: []config.ProviderProfile{{
Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY",
// ResolvedConfig materializes the env value here before the TUI sees it.
APIKey: "aimlapi-env-secret",
}}}
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
if !ok {
t.Fatal("expected AIMLAPI_API_KEY to be detected")
}
if runtimeKey != "aimlapi-env-secret" {
t.Fatalf("runtime key = %q", runtimeKey)
}
if profile.APIKeyEnv != "AIMLAPI_API_KEY" || profile.APIKey != "" || profile.APIKeyStored {
t.Fatalf("env credential source was not preserved: %+v", profile)
}
}
func TestExistingAimlapiEnvironmentFallbackUsesResolvedEndpoint(t *testing.T) {
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
t.Setenv("AIMLAPI_INFERENCE_URL", "https://staging.example.test/v1")
profile, runtimeKey, ok := (model{}).existingAimlapiConfiguration()
if !ok || runtimeKey != "aimlapi-env-secret" {
t.Fatalf("configuration = (%+v, %q, %v)", profile, runtimeKey, ok)
}
if profile.BaseURL != "https://staging.example.test/v1" {
t.Fatalf("BaseURL = %q, want resolved override", profile.BaseURL)
}
if profile.APIKeyEnv != "AIMLAPI_API_KEY" || profile.APIKey != "" {
t.Fatalf("credential source changed: %+v", profile)
}
if len(profile.CustomHeaders) != 0 {
t.Fatalf("catalog headers leaked to custom endpoint: %#v", profile.CustomHeaders)
}
}
func TestExistingAimlapiBalanceUsesProfileEndpointAndCancelsOnAbandon(t *testing.T) {
started := make(chan struct{})
done := make(chan struct{})
canonical := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-r.Context().Done()
close(done)
}))
defer canonical.Close()
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("production profile key was sent to process override: %s", r.URL)
}))
defer override.Close()
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
wizard := &providerWizardState{
step: providerWizardStepAimlapiConfigured,
aimlapiExistingProfile: config.ProviderProfile{
Name: "aimlapi", CatalogID: "aimlapi", BaseURL: canonical.URL,
},
aimlapiRuntimeKey: "production-secret",
}
m := model{ctx: context.Background(), providerWizard: wizard}
_, cmd := m.checkExistingAimlapiBalance()
result := make(chan tea.Msg, 1)
go func() { result <- execCmd(cmd) }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("profile endpoint did not receive the balance request")
}
wizard.clearAimlapiExisting()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("abandoning the preflight did not cancel the balance request")
}
select {
case <-result:
case <-time.After(2 * time.Second):
t.Fatal("cancelled balance command did not return")
}
}
func TestProviderWizardAimlapiPartnerOverrideOnlyOnCanonicalEndpoint(t *testing.T) {
provider, err := providercatalog.Require("aimlapi")
if err != nil {
t.Fatal(err)
}
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
canonical := providerWizardProfile(provider, provider.DefaultModel, "", provider.DefaultBaseURL, "")
if got := canonical.CustomHeaders[aimlapi.PartnerHeaderName]; got != "part_override" {
t.Fatalf("canonical partner header = %q, want part_override", got)
}
custom := providerWizardProfile(provider, provider.DefaultModel, "", "https://staging.example.test/v1", "")
if len(custom.CustomHeaders) != 0 {
t.Fatalf("partner headers leaked to custom endpoint: %#v", custom.CustomHeaders)
}
}
func TestExistingAimlapiConfigurationResolvesStoredKey(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
if err := store.Set("work", "aimlapi-stored-secret"); err != nil {
t.Fatal(err)
}
m := model{
userConfigPath: configPath,
savedProviders: []config.ProviderProfile{{Name: "work", CatalogID: "aimlapi", APIKeyStored: true}},
}
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
if !ok || runtimeKey != "aimlapi-stored-secret" {
t.Fatalf("stored configuration = (%+v, %q, %v)", profile, runtimeKey, ok)
}
if !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
t.Fatalf("stored credential source was not preserved: %+v", profile)
}
}
func TestExistingAimlapiConfigurationSkipsCustomEndpointCredential(t *testing.T) {
t.Setenv("AIMLAPI_API_KEY", "")
m := model{savedProviders: []config.ProviderProfile{{
Name: "aimlapi-staging",
CatalogID: "aimlapi",
BaseURL: "https://staging.example.test/v1",
APIKey: "staging-secret",
}}}
profile, runtimeKey, ok := m.existingAimlapiConfiguration()
if ok || profile.Name != "" || runtimeKey != "" {
t.Fatalf("custom endpoint credential entered production preflight: (%+v, %q, %v)", profile, runtimeKey, ok)
}
}
func TestAdvanceAimlapiWithExistingCredentialShowsConfiguredPreflight(t *testing.T) {
t.Setenv("AIMLAPI_API_KEY", "aimlapi-env-secret")
provider, err := providercatalog.Require("aimlapi")
if err != nil {
t.Fatal(err)
}
m := model{providerWizard: &providerWizardState{
step: providerWizardStepProvider,
providers: []providercatalog.Descriptor{provider},
}}
next, _ := m.advanceProviderWizard()
if next.providerWizard.step != providerWizardStepAimlapiConfigured {
t.Fatalf("step = %v, want configured preflight", next.providerWizard.step)
}
if next.providerWizard.aimlapiExistingProfile.APIKeyEnv != "AIMLAPI_API_KEY" {
t.Fatalf("profile = %+v", next.providerWizard.aimlapiExistingProfile)
}
if !maps.Equal(next.providerWizard.aimlapiExistingProfile.CustomHeaders, provider.CustomHeaders) {
t.Fatalf("catalog headers were not preserved: %#v", next.providerWizard.aimlapiExistingProfile.CustomHeaders)
}
}
func TestAimlapiConfigureAgainUsesTwoPathOnboarding(t *testing.T) {
wizard := &providerWizardState{
step: providerWizardStepAimlapiConfigured,
aimlapiConfiguredCursor: 1,
aimlapiExistingProfile: config.ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY"},
aimlapiRuntimeKey: "secret",
}
m := model{providerWizard: wizard}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard.step != providerWizardStepAimlapi || next.providerWizard.aimlapi == nil {
t.Fatalf("configure again did not enter onboarding: %+v", next.providerWizard)
}
if next.providerWizard.aimlapiExistingProfile.Name != "" || next.providerWizard.aimlapiRuntimeKey != "" {
t.Fatal("configure again must discard the old credential context")
}
}
func TestExistingAimlapiLowBalanceReusesByKeyTopUp(t *testing.T) {
wizard := &providerWizardState{
step: providerWizardStepAimlapiConfigured,
baseURL: "https://validated.example.test/v1",
aimlapiExistingProfile: config.ProviderProfile{Name: "aimlapi", CatalogID: "aimlapi", BaseURL: "https://validated.example.test/v1", APIKeyEnv: "AIMLAPI_API_KEY"},
aimlapiRuntimeKey: "secret",
aimlapiExistingBusy: true,
aimlapiExistingGen: 4,
}
m := model{providerWizard: wizard}
next, _ := m.applyExistingAimlapiBalance(aimlapiExistingBalanceMsg{
wizard: wizard,
gen: 4,
balance: aimlapi.BalanceResult{
LowBalance: true,
},
})
if next.providerWizard.step != providerWizardStepAimlapi || next.providerWizard.aimlapi == nil {
t.Fatalf("low balance did not enter aimlapi top-up: %+v", next.providerWizard)
}
if !next.providerWizard.aimlapi.byKey || next.providerWizard.aimlapi.step != aimlapiStepLowBalance || next.providerWizard.aimlapi.apiKey != "secret" {
t.Fatalf("top-up state = %+v", next.providerWizard.aimlapi)
}
if next.providerWizard.aimlapi.baseURL != "https://validated.example.test/v1" {
t.Fatalf("top-up endpoint = %q, want validated endpoint", next.providerWizard.aimlapi.baseURL)
}
}
func TestApplyExistingAimlapiPreservesEnvCredentialSource(t *testing.T) {
t.Setenv("AIMLAPI_API_KEY", "runtime-secret")
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"providers":[]}`), 0o600); err != nil {
t.Fatal(err)
}
provider := providercatalog.Descriptor{
ID: "aimlapi", Name: "aimlapi.com", Transport: providercatalog.TransportOpenAICompatible,
CustomHeaders: map[string]string{"X-AIMLAPI-Partner-ID": "partner"},
}
m := model{
userConfigPath: configPath,
providerWizard: &providerWizardState{
step: providerWizardStepDone,
providers: []providercatalog.Descriptor{provider},
models: []providerWizardModel{{ID: "model/new"}},
selectedModel: 0,
modelSource: "live",
aimlapiExistingProfile: config.ProviderProfile{
Name: "aimlapi", CatalogID: "aimlapi", APIKeyEnv: "AIMLAPI_API_KEY", Model: "model/old",
CustomHeaders: maps.Clone(provider.CustomHeaders),
},
aimlapiRuntimeKey: "runtime-secret",
},
}
next, _ := m.applyProviderWizard()
if next.providerWizard != nil {
t.Fatal("successful apply should close the wizard")
}
cfg := readProviderWizardConfigFixture(t, configPath)
if len(cfg.Providers) != 1 {
t.Fatalf("providers = %+v", cfg.Providers)
}
got := cfg.Providers[0]
if got.APIKeyEnv != "AIMLAPI_API_KEY" || got.APIKey != "" || got.APIKeyStored {
t.Fatalf("credential source changed: %+v", got)
}
if got.Model != "model/new" {
t.Fatalf("model = %q, want model/new", got.Model)
}
if !maps.Equal(got.CustomHeaders, provider.CustomHeaders) {
t.Fatalf("custom headers changed: %#v", got.CustomHeaders)
}
}
func readProviderWizardConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
+1 -1
View File
@@ -98,7 +98,7 @@ func (m model) sidebarAvailable() bool {
// list) take over the chat column and render at full width; suppress the
// second column while any is active so their geometry and mouse hit-testing
// stay full-width as before.
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
if m.setup.visible || m.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return false
}
-66
View File
@@ -333,38 +333,6 @@ var everforestPalette = palette{
cardPerm: "#96896b",
}
// neonPalette is a neon-on-black color scheme: pitch-black surface with
// neon green ink and a cyan accent.
var neonPalette = palette{
panel: "#050b06",
promptBg: "#0c180d",
line: "#1c3820",
line2: "#2c5230",
ink: "#c9ffd2",
muted: "#80db8f",
faint: "#6eca7d",
faintest: "#74c468", // brightened from #58af69 so line numbers quantize to #87d75f and stay AA on the xterm-green addBg (#005f00); still dimmer than faint, keeping the ramp monotonic
accent: "#00e5c8",
green: "#39ff6a",
red: "#ff4d6d",
amber: "#f4ff3a",
blue: "#22e0ff",
gitAdd: "#4fdc6a",
gitDel: "#ff6f80",
addBg: "#083c10", // quantizes to xterm green #005f00 instead of the same gray as delBg, keeping add/del rows distinct on 256-color terminals
delBg: "#3c0810", // quantizes to xterm red #5f0000 (see addBg)
addBgWord: "#147828", // quantizes to xterm green #008700, distinct from both addBg's #005f00 and delBgWord's red
delBgWord: "#74202e", // quantizes to xterm red #870000 (see addBgWord)
permBg: "#2a2a0c",
selBg: "#123a1e",
addInk: "#ecffdc", // quantizes to #ffffd7, which keeps AA on addBgWord's xterm #008700 (the old #c8ffcf quantized to #d7ffd7 at 4.29:1)
delInk: "#ffd0d6",
onAccent: "#001410",
cardRun: "#1f8a6e",
cardErr: "#9a4042", // raised from #8a2f42 for the 3:1 non-text border threshold against the panel (2.43:1 before), holding after xterm-256 quantization too
cardPerm: "#8a8a1f",
}
// lightPalette is dark-on-light: a warm cream surface (so cards lift off the
// terminal page, which Zero never paints) with near-black ink and an olive-lime
// accent that keeps the brand identity while clearing AA on the light panel. The
@@ -433,38 +401,6 @@ var solarizedLightPalette = palette{
cardPerm: "#c4ae63",
}
// dunePalette is a warm sand-and-cream color scheme: sand/cream surface,
// charcoal ink, and a soft amber accent.
var dunePalette = palette{
panel: "#f2e9d8",
promptBg: "#e9dcbf",
line: "#d9c7a3",
line2: "#c2a97c",
ink: "#2b241a",
muted: "#473e32",
faint: "#554a3a",
faintest: "#655648",
accent: "#724028", // darkened from #8f5215 for AA on selBg (5.46:1) that also survives ANSI-256 downsampling (quantizes to #444444, 6.47:1 on quantized selBg; the previous #7c4712 quantized to #875f00 at 3.81:1)
green: "#38572a",
red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling (true 6.57:1, 256 7.86:1)
amber: "#6d4600",
blue: "#2f5680", // darkened from #3d6a9e for AA on selBg (was 3.61:1, now 4.90:1)
gitAdd: "#38572a",
gitDel: "#963328",
addBg: "#dcecd0",
delBg: "#f5dbd5",
addBgWord: "#b9dc9e",
delBgWord: "#eebba9",
permBg: "#f0dfae",
selBg: "#e0cf98",
addInk: "#264018",
delInk: "#5c1810",
onAccent: "#fdf6ea",
cardRun: "#b08a4a",
cardErr: "#b57560",
cardPerm: "#c2a04a",
}
// themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme
// token (lowercase, kebab), Label is the picker display text, and IsDark groups the
// picker (Dark/Light sections) and drives which built-in `auto` resolves to.
@@ -490,10 +426,8 @@ var themeRegistry = []themeEntry{
{Name: "solarized-dark", Label: "Solarized Dark", Palette: solarizedDarkPalette, IsDark: true},
{Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true},
{Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true},
{Name: "neon", Label: "Neon", Palette: neonPalette, IsDark: true},
{Name: "light", Label: "light", Palette: lightPalette, IsDark: false},
{Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false},
{Name: "dune", Label: "Dune", Palette: dunePalette, IsDark: false},
}
// themeByName indexes the registry by lowercased name for O(1) lookup. Built as a
-220
View File
@@ -1,7 +1,6 @@
package tui
import (
"fmt"
"math"
"strconv"
"strings"
@@ -262,225 +261,6 @@ func TestHandleThemeCommand(t *testing.T) {
}
}
func TestNewThemePresetsWired(t *testing.T) {
neon, ok := lookupTheme("neon")
if !ok {
t.Fatal("theme 'neon' is not registered")
}
if !neon.IsDark {
t.Error("theme 'neon' should be marked as dark")
}
dune, ok := lookupTheme("dune")
if !ok {
t.Fatal("theme 'dune' is not registered")
}
if dune.IsDark {
t.Error("theme 'dune' should be marked as light")
}
for _, name := range []string{"neon", "dune"} {
if !validThemeMode(name) {
t.Errorf("%q should be a valid --theme/ZERO_THEME value", name)
}
}
if !contains(themeModes, "neon") || !contains(themeModes, "dune") {
t.Errorf("themeModes = %v, want it to include neon and dune (the /theme picker list)", themeModes)
}
}
// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and
// applyTheme must actually swap zeroTheme to the resolved preset's own palette,
// not silently fall back to a built-in.
func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
defer applyTheme(themeDark, true)
if got := resolveThemeMode("dune", ""); got != themeMode("dune") {
t.Fatalf(`resolveThemeMode("dune", "") = %q, want "dune"`, got)
}
if got := resolveThemeMode("", "neon"); got != themeMode("neon") {
t.Fatalf(`resolveThemeMode("", "neon") = %q, want "neon"`, got)
}
applyTheme(themeMode("dune"), true)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, dunePalette.ink) {
t.Error("applying \"dune\" did not swap zeroTheme to the dune palette")
}
applyTheme(themeMode("neon"), true)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, neonPalette.ink) {
t.Error("applying \"neon\" did not swap zeroTheme to the neon palette")
}
}
func TestExtendedThemeContrastInvariants(t *testing.T) {
// Skip validation for old built-in themes if they have established, non-compliant palettes,
// but enforce strict compliance on the newly introduced 'neon' and 'dune' themes.
for _, entry := range themeRegistry {
if entry.Name != "neon" && entry.Name != "dune" {
continue
}
name, pal := entry.Name, entry.Palette
// Finding 1: Permission and status/success surfaces
if r := wcagRatio(t, pal.amber, pal.permBg); r < 4.5 {
t.Errorf("%s: amber on permBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.onAccent, pal.amber); r < 4.5 {
t.Errorf("%s: onAccent on amber contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.green, pal.panel); r < 4.5 {
t.Errorf("%s: green on panel contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.amber, pal.panel); r < 4.5 {
t.Errorf("%s: amber on panel contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.red, pal.panel); r < 4.5 {
t.Errorf("%s: red on panel contrast %.2f < 4.5", name, r)
}
// Finding 2: Selected row secondary text
if r := wcagRatio(t, pal.faint, pal.selBg); r < 4.5 {
t.Errorf("%s: faint on selBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.faintest, pal.selBg); r < 4.5 {
t.Errorf("%s: faintest on selBg contrast %.2f < 4.5", name, r)
}
// Finding 3: Diff gutter pairings
if r := wcagRatio(t, pal.faintest, pal.addBg); r < 4.5 {
t.Errorf("%s: faintest on addBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.faintest, pal.delBg); r < 4.5 {
t.Errorf("%s: faintest on delBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.green, pal.addBg); r < 4.5 {
t.Errorf("%s: green on addBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 {
t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r)
}
}
}
// hexChannels splits a #rrggbb token into its 8-bit channels.
func hexChannels(t *testing.T, hexColor string) (int, int, int) {
t.Helper()
h := strings.TrimPrefix(hexColor, "#")
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 6 {
t.Fatalf("bad hex %q", hexColor)
}
return int((v >> 16) & 0xff), int((v >> 8) & 0xff), int(v & 0xff)
}
// xterm256Hex returns the nearest xterm-256 color (the 6x6x6 cube plus the
// 24-step grayscale ramp, by squared RGB distance): how a terminal without
// truecolor support downsamples the palette's hex tokens before rendering.
func xterm256Hex(t *testing.T, hexColor string) string {
t.Helper()
r, g, b := hexChannels(t, hexColor)
levels := []int{0, 95, 135, 175, 215, 255}
bestR, bestG, bestB := 0, 0, 0
bestDistance := math.MaxFloat64
try := func(cr, cg, cb int) {
d := float64((r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb))
if d < bestDistance {
bestDistance, bestR, bestG, bestB = d, cr, cg, cb
}
}
for _, cr := range levels {
for _, cg := range levels {
for _, cb := range levels {
try(cr, cg, cb)
}
}
}
for i := 0; i < 24; i++ {
gray := 8 + 10*i
try(gray, gray, gray)
}
return fmt.Sprintf("#%02x%02x%02x", bestR, bestG, bestB)
}
// Hex-level AA does not guarantee the rendered pairs hold on a 256-color
// terminal, which quantizes every token to its nearest xterm entry first.
// Guard the pairs that regressed: Dune's selected-row affordances (accent
// caret/favorite star and blue local-model dot over selBg via onSel) and
// Neon's diff bands, whose previous values all quantized to the same grays.
func TestExtendedThemeANSI256Contrast(t *testing.T) {
palettes := map[string]palette{}
for _, entry := range themeRegistry {
palettes[entry.Name] = entry.Palette
}
q := func(hexColor string) string { return xterm256Hex(t, hexColor) }
dune := palettes["dune"]
for _, pair := range []struct{ name, fg, bg string }{
{"accent on selBg", dune.accent, dune.selBg},
{"blue on selBg", dune.blue, dune.selBg},
{"faintest on selBg", dune.faintest, dune.selBg},
{"ink on selBg", dune.ink, dune.selBg},
} {
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
t.Errorf("dune: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
}
}
neon := palettes["neon"]
greenish := func(hexColor string) bool {
r, g, b := hexChannels(t, hexColor)
return g > r && g > b
}
reddish := func(hexColor string) bool {
r, g, b := hexChannels(t, hexColor)
return r > g && r > b
}
if q(neon.addBg) == q(neon.delBg) || !greenish(q(neon.addBg)) || !reddish(q(neon.delBg)) {
t.Errorf("neon: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s",
neon.addBg, q(neon.addBg), neon.delBg, q(neon.delBg))
}
if q(neon.addBgWord) == q(neon.delBgWord) || !greenish(q(neon.addBgWord)) || !reddish(q(neon.delBgWord)) {
t.Errorf("neon: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s",
neon.addBgWord, q(neon.addBgWord), neon.delBgWord, q(neon.delBgWord))
}
if q(neon.addBgWord) == q(neon.addBg) {
t.Errorf("neon: changed span is indistinguishable from its add row after quantization (both %s)", q(neon.addBg))
}
if q(neon.delBgWord) == q(neon.delBg) {
t.Errorf("neon: changed span is indistinguishable from its del row after quantization (both %s)", q(neon.delBg))
}
if r := wcagRatio(t, q(neon.green), q(neon.addBg)); r < 4.5 {
t.Errorf("neon: green on addBg = %.2f < 4.5 after quantization", r)
}
if r := wcagRatio(t, q(neon.red), q(neon.delBg)); r < 4.5 {
t.Errorf("neon: red on delBg = %.2f < 4.5 after quantization", r)
}
// The two rendered diff-content pairs a 256-color terminal actually
// shows: line numbers (faintest on addBg/delBg) and highlighted changed
// spans (addInk/delInk on their word bands).
for _, pair := range []struct{ name, fg, bg string }{
{"faintest on addBg", neon.faintest, neon.addBg},
{"faintest on delBg", neon.faintest, neon.delBg},
{"addInk on addBgWord", neon.addInk, neon.addBgWord},
{"delInk on delBgWord", neon.delInk, neon.delBgWord},
} {
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
t.Errorf("neon: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
}
}
// Error-card borders are non-text UI components: they need 3:1 against
// the panel (WCAG 1.4.11) to be distinguishable at all, in both truecolor
// and 256-color renderings.
if r := wcagRatio(t, neon.cardErr, neon.panel); r < 3.0 {
t.Errorf("neon: cardErr border on panel = %.2f < 3.0", r)
}
if r := wcagRatio(t, q(neon.cardErr), q(neon.panel)); r < 3.0 {
t.Errorf("neon: cardErr border on panel = %.2f < 3.0 after quantization", r)
}
}
func mustR(t *testing.T, hex string) uint32 {
t.Helper()
r, _, _, _ := lipgloss.Color(hex).RGBA()
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@gitlawb/zero",
"version": "0.4.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"version": "0.4.0",
"version": "0.3.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/zero",
"version": "0.4.0",
"version": "0.3.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",