Compare commits

...
Author SHA1 Message Date
github-actions[bot] e666395d47 chore(main): release 0.4.0 (#625)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-17 13:25:31 +08:00
PierrunoYTandClaude Sonnet 5 9043baedcf fix(sandbox): scrub dynamic credential env vars (#682)
* fix(sandbox): scrub dynamic credential env vars

* fix(sandbox): tolerate env assignments in configured sensitive keys

A config value mistakenly given as a full assignment (e.g.
"COMPANY_LLM_SECRET=...") would never match the real env key during
scrubbing, silently re-exposing the credential to sandboxed commands.
normalizeSensitiveEnvKeys now keeps only the name part before '=' and
re-trims it, per PR review.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): scrub credentials on direct/degraded command plans

directCommandPlan (used when the native sandbox backend is unavailable,
disabled, or not required, including the EnforcementDegraded fallback)
left Env nil whenever spec.Env was nil, so exec.Cmd inherited the full
caller environment unscrubbed. Configured apiKeyEnv credentials and
dynamically named ZERO_OAUTH_<NAME>_CLIENT_SECRET variables leaked
through that path even though the wrapped sandbox plans already
scrubbed them.

Route direct plans through scrubSensitiveEnv the same way wrapped
plans do, snapshotting os.Environ() when spec.Env is nil so scrubbing
still applies. Adds a regression test covering the degraded-fallback
path with both a configured and a dynamically named secret.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-17 13:04:12 +08:00
Ashwinhegde19 4d8c31cf16 fix(config): enforce MCP trust boundary so project config cannot override user disable (#609)
A user-level MCP server disable is now sticky. The project config
(a cloned repo's ./.zero/config.json) cannot re-enable a server the
user disabled, and cannot disable a server the user explicitly enabled.

The previous merge just assigned base.Disabled = next.Disabled, so a repo
that set disabled:true could silently defeat a user's explicit mcp enable
<server>. The guard is now symmetric via a mergeMCPServer canReenable
flag: once a higher-trust scope has explicitly set the disabled flag, a
lower-trust layer may not override that decision in either direction. Only
the CLI override scope (canReenable=true) may lift a sticky user decision.

The prior disabledSet-before-check ordering bug is also fixed: the
higher-trust scope's prior disabledSet/Disabled is captured before folding
in next's flag, so a project layer can still disable a default-enabled
server (e.g. firecrawl) rather than being silently ignored.

Ports the fix onto main's refactored MCP merge (internal/config/mcp_merge.go
and mergeProjectMCPConfig), which splits project-scope merging from the
general mergeMCPConfig path.

Fixes #512
2026-07-17 13:03:32 +08:00
PierrunoYTandClaude Sonnet 5 824ecdbcf9 fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts (#606)
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts

On interrupt, Run returns with ctx already canceled. The deferred
dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose
context.WithTimeout derives an already-canceled child context, so the
hook process never actually launches (recorded as timed-out/error in
the audit log). Since most sessions end via interrupt, sessionEnd
hooks were effectively unreliable.

Detach the sessionEnd dispatch from the run context's cancellation
with context.WithoutCancel so the dispatcher's own timeout still
applies without being short-circuited by the run's cancellation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* test(agent): fall back to GOROOT for hook regression

* test(agent): justify GOROOT fallback

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-17 13:03:14 +08:00
6b9c2f0c08 feat(aimlapi): AI/ML API provider with guided onboarding (top-up + key issuance) (#655)
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance)

Add the AI/ML API provider integration to the zero CLI: a guided TUI
onboarding sub-flow that takes an existing key or an email top-up and
writes the issued key into the provider profile, attributed to the
Gitlawb rebate partner.

- internal/aimlapi: OpenAI-compatible client, partner-checkout top-up,
  key validation, streaming, and endpoint config (Paths A/B/C).
- providercatalog / providermodelcatalog: register the aimlapi provider
  with default headers and models.
- tui: aimlapi onboarding screen wired into the provider wizard and
  onboarding flow.

Default endpoints point at production (auth/app/api/pay/aimlapi.com);
each is overridable via AIMLAPI_*_URL env vars.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): address review findings in onboarding flow

- topup: use errors.As for the 5xx retry check so a wrapped APIError
  still retries instead of aborting a paid checkout.
- onboarding: a Path-A top-up on an email with no account now lands on a
  single coherent "key saved, balance not topped up" screen instead of a
  green "ready" plus a red error.
- onboarding: an empty amount field is rejected instead of silently
  falling through to the $25 default.
- collapse the two identical top-up prompt constants into MsgTopUpPrompt
  and drop the dead amountPrompt() branch.
- reuse stdlib maps.Clone instead of a third bespoke header-copy helper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): retry transient poll errors and sharpen amount validation

* fix(aimlapi): retry transient poll errors and surface real amount errors

* docs(aimlapi): document exported client API; drop out-of-scope gitignore entry

Addresses the two CodeRabbit PR checks on #655:

- Add doc comments to the exported aimlapi client API (Client, APIError,
  the session/checkout/onboarding types and their methods) and
  ParseAmountUSD, to satisfy the docstring-coverage check.
- Remove the unrelated /zero.exe~ .gitignore entry that an earlier
  review-fix commit added; it is out of scope for the provider work, so
  the PR no longer touches .gitignore.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): render aimlapi row like other recommended providers

* fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation)

* fix(aimlapi): validate pasted API keys

* test(aimlapi): clarify pasted key validation

* test(aimlapi): cover key-balance low-balance transition

* fix(aimlapi): reject non-finite top-up amounts

* fix(cli): scope and canonicalize provider headers

* fix(tui): distinguish invalid verification codes

* feat(aimlapi): resume account-bound top-ups safely

* feat(tui): offer top-up for existing AIMLAPI keys

* feat(tui): reuse AIMLAPI env key on first run

* feat(tui): reuse configured AIMLAPI profiles

* test(aimlapi): trim redundant flow coverage

* fix(tui): preserve existing AIMLAPI profile metadata

* fix(tui): clone AIMLAPI headers in env fallback

* fix(aimlapi): address remaining review findings

* fix(aimlapi): harden onboarding lifecycle

* fix(aimlapi): preserve validated checkout context

* fix(aimlapi): harden checkout retry state

* fix(tui): clarify AIMLAPI configured choices

* fix(aimlapi): make checkout retries idempotent

* fix(aimlapi): validate checkout responses

* fix(providers): reconcile aimlapi catalog after rebase

---------

Co-authored-by: Lookoff123 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-17 12:49:38 +08:00
KRATOSandKRATOS 8ef8576df7 perf(tools): add explicit effect metadata for safe concurrency (#705)
* 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.

* 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

* fix(tools): keep multiFileResourceKeys used by apply_patch keys

Satisfies unused lint after switching apply_patch to applyPatchResourceKeys.

* 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

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-17 12:48:36 +08:00
Anandanandanandh8x 1c5c6e78a8 feat(perf): emit prompt-prefix hash fingerprint per turn (#704)
Compute a seven-field SHA-256 fingerprint of the cacheable prompt
prefix (base instructions, confirmation policy, project context, skills,
tools, tool schemas, complete) and emit one prefix_hash event per turn
through the trace recorder. The complete hash is the SHA-256 of the
canonical join of the other six, so any sub-component drift is
observable both individually and in aggregate.

The decomposition exists so a downstream observer can detect which
sub-component of the prefix broke cache stability across turns: a
stable complete_prefix hash means the cacheable prefix is byte-
identical, and the sub-hash that changed names the culprit.

No behavior change for untraced runs. For OpenAI, the existing
prompt_cache_key (session ID) is unchanged; the fingerprint is purely
additive observability. The Anthropic cache_control breakpoint work
already in main is not touched.

Co-authored-by: anandh8x <[email protected]>
2026-07-17 12:47:53 +08:00
Leonardo Faoro 7f669f4550 feat(tui): Ctrl+X leader chords and emacs menu navigation (#699)
* feat(tui): Ctrl+X leader chords and emacs menu navigation

Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ?
chord map), and Ctrl+P/N previous/next selection in modals and pickers.

* fix(tui): drop Ctrl+X e /edit leader chord

/edit replaces the composer and would discard an in-progress draft; leave
it unbound so leader shortcuts stay draft-preserving.

* fix(tui): consume idle Ctrl+N as a no-op

Reserve Ctrl+N for emacs menu navigation so it never falls through to
remapped global bindings when no selection surface is open.

* docs: drop manual CHANGELOG entries for TUI shortcuts
2026-07-17 12:44:00 +08:00
euxaristia 2b42cd567b fix(tui): stop the composer cursor blinking while typing or unfocused (#672)
* fix(tui): stop the composer cursor blinking while typing or unfocused

The composer's cursor blinked on a fixed timer unconditionally, including
mid-keystroke, which made typing feel janky since the cursor's job is to mark
the current position. Gate the existing blink tick on two states instead:
force solid while a keystroke landed within the last 500ms (reusing the
existing lastCharTime tracker), and hide it entirely while the terminal
window is unfocused (new terminalFocused field, driven by tea.FocusMsg/
BlurMsg). Idle and focused keeps the prior blink behavior unchanged.

Also decouples view.ReportFocus from the notifier (it was previously gated on
m.notifier != nil, which is effectively always true in production but left
cursor-focus behavior fragile if that ever changes) so focus reporting is
requested unconditionally.

* fix(tui): apply caret visibility synchronously and in the argument-hint line

- A keypress, focus, or blur now updates composerCursorVisible in its own
  message handler instead of waiting for the next 530ms composerBlinkMsg
  tick, which could render a typed character caret-less, leave a visible
  caret in an unfocused terminal, or keep it hidden after a refocus.
- commandArgumentHintComposerLine takes the caret visibility state instead
  of painting its cursor cell unconditionally, so the required-argument
  hint path follows the same focus and blink contract as the main
  composer renderer.
- New tests assert the state immediately after each message, with no blink
  tick in between, and cover the argument-hint render in both states.

* fix(tui): apply the caret state on composer paste

A paste is the same immediate-input transition as a keypress: routePaste now
refreshes lastCharTime and sets the caret visible for both the bracketed
tea.PasteMsg and right-click clipboardReadMsg paths, so a paste right after
the blink phase hid the caret renders solid immediately instead of waiting
for a tick that would toggle off a stale idle state.
2026-07-17 12:43:30 +08:00
euxaristia 665bd4b3d0 WIP: feat(tui): TUI theme presets (Dune and Neon) (#634)
* draft(tui): add theme interface and Claude/Codex presets proposal

Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md.

* fix(tui): implement Claude/Codex themes on the existing palette system

theme.go had replaced the tuiTheme/buildTheme/palette architecture every
renderer depends on with a standalone Theme interface using the wrong
lipgloss import, breaking the build and orphaning the existing theme
registry, /theme command, and picker.

Revert theme.go to the working implementation and add claude and codex
as real palette entries in theme_palettes.go, following the file's own
extension pattern. Both pass the WCAG AA contrast and gray-ramp
invariants asserted across the whole registry, and are picked up
automatically by --theme, ZERO_THEME, /theme, and the picker.

Rewrite docs/THEMES.md to describe the actual palette-registry
architecture instead of the removed interface.

* fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests

* style(tui): gofmt theme_select_test.go (drop trailing whitespace)

The ubuntu smoke run fails on gofmt -l; this clears the only unformatted
file in the draft so CI goes green.

* test(tui): cover the CLI/env resolution path for the dune and neon presets

TestNewThemePresetsWired only checked lookupTheme and the IsDark
grouping. Add coverage for the actual --theme/ZERO_THEME precedence
path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each
preset's own palette rather than a built-in fallback.

* fix(tui): raise Dune/Neon palette contrast and fix theme docs

Darken dune's accent, blue, and red tokens so the selected-row caret,
local-model dot, and diff deletion text clear WCAG AA against selBg and
delBg, including after ANSI-256 downsampling. Also correct THEMES.md's
description of bare /theme (it opens a live-preview picker, not a
list), and add the dune/neon names to the ZERO_THEME/--theme tables in
README.md and README_ZH.md.

* fix(tui): keep Dune and Neon readable after ANSI-256 downsampling

- Dune's accent becomes #724028: still AA on selBg and panel in truecolor
  (5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears
  AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to
  #875f00 at 3.81:1. Blue already survived quantization at 4.67:1.
- Neon's diff bands move to hexes that quantize to distinct green and red
  xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000,
  addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of
  collapsing to the same grays, while keeping every truecolor invariant.
- New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way
  a 256-color terminal does and asserts the Dune selected-row pairs stay AA
  and the Neon bands keep their green/red identity and mutual separation.
- docs/THEMES.md is now linked from the Documentation sections of both
  READMEs, and the Unreleased changelog inventory says twelve themes and
  names dune and neon.

* fix(tui): keep Neon diff content AA after quantization, fix error border

- faintest becomes #74c468: line numbers now quantize to #87d75f, which
  clears AA on the xterm-green addBg (#005f00) where the old #58af69
  quantized to 2.95:1, while staying dimmer than faint so the gray ramp
  stays monotonic.
- addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word
  band's #008700 (was 4.29:1). Both rendered diff-content pairs are now
  asserted in the ANSI-256 test instead of documented as omissions.
- cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and
  3.65:1 quantized, meeting the 3:1 non-text component threshold, with the
  border surface asserted in both renderings.
- docs/THEMES.md no longer claims the extended invariants run against the
  whole registry: it now says new palettes must be added to the
  per-palette contrast tests or given equivalent coverage.
2026-07-17 12:38:51 +08:00
93 changed files with 8618 additions and 304 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.3.0"}
{".":"0.4.0"}
+60 -4
View File
@@ -6,6 +6,60 @@ 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)
@@ -134,10 +188,12 @@ 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).
- 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.
- 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.
- `--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`,
+4 -1
View File
@@ -191,6 +191,8 @@ 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 |
@@ -339,7 +341,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`, `solarized-light`) |
| `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`) |
| `--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 |
@@ -386,6 +388,7 @@ 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)
+2 -1
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`、`solarized-light`) |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
@@ -261,6 +261,7 @@ 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
@@ -0,0 +1,39 @@
# 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.
+25 -1
View File
@@ -185,7 +185,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result = Result{Messages: copyMessages(messages)}
dispatchSessionStart(ctx, options)
defer func() {
dispatchSessionEnd(ctx, options, result, err)
// 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)
}()
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
@@ -213,6 +217,26 @@ 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,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -93,6 +94,87 @@ 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
@@ -0,0 +1,300 @@
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
@@ -0,0 +1,243 @@
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
@@ -0,0 +1,259 @@
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
@@ -0,0 +1,106 @@
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
@@ -0,0 +1,159 @@
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
@@ -0,0 +1,113 @@
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
@@ -0,0 +1,55 @@
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
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,335 @@
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
@@ -0,0 +1,457 @@
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
@@ -0,0 +1,186 @@
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
@@ -0,0 +1,38 @@
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
}
+6 -5
View File
@@ -766,11 +766,12 @@ 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,
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
+77
View File
@@ -348,6 +348,83 @@ 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
+19 -5
View File
@@ -853,14 +853,28 @@ 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,
WorkspaceRoot: workspaceRoot,
Policy: policy,
Store: store,
Backend: backend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
}), 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,7 +17,6 @@ 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"},
@@ -48,21 +47,6 @@ 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")
+62 -3
View File
@@ -3,8 +3,10 @@ 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"
@@ -424,22 +426,39 @@ 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: firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL),
BaseURL: baseURL,
APIKeyEnv: apiKeyEnv,
APIFormat: firstAPIFormat(descriptor),
AuthHeader: strings.TrimSpace(options.authHeader),
AuthScheme: normalizeAuthScheme(options.authScheme),
AuthHeaderValue: strings.TrimSpace(options.authHeaderValue),
CustomHeaders: copyProviderHeaders(options.customHeaders),
Model: firstNonEmptyCLI(options.model, descriptor.DefaultModel),
// 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),
}
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 == "" {
@@ -544,3 +563,43 @@ 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
}
@@ -0,0 +1,23 @@
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,6 +200,54 @@ 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")
+32 -6
View File
@@ -6,7 +6,7 @@ import (
"strings"
)
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
func mergeMCPConfig(dst *MCPConfig, src MCPConfig, canReenable bool) {
if len(src.Servers) == 0 {
return
}
@@ -14,10 +14,14 @@ func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server, canReenable)
}
}
// 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
@@ -27,7 +31,7 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
}
for name, server := range src.Servers {
base := dst.Servers[name]
candidate := mergeMCPServer(base, server)
candidate := mergeMCPServer(base, server, false)
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)
}
@@ -37,7 +41,11 @@ func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
return nil
}
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
// 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 {
if strings.TrimSpace(next.Type) != "" {
base.Type = next.Type
}
@@ -62,8 +70,26 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig
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 {
base.Disabled = 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
}
}
if next.configured {
base.configured = true
@@ -75,7 +101,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig
}
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next, true)) {
return true
}
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
+47 -4
View File
@@ -9,6 +9,7 @@ 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"
@@ -170,7 +171,10 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
if err != nil {
return MCPConfig{}, err
}
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
// 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)
}
// 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:
@@ -184,7 +188,7 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
return MCPConfig{}, err
}
}
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP, true)
return cfg.MCP, nil
}
@@ -211,7 +215,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
for _, provider := range src.Providers {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP)
mergeMCPConfig(&dst.MCP, src.MCP, true)
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
@@ -709,7 +713,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if hasProviderFields(overrides.Provider) {
mergeProvider(cfg, overrides.Provider)
}
mergeMCPConfig(&cfg.MCP, overrides.MCP)
mergeMCPConfig(&cfg.MCP, overrides.MCP, true)
}
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
@@ -1039,6 +1043,45 @@ 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 {
+190 -3
View File
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
)
func TestResolveAppliesLayerPrecedence(t *testing.T) {
@@ -554,7 +555,7 @@ func TestResolveReplacesMCPServerOverlayCollections(t *testing.T) {
}
}
func TestResolveMCPServerLayersCanClearAndReenable(t *testing.T) {
func TestResolveMCPServerLayersCannotReenableUserDisabled(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
@@ -588,9 +589,11 @@ func TestResolveMCPServerLayersCanClearAndReenable(t *testing.T) {
}
docs := resolved.MCP.Servers["docs"]
if docs.Disabled {
t.Fatalf("docs.Disabled = true, want project layer to re-enable")
// 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")
}
// 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)
}
@@ -599,6 +602,125 @@ func TestResolveMCPServerLayersCanClearAndReenable(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": {
@@ -1374,6 +1496,71 @@ 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",
+39 -7
View File
@@ -34,7 +34,7 @@ const (
var ErrUnknownProvider = errors.New("unknown provider")
// AIMLAPIID is the provider catalog identifier for the AI/ML API preset.
// AIMLAPIID is the provider catalog identifier for the aimlapi.com preset.
const AIMLAPIID = "aimlapi"
type Descriptor struct {
@@ -44,6 +44,7 @@ type Descriptor struct {
DefaultBaseURL string
DefaultModel string
AuthEnvVars []string
CustomHeaders map[string]string
RequiresAuth bool
UsesAmbientAuth bool
Public bool
@@ -70,9 +71,9 @@ type Descriptor struct {
// headless / SSH use) in addition to the browser flow.
OAuthDeviceFlow bool
// 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 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 bool
}
@@ -103,6 +104,11 @@ 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"),
@@ -110,7 +116,6 @@ 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
@@ -302,8 +307,21 @@ func google(id string, name string, baseURL string, model string, env []string,
}
}
// recommended marks a descriptor as the recommended default so list/picker
// surfaces sort it first and render the ★ … (recommended) badge.
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.
func recommended(descriptor Descriptor) Descriptor {
descriptor.Recommended = true
return descriptor
@@ -384,5 +402,19 @@ 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
}
+23 -17
View File
@@ -9,6 +9,7 @@ import (
var expectedCatalogIDs = []string{
"gitlawb-opengateway",
"aimlapi",
"openai",
"anthropic",
"google",
@@ -16,7 +17,6 @@ var expectedCatalogIDs = []string{
"ollama",
"lmstudio",
"openrouter",
"aimlapi",
"huggingface",
"chatgpt",
"groq",
@@ -69,25 +69,31 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
}
}
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
descriptors := All()
if len(descriptors) == 0 {
t.Fatal("All() returned no descriptors")
// 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 !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")
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)
}
}
// Exactly those are recommended, and they are contiguous at the top.
recommended := 0
for _, descriptor := range descriptors {
if descriptor.Recommended {
recommended++
}
}
if recommended != 1 {
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
if recommended != len(wantTop) {
t.Fatalf("recommended descriptor count = %d, want %d", recommended, len(wantTop))
}
}
@@ -109,14 +115,14 @@ func TestAIMLAPIDescriptor(t *testing.T) {
if err != nil {
t.Fatalf("Require(aimlapi) error = %v", err)
}
if descriptor.Name != "AI/ML API" {
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
if descriptor.Name != "aimlapi.com" {
t.Fatalf("Name = %q, want aimlapi.com", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
t.Fatalf("DefaultBaseURL = %q, want aimlapi.com endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "openai/gpt-5-chat" {
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
if descriptor.DefaultModel != "anthropic/claude-sonnet-5" {
t.Fatalf("DefaultModel = %q, want anthropic/claude-sonnet-5", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
@@ -316,7 +322,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-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"},
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"},
}
for transport, wantIDs := range cases {
+11
View File
@@ -61,6 +61,17 @@ 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,6 +33,11 @@ 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"},
+23 -18
View File
@@ -14,18 +14,22 @@ 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
sessionGrants *memoryGrantSet
sessionProfiles *permissionProfileGrantSet
turnProfiles *permissionProfileGrantSet
commandPrefixes *commandPrefixGrantSet
workspaceRoot string
policy Policy
store *GrantStore
backend Backend
scope *Scope
sensitiveEnvKeys []string
sessionGrants *memoryGrantSet
sessionProfiles *permissionProfileGrantSet
turnProfiles *permissionProfileGrantSet
commandPrefixes *commandPrefixGrantSet
}
func NewEngine(options EngineOptions) *Engine {
@@ -49,15 +53,16 @@ func NewEngine(options EngineOptions) *Engine {
scope = newScopeBestEffort(workspaceRoot)
}
return &Engine{
workspaceRoot: workspaceRoot,
policy: policy,
store: options.Store,
backend: options.Backend,
scope: scope,
sessionGrants: newMemoryGrantSet(),
sessionProfiles: newPermissionProfileGrantSet(),
turnProfiles: newPermissionProfileGrantSet(),
commandPrefixes: newCommandPrefixGrantSet(),
workspaceRoot: workspaceRoot,
policy: policy,
store: options.Store,
backend: options.Backend,
scope: scope,
sensitiveEnvKeys: normalizeSensitiveEnvKeys(options.SensitiveEnvKeys),
sessionGrants: newMemoryGrantSet(),
sessionProfiles: newPermissionProfileGrantSet(),
turnProfiles: newPermissionProfileGrantSet(),
commandPrefixes: newCommandPrefixGrantSet(),
}
}
+66 -6
View File
@@ -29,6 +29,9 @@ 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 {
@@ -122,6 +125,7 @@ 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 == "" {
@@ -207,7 +211,7 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap, "")
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
@@ -253,10 +257,27 @@ func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspa
Name: spec.Name,
Args: cloneStrings(spec.Args),
Dir: spec.Dir,
Env: cloneStrings(spec.Env),
Env: directCommandEnv(spec),
}
}
// 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 == "" {
@@ -318,7 +339,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
if envBackend == "" {
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend, "")
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
@@ -377,6 +398,10 @@ 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
@@ -384,7 +409,7 @@ func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend Backe
// command env values still replace inherited values below.
env = os.Environ()
}
env = scrubSensitiveEnv(env)
env = scrubSensitiveEnv(env, sensitiveEnvKeys...)
pathValue := envListValue(env, "PATH", defaultPath())
if runtime.GOOS == "darwin" {
// Preserve standard user tool locations so a bare `python3`/`node`
@@ -967,7 +992,7 @@ func regexpQuoteMeta(value string) string {
return replacer.Replace(value)
}
func scrubSensitiveEnv(env []string) []string {
func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
// Secrets not covered by the provider catalog: cloud/VCS credentials and
// providers Zero talks to through generic OpenAI-compatible endpoints.
sensitiveKeys := []string{
@@ -997,6 +1022,7 @@ func scrubSensitiveEnv(env []string) []string {
sensitiveKeys = append(sensitiveKeys, key)
}
}
sensitiveKeys = append(sensitiveKeys, normalizeSensitiveEnvKeys(additionalKeys)...)
out := make([]string, 0, len(env))
for _, kv := range env {
@@ -1005,7 +1031,7 @@ func scrubSensitiveEnv(env []string) []string {
out = append(out, kv)
continue
}
drop := false
drop := isDynamicSensitiveEnvKey(key)
for _, sensitive := range sensitiveKeys {
if strings.EqualFold(key, sensitive) {
drop = true
@@ -1018,3 +1044,37 @@ func scrubSensitiveEnv(env []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)
}
+81 -4
View File
@@ -122,6 +122,47 @@ 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{
@@ -653,14 +694,19 @@ 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)
scrubbed := scrubSensitiveEnv(inputEnv, " COMPANY_LLM_SECRET ", "company_llm_secret", "GITHUB_TOKEN=ghp_pasted-assignment", "=", "")
expected := map[string]string{
"PATH": "/usr/bin",
"SAFE_VAR": "hello",
"AWS_PROFILE": "staging",
"PATH": "/usr/bin",
"SAFE_VAR": "hello",
"AWS_PROFILE": "staging",
"ZERO_OAUTH_CLIENT_SECRET": "not-a-provider-secret",
}
actual := make(map[string]string, len(scrubbed))
for _, entry := range scrubbed {
@@ -674,3 +720,34 @@ 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 := sandboxEnvironmentForCommand(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot)
childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys)
// 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.
+2 -1
View File
@@ -34,7 +34,8 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"patch"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: applyPatchResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+2
View File
@@ -88,6 +88,8 @@ func NewAskUserTool() *askUserTool {
AdditionalProperties: false,
},
safety: readOnlySafety("Asks the user clarifying questions; gathers input only."),
// EffectInteractive: blocks on user input; ordering matters.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
}
}
+4
View File
@@ -52,6 +52,10 @@ func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
// One-shot shell can rewrite the workspace (and more). Serialized
// as WorkspaceWrite so external-mutator audits include bash; not
// session-bound Interactive (that is reserved for PTY/terminal tools).
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+94
View File
@@ -0,0 +1,94 @@
package tools
import "github.com/Gitlawb/zero/internal/localcontrol"
// BuiltinCatalog returns every built-in tool that Zero can expose to the model
// from this package (core + control + optional local-control helpers).
//
// It is the source of truth for metadata coverage tests: every tool returned
// here must declare an explicit Effect other than EffectUnknown.
//
// Not included (fail-closed EffectUnknown by design):
// - MCP tools (internal/mcp registryTool)
// - Plugin tools (internal/plugins pluginTool)
// - Specialist tools registered outside this package (Task, Generate, …)
//
// workspaceRoot is used only to construct path-scoped tools; it need not exist.
func BuiltinCatalog(workspaceRoot string) []Tool {
if workspaceRoot == "" {
workspaceRoot = "."
}
var all []Tool
all = append(all, CoreReadOnlyTools(workspaceRoot)...)
all = append(all, CoreWriteTools(workspaceRoot)...)
all = append(all, CoreShellTools(workspaceRoot)...)
// Network tools: always include web_fetch; always include web_search for
// classification even when no backend is configured at runtime (registration
// remains conditional in CoreNetworkTools).
all = append(all, NewWebFetchTool(), NewWebSearchTool())
all = append(all, NewEscalateModelTool())
// tool_search needs a live registry of the tools above.
reg := NewRegistry()
for _, tool := range all {
reg.Register(tool)
}
search := NewToolSearchTool(reg)
reg.Register(search)
all = append(all, search)
// Local-control tools are feature-gated at registration time but always
// exist as built-in implementations with fixed classifications.
all = append(all, NewLocalBrowserTools(localcontrol.BrowserOptions{})...)
all = append(all, NewLocalDesktopTools(localcontrol.DesktopOptions{})...)
all = append(all, NewLocalTerminalTools(localcontrol.TerminalOptions{})...)
all = append(all, NewLocalControlArtifactTools(LocalControlArtifactOptions{})...)
return all
}
// ValidateBuiltinCatalog returns diagnostics for every catalog tool that is
// missing or inconsistently classified. Empty means full coverage.
//
// Duplicate builtin names are always rejected: ResourceKeys is a function and
// cannot be compared, so partial metadata comparison would miss conflict-scope
// drift between two same-name tools.
//
// Validation uses constructor-declared capabilities (before ThreadSafe
// normalization) so a mutator wired with ThreadSafe=true is still reported.
// Runtime callers continue to use CapabilitiesOf, which normalizes.
func ValidateBuiltinCatalog(workspaceRoot string) []string {
var problems []string
seen := map[string]struct{}{}
for _, tool := range BuiltinCatalog(workspaceRoot) {
name := tool.Name()
if _, ok := seen[name]; ok {
problems = append(problems, name+": duplicate builtin name in catalog")
continue
}
seen[name] = struct{}{}
// Runtime view: must not remain Unknown after normalization.
runtimeCaps := CapabilitiesOf(tool)
if runtimeCaps.Effect == EffectUnknown {
problems = append(problems, name+": built-in tool remains EffectUnknown (metadata required)")
}
// Declaration view: catch invalid ThreadSafe on mutators that
// CapabilitiesOf would silently clear.
declared := declaredCapabilitiesOf(tool)
problems = append(problems, ValidateCapabilities(name, declared)...)
}
return problems
}
// declaredCapabilitiesOf returns constructor metadata when available; otherwise
// falls back to CapabilitiesOf (already normalized).
func declaredCapabilitiesOf(tool Tool) ToolCapabilities {
if tool == nil {
return UnknownCapabilities()
}
if d, ok := tool.(declaredCapabilityProvider); ok {
return d.declaredCapabilities()
}
return CapabilitiesOf(tool)
}
+172
View File
@@ -0,0 +1,172 @@
package tools
import "strings"
// EffectClass classifies a tool's side effects for concurrency safety.
// Concurrent execution (a later PR) must only consider tools that are both
// ThreadSafe and EffectReadOnly; this package only declares metadata.
// ThreadSafe is force-cleared for every non-ReadOnly effect at read time
// (see normalizeCapabilities).
type EffectClass int
const (
// EffectUnknown is the fail-closed default for unclassified tools.
// Never concurrency-eligible; always serialized.
EffectUnknown EffectClass = iota
// EffectReadOnly reads local or remote state without mutating it.
// The only effect class that may set ThreadSafe=true after audit.
EffectReadOnly
// EffectProcessRead observes a retained process, PTY, or terminal.
EffectProcessRead
// EffectWorkspaceWrite mutates the local repository or workspace.
// Includes one-shot shell commands that can rewrite the workspace.
EffectWorkspaceWrite
// EffectExternalWrite mutates state outside the local workspace.
EffectExternalWrite
// EffectInteractive uses browser, PTY/terminal, prompts, or other
// session-bound interaction where ordering matters.
EffectInteractive
)
// String returns a stable diagnostic name for the effect class.
func (e EffectClass) String() string {
switch e {
case EffectUnknown:
return "unknown"
case EffectReadOnly:
return "read_only"
case EffectProcessRead:
return "process_read"
case EffectWorkspaceWrite:
return "workspace_write"
case EffectExternalWrite:
return "external_write"
case EffectInteractive:
return "interactive"
default:
return "invalid"
}
}
// Valid reports whether e is one of the defined effect classes.
func (e EffectClass) Valid() bool {
return e >= EffectUnknown && e <= EffectInteractive
}
// ToolCapabilities is the concurrency-safety contract for a tool.
// ResourceKeys, when set, returns deterministic conflict keys for a call's
// arguments. Returning no keys does NOT make a tool parallel-safe.
type ToolCapabilities struct {
Effect EffectClass
ThreadSafe bool
ResourceKeys func(args map[string]any) []string
}
// CapabilityProvider is the optional interface tools implement to declare
// effect metadata. Tools that do not implement it are treated as EffectUnknown
// and not thread-safe (fail-closed).
type CapabilityProvider interface {
Capabilities() ToolCapabilities
}
// UnknownCapabilities is the fail-closed default for tools without metadata.
func UnknownCapabilities() ToolCapabilities {
return ToolCapabilities{
Effect: EffectUnknown,
ThreadSafe: false,
}
}
// normalizeCapabilities is the single read path for capability metadata.
// ThreadSafe is cleared unless Effect is a valid EffectReadOnly — mutators,
// interactive tools, process readers, unknown, and invalid effects must never
// appear concurrency-eligible to a future batch planner that only checks
// ThreadSafe && Effect.
func normalizeCapabilities(caps ToolCapabilities) ToolCapabilities {
if !caps.Effect.Valid() || caps.Effect != EffectReadOnly {
caps.ThreadSafe = false
}
return caps
}
// CapabilitiesOf returns a tool's declared capabilities, or the fail-closed
// unknown default when the tool does not implement CapabilityProvider.
func CapabilitiesOf(tool Tool) ToolCapabilities {
if tool == nil {
return UnknownCapabilities()
}
if provider, ok := tool.(CapabilityProvider); ok {
return normalizeCapabilities(provider.Capabilities())
}
return UnknownCapabilities()
}
// (baseTool).Capabilities exposes the capabilities field set at construction,
// normalized so ThreadSafe cannot surface on non-ReadOnly effects.
// Tools that embed baseTool inherit this method.
func (tool baseTool) Capabilities() ToolCapabilities {
return normalizeCapabilities(tool.capabilities)
}
// declaredCapabilities returns the constructor-set metadata without runtime
// normalization. Catalog validation uses this so a mis-declared
// ThreadSafe=true on a mutator is still reported even though CapabilitiesOf
// clears it for callers.
func (tool baseTool) declaredCapabilities() ToolCapabilities {
return tool.capabilities
}
// declaredCapabilityProvider is implemented by tools that can report their
// raw constructor metadata (baseTool embedders).
type declaredCapabilityProvider interface {
declaredCapabilities() ToolCapabilities
}
// Registry.Capabilities looks up a registered tool's capabilities by name.
// Unknown tools return the fail-closed default (not an error) so callers can
// treat missing tools as non-concurrent without panicking.
func (registry *Registry) Capabilities(name string) ToolCapabilities {
if registry == nil {
return UnknownCapabilities()
}
tool, ok := registry.Get(name)
if !ok {
return UnknownCapabilities()
}
return CapabilitiesOf(tool)
}
// ValidateCapabilities checks a single capability record for consistency.
// It returns a non-empty slice of human-readable problems when invalid.
// Note: CapabilitiesOf already normalizes ThreadSafe away for non-ReadOnly
// effects; validation still rejects the raw combination so catalog tests
// catch mis-declared constructors.
func ValidateCapabilities(name string, caps ToolCapabilities) []string {
var problems []string
name = strings.TrimSpace(name)
if name == "" {
name = "<unnamed>"
}
if !caps.Effect.Valid() {
problems = append(problems, name+": invalid effect value")
}
if caps.Effect == EffectUnknown && caps.ThreadSafe {
problems = append(problems, name+": ThreadSafe=true is forbidden with EffectUnknown")
}
// Mutating / interactive / process-read effects are never concurrency-eligible.
switch caps.Effect {
case EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead:
if caps.ThreadSafe {
problems = append(problems, name+": ThreadSafe=true is not permitted for effect "+caps.Effect.String())
}
}
return problems
}
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
func ValidateToolCapabilities(tool Tool) []string {
if tool == nil {
return []string{"<nil tool>"}
}
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
}
+449
View File
@@ -0,0 +1,449 @@
package tools
import (
"context"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
// plainTool implements Tool without CapabilityProvider — fail-closed default.
type plainTool struct {
name string
}
func (t plainTool) Name() string { return t.name }
func (t plainTool) Description() string { return "plain" }
func (t plainTool) Parameters() Schema { return Schema{Type: "object"} }
func (t plainTool) Safety() Safety { return readOnlySafety("plain") }
func (t plainTool) Run(context.Context, map[string]any) Result {
return okResult("ok")
}
func TestCapabilitiesOfDefaultsUnknown(t *testing.T) {
caps := CapabilitiesOf(plainTool{name: "mystery"})
if caps.Effect != EffectUnknown {
t.Fatalf("Effect = %v, want Unknown", caps.Effect)
}
if caps.ThreadSafe {
t.Fatal("ThreadSafe must be false for unclassified tools")
}
}
func TestCapabilitiesOfNilTool(t *testing.T) {
caps := CapabilitiesOf(nil)
if caps.Effect != EffectUnknown || caps.ThreadSafe {
t.Fatalf("nil tool = %+v, want fail-closed unknown", caps)
}
}
func TestUnknownNotThreadSafeEvenIfProviderLies(t *testing.T) {
tool := baseTool{
name: "liar",
capabilities: ToolCapabilities{
Effect: EffectUnknown,
ThreadSafe: true, // invalid — Capabilities() must clear it
},
}
caps := tool.Capabilities()
if caps.ThreadSafe {
t.Fatal("Capabilities() must force ThreadSafe=false for EffectUnknown")
}
}
func TestNormalizeClearsThreadSafeOnMutators(t *testing.T) {
tool := baseTool{
name: "writer",
capabilities: ToolCapabilities{
Effect: EffectWorkspaceWrite,
ThreadSafe: true, // must be cleared on read
},
}
if tool.Capabilities().ThreadSafe {
t.Fatal("WorkspaceWrite must never surface ThreadSafe=true via Capabilities()")
}
// Wrap with Run so CapabilitiesOf's CapabilityProvider path is exercised.
wrapped := deferredCapableTool{baseTool: tool}
if CapabilitiesOf(wrapped).ThreadSafe {
t.Fatal("CapabilitiesOf must normalize mutator ThreadSafe away")
}
}
func TestValidateRejectsThreadSafeUnknown(t *testing.T) {
problems := ValidateCapabilities("x", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
if len(problems) == 0 {
t.Fatal("expected validation problems for ThreadSafe+Unknown")
}
}
func TestValidateRejectsThreadSafeMutators(t *testing.T) {
for _, effect := range []EffectClass{EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive, EffectProcessRead} {
problems := ValidateCapabilities("m", ToolCapabilities{Effect: effect, ThreadSafe: true})
if len(problems) == 0 {
t.Fatalf("effect %s: expected ThreadSafe rejection", effect)
}
}
}
func TestEveryBuiltinHasExplicitMetadata(t *testing.T) {
problems := ValidateBuiltinCatalog(t.TempDir())
if len(problems) > 0 {
t.Fatalf("builtin metadata coverage failed:\n %s", strings.Join(problems, "\n "))
}
}
func TestBuiltinCatalogNoUnknown(t *testing.T) {
counts := map[EffectClass]int{}
for _, tool := range BuiltinCatalog(t.TempDir()) {
caps := CapabilitiesOf(tool)
if caps.Effect == EffectUnknown {
t.Errorf("tool %q is EffectUnknown", tool.Name())
}
counts[caps.Effect]++
}
if counts[EffectUnknown] != 0 {
t.Fatalf("Unknown built-ins = %d, want 0", counts[EffectUnknown])
}
t.Logf("classification counts: %+v", counts)
}
func TestRegistryCapabilitiesLookup(t *testing.T) {
reg := NewRegistry()
reg.Register(NewScopedReadFileTool(t.TempDir(), nil))
caps := reg.Capabilities("read_file")
if caps.Effect != EffectReadOnly {
t.Fatalf("read_file effect = %v, want ReadOnly", caps.Effect)
}
// Missing tool → fail-closed.
missing := reg.Capabilities("does_not_exist")
if missing.Effect != EffectUnknown || missing.ThreadSafe {
t.Fatalf("missing tool = %+v", missing)
}
}
func TestFileResourceKeysNormalized(t *testing.T) {
keys := fileResourceKeys(map[string]any{"path": `./src/../src/main.go`})
if len(keys) != 1 {
t.Fatalf("keys = %v", keys)
}
want := ResourceKeyFile + NormalizeResourcePath("./src/../src/main.go")
if keys[0] != want {
t.Fatalf("key = %q, want %q", keys[0], want)
}
// Missing args → nil, no panic.
if fileResourceKeys(nil) != nil {
t.Fatal("nil args should yield nil keys")
}
if fileResourceKeys(map[string]any{}) != nil {
t.Fatal("empty args should yield nil keys")
}
}
func TestResourcePathNormalizePlatform(t *testing.T) {
// Unix-style clean
got := NormalizeResourcePath("a//b/../c")
if got != "a/c" && got != filepath.ToSlash(filepath.Clean("a//b/../c")) {
// After ToSlash clean should be a/c
if NormalizeResourcePath("a//b/../c") != "a/c" {
t.Fatalf("unix clean = %q", got)
}
}
if runtime.GOOS == "windows" {
// Case-insensitive
if NormalizeResourcePath(`C:\Foo\Bar`) != NormalizeResourcePath(`c:\foo\bar`) {
t.Fatalf("windows case fold failed: %q vs %q",
NormalizeResourcePath(`C:\Foo\Bar`), NormalizeResourcePath(`c:\foo\bar`))
}
}
// Empty / URL-shaped
if NormalizeResourcePath("") != "" {
t.Fatal("empty path")
}
if NormalizeResourcePath("https://user:pass@host/path") != "" {
t.Fatal("URL must not become a file resource path")
}
}
func TestEndpointResourceKeysStripSecrets(t *testing.T) {
keys := endpointResourceKeys(map[string]any{
"url": "https://user:[email protected]:443/v1?token=abc",
})
if len(keys) != 1 {
t.Fatalf("keys = %v", keys)
}
if strings.Contains(keys[0], "secret") || strings.Contains(keys[0], "token") || strings.Contains(keys[0], "user") {
t.Fatalf("endpoint key leaked secret material: %q", keys[0])
}
if !strings.Contains(keys[0], "api.example.com") {
t.Fatalf("endpoint key missing host: %q", keys[0])
}
}
func TestEndpointResourceKeysAtInQueryDoesNotStealHost(t *testing.T) {
// Path/query must be stripped BEFORE userinfo so ?x=@bad cannot become the host.
keys := endpointResourceKeys(map[string]any{
"url": "https://api.example.com/v1?token=@secret",
})
if len(keys) != 1 || keys[0] != ResourceKeyEndpoint+"api.example.com" {
t.Fatalf("keys = %v, want endpoint:api.example.com", keys)
}
}
func TestProcessResourceKeysNumericSessionID(t *testing.T) {
// write_stdin declares session_id as integer; JSON decodes as float64.
keys := processResourceKeys(map[string]any{"session_id": float64(42)})
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"42" {
t.Fatalf("keys = %v, want process:42", keys)
}
}
func TestProcessResourceKeysSessionArg(t *testing.T) {
// terminal_session requires "session", not session_id.
keys := processResourceKeys(map[string]any{"session": "term-7"})
if len(keys) != 1 || keys[0] != ResourceKeyProcess+"term-7" {
t.Fatalf("keys = %v, want process:term-7", keys)
}
}
func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
patch := "--- a/old.go\n+++ b/new.go\n@@ -1 +1 @@\n-x\n+y\n"
keys := applyPatchResourceKeys(map[string]any{
"patch": patch,
"cwd": "pkg",
})
// Diff paths must be joined under cwd so the same basename under different
// cwds does not collide for the future conflict planner.
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, k := range keys {
if !want[k] {
t.Fatalf("unexpected key %q in %v", k, keys)
}
delete(want, k)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
tool := deferredCapableTool{baseTool: baseTool{
name: "bad_writer",
capabilities: ToolCapabilities{
Effect: EffectWorkspaceWrite,
ThreadSafe: true,
},
}}
declared := declaredCapabilitiesOf(tool)
if !declared.ThreadSafe || declared.Effect != EffectWorkspaceWrite {
t.Fatalf("declared = %+v, want raw WorkspaceWrite+ThreadSafe", declared)
}
problems := ValidateCapabilities(tool.Name(), declared)
if len(problems) == 0 {
t.Fatal("expected ValidateCapabilities to reject raw WorkspaceWrite+ThreadSafe")
}
// Runtime view stays safe.
if CapabilitiesOf(tool).ThreadSafe {
t.Fatal("runtime CapabilitiesOf must still clear ThreadSafe")
}
}
func TestApplyPatchResourceKeysFallbackWorkspace(t *testing.T) {
keys := applyPatchResourceKeys(map[string]any{"patch": "not a real diff"})
if len(keys) != 1 || keys[0] != ResourceKeyWorkspace+"root" {
t.Fatalf("keys = %v, want workspace:root fallback", keys)
}
}
func TestWorkspaceWriteClassifications(t *testing.T) {
root := t.TempDir()
for _, tool := range []Tool{
NewScopedWriteFileTool(root, nil),
NewScopedEditFileTool(root, nil),
NewScopedApplyPatchTool(root, nil),
NewScopedBashTool(root, nil),
} {
if got := CapabilitiesOf(tool).Effect; got != EffectWorkspaceWrite {
t.Errorf("%s effect = %v, want WorkspaceWrite", tool.Name(), got)
}
if CapabilitiesOf(tool).ThreadSafe {
t.Errorf("%s must not be ThreadSafe", tool.Name())
}
}
}
func TestInteractiveClassifications(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"ask_user", "exec_command", "write_stdin", "update_plan", "escalate_model", "request_permissions", "browser_open", "terminal_session", "desktop_action"} {
var found Tool
for _, tool := range BuiltinCatalog(root) {
if tool.Name() == name {
found = tool
break
}
}
if found == nil {
t.Fatalf("tool %q not in catalog", name)
}
if got := CapabilitiesOf(found).Effect; got != EffectInteractive {
t.Errorf("%s effect = %v, want Interactive", name, got)
}
}
}
func TestReadOnlyClassifications(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"read_file", "read_minified_file", "list_directory", "glob", "grep", "lsp_navigate", "skill", "web_fetch", "web_search", "tool_search"} {
var found Tool
for _, tool := range BuiltinCatalog(root) {
if tool.Name() == name {
found = tool
break
}
}
if found == nil {
t.Fatalf("tool %q not in catalog", name)
}
caps := CapabilitiesOf(found)
if caps.Effect != EffectReadOnly {
t.Errorf("%s effect = %v, want ReadOnly", name, caps.Effect)
}
// Current catalog intentionally keeps read tools non-thread-safe
// until each is audited for shared mutable state (FileTracker, etc.).
if caps.ThreadSafe {
t.Errorf("%s ThreadSafe=true without explicit audit", name)
}
}
}
func TestValidateBuiltinCatalogRejectsUnknownRaw(t *testing.T) {
// Prove the gate is not a rubber stamp: raw Unknown+ThreadSafe is rejected.
problems := ValidateCapabilities("ghost", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
if len(problems) == 0 {
t.Fatal("expected ValidateCapabilities to reject Unknown+ThreadSafe")
}
}
func TestCaptureArtifactIsWorkspaceWrite(t *testing.T) {
for _, tool := range NewLocalControlArtifactTools(LocalControlArtifactOptions{}) {
if CapabilitiesOf(tool).Effect != EffectWorkspaceWrite {
t.Fatalf("%s effect = %v, want WorkspaceWrite", tool.Name(), CapabilitiesOf(tool).Effect)
}
}
}
func TestCapabilitiesLookupRace(t *testing.T) {
reg := NewRegistry()
for _, tool := range BuiltinCatalog(t.TempDir()) {
reg.Register(tool)
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for _, tool := range reg.All() {
_ = reg.Capabilities(tool.Name())
_ = CapabilitiesOf(tool)
caps := CapabilitiesOf(tool)
if caps.ResourceKeys != nil {
_ = caps.ResourceKeys(map[string]any{"path": "a/b.go", "url": "https://example.com"})
}
}
}()
}
wg.Wait()
}
func TestEffectStringAndValid(t *testing.T) {
for _, e := range []EffectClass{EffectUnknown, EffectReadOnly, EffectProcessRead, EffectWorkspaceWrite, EffectExternalWrite, EffectInteractive} {
if !e.Valid() {
t.Fatalf("%v should be valid", e)
}
if e.String() == "invalid" || e.String() == "" {
t.Fatalf("String for %d", e)
}
}
if EffectClass(99).Valid() {
t.Fatal("invalid class reported valid")
}
}
// deferredCapableTool models a deferred-eligible built-in that still carries
// explicit capabilities (MCP tools do not implement CapabilityProvider and
// stay Unknown by design).
type deferredCapableTool struct {
baseTool
}
func (t deferredCapableTool) Deferred() bool { return true }
func (t deferredCapableTool) Run(context.Context, map[string]any) Result {
return okResult("ok")
}
func TestDeferredToolPreservesCapabilities(t *testing.T) {
tool := deferredCapableTool{baseTool: baseTool{
name: "deferred_reader",
capabilities: ToolCapabilities{
Effect: EffectReadOnly,
ThreadSafe: false,
ResourceKeys: fileResourceKeys,
},
}}
if !IsDeferred(tool) {
t.Fatal("expected deferred")
}
reg := NewRegistry()
reg.Register(tool)
got, ok := reg.Get("deferred_reader")
if !ok {
t.Fatal("missing registration")
}
caps := CapabilitiesOf(got)
if caps.Effect != EffectReadOnly {
t.Fatalf("effect = %v after deferred register", caps.Effect)
}
keys := caps.ResourceKeys(map[string]any{"path": "pkg/a.go"})
if len(keys) != 1 || keys[0] != ResourceKeyFile+NormalizeResourcePath("pkg/a.go") {
t.Fatalf("resource keys = %v", keys)
}
}
func TestPluginAndMCPStyleDefaultUnknown(t *testing.T) {
// Plugin and MCP adapters do not implement CapabilityProvider; they must
// remain fail-closed (serialized) until they opt into a trusted contract.
for _, name := range []string{"mcp_github_create_issue", "plugin_custom_tool"} {
caps := CapabilitiesOf(plainTool{name: name})
if caps.Effect != EffectUnknown || caps.ThreadSafe {
t.Fatalf("%s = %+v, want Unknown/not-thread-safe", name, caps)
}
}
}
func TestSchemasUnchangedForCoreTools(t *testing.T) {
// Metadata must not alter tool names or parameter shapes.
root := t.TempDir()
read := NewScopedReadFileTool(root, nil)
if read.Name() != "read_file" {
t.Fatalf("name = %q", read.Name())
}
params := read.Parameters()
if params.Type != "object" {
t.Fatalf("params type = %q", params.Type)
}
if _, ok := params.Properties["path"]; !ok {
t.Fatal("path property missing")
}
// Safety path unchanged.
if read.Safety().SideEffect != SideEffectRead {
t.Fatalf("safety side effect changed: %v", read.Safety().SideEffect)
}
}
+2 -1
View File
@@ -34,7 +34,8 @@ func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"path", "old_string", "new_string"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Edits files in place."),
safety: promptSafety(SideEffectWrite, "Edits files in place."),
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+2
View File
@@ -57,6 +57,8 @@ func newEscalateModelTool(registry modelregistry.Registry) escalateModelTool {
Reason: "Requests a mid-run switch to a stronger model; the agent loop performs the switch.",
AdvertiseInAuto: true,
},
// Requests mid-run model switch; session-bound.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
registry: registry,
}
+4
View File
@@ -465,6 +465,8 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
// PTY/shell session — never concurrent.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
@@ -717,6 +719,8 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
Reason: "Sending stdin can drive an existing shell process beyond the original command; empty polling and Ctrl-C interrupts are allowed automatically.",
AdvertiseInAuto: true,
},
// Writes to a retained process stdin — process interaction.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
manager: manager,
}
+2 -1
View File
@@ -38,7 +38,8 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"pattern"},
AdditionalProperties: false,
},
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+2 -1
View File
@@ -53,7 +53,8 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"pattern"},
AdditionalProperties: false,
},
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+2 -1
View File
@@ -33,7 +33,8 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool {
},
AdditionalProperties: false,
},
safety: readOnlySafety("Lists directory entries without modifying files."),
safety: readOnlySafety("Lists directory entries without modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+18 -9
View File
@@ -53,7 +53,8 @@ func newBrowserInstallTool(options localcontrol.BrowserOptions) Tool {
},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
@@ -98,7 +99,8 @@ func newBrowserLaunchToolWithLauncher(options localcontrol.BrowserOptions, launc
Required: []string{"app"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
launcher: launcher,
@@ -172,7 +174,8 @@ func newBrowserConnectTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"target"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
@@ -211,7 +214,8 @@ func newBrowserOpenTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"url"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
@@ -253,7 +257,8 @@ func newBrowserSnapshotTool(options localcontrol.BrowserOptions) Tool {
},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
@@ -292,7 +297,8 @@ func newBrowserClickTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"ref"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
@@ -332,7 +338,8 @@ func newBrowserTypeTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"ref", "text"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
@@ -371,7 +378,8 @@ func newBrowserPressTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"key"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
@@ -456,7 +464,8 @@ func newBrowserActionTool(options localcontrol.BrowserOptions) Tool {
Required: []string{"command"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
browser: localcontrol.NewBrowser(options),
}
+2
View File
@@ -58,6 +58,8 @@ func newCaptureArtifactTool(options LocalControlArtifactOptions) Tool {
AdditionalProperties: false,
},
safety: localControlSafety(enabled, SideEffectLocalControl, "Captures local browser, desktop, or terminal state into the configured artifact directory."),
// Captures write artifacts under the configured directory.
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false},
},
browser: localcontrol.NewBrowser(options.Browser),
desktop: localcontrol.NewDesktop(options.Desktop),
+6 -3
View File
@@ -35,7 +35,8 @@ func newDesktopWindowsTool(options localcontrol.DesktopOptions) Tool {
},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads native desktop window metadata."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
desktop: localcontrol.NewDesktop(options),
}
@@ -81,7 +82,8 @@ func newDesktopSnapshotTool(options localcontrol.DesktopOptions) Tool {
Required: []string{"pid", "window_id"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Reads a native desktop window state."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
desktop: localcontrol.NewDesktop(options),
}
@@ -125,7 +127,8 @@ func newDesktopActionTool(options localcontrol.DesktopOptions) Tool {
Required: []string{"command", "input"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
safety: localControlSafety(options.Enabled, SideEffectLocalDesktop, "Interacts with native desktop windows."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
desktop: localcontrol.NewDesktop(options),
}
+2 -1
View File
@@ -45,7 +45,8 @@ func newTerminalSessionTool(options localcontrol.TerminalOptions) Tool {
Required: []string{"action", "session"},
AdditionalProperties: false,
},
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
safety: localControlSafety(options.Enabled, SideEffectLocalTerminal, "Launches or interacts with a local virtual terminal session."),
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
terminal: localcontrol.NewTerminal(options),
}
+2 -1
View File
@@ -58,7 +58,8 @@ func NewScopedLSPNavigateTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"op", "path"},
AdditionalProperties: false,
},
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
},
workspaceRoot: root,
scope: scope,
+2
View File
@@ -39,6 +39,8 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
AdditionalProperties: false,
},
safety: readOnlySafety("Reads file contents without modifying files."),
// ThreadSafe=false: may update FileTracker session state on read.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+2 -1
View File
@@ -33,7 +33,8 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"path"},
AdditionalProperties: false,
},
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+2
View File
@@ -34,6 +34,8 @@ func NewRequestPermissionsTool() Tool {
Permission: PermissionAllow,
Reason: "Requests a user permission decision but does not read, write, run commands, or use the network.",
},
// Interactive permission escalation.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
},
}
}
+319
View File
@@ -0,0 +1,319 @@
package tools
import (
"fmt"
"path/filepath"
"runtime"
"strings"
)
// Resource-key prefixes used for future conflict detection. Keys are pure
// metadata in this PR — no locks or scheduling are applied.
//
// Unused prefixes (repository/pty/browser) are reserved for follow-up
// classifiers so PR6 conflict keys stay vocabulary-stable.
const (
ResourceKeyFile = "file:"
ResourceKeyDirectory = "directory:"
ResourceKeyRepository = "repository:" // reserved
ResourceKeyProcess = "process:"
ResourceKeyPTY = "pty:" // reserved
ResourceKeyBrowser = "browser:" // reserved
ResourceKeyEndpoint = "endpoint:"
ResourceKeySession = "session:"
ResourceKeyWorkspace = "workspace:"
)
// NormalizeResourcePath produces a deterministic path token for resource keys.
// It never touches the filesystem (no EvalSymlinks / Stat) so it cannot cause
// side effects or panic on missing paths. Empty input returns "".
//
// Rules:
// - Trim space
// - filepath.Clean
// - Convert separators to '/'
// - On Windows, lower-case for case-insensitive comparison
// - Strip a leading "./"
// - Never include credentials or query fragments (paths only)
func NormalizeResourcePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
// Reject URL-shaped values — those use endpoint: keys.
if strings.Contains(path, "://") {
return ""
}
cleaned := filepath.Clean(path)
if cleaned == "." {
return "."
}
normalized := filepath.ToSlash(cleaned)
normalized = strings.TrimPrefix(normalized, "./")
if runtime.GOOS == "windows" {
normalized = strings.ToLower(normalized)
}
return normalized
}
// fileResourceKeys extracts a file: resource key from common path argument names.
// Missing or empty path returns nil (not an error); never panics.
func fileResourceKeys(args map[string]any) []string {
path := firstStringArg(args, "path", "file", "file_path", "filepath", "filename", "target")
normalized := NormalizeResourcePath(path)
if normalized == "" {
return nil
}
return []string{ResourceKeyFile + normalized}
}
// directoryResourceKeys extracts a directory: key from path/directory/cwd args.
func directoryResourceKeys(args map[string]any) []string {
path := firstStringArg(args, "path", "directory", "dir", "cwd", "workdir")
normalized := NormalizeResourcePath(path)
if normalized == "" {
return nil
}
return []string{ResourceKeyDirectory + normalized}
}
// endpointResourceKeys extracts an endpoint: key from url/endpoint args.
// Host only is kept when a full URL is provided (no secrets/query/userinfo).
func endpointResourceKeys(args map[string]any) []string {
raw := firstStringArg(args, "url", "endpoint", "uri")
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
host := resourceHost(raw)
if host == "" {
return nil
}
return []string{ResourceKeyEndpoint + host}
}
// sessionResourceKeys extracts a session: key from session / session_id args.
// Numeric session ids (JSON numbers) are coerced to strings.
func sessionResourceKeys(args map[string]any) []string {
return scopedIDResourceKeys(ResourceKeySession, args, "session", "session_id", "sessionId")
}
// processResourceKeys extracts process: keys for retained process / terminal
// tools. Accepts session, session_id, pid, and numeric JSON values so
// write_stdin (integer session_id) and terminal_session (string session)
// both produce keys.
func processResourceKeys(args map[string]any) []string {
return scopedIDResourceKeys(ResourceKeyProcess, args,
"session", "session_id", "sessionId", "process_id", "pid", "id")
}
// scopedIDResourceKeys is the shared extract/trim/bound/prefix path for
// session- and process-scoped resource keys.
func scopedIDResourceKeys(prefix string, args map[string]any, keys ...string) []string {
id := strings.TrimSpace(firstStringArg(args, keys...))
if id == "" {
return nil
}
// Never put free-form content in keys — IDs only, bounded length.
if len(id) > 128 {
id = id[:128]
}
return []string{prefix + id}
}
// workspaceResourceKeys returns a single workspace: root marker when the call
// affects the whole workspace (e.g. glob without a path root).
func workspaceResourceKeys(_ map[string]any) []string {
return []string{ResourceKeyWorkspace + "root"}
}
// multiFileResourceKeys collects path and paths[] arguments into file: keys.
func multiFileResourceKeys(args map[string]any) []string {
var keys []string
keys = append(keys, fileResourceKeys(args)...)
if raw, ok := args["paths"]; ok {
switch typed := raw.(type) {
case []string:
for _, p := range typed {
if n := NormalizeResourcePath(p); n != "" {
keys = append(keys, ResourceKeyFile+n)
}
}
case []any:
for _, item := range typed {
if s, ok := item.(string); ok {
if n := NormalizeResourcePath(s); n != "" {
keys = append(keys, ResourceKeyFile+n)
}
}
}
}
}
return uniqueKeys(keys)
}
// applyPatchResourceKeys derives conflict keys for apply_patch: the cwd as a
// directory key, optional path/paths args, plus any +++/--- file paths
// parseable from the unified diff. Relative diff paths are joined under cwd
// so the same file under different cwd values does not collide (e.g. cwd=pkg
// + +++ b/new.go → file:pkg/new.go). Falls back to workspace:root when nothing
// is available so a patch call is never "keyless" for the future conflict planner.
func applyPatchResourceKeys(args map[string]any) []string {
var keys []string
keys = append(keys, directoryResourceKeys(args)...)
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromUnifiedDiff(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
}
}
keys = uniqueKeys(keys)
if len(keys) == 0 {
return workspaceResourceKeys(args)
}
return keys
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
func joinUnderResourceCwd(cwd, rel string) string {
rel = strings.TrimSpace(rel)
if rel == "" {
return ""
}
// Absolute (Unix or Windows) paths are already workspace-rooted or outside;
// do not re-prefix.
if filepath.IsAbs(rel) {
return rel
}
cwd = strings.TrimSpace(cwd)
if cwd == "" || cwd == "." {
return rel
}
return filepath.ToSlash(filepath.Join(cwd, rel))
}
// pathsFromUnifiedDiff extracts a/ and b/ file paths from unified-diff headers.
// Malformed input returns nil; never panics.
func pathsFromUnifiedDiff(patch string) []string {
if strings.TrimSpace(patch) == "" {
return nil
}
var out []string
for _, line := range strings.Split(patch, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- "):
rest := strings.TrimSpace(line[4:])
// Drop trailing tab timestamp if present.
if i := strings.IndexByte(rest, '\t'); i >= 0 {
rest = rest[:i]
}
if rest == "/dev/null" || rest == "" {
continue
}
// Strip a/ or b/ prefix common in git diffs.
if len(rest) > 2 && (rest[0] == 'a' || rest[0] == 'b') && rest[1] == '/' {
rest = rest[2:]
}
out = append(out, rest)
}
}
return out
}
// firstStringArg returns the first present argument as a string. Numeric JSON
// values (float64/int from encoding/json) are coerced so tools that declare
// integer session ids still yield resource keys.
func firstStringArg(args map[string]any, keys ...string) string {
if args == nil {
return ""
}
for _, key := range keys {
value, ok := args[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case string:
return typed
case float64:
// JSON numbers decode as float64. Prefer integer form when exact.
if typed == float64(int64(typed)) {
return fmt.Sprintf("%d", int64(typed))
}
return fmt.Sprintf("%v", typed)
case float32:
return fmt.Sprintf("%v", typed)
case int:
return fmt.Sprintf("%d", typed)
case int64:
return fmt.Sprintf("%d", typed)
case int32:
return fmt.Sprintf("%d", typed)
case jsonNumber:
return typed.String()
}
}
return ""
}
// jsonNumber matches encoding/json.Number without importing encoding/json here
// for a one-line type assert (tests may inject it).
type jsonNumber interface {
String() string
}
// resourceHost returns a lower-cased host for endpoint keys, stripping
// userinfo, path, query, and fragment. Returns "" when not parseable safely.
//
// Order matters: path/query/fragment are removed BEFORE userinfo so a query
// value containing "@" cannot steal the authority (e.g.
// https://api.example.com/v1?token=@secret → endpoint:api.example.com).
// host:port is preserved so two services on different ports do not collide.
func resourceHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
// Strip scheme.
if idx := strings.Index(raw, "://"); idx >= 0 {
raw = raw[idx+3:]
}
// Isolate authority: strip path/query/fragment first.
if slash := strings.IndexAny(raw, "/?#"); slash >= 0 {
raw = raw[:slash]
}
// Strip userinfo from the authority only.
if at := strings.LastIndex(raw, "@"); at >= 0 {
raw = raw[at+1:]
}
raw = strings.TrimSpace(raw)
if raw == "" || strings.Contains(raw, "@") {
return ""
}
return strings.ToLower(raw)
}
func uniqueKeys(keys []string) []string {
if len(keys) == 0 {
return nil
}
seen := make(map[string]struct{}, len(keys))
out := make([]string, 0, len(keys))
for _, k := range keys {
if k == "" {
continue
}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, k)
}
return out
}
+2 -1
View File
@@ -48,7 +48,8 @@ func NewSkillTool(dir string) *skillTool {
// unknown keys (AdditionalProperties:false).
AdditionalProperties: false,
},
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
},
}
}
+2
View File
@@ -51,6 +51,8 @@ func NewToolSearchTool(registry *Registry) Tool {
Reason: "Lists and loads already-registered tool schemas; performs no side effects.",
AdvertiseInAuto: true,
},
// Reads registry schemas only; no workspace mutation.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
},
registry: registry,
}
+5 -4
View File
@@ -143,10 +143,11 @@ type PrePermissionRejecter interface {
}
type baseTool struct {
name string
description string
parameters Schema
safety Safety
name string
description string
parameters Schema
safety Safety
capabilities ToolCapabilities // zero value = EffectUnknown, not thread-safe
}
func (tool baseTool) Name() string {
+2
View File
@@ -63,6 +63,8 @@ func NewUpdatePlanTool() *updatePlanTool {
AdditionalProperties: false,
},
safety: readOnlySafety("Updates in-memory planning state only."),
// Session plan state — ordering matters across turns.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
}
}
+2
View File
@@ -141,6 +141,8 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
Reason: "Fetches remote URL content over the network.",
AdvertiseInAuto: true,
},
// Network read; ThreadSafe=false until client proven concurrent-safe.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: endpointResourceKeys},
},
client: client,
resolver: resolver,
+2
View File
@@ -90,6 +90,8 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
},
// Search backends may share non-thread-safe clients.
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
},
backend: backend,
}
+2 -1
View File
@@ -33,7 +33,8 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
Required: []string{"path", "content"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
+23
View File
@@ -94,6 +94,29 @@ 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
}
+30 -2
View File
@@ -97,6 +97,25 @@ 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.
@@ -117,12 +136,21 @@ 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 {
return nil, errors.New("parse trace: header present but no spans or counters recovered (corrupt or truncated)")
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)")
}
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,6 +172,22 @@ 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.
@@ -190,6 +206,7 @@ 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
}
+47 -10
View File
@@ -82,16 +82,52 @@ 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"`
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"`
}
// WallDuration is the total traced wall time of the run.
@@ -229,5 +265,6 @@ func OptionalEventKeys() []string {
"counter:" + CounterAcceptanceChecks,
"counter:" + CounterPollingTurn,
"counter:" + CounterModelSwitches,
"event:prefix_hash",
}
}
+39 -2
View File
@@ -370,6 +370,27 @@ 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
@@ -398,6 +419,20 @@ 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) {
@@ -411,10 +446,12 @@ func TestReadNDJSONRejectsNonTrace(t *testing.T) {
}
func TestReadNDJSONRejectsHeaderOnly(t *testing.T) {
// A header with no recoverable spans/counters is corrupt/truncated, not empty.
// 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.
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 or counters")
t.Fatal("expected error for a trace header with no spans, counters, or prefix hashes")
}
}
File diff suppressed because it is too large. Load diff
+8
View File
@@ -63,6 +63,14 @@ 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.
+4 -1
View File
@@ -39,6 +39,8 @@ 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)"},
},
},
@@ -47,7 +49,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"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
},
},
{
@@ -55,6 +57,7 @@ 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"},
+6 -3
View File
@@ -79,6 +79,7 @@ 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) {
@@ -224,7 +225,9 @@ 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")
m.keyBindings.toggleSidebar = parseBinding("ctrl+n")
// 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")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
}
@@ -245,10 +248,10 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
}
initialSidebar := next.sidebarHidden
updated, _ = next.Update(testKeyCtrl('n'))
updated, _ = next.Update(testKeyCtrl('y'))
next = updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("remapped Ctrl+N toggleSidebar binding should still fire with a non-empty composer")
t.Fatal("remapped Ctrl+Y 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
@@ -0,0 +1,170 @@
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
@@ -0,0 +1,338 @@
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
@@ -0,0 +1,62 @@
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
@@ -0,0 +1,189 @@
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)
}
}
+180 -131
View File
@@ -145,13 +145,19 @@ 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
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
transcriptBodyHeights *transcriptBodyHeightCache
input textinput.Model
composer composerState
@@ -199,8 +205,13 @@ type model struct {
// lastKeyTime tracks every keypress timestamp for burst calculation.
lastKeyTime time.Time
// burstCount counts consecutive keypresses within 100ms (paste mode).
burstCount int
queuedMessage string
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
// 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
@@ -770,6 +781,7 @@ 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,
@@ -881,6 +893,11 @@ 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.
@@ -970,6 +987,9 @@ 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()
@@ -1074,7 +1094,14 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case composerBlinkMsg:
m.composerCursorVisible = !m.composerCursorVisible
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
}
return m, composerBlinkCmd()
case tea.BackgroundColorMsg:
// Terminal background-color reply (from Init's RequestBackgroundColor). In
@@ -1118,6 +1145,11 @@ 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
@@ -1126,6 +1158,10 @@ 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:
@@ -1203,6 +1239,11 @@ 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)
}
@@ -1233,9 +1274,44 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.burstCount = 0
return m, nil
}
switch {
case keyCtrl(msg, 'c'):
// 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 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"):
@@ -1497,6 +1573,7 @@ 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
@@ -1594,34 +1671,14 @@ 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
}
// Suggestions keep Shift+arrows for the composer path (unchanged); plain
// ↑/↓ and Ctrl+P/N still move the palette via moveModalSelection.
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
}
@@ -1633,34 +1690,12 @@ 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
}
@@ -1671,34 +1706,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.clearHover()
return m.scrollChat(-1), 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() {
m.moveSuggestion(1)
return m, nil
if next, cmd, ok := m.moveModalSelection(1); ok {
return next, cmd
}
if next, ok := m.moveComposerVisualCursor(1); ok {
return next, nil
@@ -1717,34 +1726,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.clearHover()
return m.scrollChat(1), 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() {
m.moveSuggestion(-1)
return m, nil
if next, cmd, ok := m.moveModalSelection(-1); ok {
return next, cmd
}
if next, ok := m.moveComposerVisualCursor(-1); ok {
return next, nil
@@ -1870,11 +1853,19 @@ 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)
}
@@ -1952,11 +1943,21 @@ 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 {
if m.sidebarHasAgents() && !m.reducedMotion {
m.spinner, _ = m.spinner.Update(msg)
// 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)
m.spinnerPhase++
m.spinnerTicking = true
return m, m.spinner.Tick
return m, cmd
}
m.spinnerTicking = false
return m, nil
@@ -2519,9 +2520,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.transcriptDetailed {
// When helpOverlay is active the help panel is composited into the normal
// transcript view as a true overlay (scrim + vertical centering), matching
} 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
// how the suggestion picker / provider wizard / pickers are drawn.
content = m.transcriptView()
} else {
@@ -2541,7 +2542,11 @@ func (m model) View() tea.View {
if m.altScreen {
view.BackgroundColor = zeroTheme.bgPanel
}
view.ReportFocus = m.notifier != nil
// 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
// 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).
@@ -2613,6 +2618,10 @@ 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)
@@ -2626,6 +2635,8 @@ func (m model) transcriptView() string {
viewportOverlay = sttKeyOverlay
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case leaderHelpOverlayContent != "":
viewportOverlay = leaderHelpOverlayContent
case providerOverlay != "":
viewportOverlay = providerOverlay
case mcpAddOverlay != "":
@@ -2770,6 +2781,11 @@ 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.
@@ -2788,9 +2804,9 @@ func (m model) composerIdleHint() string {
case tierNarrow:
hint = "? shortcuts"
case tierMedium:
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey)
default:
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
}
return zeroTheme.faint.Render(hint)
}
@@ -3475,7 +3491,7 @@ func (m model) composerLine(width int) string {
}
if argumentHint != "" {
input.SetWidth(0)
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint), width)
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint, m.composerCursorVisible), width)
}
previews := validComposerPastePreviews(state, m.composerPastePreviews)
displayState := composerDisplayStateForPastePreviews(state, previews)
@@ -3751,16 +3767,23 @@ func composerCursorForVisualColumn(state composerState, segment composerVisualLi
return segment.end
}
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string) string {
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string, cursorVisible bool) 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(" ") +
composerCursor(zeroTheme.faint.Render(string(hintRunes[0]))) +
cursor +
zeroTheme.faint.Render(string(hintRunes[1:]))
}
@@ -4069,6 +4092,12 @@ 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
@@ -4443,6 +4472,23 @@ func (m model) handleSubmit() (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.
@@ -4562,7 +4608,10 @@ 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 || !m.sidebarHasAgents() {
if m.spinnerTicking || m.reducedMotion {
return nil
}
if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() {
return nil
}
m.spinnerTicking = true
+196
View File
@@ -11,6 +11,7 @@ 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"
@@ -1964,6 +1965,35 @@ 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"})
@@ -2647,6 +2677,172 @@ 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 != " " {
+142 -5
View File
@@ -3,6 +3,7 @@ package tui
import (
"context"
"fmt"
"os"
"strings"
"time"
"unicode"
@@ -11,6 +12,7 @@ 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"
@@ -29,6 +31,9 @@ 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
@@ -62,6 +67,25 @@ 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.
@@ -137,6 +161,16 @@ 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)
}
@@ -183,7 +217,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.advanceSetup()
}
return m, nil
case keyIs(msg, tea.KeyUp):
case keyIs(msg, tea.KeyUp) || keyCtrl(msg, 'p'):
if m.setup.stage == setupStageMethod {
m.moveSetupMethod(-1)
} else if m.setup.stage == setupStageProvider {
@@ -192,7 +226,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveSetupModel(-1)
}
return m, nil
case keyIs(msg, tea.KeyDown):
case keyIs(msg, tea.KeyDown) || keyCtrl(msg, 'n'):
if m.setup.stage == setupStageMethod {
m.moveSetupMethod(1)
} else if m.setup.stage == setupStageProvider {
@@ -269,6 +303,10 @@ 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
@@ -421,10 +459,21 @@ 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 (the login provides the credential).
// OAuth path skips endpoint/name/credentials because 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)
}
@@ -432,6 +481,14 @@ 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 {
@@ -607,6 +664,54 @@ 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 {
@@ -621,6 +726,14 @@ 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 {
@@ -699,6 +812,9 @@ 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 = ""
@@ -835,7 +951,7 @@ func (m model) setupModelDiscoveryCmd(gen uint64) tea.Cmd {
apiKey = resolved
}
}
profile := providerWizardDiscoveryProfile(provider, apiKey)
profile := providerWizardDiscoveryProfile(provider, apiKey, m.setupBaseURL(option))
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}
@@ -1099,6 +1215,16 @@ 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 ""
}
@@ -1226,6 +1352,11 @@ 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:
@@ -1606,7 +1737,11 @@ 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 {
target = maxInt(target, 2+lipgloss.Width(displayValue(provider.Name, provider.ID)))
label := displayValue(provider.Name, provider.ID)
if provider.Recommended {
label = "★ " + label + " (recommended)"
}
target = maxInt(target, 2+lipgloss.Width(label))
}
target = maxInt(target, 32)
return minInt(target, available)
@@ -1694,6 +1829,8 @@ 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,6 +3,7 @@ package tui
import (
"context"
"errors"
"net/http"
"os"
"strings"
"testing"
@@ -10,6 +11,7 @@ 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"
)
@@ -49,6 +51,611 @@ 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))
models, err := discover(ctx, providerWizardDiscoveryProfile(descriptor, k, authed.BaseURL))
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)
rendered := m.providerWizard.render(m.width, m.spinnerGlyph())
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
if !strings.Contains(rendered, want) {
t.Fatalf("manager render missing %q:\n%s", want, rendered)
+245 -11
View File
@@ -2,6 +2,7 @@ package tui
import (
"context"
"maps"
"net"
"net/http"
"net/url"
@@ -14,6 +15,7 @@ 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"
@@ -74,6 +76,52 @@ 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.
@@ -300,6 +348,8 @@ const (
providerWizardStepManageKey
providerWizardStepEndpoint
providerWizardStepName
providerWizardStepAimlapiConfigured
providerWizardStepAimlapi
providerWizardStepCredential
providerWizardStepModel
providerWizardStepDone
@@ -371,14 +421,27 @@ type providerWizardState struct {
// Manage-key step: shown when the selected provider already has a stored key.
manageKeyCursor int
manageProviderName string
oauthMode bool
oauthPending bool
oauthAttemptID int
oauthErr 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
// 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
@@ -453,6 +516,34 @@ 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{}
@@ -501,6 +592,7 @@ func (wizard *providerWizardState) move(delta int) {
wizard.modelLoadError = ""
wizard.oauthPending = false
wizard.oauthErr = ""
wizard.resetAimlapiOnboard()
wizard.refreshModels()
case providerWizardStepModel:
wizard.refreshModels()
@@ -556,6 +648,8 @@ 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 {
@@ -570,7 +664,9 @@ func (wizard *providerWizardState) advance() {
wizard.step = providerWizardStepName
case providerWizardStepName:
wizard.err = ""
if providerWizardNeedsCredential(wizard.currentProvider()) {
if providerWizardIsAimlapi(wizard.currentProvider()) {
wizard.enterAimlapi()
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
wizard.step = providerWizardStepCredential
} else {
wizard.step = providerWizardStepModel
@@ -618,6 +714,15 @@ 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
@@ -627,7 +732,13 @@ func (wizard *providerWizardState) retreat() {
wizard.step = providerWizardStepProvider
}
case providerWizardStepModel:
if providerWizardNeedsCredential(wizard.currentProvider()) {
if providerWizardIsAimlapi(wizard.currentProvider()) {
if aimlapiProfile(wizard.aimlapiExistingProfile) {
wizard.step = providerWizardStepAimlapiConfigured
} else {
wizard.step = providerWizardStepProvider
}
} else if providerWizardNeedsCredential(wizard.currentProvider()) {
wizard.step = providerWizardStepCredential
} else if providerWizardNeedsEndpoint(wizard.currentProvider()) {
wizard.step = providerWizardStepEndpoint
@@ -677,6 +788,10 @@ 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":
@@ -713,6 +828,10 @@ 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
@@ -812,6 +931,34 @@ 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):
@@ -893,6 +1040,10 @@ 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:
@@ -1082,6 +1233,18 @@ 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
@@ -1102,7 +1265,9 @@ 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
profile = config.SecureProviderProfile(profile, m.userConfigPath)
if !preserveExistingCredentialReference {
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
@@ -1193,10 +1358,12 @@ func (m model) providerWizardOverlay(width int) string {
if m.providerWizard == nil {
return ""
}
return m.providerWizard.render(width)
return m.providerWizard.render(width, m.spinnerGlyph())
}
func (wizard *providerWizardState) render(width int) string {
// 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 {
if wizard == nil {
return ""
}
@@ -1233,6 +1400,12 @@ func (wizard *providerWizardState) render(width int) 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:
@@ -1306,6 +1479,10 @@ 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"
@@ -1329,7 +1506,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
switch step {
case providerWizardStepProvider:
target = providerWizardProviderWidth
case providerWizardStepModel, providerWizardStepManage:
case providerWizardStepModel, providerWizardStepManage, providerWizardStepAimlapi, providerWizardStepAimlapiConfigured:
target = providerWizardModelWidth
}
target = minInt(target, width)
@@ -1367,6 +1544,16 @@ 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"},
@@ -1411,6 +1598,32 @@ 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 {
@@ -1824,14 +2037,25 @@ 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: firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL),
BaseURL: resolvedBaseURL,
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 {
@@ -1845,6 +2069,16 @@ 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 == "" {
+153 -3
View File
@@ -3,11 +3,14 @@ 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"
@@ -24,6 +27,13 @@ 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
@@ -43,6 +53,18 @@ 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
@@ -67,6 +89,133 @@ 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 {
@@ -96,6 +245,7 @@ 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()
@@ -105,7 +255,7 @@ func (m model) providerModelDiscoveryCmd() tea.Cmd {
apiKey = resolved
}
}
profile := providerWizardDiscoveryProfile(provider, apiKey)
profile := providerWizardDiscoveryProfile(provider, apiKey, baseURL)
models, err := discover(ctx, profile)
return providerModelsDiscoveredMsg{providerID: providerID, token: token, models: models, err: err, secrets: []string{apiKey, profile.APIKey}}
}
@@ -165,8 +315,8 @@ func providerWizardModelsSource(models []providermodeldiscovery.Model) string {
return ""
}
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string) config.ProviderProfile {
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, "", "")
func providerWizardDiscoveryProfile(provider providercatalog.Descriptor, apiKey string, baseURL string) config.ProviderProfile {
profile := providerWizardProfile(provider, provider.DefaultModel, apiKey, baseURL, "")
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
}
+297 -1
View File
@@ -5,13 +5,18 @@ 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"
@@ -53,7 +58,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", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
for _, want := range []string{"Choose provider", "aimlapi.com", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
assertContains(t, listView, want)
}
}
@@ -1167,6 +1172,39 @@ 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
@@ -1232,6 +1270,264 @@ 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.providerWizard != nil || m.mcpAddWizard != nil ||
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return false
}
+66
View File
@@ -333,6 +333,38 @@ 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
@@ -401,6 +433,38 @@ 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.
@@ -426,8 +490,10 @@ 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,6 +1,7 @@
package tui
import (
"fmt"
"math"
"strconv"
"strings"
@@ -261,6 +262,225 @@ 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.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"version": "0.3.0",
"version": "0.4.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/zero",
"version": "0.3.0",
"version": "0.4.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",