Compare commits

..
Author SHA1 Message Date
anandh8x 84d0bc478b fix(sandbox): restore strict macOS isolation
Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces.

Track process reap completion so retained sessions cannot signal a stale numeric PID.
2026-07-21 17:47:36 +05:30
anandh8x b9b96c6f82 fix(sandbox): close execution lifecycle races
Bound retained interactive output, preserve interruption state, and start workspace observation before process launch.

Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback.
2026-07-21 17:27:13 +05:30
anandh8x b8eec2d86e test(sandbox): make platform contracts portable 2026-07-20 23:52:42 +05:30
anandh8x 1550c4131c feat(sandbox): unify command execution and enforcement 2026-07-20 23:39:36 +05:30
211 changed files with 3355 additions and 6700 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.5.0"}
{".":"0.4.0"}
-47
View File
@@ -6,53 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
### Features
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
### Bug Fixes
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
### Performance Improvements
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
-22
View File
@@ -67,28 +67,6 @@ An approved issue means only that the specific issue has been accepted for work.
It does not approve unrelated future work, future pull requests, or future issues
from the same author. Each issue is reviewed case by case.
## Contribution Volume and Batching
To keep triage and review sustainable while the project stabilizes, please keep
your contribution flow to a size the core team can realistically work through:
- One issue per bug. File each defect as its own issue with its own
reproduction. Please do not bundle several unrelated findings into a single
"audit" issue; separate issues are far easier to triage, approve, and close
individually.
- Keep only a few items open at a time. If you already have several issues or
pull requests awaiting review, let those move through the queue before opening
more. A large simultaneous burst from one contributor slows review for
everyone, even when each item is valid.
- AI-assisted contributions are welcome, held to the same bar. Automated audits
and generated patches are fine, but each issue and pull request must be
individually verified, scoped, and reproducible before you open it. Please do
not post bulk machine-generated findings unfiltered.
These apply to all contributors and are about review bandwidth, not the value of
any single report. Maintainers may ask you to consolidate, split, or hold
submissions to keep the queue manageable.
## Pull Request Requirements
Before opening a community pull request, make sure all of the following are true:
-1
View File
@@ -289,7 +289,6 @@ Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins remove github-pr-review # alias: rm
```
+1 -1
View File
@@ -749,7 +749,7 @@ flowchart TD
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
`LoadFromRoots`; single-root `Load` remains for install/write.
`LoadFromRoots` / `DiscoveryRoots`; single-root `Load` remains for install/write.
- **Hooks** can observe or block tool lifecycle events.
## End-to-End Data Flow
+27 -178
View File
@@ -11,9 +11,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -26,10 +23,9 @@ import (
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
// operation.
type Deps struct {
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
// exactly like the exec surface — never run unconfined on the host.
@@ -64,16 +60,13 @@ type acpSession struct {
// turnMu serializes prompt turns for one session: concurrent session/prompt
// calls run one at a time so they can't interleave history or clobber the
// single cancel slot.
turnMu sync.Mutex
modelMu sync.Mutex
turnMu sync.Mutex
mu sync.Mutex
mode agent.PermissionMode
model string
models []SessionConfigOptionValue
restrictModels bool
cancel context.CancelFunc
history []turnRecord
mu sync.Mutex
mode agent.PermissionMode
model string // override; "" => config default
cancel context.CancelFunc
history []turnRecord
}
// NewAgent builds the ACP server and registers its method handlers on conn.
@@ -90,6 +83,9 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
return a
}
// Serve runs the connection read loop until the stream closes or ctx is done.
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
// ---- initialize ----
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
@@ -124,7 +120,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
// ---- session lifecycle ----
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
var p NewSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/new params")
@@ -133,23 +129,18 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
}
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
sess := a.registerSession(meta.SessionID, root, nil)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
Modes: a.modeState(sess),
SessionID: sess.id,
Modes: a.modeState(sess),
}, nil
}
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
var p LoadSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/load params")
@@ -170,17 +161,7 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
history, historyErr := a.loadHistory(meta.SessionID)
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
}
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
model = persistedModel
if !modelChoiceExists(models, persistedModel) {
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
}
}
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
sess := a.registerSession(meta.SessionID, root, history)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
@@ -188,8 +169,7 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
historyErr,
)
return LoadSessionResult{
ConfigOptions: a.configOptions(sess),
Modes: a.modeState(sess),
Modes: a.modeState(sess),
}, nil
}
@@ -374,27 +354,11 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
switch p.ConfigID {
case configIDModel:
model := strings.TrimSpace(p.Value)
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
return nil, err
}
case configIDMode:
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
}
default:
if p.ConfigID != configIDModel {
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
}
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
sess.setModel(p.Value)
return SetSessionConfigOptionResult{}, nil
}
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
@@ -406,24 +370,8 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
model := strings.TrimSpace(p.Model)
if err := a.updateModel(sess, model, false); err != nil {
return nil, err
}
return ZeroSetModelResult{Model: model}, nil
}
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
sess.modelMu.Lock()
defer sess.modelMu.Unlock()
if restrictModels && !sess.hasModel(model) {
return RPCError(codeInvalidParams, "unknown model: "+model)
}
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
return RPCError(codeInternalError, "save model selection: "+err.Error())
}
sess.setModel(model)
return nil
sess.setModel(p.Model)
return ZeroSetModelResult{Model: p.Model}, nil
}
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
@@ -450,76 +398,6 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
}
}
// resolveModelChoices advertises authenticated live models when discovery is
// available. The configured model is always retained as the sole fallback.
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
if err != nil {
return "", nil, false, err
}
selected := strings.TrimSpace(resolved.Provider.Model)
options := make([]SessionConfigOptionValue, 0, 8)
seen := make(map[string]bool)
add := func(id, description string) {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
return
}
seen[id] = true
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
}
add(selected, "")
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
restrictModels := knownProvider && !descriptor.Custom
if a.deps.DiscoverModels != nil {
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
if ctx.Err() != nil {
return "", nil, false, ctx.Err()
}
if discoverErr == nil && len(discovered) > 0 {
for _, model := range discovered {
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
add(model.ID, model.Description)
}
}
}
}
return selected, options, restrictModels, nil
}
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
for _, option := range models {
if option.Value == model {
return true
}
}
return false
}
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
model, models, mode := s.configState()
return []SessionConfigOption{{
ID: configIDModel,
Name: "Model",
Description: "Model used for this session.",
Category: configCategoryModel,
Type: configOptionTypeSelect,
CurrentValue: model,
Options: models,
}, {
ID: configIDMode,
Name: "Mode",
Description: "Permission mode used for this session.",
Category: configCategoryMode,
Type: configOptionTypeSelect,
CurrentValue: string(mode),
Options: []SessionConfigOptionValue{
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
},
}}
}
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
@@ -647,13 +525,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// session is returned unchanged rather than orphaning its turn or resetting its
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
// half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing
}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
a.sessions[id] = sess
return sess
}
@@ -692,18 +570,7 @@ func (s *acpSession) currentMode() agent.PermissionMode {
}
func (s *acpSession) setModel(model string) {
model = strings.TrimSpace(model)
s.mu.Lock()
found := false
for _, option := range s.models {
if option.Value == model {
found = true
break
}
}
if !found && model != "" {
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
}
s.model = model
s.mu.Unlock()
}
@@ -714,24 +581,6 @@ func (s *acpSession) currentModel() string {
return s.model
}
func (s *acpSession) hasModel(model string) bool {
model = strings.TrimSpace(model)
s.mu.Lock()
defer s.mu.Unlock()
for _, option := range s.models {
if option.Value == model {
return true
}
}
return false
}
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
s.mu.Lock()
defer s.mu.Unlock()
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
}
func (s *acpSession) appendHistory(rec turnRecord) {
s.mu.Lock()
s.history = append(s.history, rec)
-212
View File
@@ -3,7 +3,6 @@ package acp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -14,7 +13,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -139,12 +137,6 @@ func TestACPEndToEndPrompt(t *testing.T) {
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
}
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
}
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
}
// session/prompt
var promptRes PromptResult
@@ -164,201 +156,6 @@ func TestACPEndToEndPrompt(t *testing.T) {
}
}
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "gpt-5.5"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
}}, nil
}
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: " gpt-5.4-mini ", Description: "Fast"},
{ID: "gpt-5.5", Description: "duplicate configured model"},
}, nil
}
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
option := created.ConfigOptions[0]
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
t.Fatalf("new model option = %+v", option)
}
for _, choice := range option.Options {
if choice.Name != choice.Value {
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
}
}
var selected SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
}, &selected); err != nil {
t.Fatalf("set_config_option: %v", err)
}
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
t.Fatalf("selected model = %q", got)
}
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
}, &SetSessionConfigOptionResult{}); err == nil {
t.Fatal("unknown standard model selection was accepted")
}
h.stop()
h = newHarness(t, deps)
defer h.stop()
var loaded LoadSessionResult
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
t.Fatalf("session/load: %v", err)
}
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
}
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
}
}
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
}}, nil
}
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, fmt.Errorf("discovery unavailable")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
option := created.ConfigOptions[0]
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
t.Fatalf("fallback model option = %+v", option)
}
}
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
deps := testDeps(t)
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
model := "configured-model"
if o.Provider.Model != "" {
model = o.Provider.Model
}
return config.ResolvedConfig{Provider: config.ProviderProfile{
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
}}, nil
}
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
var selected SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
}, &selected); err != nil {
t.Fatalf("set custom model: %v", err)
}
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
t.Fatalf("custom model = %q", got)
}
h.stop()
h = newHarness(t, deps)
defer h.stop()
var loaded LoadSessionResult
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
t.Fatalf("session/load: %v", err)
}
option := loaded.ConfigOptions[0]
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
t.Fatalf("loaded custom model option = %+v", option)
}
}
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
a := &Agent{deps: Deps{
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
}}, nil
},
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
},
}}
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
if err != nil {
t.Fatal(err)
}
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
}
}
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := &Agent{deps: Deps{
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
},
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, context.Canceled
},
}}
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
t.Fatalf("resolve error = %v, want context canceled", err)
}
}
func TestACPConfigOptionWireSchema(t *testing.T) {
b, err := json.Marshal(SessionConfigOption{
ID: "model", Name: "Model", Description: "desc", Category: "model",
Type: configOptionTypeSelect, CurrentValue: "m1",
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
})
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(b, &wire); err != nil {
t.Fatal(err)
}
for _, key := range []string{"type", "category", "currentValue", "options"} {
if _, ok := wire[key]; !ok {
t.Errorf("wire field %q absent: %s", key, b)
}
}
if _, ok := wire["value"]; ok {
t.Errorf("obsolete option value field present: %s", b)
}
if _, ok := wire["values"]; ok {
t.Errorf("obsolete values field present: %s", b)
}
choice := wire["options"].([]any)[0].(map[string]any)
if choice["value"] != "m1" {
t.Errorf("options[].value = %#v", choice["value"])
}
}
func TestACPUnknownSessionPromptErrors(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
@@ -384,15 +181,6 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode ask: %v", err)
}
var configured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
}, &configured); err != nil {
t.Fatalf("set_config_option mode: %v", err)
}
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
t.Fatalf("configured mode = %q", got)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
-11
View File
@@ -1,11 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package acp
import "context"
// Serve runs the connection read loop until the stream closes or ctx is done.
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
func ImageBlock(base64Data, mimeType string) ContentBlock {
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
}
+19 -19
View File
@@ -103,6 +103,10 @@ type ContentBlock struct {
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
func ImageBlock(base64Data, mimeType string) ContentBlock {
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
}
// ---- sessions ----
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
@@ -229,6 +233,10 @@ func ToolContent(block ContentBlock) ToolCallContent {
return ToolCallContent{Type: "content", Content: &block}
}
func ToolDiff(path, oldText, newText string) ToolCallContent {
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
}
type ToolCallLocation struct {
Path string `json:"path"`
Line *int `json:"line,omitempty"`
@@ -329,22 +337,19 @@ type SetSessionModeParams struct {
type SetSessionModeResult struct{}
// ---- session config options ----
// ---- session config options (model selection) ----
type SessionConfigOptionValue struct {
Value string `json:"value"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
}
type SessionConfigOption struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Type string `json:"type"`
CurrentValue string `json:"currentValue"`
Options []SessionConfigOptionValue `json:"options"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Value string `json:"value"`
Values []SessionConfigOptionValue `json:"values,omitempty"`
}
type SetSessionConfigOptionParams struct {
@@ -368,11 +373,6 @@ type ZeroSetModelResult struct {
Model string `json:"model"`
}
const (
configIDModel = "model"
configIDMode = "mode"
configOptionTypeSelect = "select"
configCategoryModel = "model"
configCategoryMode = "mode"
)
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
// through the standard session/set_config_option method.
const configIDModel = "model"
@@ -16,7 +16,7 @@ import (
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
@@ -51,7 +51,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
@@ -88,7 +88,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
result, err := executeToolCall(
context.Background(),
@@ -117,7 +117,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
promptCalls := 0
_, err := executeToolCall(
+12
View File
@@ -501,3 +501,15 @@ func hasStringPrefix(values []string, prefix []string) bool {
}
return true
}
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
+9
View File
@@ -610,6 +610,15 @@ func formatPreservedState(plan string, task *preservedTaskState, edits, tools, s
return preservedStateLabel + "\n" + string(encoded)
}
// parsePreservedState recovers the plan + skills from a prior summary's preserved
// block. JSON escaping makes this lossless even when a skill body contains
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
// malformed.
func parsePreservedState(summaryContent string) (string, []skillEntry) {
state := parsePreservedStateBlock(summaryContent)
return state.Plan, preservedSkillsToEntries(state.Skills)
}
func parsePreservedStateBlock(summaryContent string) preservedState {
idx := strings.LastIndex(summaryContent, preservedStateLabel)
if idx < 0 {
+4 -4
View File
@@ -336,7 +336,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
registry.Register(tools.NewReadFileTool(t.TempDir()))
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
@@ -364,7 +364,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
},
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
registry.Register(tools.NewReadFileTool(t.TempDir()))
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
@@ -427,7 +427,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// the compaction closure. read_file also returns a sizeable result that
// bloats the history.
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
registry.Register(tools.NewReadFileTool(t.TempDir()))
// ContextWindow large enough that proactive compaction never triggers, so
// only the reactive path can save the run.
@@ -491,7 +491,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
finalText: "recovered",
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
registry.Register(tools.NewReadFileTool(t.TempDir()))
var deltas []string
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
+3 -3
View File
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
registry.Register(fakeToolSearchTool{})
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
DisabledTools: []string{"read_file"},
-41
View File
@@ -1,41 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package agent
import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
// parsePreservedState recovers the plan + skills from a prior summary's preserved
// block. JSON escaping makes this lossless even when a skill body contains
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
// malformed.
func parsePreservedState(summaryContent string) (string, []skillEntry) {
state := parsePreservedStateBlock(summaryContent)
return state.Plan, preservedSkillsToEntries(state.Skills)
}
// partitionTools builds the per-turn advertised tool list and optional
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
// (dropped so it is never advertised when it cannot help), and the discovery text is
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
// through tool_search. Non-deferred tools (including tool_search) are always
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
// so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
+7 -7
View File
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -260,7 +260,7 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -291,7 +291,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -319,7 +319,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{
@@ -349,7 +349,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewUpdatePlanTool())
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
@@ -383,7 +383,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewUpdatePlanTool())
turns := [][]zeroruntime.StreamEvent{
@@ -418,7 +418,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
for i := 0; i < toolOnlyProgressReminderAt; i++ {
+17
View File
@@ -267,6 +267,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
// the messages, so computing it before compaction is safe.
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
@@ -2862,6 +2866,19 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
}
}
// partitionTools builds the per-turn advertised tool list and optional
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
// (dropped so it is never advertised when it cannot help), and the discovery text is
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
// through tool_search. Non-deferred tools (including tool_search) are always
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
// so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
// partitionToolsCached is partitionTools with an optional per-tool definition
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
+54 -60
View File
@@ -863,6 +863,9 @@ func TestRunReportsTruncationFinishReason(t *testing.T) {
if result.FinishReason != zeroruntime.FinishReasonLength {
t.Fatalf("FinishReason = %q, want %q", result.FinishReason, zeroruntime.FinishReasonLength)
}
if !result.Truncated() {
t.Fatal("Truncated() = false, want true for a length-capped response")
}
if result.TruncationNotice() == "" {
t.Fatal("TruncationNotice() empty for a truncated response")
}
@@ -880,7 +883,7 @@ func TestRunNormalCompletionIsNotTruncated(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result.TruncationNotice() != "" {
if result.Truncated() || result.TruncationNotice() != "" {
t.Fatalf("normal completion reported as truncated: reason=%q", result.FinishReason)
}
}
@@ -957,7 +960,7 @@ func TestRunEmitsUsageEvents(t *testing.T) {
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
@@ -1164,9 +1167,9 @@ func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
func TestRunFiltersAdvertisedTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewScopedGrepTool(root, nil))
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewGrepTool(root))
registry.Register(tools.NewWriteFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
@@ -1194,7 +1197,7 @@ func TestRunFiltersAdvertisedTools(t *testing.T) {
func TestRunRejectsFilteredToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -1231,7 +1234,7 @@ func TestRunRejectsFilteredToolCalls(t *testing.T) {
func TestRunRejectsToolCallsOutsideEnabledList(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -1269,7 +1272,7 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\n")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -1353,7 +1356,7 @@ func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -1421,7 +1424,7 @@ func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -1468,8 +1471,8 @@ func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -1544,7 +1547,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
// after a later call in the same turn supersedes it.
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -1601,7 +1604,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write denied")
var permissionEvents []PermissionEvent
@@ -1644,7 +1647,7 @@ func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write approved")
var requests []PermissionRequest
var permissionEvents []PermissionEvent
@@ -1700,7 +1703,7 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write done")
var permissionEvents []PermissionEvent
@@ -1749,7 +1752,7 @@ func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write denied")
var requests []PermissionRequest
var permissionEvents []PermissionEvent
@@ -1797,7 +1800,7 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write should not continue")
var permissionEvents []PermissionEvent
@@ -1893,7 +1896,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write approved")
var permissionEvents []PermissionEvent
policy := sandbox.DefaultPolicy()
@@ -1965,7 +1968,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -2046,7 +2049,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -2248,7 +2251,7 @@ func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
policy := sandbox.DefaultPolicy()
policy.Network = sandbox.NetworkAllow
@@ -2355,7 +2358,7 @@ func TestRunPersistentCommandPrefixStillPromptsForNetwork(t *testing.T) {
t.Fatalf("seed command prefix: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -2404,11 +2407,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
root := t.TempDir()
command := "PATH=.:$PATH curl https://example.com"
if runtime.GOOS == "windows" {
if windowsTestUsesPowerShell() {
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
} else {
command = "set PATH=.;%PATH% && curl https://example.com"
}
command = "set PATH=.;%PATH% && curl https://example.com"
fakeCurl := filepath.Join(root, "curl.cmd")
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
t.Fatal(err)
@@ -2420,7 +2419,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
}
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -2487,7 +2486,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -2529,17 +2528,13 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
root := t.TempDir()
command := "echo rm -rf /"
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
command = `Write-Output 'rm -rf /'`
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2603,7 +2598,7 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
// prior code denied it because persistPermissionGrant errors when Sandbox==nil.
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write approved")
var permissionEvents []PermissionEvent
@@ -2635,12 +2630,6 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
}
}
func windowsTestUsesPowerShell() bool {
executable, _ := tools.HostShellCommand("")
name := strings.ToLower(filepath.Base(executable))
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
}
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
for _, decision := range decisions {
if decision == want {
@@ -2673,7 +2662,7 @@ func TestRunCancellationPreservesContextCanceledIdentity(t *testing.T) {
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write done")
var permissionEvents []PermissionEvent
@@ -2725,7 +2714,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write done")
var permissionEvents []PermissionEvent
@@ -2904,7 +2893,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
var permissionEvents []PermissionEvent
@@ -2953,7 +2942,7 @@ func TestRunStopsAfterMaxTurns(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
@@ -2983,7 +2972,7 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -3174,17 +3163,22 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
wants := []string{"workdir/cwd", "MSYS binaries"}
if windowsTestUsesPowerShell() {
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
} else {
wants = append(wants, "cmd.exe", "double quotes")
}
for _, want := range wants {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
}
@@ -3237,7 +3231,7 @@ func TestBuildSystemPromptAllowsSpecModeOverride(t *testing.T) {
func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(root, nil) {
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
specmode.RegisterDraftTools(registry, root, nil)
@@ -3274,7 +3268,7 @@ func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedWriteFileTool(root, nil))
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("done")
result, err := Run(context.Background(), "draft", provider, Options{
@@ -3307,7 +3301,7 @@ func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
@@ -3448,7 +3442,7 @@ func TestRunSurfacesDroppedToolCallAlongsideValidCall(t *testing.T) {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
+1 -1
View File
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
},
}
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
}
}
+5 -2
View File
@@ -11,7 +11,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -311,7 +310,11 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
+7
View File
@@ -427,6 +427,13 @@ type Result struct {
IncompleteReason string
}
// Truncated reports whether the final response ended abnormally (cut off at the
// output token cap or withheld by a content filter) rather than completing
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
func (result Result) Truncated() bool {
return result.FinishReason != ""
}
// TruncationNotice returns a user-facing warning when the final response was
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
// wording stays consistent.
+4
View File
@@ -31,6 +31,10 @@ type AgentRunner interface {
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
return fn(ctx, input)
}
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
// runaway agent cannot exhaust memory or bloat the benchmark report.
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
-8
View File
@@ -1,8 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package agenteval
import "context"
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
return fn(ctx, input)
}
-5
View File
@@ -1,7 +1,6 @@
package cli
import (
"context"
"fmt"
"io"
"os"
@@ -10,7 +9,6 @@ import (
"github.com/Gitlawb/zero/internal/acp"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -45,9 +43,6 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
conn := acp.NewConn(deps.stdin, stdout)
acp.NewAgent(conn, acp.Deps{
ResolveConfig: deps.resolveConfig,
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
},
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
// so ACP is authenticated for apiKeyStored profiles like every other
// surface — no ACP-specific credential handling needed.
+1 -9
View File
@@ -59,11 +59,7 @@ type appDeps struct {
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
// by fillAppDeps, so tests never mutate the process environment unless they
// inject it). nil ⇒ no export.
exportActiveProvider func(providerName string)
// getenv reads a process environment variable (production: os.Getenv, set in
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
getenv func(string) string
exportActiveProvider func(providerName string)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
@@ -123,7 +119,6 @@ func defaultAppDeps() appDeps {
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
exportActiveProvider: config.SetActiveProviderEnv,
getenv: os.Getenv,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
@@ -374,8 +369,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
case "exec":
// Forward leading --add-dir occurrences so exec's own parser collects them.
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
case "completions":
return runCompletions(args[1:], stdout, stderr)
case "daemon":
return runDaemon(args[1:], stdout, stderr, deps)
case "config":
@@ -1174,7 +1167,6 @@ Usage:
Commands:
exec Run a one-shot prompt through the Go agent runtime
completions Generate shell completion scripts
daemon Manage the local background worker daemon (start/stop/status/run/attach)
setup Guide first-run provider setup
config Inspect resolved Go configuration without leaking secrets
-404
View File
@@ -1,404 +0,0 @@
package cli
import (
"fmt"
"io"
"sort"
"strings"
)
type completionNode struct {
names []string
flags []string
children []completionNode
}
type completionContext struct {
path string
candidates []string
transitions map[string]string
}
var completionRoot = completionNode{
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--theme", "--skip-permissions-unsafe"},
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--max-turns", "--exec-profile",
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
"--notify", "--no-notify", "--no-completion-gate",
}},
{
names: []string{"completions"},
flags: []string{"-h", "--help"},
children: leafNodes("bash", "zsh", "fish", "powershell", "elvish"),
},
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
{names: []string{"setup"}},
{names: []string{"config"}},
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
{names: []string{"providers"}, children: []completionNode{
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
{names: []string{"add"}}, {names: []string{"check"}}, {names: []string{"use"}},
{names: []string{"remove", "rm"}}, {names: []string{"rename"}}, {names: []string{"setup"}},
{names: []string{"detect"}}, {names: []string{"models"}},
}},
{names: []string{"doctor"}},
{names: []string{"context"}},
{names: []string{"repo-map", "repomap"}},
{names: []string{"search", "find"}},
{names: []string{"sessions", "session"}, children: leafNodes("list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan")},
{names: []string{"spec"}, children: leafNodes("show", "approve", "reject")},
{names: []string{"init"}},
{names: []string{"specialists", "specialist"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"show"}}, {names: []string{"create"}},
{names: []string{"delete", "rm"}}, {names: []string{"edit"}}, {names: []string{"path"}},
}},
{names: []string{"plugins", "plugin"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
}},
{names: []string{"backends", "backend"}, children: leafNodes("doctor")},
{names: []string{"skills", "skill"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
}},
{names: []string{"tools", "tool"}, children: leafNodes("make", "list")},
{names: []string{"hooks"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
{names: []string{"enable"}}, {names: []string{"disable"}},
}},
{names: []string{"mcp"}, children: []completionNode{
{names: []string{"add"}}, {names: []string{"remove", "rm"}}, {names: []string{"enable"}},
{names: []string{"disable"}}, {names: []string{"check"}}, {names: []string{"list"}},
{names: []string{"permissions"}, children: leafNodes("list", "revoke", "clear")},
{names: []string{"tools"}, children: leafNodes("list")},
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
}},
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh")},
{names: []string{"sandbox"}, children: []completionNode{
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
}},
{names: []string{"update"}},
{names: []string{"upgrade"}},
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare")},
{names: []string{"verify"}},
{names: []string{"trust"}, children: leafNodes("list", "remove")},
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
{names: []string{"changes", "change"}, children: []completionNode{
{names: []string{"inspect", "status"}}, {names: []string{"commit"}}, {names: []string{"push"}},
{names: []string{"pr", "pull-request"}},
}},
{names: []string{"usage"}, children: leafNodes("report")},
{names: []string{"cron"}, children: leafNodes("add", "list", "rm", "pause", "resume", "run")},
{names: []string{"repo-info", "repoinfo"}},
{names: []string{"serve"}},
{names: []string{"acp"}},
{names: []string{"help"}},
{names: []string{"version"}},
},
}
func leafNodes(names ...string) []completionNode {
nodes := make([]completionNode, 0, len(names))
for _, name := range names {
nodes = append(nodes, completionNode{names: []string{name}})
}
return nodes
}
func runCompletions(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
if err := writeCompletionsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if len(args) == 0 {
return writeExecUsageError(stderr, "shell required. Use `zero completions <bash|zsh|fish|powershell|elvish>`.")
}
if len(args) != 1 {
return writeExecUsageError(stderr, fmt.Sprintf("unexpected completions argument %q", args[1]))
}
contexts := completionContexts(completionRoot)
var err error
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "bash":
err = writeBashCompletions(stdout, contexts)
case "zsh":
err = writeZshCompletions(stdout, contexts)
case "fish":
err = writeFishCompletions(stdout, contexts)
case "powershell":
err = writePowerShellCompletions(stdout, contexts)
case "elvish":
err = writeElvishCompletions(stdout, contexts)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unsupported shell %q; expected bash, zsh, fish, powershell, or elvish", args[0]))
}
if err != nil {
return exitCrash
}
return exitSuccess
}
func writeCompletionsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Generate shell completion scripts.
Usage:
zero completions <shell>
Arguments:
<shell> Target shell: bash, zsh, fish, powershell, or elvish
Examples:
source <(zero completions bash)
source <(zero completions zsh)
zero completions fish > ~/.config/fish/completions/zero.fish
zero completions powershell >> $PROFILE
eval (zero completions elvish | slurp)
Flags:
-h, --help Show this help
`)
return err
}
func completionContexts(root completionNode) []completionContext {
contexts := []completionContext{}
var visit func(completionNode, []string)
visit = func(node completionNode, paths []string) {
candidates := append([]string{}, node.flags...)
transitions := map[string]string{}
for _, child := range node.children {
candidates = append(candidates, child.names...)
for _, path := range paths {
for _, name := range child.names {
childPath := strings.TrimSpace(path + " " + name)
transitions[path+"|"+name] = childPath
}
}
}
candidates = uniqueStrings(candidates)
for _, path := range paths {
contexts = append(contexts, completionContext{path: path, candidates: candidates, transitions: transitions})
}
for _, child := range node.children {
childPaths := make([]string, 0, len(paths)*len(child.names))
for _, path := range paths {
for _, name := range child.names {
childPaths = append(childPaths, strings.TrimSpace(path+" "+name))
}
}
visit(child, childPaths)
}
}
visit(root, []string{""})
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].path < contexts[j].path })
return contexts
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func allTransitions(contexts []completionContext) map[string]string {
transitions := map[string]string{}
for _, context := range contexts {
for key, value := range context.transitions {
transitions[key] = value
}
}
return transitions
}
func sortedTransitionKeys(transitions map[string]string) []string {
keys := make([]string, 0, len(transitions))
for key := range transitions {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func shellWords(values []string) string {
return strings.Join(values, " ")
}
func writeBashCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# bash completion for zero\n_zero() {\n local cur context token i candidates\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n context=\"\""); err != nil {
return err
}
if err := writePOSIXContextLoop(w, contexts, " for ((i = 1; i < COMP_CWORD; i++)); do", " token=\"${COMP_WORDS[i]}\"", " done"); err != nil {
return err
}
if err := writePOSIXCandidateCase(w, contexts, " "); err != nil {
return err
}
_, err := fmt.Fprintln(w, " COMPREPLY=( $(compgen -W \"$candidates\" -- \"$cur\") )\n}\ncomplete -F _zero zero")
return err
}
func writeZshCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "#compdef zero\n_zero() {\n local context token i\n local -a candidates\n context=\"\""); err != nil {
return err
}
if err := writePOSIXContextLoop(w, contexts, " for ((i = 2; i < CURRENT; i++)); do", " token=\"${words[i]}\"", " done"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " case \"$context\" in"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, " %q) candidates=(%s) ;;\n", context.path, quotedWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " esac\n compadd -- \"${candidates[@]}\"\n}\ncompdef _zero zero")
return err
}
func writePOSIXContextLoop(w io.Writer, contexts []completionContext, start string, token string, end string) error {
if _, err := fmt.Fprintln(w, start); err != nil {
return err
}
if _, err := fmt.Fprintln(w, token); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " case \"$context|$token\" in"); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " %q) context=%q ;;\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " esac"); err != nil {
return err
}
_, err := fmt.Fprintln(w, end)
return err
}
func writePOSIXCandidateCase(w io.Writer, contexts []completionContext, indent string) error {
if _, err := fmt.Fprintln(w, indent+"case \"$context\" in"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, "%s %q) candidates=%q ;;\n", indent, context.path, shellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, indent+"esac")
return err
}
func quotedWords(values []string) string {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, fmt.Sprintf("%q", value))
}
return strings.Join(quoted, " ")
}
func writeFishCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# fish completion for zero\nfunction __zero_completion_context\n set -l context ''\n set -l tokens (commandline -opc)"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " for token in $tokens[2..-1]\n switch \"$context|$token\""); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " case %q\n set context %q\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " end\n end\n if test -z \"$context\"\n echo __root__\n else\n echo \"$context\"\n end\nend"); err != nil {
return err
}
for _, context := range contexts {
path := context.path
if path == "" {
path = "__root__"
}
if _, err := fmt.Fprintf(w, "complete -c zero -f -n 'test (__zero_completion_context) = %q' -a %q\n", path, shellWords(context.candidates)); err != nil {
return err
}
}
return nil
}
func writePowerShellCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# PowerShell completion for zero\nRegister-ArgumentCompleter -Native -CommandName zero -ScriptBlock {\n param($wordToComplete, $commandAst, $cursorPosition)\n $context = ''\n $elements = @($commandAst.CommandElements)\n $limit = $elements.Count\n if ($wordToComplete -ne '' -and $limit -gt 1) { $limit-- }\n for ($i = 1; $i -lt $limit; $i++) {\n $token = $elements[$i].Extent.Text\n switch (\"$context|$token\") {"); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " '%s' { $context = '%s'; break }\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " }\n }\n $candidates = switch ($context) {"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, " '%s' { @(%s) }\n", context.path, powershellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " }\n $candidates | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {\n [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)\n }\n}")
return err
}
func powershellWords(values []string) string {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, "'"+strings.ReplaceAll(value, "'", "''")+"'")
}
return strings.Join(quoted, ", ")
}
func writeElvishCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# Elvish completion for zero\nset edit:completion:arg-completer[zero] = {|@args|\n var context = ''\n for token $args[1..-1] {"); err != nil {
return err
}
transitions := allTransitions(contexts)
for index, key := range sortedTransitionKeys(transitions) {
keyword := "if"
if index > 0 {
keyword = "} elif"
}
if _, err := fmt.Fprintf(w, " %s (eq $context'|'$token %q) {\n set context = %q\n", keyword, key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " }\n }"); err != nil {
return err
}
for index, context := range contexts {
keyword := "if"
if index > 0 {
keyword = "} elif"
}
if _, err := fmt.Fprintf(w, " %s (eq $context %q) {\n put %s\n", keyword, context.path, shellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " }\n}")
return err
}
-177
View File
@@ -1,177 +0,0 @@
package cli
import (
"bytes"
"os/exec"
"strings"
"testing"
)
func TestCompletionsHelpAndRootHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runWithDeps([]string{"completions", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("completion help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
}
for _, want := range []string{
"zero completions <shell>",
"bash, zsh, fish, powershell, or elvish",
"source <(zero completions bash)",
"~/.config/fish/completions/zero.fish",
"eval (zero completions elvish | slurp)",
} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("completion help missing %q:\n%s", want, stdout.String())
}
}
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("root help exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "completions Generate shell completion scripts") {
t.Fatalf("root help does not list completions command:\n%s", stdout.String())
}
}
func TestCompletionsRejectsMissingUnknownAndExtraShellArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "missing", args: []string{"completions"}, want: "shell required"},
{name: "unknown", args: []string{"completions", "nu"}, want: "unsupported shell"},
{name: "extra", args: []string{"completions", "bash", "extra"}, want: "unexpected completions argument"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runWithDeps(test.args, &stdout, &stderr, appDeps{}); code != exitUsage {
t.Fatalf("exit code = %d, want %d: %s", code, exitUsage, stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr = %q, want substring %q", stderr.String(), test.want)
}
})
}
}
func TestCompletionsGeneratesEverySupportedShell(t *testing.T) {
tests := []struct {
shell string
marker string
syntaxShell string
}{
{shell: "bash", marker: "complete -F _zero zero", syntaxShell: "bash"},
{shell: "zsh", marker: "#compdef zero", syntaxShell: "zsh"},
{shell: "fish", marker: "complete -c zero"},
{shell: "powershell", marker: "Register-ArgumentCompleter -Native -CommandName zero"},
{shell: "elvish", marker: "edit:completion:arg-completer[zero]"},
}
for _, test := range tests {
t.Run(test.shell, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runWithDeps([]string{"completions", test.shell}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", code, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), test.marker) {
t.Fatalf("%s completion missing marker %q:\n%s", test.shell, test.marker, stdout.String())
}
for _, want := range []string{"daemon", "mcp oauth", "sandbox grants", "--output-format"} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("%s completion missing %q", test.shell, want)
}
}
script := stdout.String()
switch test.shell {
case "fish":
assertBalancedFishBlocks(t, script)
case "powershell", "elvish":
assertBalancedBraces(t, script)
}
if test.syntaxShell != "" {
assertNativeShellSyntax(t, test.syntaxShell, script)
}
})
}
}
func assertBalancedBraces(t *testing.T, script string) {
t.Helper()
if opens, closes := strings.Count(script, "{"), strings.Count(script, "}"); opens != closes {
t.Errorf("unbalanced braces: %d opening, %d closing", opens, closes)
}
}
func assertBalancedFishBlocks(t *testing.T, script string) {
t.Helper()
depth := 0
for _, line := range strings.Split(script, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
switch fields[0] {
case "function", "for", "switch", "if":
depth++
case "end":
depth--
if depth < 0 {
t.Fatal("fish completion closes a block before one is opened")
}
}
}
if depth != 0 {
t.Errorf("fish completion has %d unclosed blocks", depth)
}
}
func assertNativeShellSyntax(t *testing.T, shell, script string) {
t.Helper()
path, err := exec.LookPath(shell)
if err != nil {
t.Skipf("%s is not installed", shell)
}
command := exec.Command(path, "-n")
command.Stdin = strings.NewReader(script)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("%s syntax check failed: %v\n%s", shell, err, output)
}
}
func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
contexts := completionContexts(completionRoot)
byPath := make(map[string][]string, len(contexts))
for _, context := range contexts {
byPath[context.path] = context.candidates
}
assertCandidates(t, byPath[""], "sessions", "session", "plugins", "plugin", "worktrees", "worktree", "--add-dir", "--theme", "-p", "--prompt")
assertCandidates(t, byPath["exec"], "--model", "--cwd", "--worktree", "--output-format", "--resume", "--skip-permissions-unsafe")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
}
func assertCandidates(t *testing.T, got []string, wants ...string) {
t.Helper()
set := make(map[string]bool, len(got))
for _, candidate := range got {
set[candidate] = true
}
for _, want := range wants {
if !set[want] {
t.Errorf("candidates %v do not contain %q", got, want)
}
}
}
-122
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"sort"
"strings"
@@ -253,115 +252,6 @@ func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writ
return exitSuccess
}
func runPluginInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
id, options, help, err := parsePluginInfoArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writePluginInfoHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if id == "" {
return writeExecUsageError(stderr, "usage: zero plugins info <id> [--user] [--json]")
}
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
info, err := plugins.Info(plugins.InfoOptions{
LoadOptions: pluginLoadOptions(deps, cwd, options.user),
}, id)
if err != nil {
if errors.Is(err, plugins.ErrNotInstalled) {
return writeExecUsageError(stderr, err.Error())
}
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(info, redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
plugin := info.Plugin
lines := []string{fmt.Sprintf("%s (%s) %s", plugin.ID, plugin.Name, plugin.Version)}
state := "enabled"
if !plugin.Enabled {
state = "disabled"
}
lines = append(lines, " state: "+state)
lines = append(lines, " source: "+string(plugin.Source))
if len(plugin.Tools) > 0 {
lines = append(lines, fmt.Sprintf(" tools: %d", len(plugin.Tools)))
}
if len(plugin.Hooks) > 0 {
lines = append(lines, fmt.Sprintf(" hooks: %d", len(plugin.Hooks)))
}
if len(plugin.Skills) > 0 {
lines = append(lines, fmt.Sprintf(" skills: %d", len(plugin.Skills)))
}
if len(plugin.Prompts) > 0 {
lines = append(lines, fmt.Sprintf(" prompts: %d", len(plugin.Prompts)))
}
if info.LockSource != "" {
lines = append(lines, " lock source: "+info.LockSource)
}
if info.LockHash != "" {
lines = append(lines, " lock hash: "+info.LockHash)
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
}
lines = append(lines, " manifest: "+plugin.ManifestPath)
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
return exitCrash
}
return exitSuccess
}
type pluginInfoOptions struct {
json bool
user bool
}
func parsePluginInfoArgs(args []string) (string, pluginInfoOptions, bool, error) {
options := pluginInfoOptions{}
id := ""
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return "", options, true, nil
case "--json":
options.json = true
case "--user":
options.user = true
default:
if strings.HasPrefix(arg, "-") {
return "", options, false, execUsageError{fmt.Sprintf("unknown plugins info flag %q", arg)}
}
if id != "" {
return "", options, false, execUsageError{"plugins info takes a single id"}
}
id = arg
}
}
return id, options, false, nil
}
func pluginLoadOptions(deps appDeps, cwd string, userOnly bool) plugins.LoadOptions {
opts := plugins.LoadOptions{Cwd: cwd, ExcludeProject: userOnly}
roots := []plugins.Root{{Source: plugins.SourceUser, Path: deps.pluginsDir()}}
if !userOnly {
roots = append(roots, plugins.Root{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")})
}
opts.Roots = roots
return opts
}
// --- tools make / list ---
func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
@@ -677,18 +567,6 @@ Removes an installed plugin directory and its plugins.lock entry.
return err
}
func writePluginInfoHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero plugins info <id> [flags]
Flags:
--user Target only user plugins (ignore ./.zero/plugins)
--json Print plugin info as JSON
-h, --help Show this help
`)
return err
}
func writeToolsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero tools <command>
-9
View File
@@ -177,15 +177,6 @@ func TestRunPluginAddListRemove(t *testing.T) {
t.Fatalf("add output missing id:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
if exit := runWithDeps([]string{"plugin", "info", "zero.demo"}, &stdout, &stderr, deps); exit != 0 {
t.Fatalf("plugin info exit = %d, stderr = %s", exit, stderr.String())
}
if !strings.Contains(stdout.String(), "zero.demo") || !strings.Contains(stdout.String(), "manifest:") {
t.Fatalf("info output missing plugin details:\n%s", stdout.String())
}
// remove
stdout.Reset()
stderr.Reset()
-6
View File
@@ -939,12 +939,6 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
// the default policy.
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
// request is allowed and commands run unwrapped. Only reachable from global
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
if cfg.Enabled != nil && !*cfg.Enabled {
policy.Mode = sandbox.ModeDisabled
}
if network := strings.TrimSpace(cfg.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
-3
View File
@@ -78,8 +78,6 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return exitSuccess
case "add":
return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr)
case "info":
return runPluginInfo(args[1:], stdout, stderr, deps)
case "remove", "rm":
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
default:
@@ -563,7 +561,6 @@ func writePluginsHelp(w io.Writer) error {
Commands:
list List local Zero plugins
info <id> Show plugin details and lockfile metadata
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
remove <id> Remove an installed plugin and its lockfile entry
`)
+1 -7
View File
@@ -10,7 +10,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
)
type providerModelsOptions struct {
@@ -138,12 +137,7 @@ func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderP
// the provider's model-listing endpoint with no curated-catalog merge or
// coding-model filtering, so a custom provider's full model list is returned.
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
resolver, loginKey := oauthLoginForProfile(profile)
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{
OAuthResolver: resolver,
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
UserAgent: userAgent(),
})
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
}
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
+2 -32
View File
@@ -74,18 +74,11 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
return writeAppError(stderr, err.Error(), exitCrash)
}
override := activeProviderEnvOverride(deps.getenv, cfg.ActiveProvider)
if options.json {
payload := map[string]any{
if err := writePrettyJSON(stdout, map[string]any{
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if override != "" {
// A JSON consumer must not read this as an effective switch either.
payload["effectiveProvider"] = override
payload["overriddenByEnv"] = config.ActiveProviderEnv
}
if err := writePrettyJSON(stdout, payload); err != nil {
}); err != nil {
return exitCrash
}
return exitSuccess
@@ -93,32 +86,9 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
if _, err := fmt.Fprintf(stdout, "Active provider set to %s\nnext: %s\n", cfg.ActiveProvider, providerCheckCommand(cfg.ActiveProvider, false)); err != nil {
return exitCrash
}
if override != "" {
if _, err := fmt.Fprintf(stderr, "Note: %s=%s is set and overrides config.json, so %s stays the active provider until you unset %s.\n", config.ActiveProviderEnv, override, override, config.ActiveProviderEnv); err != nil {
return exitCrash
}
}
return exitSuccess
}
// activeProviderEnvOverride returns the ZERO_PROVIDER value when it is set and
// names a DIFFERENT provider than the one just selected, meaning the saved
// `providers use` selection will NOT be the effective active provider until the
// env var is unset. applyEnv (resolver.go) makes ZERO_PROVIDER win over
// config.json unconditionally, so reporting the write as a plain success reads as
// a switch that silently has no effect (issue #721). Empty when nothing overrides
// (including when getenv is nil, e.g. a test that did not inject the environment).
func activeProviderEnvOverride(getenv func(string) string, selected string) string {
if getenv == nil {
return ""
}
override := strings.TrimSpace(getenv(config.ActiveProviderEnv))
if override == "" || strings.EqualFold(override, strings.TrimSpace(selected)) {
return ""
}
return override
}
func runProvidersSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderSetupArgs(args)
if err != nil {
-96
View File
@@ -75,102 +75,6 @@ func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) {
}
}
func providersUseOverrideConfig(t *testing.T) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{
ActiveProvider: "work",
Providers: []config.ProviderProfile{
{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, Model: "gpt-4.1"},
{Name: "fast", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", Model: "llama-3.3-70b-versatile"},
},
})
return configPath
}
// The write to config.json still succeeds, but when ZERO_PROVIDER names a
// different provider the saved selection is NOT effective, so the command must
// warn instead of reporting a silent success (issue #721).
func TestRunProvidersUseWarnsWhenEnvOverrides(t *testing.T) {
var stdout, stderr bytes.Buffer
configPath := providersUseOverrideConfig(t)
deps := providerSetupDeps(configPath)
deps.getenv = func(key string) string {
if key == config.ActiveProviderEnv {
return "work" // ZERO_PROVIDER=work overrides the switch to fast
}
return ""
}
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
}
if cfg := readFileConfig(t, configPath); cfg.ActiveProvider != "fast" {
t.Fatalf("ActiveProvider = %q, want fast (the write still lands)", cfg.ActiveProvider)
}
if !strings.Contains(stdout.String(), "Active provider set to fast") {
t.Fatalf("stdout missing the success line: %q", stdout.String())
}
note := stderr.String()
for _, want := range []string{config.ActiveProviderEnv, "overrides config.json", "work"} {
if !strings.Contains(note, want) {
t.Fatalf("override note missing %q, got %q", want, note)
}
}
}
func TestRunProvidersUseJSONFlagsEnvOverride(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := providerSetupDeps(providersUseOverrideConfig(t))
deps.getenv = func(key string) string {
if key == config.ActiveProviderEnv {
return "work"
}
return ""
}
if code := runWithDeps([]string{"providers", "use", "fast", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String())
}
var payload struct {
ActiveProvider string `json:"activeProvider"`
EffectiveProvider string `json:"effectiveProvider"`
OverriddenByEnv string `json:"overriddenByEnv"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("JSON did not decode: %v\n%s", err, stdout.String())
}
if payload.ActiveProvider != "fast" || payload.EffectiveProvider != "work" || payload.OverriddenByEnv != config.ActiveProviderEnv {
t.Fatalf("JSON must flag the env override, got %#v", payload)
}
}
// No override note when ZERO_PROVIDER is unset or already names the selection.
func TestRunProvidersUseNoWarnWithoutEnvOverride(t *testing.T) {
cases := map[string]func(string) string{
"env unset": func(string) string { return "" },
"env matches fast": func(key string) string {
if key == config.ActiveProviderEnv {
return "fast"
}
return ""
},
}
for name, getenv := range cases {
t.Run(name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := providerSetupDeps(providersUseOverrideConfig(t))
deps.getenv = getenv
if code := runWithDeps([]string{"providers", "use", "fast"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("exit = %d: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected no override note, got %q", stderr.String())
}
})
}
}
func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
-15
View File
@@ -1001,18 +1001,3 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
}
return ""
}
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
disabled := false
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
}
// Omitted (nil) and explicit true keep the default enforcing mode.
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
t.Fatal("omitted enabled must not disable the sandbox")
}
enabled := true
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
t.Fatal("enabled:true must not disable the sandbox")
}
}
+3 -19
View File
@@ -42,20 +42,16 @@ var errProviderCommandTimeout = errors.New("provider command timeout")
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
cmd := shellCommand(command)
// Bound the time Wait spends draining I/O pipes after the process exits,
// in case an orphaned descendant still holds the write ends open.
cmd.WaitDelay = time.Second
configureCommandProcess(cmd)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
proc, err := startCommandProcess(cmd)
if err != nil {
if err := cmd.Start(); err != nil {
return nil, nil, err
}
defer proc.Close()
done := make(chan error, 1)
go func() {
@@ -67,21 +63,9 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
select {
case err := <-done:
if err != nil {
// The shell exited (whether via ErrWaitDelay because a
// background descendant kept the inherited stdout/stderr pipes
// open, or via a nonzero exit status) but a leftover descendant
// may still be running. Terminate is a no-op against an
// already-dead tree, so always call it rather than gating on
// the specific error to avoid leaking that descendant.
proc.Terminate()
}
if errors.Is(err, exec.ErrWaitDelay) {
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
}
return stdout.Bytes(), stderr.Bytes(), err
case <-timer.C:
proc.Terminate()
terminateCommandProcess(cmd)
<-done
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
}
+2 -142
View File
@@ -1,11 +1,9 @@
package config
import (
"errors"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
@@ -69,8 +67,7 @@ func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
}
func TestLoadProviderCommandTimeout(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "sleep.pid")
command := writeCommand(t, commandScript{SleepSeconds: 10, PidFile: pidFile})
command := writeCommand(t, commandScript{SleepSeconds: 10})
start := time.Now()
_, err := LoadProviderCommand(command)
@@ -88,102 +85,6 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
if elapsed > maxElapsed {
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
}
assertProcessTerminatedIfStarted(t, pidFile)
}
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
// exits immediately but leaves a detached child holding the inherited
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
// unblocks once WaitDelay elapses, and that path must still terminate the
// leftover child instead of just returning and leaking it.
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "bg.pid")
childLifetime := 10 * time.Second
command := writeCommand(t, commandScript{
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
BackgroundSleepSeconds: int(childLifetime / time.Second),
BackgroundPidFile: pidFile,
})
start := time.Now()
_, err := LoadProviderCommand(command)
elapsed := time.Since(start)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait")
}
if !strings.Contains(err.Error(), "timed out after 5s") {
t.Fatalf("error = %q, want timeout", err.Error())
}
if elapsed >= childLifetime {
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
}
assertProcessTerminated(t, pidFile)
}
// TestLoadProviderCommandTerminatesBackgroundChildOnFailure covers the same
// leaked-descendant scenario as TestLoadProviderCommandTerminatesBackgroundChild,
// but with a shell that exits nonzero. Go's exec.Cmd.Wait only returns
// exec.ErrWaitDelay when the command itself exited successfully; a nonzero
// exit yields the bare *ExitError instead, so the leftover child must still
// be terminated on that path.
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
childLifetime := 10 * time.Second
command := writeCommand(t, commandScript{
Stderr: "boom",
ExitCode: 7,
BackgroundSleepSeconds: int(childLifetime / time.Second),
BackgroundPidFile: pidFile,
})
start := time.Now()
_, err := LoadProviderCommand(command)
elapsed := time.Since(start)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit")
}
if !strings.Contains(err.Error(), "exit status") {
t.Fatalf("error = %q, want exit status failure", err.Error())
}
if elapsed >= childLifetime {
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
}
assertProcessTerminated(t, pidFile)
}
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
t.Helper()
// The basic timeout fixture is intentionally unsynchronized. On a loaded
// Windows runner, the cold PowerShell child can be terminated before it
// records its PID; the synchronized background-child tests below retain
// the strict PID and liveness checks.
if _, err := os.Stat(pidFile); errors.Is(err, os.ErrNotExist) {
return
}
assertProcessTerminated(t, pidFile)
}
func assertProcessTerminated(t *testing.T, pidFile string) {
t.Helper()
data, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("read sleeper pid file: %v", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
t.Fatalf("parse sleeper pid %q: %v", data, err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if !processAlive(pid) {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("sleeper process %d still alive after timeout", pid)
}
func TestLoadProviderCommandInvalidJSON(t *testing.T) {
@@ -217,14 +118,6 @@ type commandScript struct {
Stderr string
ExitCode int
SleepSeconds int
PidFile string
// BackgroundSleepSeconds, if set, spawns a detached child that keeps the
// inherited stdout/stderr handles open well after the script itself
// exits, simulating a `sleep 600 & exit` style command. BackgroundPidFile
// records the detached child's PID.
BackgroundSleepSeconds int
BackgroundPidFile string
}
func writeCommand(t *testing.T, script commandScript) string {
@@ -235,11 +128,7 @@ func writeCommand(t *testing.T, script commandScript) string {
path := filepath.Join(dir, "provider.cmd")
lines := []string{"@echo off"}
if script.SleepSeconds > 0 {
sleep := "Start-Sleep -Seconds " + itoa(script.SleepSeconds)
if script.PidFile != "" {
sleep = "Set-Content -Path '" + psSingleQuote(script.PidFile) + "' -Value $PID -Encoding Ascii; " + sleep
}
lines = append(lines, "powershell -NoProfile -Command \""+sleep+"\"")
lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"")
}
if script.Stdout != "" {
lines = append(lines, "echo "+script.Stdout)
@@ -247,15 +136,6 @@ func writeCommand(t *testing.T, script commandScript) string {
if script.Stderr != "" {
lines = append(lines, "echo "+script.Stderr+" 1>&2")
}
if script.BackgroundSleepSeconds > 0 {
readyFile := script.BackgroundPidFile + ".ready"
bgSleep := "Set-Content -LiteralPath '" + psSingleQuote(script.BackgroundPidFile) + "' -Value $PID -Encoding Ascii; " +
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }"
lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"")
}
lines = append(lines, "exit /b "+itoa(script.ExitCode))
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
t.Fatalf("write command: %v", err)
@@ -266,9 +146,6 @@ func writeCommand(t *testing.T, script commandScript) string {
path := filepath.Join(dir, "provider.sh")
lines := []string{"#!/bin/sh"}
if script.SleepSeconds > 0 {
if script.PidFile != "" {
lines = append(lines, "echo $$ > '"+shSingleQuote(script.PidFile)+"'")
}
lines = append(lines, "sleep "+itoa(script.SleepSeconds))
}
if script.Stdout != "" {
@@ -277,10 +154,6 @@ func writeCommand(t *testing.T, script commandScript) string {
if script.Stderr != "" {
lines = append(lines, "printf '%s\\n' '"+script.Stderr+"' >&2")
}
if script.BackgroundSleepSeconds > 0 {
lines = append(lines, "sleep "+itoa(script.BackgroundSleepSeconds)+" &")
lines = append(lines, "echo $! > '"+shSingleQuote(script.BackgroundPidFile)+"'")
}
lines = append(lines, "exit "+itoa(script.ExitCode))
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o700); err != nil {
t.Fatalf("write command: %v", err)
@@ -288,19 +161,6 @@ func writeCommand(t *testing.T, script commandScript) string {
return path
}
// psSingleQuote escapes a value for interpolation inside a PowerShell
// single-quoted string literal, where a literal quote is doubled.
func psSingleQuote(value string) string {
return strings.ReplaceAll(value, "'", "''")
}
// shSingleQuote escapes a value for interpolation inside a POSIX shell
// single-quoted string literal, where a literal quote must close the
// quoted section, emit an escaped quote, then reopen it.
func shSingleQuote(value string) string {
return strings.ReplaceAll(value, "'", `'\''`)
}
func itoa(value int) string {
if value == 0 {
return "0"
-72
View File
@@ -1,72 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
// threshold (including to 0, which disables deferral). Use this for programmatic
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
// "unset" and will not override.
func ToolsOverride(deferThreshold int) ToolsConfig {
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
}
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
// semantic provider/model rules used during resolution. It returns the parsed
// config (zero value on parse failure) plus any structured issues. A parse
// failure yields a single issue whose Message is the underlying JSON error's
// text (already flattened via Error(), not chained — callers cannot recover
// the original *json.SyntaxError / *json.UnmarshalTypeError via errors.As).
func ValidateFile(path string) (FileConfig, []Issue) {
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, []Issue{{Message: fmt.Sprintf("invalid config JSON %s: %v", path, err)}}
}
issues := validateSemantics(cfg)
issues = append(issues, unknownFieldIssues(data)...)
return cfg, issues
}
// SetProviderDescription sets a provider's description VERBATIM — including to
// empty. The generic UpsertProvider merge treats empty fields as "leave
// unchanged", so clearing a description needs this dedicated setter.
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
cfg.Providers[index].Description = strings.TrimSpace(description)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
+7 -48
View File
@@ -3,59 +3,18 @@
package config
import (
"io"
"os/exec"
"syscall"
)
type commandProcess struct {
groupID int
anchor *exec.Cmd
anchorInput io.WriteCloser
func configureCommandProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// startCommandProcess retains a live process-group leader until cleanup. The
// provider shell may be reaped before Wait finishes draining descendant-held
// pipes, so its PID cannot safely identify the group after Wait returns.
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
anchor := exec.Command("sh", "-c", "read _")
anchor.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
anchorInput, err := anchor.StdinPipe()
if err != nil {
return nil, err
func terminateCommandProcess(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
if err := anchor.Start(); err != nil {
_ = anchorInput.Close()
return nil, err
}
proc := &commandProcess{groupID: anchor.Process.Pid, anchor: anchor, anchorInput: anchorInput}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
cmd.SysProcAttr.Pgid = proc.groupID
if err := cmd.Start(); err != nil {
proc.Close()
return nil, err
}
return proc, nil
}
func (p *commandProcess) Terminate() {
if p.groupID != 0 {
_ = syscall.Kill(-p.groupID, syscall.SIGKILL)
}
}
func (p *commandProcess) Close() {
if p.anchorInput != nil {
_ = p.anchorInput.Close()
p.anchorInput = nil
}
if p.anchor != nil {
_ = p.anchor.Wait()
p.anchor = nil
}
p.groupID = 0
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_ = cmd.Process.Kill()
}
-66
View File
@@ -1,66 +0,0 @@
//go:build !windows
package config
import (
"os/exec"
"syscall"
"testing"
)
func TestCommandProcessPreservesSysProcAttr(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
attr := &syscall.SysProcAttr{}
cmd.SysProcAttr = attr
proc, err := startCommandProcess(cmd)
if err != nil {
t.Fatalf("startCommandProcess: %v", err)
}
defer proc.Close()
if cmd.SysProcAttr != attr {
t.Fatal("startCommandProcess replaced SysProcAttr")
}
if !cmd.SysProcAttr.Setpgid || cmd.SysProcAttr.Pgid != proc.groupID {
t.Fatalf("process group = Setpgid %v, Pgid %d; want true, %d", cmd.SysProcAttr.Setpgid, cmd.SysProcAttr.Pgid, proc.groupID)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("Wait: %v", err)
}
}
func TestCommandProcessRetainsGroupIdentityAfterProviderWait(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 7")
proc, err := startCommandProcess(cmd)
if err != nil {
t.Fatalf("startCommandProcess: %v", err)
}
defer func() {
proc.Terminate()
proc.Close()
}()
providerPID := cmd.Process.Pid
anchorPID := proc.groupID
if err := cmd.Wait(); err == nil {
t.Fatal("Wait error = nil, want nonzero exit")
}
if anchorPID == providerPID {
t.Fatalf("group identity reused provider PID %d", providerPID)
}
if !processAlive(anchorPID) {
t.Fatalf("group leader %d exited before cleanup", anchorPID)
}
proc.Terminate()
proc.Close()
if processAlive(anchorPID) {
t.Fatalf("group leader %d still alive after cleanup", anchorPID)
}
}
func processAlive(pid int) bool {
err := syscall.Kill(pid, 0)
return err == nil || err == syscall.EPERM
}
+5 -147
View File
@@ -3,165 +3,23 @@
package config
import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// configureCommandProcess starts the process suspended so it can be
// assigned to the job object before its main thread (and therefore any
// code it runs) executes. Without this, a fast command can spawn and
// detach a descendant before AssignProcessToJobObject runs, letting that
// descendant escape the job and survive termination.
func configureCommandProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
}
func configureCommandProcess(cmd *exec.Cmd) {}
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
configureCommandProcess(cmd)
if err := cmd.Start(); err != nil {
return nil, err
}
return attachCommandProcess(cmd), nil
}
// commandProcess tracks a started provider command so its entire process
// tree can be terminated on timeout. It prefers a job object: taskkill /T
// walks the tree by parent PID in user space and can miss descendants,
// letting them run to completion while Wait blocks on inherited pipes.
type commandProcess struct {
cmd *exec.Cmd
job windows.Handle
processHandle windows.Handle
}
func attachCommandProcess(cmd *exec.Cmd) *commandProcess {
proc := &commandProcess{cmd: cmd}
func terminateCommandProcess(cmd *exec.Cmd) {
if cmd.Process == nil {
return proc
}
// The main thread is suspended (see configureCommandProcess); resume it
// once we're done attaching, however that turns out. If resuming fails
// outright (Toolhelp/OpenThread/ResumeThread errors are otherwise
// swallowed), the process would sit suspended forever and never exit on
// its own, making the failure look like an ordinary 5s provider-command
// timeout. Terminate it immediately instead so the real cause surfaces
// quickly and no suspended process is left behind.
defer func() {
if !resumeMainThread(cmd.Process.Pid) {
proc.Terminate()
}
}()
handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(cmd.Process.Pid))
if err != nil {
return proc
}
proc.processHandle = handle
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return proc
}
if err := windows.AssignProcessToJobObject(job, handle); err != nil {
_ = windows.CloseHandle(job)
return proc
}
proc.job = job
return proc
}
// resumeMainThread resumes every (assumed suspended) thread of pid and
// reports whether at least one was actually resumed. Callers must treat a
// false result as a failed resume, not a benign no-op: a suspended process
// that's never woken up will neither exit nor produce output on its own.
func resumeMainThread(pid int) bool {
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
if err != nil {
return false
}
defer func() { _ = windows.CloseHandle(snapshot) }()
var entry windows.ThreadEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
resumed := false
for err := windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) {
if entry.OwnerProcessID != uint32(pid) {
continue
}
thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
if err != nil {
continue
}
if _, err := windows.ResumeThread(thread); err == nil {
resumed = true
}
_ = windows.CloseHandle(thread)
}
return resumed
}
func (p *commandProcess) Terminate() {
if p.job != 0 {
// A job handle means the process tree was contained from launch, so
// TerminateJobObject alone kills every member atomically. Don't also
// fall through to the taskkill/PID path below: by the time
// Terminate runs on the ErrWaitDelay path, cmd.Wait has already
// reaped the root process, so its PID may have been reused by an
// unrelated process, and forcing /T /PID against a reused PID would
// kill the wrong tree.
_ = windows.TerminateJobObject(p.job, 1)
return
}
if p.cmd.Process == nil {
return
}
if p.processHandle == 0 {
// Without a retained identity, never target a numeric PID that may
// have been reaped and reused. os.Process.Kill uses Go's original
// process state instead.
_ = p.cmd.Process.Kill()
return
}
// Fallback for the rare case where job creation or assignment failed:
// only invoke taskkill while the identity-preserving handle confirms the
// original root is still active. Keeping that handle open through Run
// prevents Windows from reusing its PID for an unrelated process.
var exitCode uint32
if err := windows.GetExitCodeProcess(p.processHandle, &exitCode); err == nil && exitCode == stillActive {
taskkill := taskkillPath()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_ = exec.CommandContext(ctx, taskkill, "/T", "/F", "/PID", strconv.Itoa(p.cmd.Process.Pid)).Run()
cancel()
}
_ = windows.TerminateProcess(p.processHandle, 1)
taskkill := taskkillPath()
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
_ = cmd.Process.Kill()
}
// Close releases the retained handles without touching any still-running
// descendants: the job carries no KILL_ON_JOB_CLOSE limit, so on the success
// path a provider command's detached helpers keep running exactly as they did
// before job objects were introduced. Descendant termination happens
// explicitly via Terminate, called only on timeout/error.
func (p *commandProcess) Close() {
if p.job != 0 {
_ = windows.CloseHandle(p.job)
p.job = 0
}
if p.processHandle != 0 {
_ = windows.CloseHandle(p.processHandle)
p.processHandle = 0
}
}
const stillActive = uint32(259) // STILL_ACTIVE
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
-65
View File
@@ -1,65 +0,0 @@
//go:build windows
package config
import (
"os/exec"
"testing"
"golang.org/x/sys/windows"
)
// TestResumeMainThreadReportsFailureForUnknownPID covers the contract
// attachCommandProcess relies on: when no thread belonging to pid can be
// found (here, because the pid doesn't correspond to a live process),
// resumeMainThread must report false rather than silently doing nothing,
// so a caller can react (e.g. terminate the process) instead of leaving it
// suspended indefinitely.
func TestResumeMainThreadReportsFailureForUnknownPID(t *testing.T) {
if resumeMainThread(0) {
t.Fatal("resumeMainThread(0) = true, want false for a pid with no threads")
}
}
func TestCommandProcessRetainsIdentityAfterWait(t *testing.T) {
cmd := exec.Command("cmd", "/C", "exit /b 0")
configureCommandProcess(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
proc := attachCommandProcess(cmd)
t.Cleanup(proc.Close)
if proc.processHandle == 0 {
t.Fatal("attachCommandProcess did not retain a process handle")
}
if err := cmd.Wait(); err != nil {
t.Fatalf("Wait: %v", err)
}
var code uint32
if err := windows.GetExitCodeProcess(proc.processHandle, &code); err != nil {
t.Fatalf("GetExitCodeProcess after Wait: %v", err)
}
if code == stillActive {
t.Fatalf("exit code = STILL_ACTIVE after Wait")
}
proc.Close()
if proc.job != 0 || proc.processHandle != 0 {
t.Fatalf("Close left handles open: job=%v process=%v", proc.job, proc.processHandle)
}
}
func processAlive(pid int) bool {
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
defer func() { _ = windows.CloseHandle(handle) }()
var code uint32
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
return false
}
return code == stillActive
}
-20
View File
@@ -93,16 +93,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
if err != nil {
return ResolvedConfig{}, err
}
// Sandbox.Enabled is NOT accepted from a provider command, for the same
// reason project config cannot set it (see mergeProjectConfig): only
// global config and the CLI may turn the sandbox off. A provider command
// is an arbitrary executable named in config, and LoadProviderCommand
// parses its stdout into a full FileConfig — so without this, a command
// returning a valid provider plus {"sandbox":{"enabled":false}} would
// disable the very sandbox meant to constrain what it can do. Cleared
// here rather than in mergeConfig because that helper is shared with the
// trusted global-config merge, which must keep honouring the setting.
commandConfig.Sandbox.Enabled = nil
mergeConfig(&cfg, commandConfig)
}
@@ -226,9 +216,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP, true)
if src.Sandbox.Enabled != nil {
dst.Sandbox.Enabled = src.Sandbox.Enabled
}
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
@@ -286,10 +273,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
return err
}
// Sandbox.Enabled is intentionally NOT merged from project config: a cloned
// repo's .zero/config.json must not be able to disable the sandbox that
// constrains it. Only global config and CLI can turn the sandbox off.
//
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
@@ -705,9 +688,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
}
if overrides.Sandbox.Enabled != nil {
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
}
if overrides.Sandbox.BlockUnixSockets {
cfg.Sandbox.BlockUnixSockets = true
}
-79
View File
@@ -1744,85 +1744,6 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
}
}
func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) {
// Security: a cloned repo's project config must NOT be able to disable the
// sandbox that constrains it. Only global config / CLI can turn it off.
userPath := writeConfig(t, `{}`)
projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) {
// Security: a provider command is an arbitrary executable named in config, and
// its stdout is parsed into a full FileConfig — so it must NOT be able to reach
// Sandbox.Enabled, in either direction. Same rule as project config: only global
// config / CLI may turn the sandbox off.
for _, tc := range []struct {
name string
value string
}{
{name: "cannot disable", value: "false"},
{name: "cannot enable", value: "true"},
} {
t.Run(tc.name, func(t *testing.T) {
command := writeCommand(t, commandScript{
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`,
})
resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
// The provider itself must still be applied — only the sandbox toggle is dropped.
if resolved.ActiveProvider != "cmd" {
t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled)
}
})
}
}
func TestResolveSandboxEnabledCLIOverride(t *testing.T) {
// A CLI/programmatic override (applied after every config source) must be able
// to disable the sandbox, even with no config file.
disabled := false
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled)
}
// An explicit true override must win over a lower-precedence global-config false.
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
enabled := true
resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled {
t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled)
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
+8 -6
View File
@@ -62,12 +62,6 @@ func HasProviderProfile(profile ProviderProfile) bool {
}
type SandboxConfig struct {
// Enabled turns the sandbox off when set to false (`"sandbox": {"enabled":
// false}`). A pointer distinguishes an explicit false (disable) from an
// omitted key (keep the default: enabled). Honored from the GLOBAL user
// config and CLI only — deliberately NOT project config, so a cloned repo
// cannot disable the sandbox that constrains it. nil / true keep enforcement.
Enabled *bool `json:"enabled,omitempty"`
// Network controls whether shell commands classified as network-touching
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
// Empty keeps the built-in default (deny). Without this knob the engine's
@@ -322,6 +316,14 @@ type SwarmConfig struct {
MaxTeamSize int `json:"maxTeamSize,omitempty"`
}
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
// threshold (including to 0, which disables deferral). Use this for programmatic
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
// "unset" and will not override.
func ToolsOverride(deferThreshold int) ToolsConfig {
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
}
func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
type rawTools struct {
DeferThreshold *int `json:"deferThreshold"`
+23
View File
@@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"os"
)
// Issue is a single structured problem found while validating a config file.
@@ -12,6 +13,28 @@ type Issue struct {
Message string `json:"message"`
}
// ValidateFile reads and parses path as a Zero FileConfig and runs the same
// semantic provider/model rules used during resolution. It returns the parsed
// config (zero value on parse failure) plus any structured issues. A parse
// failure yields a single issue whose Message wraps the underlying JSON error
// so callers can extract *json.SyntaxError / *json.UnmarshalTypeError offsets
// via errors.As.
func ValidateFile(path string) (FileConfig, []Issue) {
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, []Issue{{Message: fmt.Sprintf("read config %s: %v", path, err)}}
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON %s: %w", path, err).Error()}}
}
issues := validateSemantics(cfg)
issues = append(issues, unknownFieldIssues(data)...)
return cfg, issues
}
// ValidateBytes parses data as a Zero FileConfig and runs the same semantic
// provider/model rules as ValidateFile. It returns the parsed config (zero
// value on parse failure) plus any structured issues. A parse failure yields a
+33
View File
@@ -431,6 +431,39 @@ func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
return cfg, nil
}
// SetProviderDescription sets a provider's description VERBATIM — including to
// empty. The generic UpsertProvider merge treats empty fields as "leave
// unchanged", so clearing a description needs this dedicated setter.
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
cfg.Providers[index].Description = strings.TrimSpace(description)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// migrateStoredProviderKey moves a credential-store entry to a new provider
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
// never a missing key. A missing source entry is a no-op (the marker may be
+2 -2
View File
@@ -16,7 +16,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
writeTestFile(t, root, "AGENTS.md", strings.Repeat("project rules\n", 200))
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(root, nil) {
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
@@ -80,7 +80,7 @@ func TestBuildHasStableJSONContractAndCategoryMath(t *testing.T) {
writeTestFile(t, root, "ZERO.md", strings.Repeat("zero rules\n", 16))
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(root, nil) {
for _, tool := range tools.CoreTools(root) {
registry.Register(tool)
}
-4
View File
@@ -1,4 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package cron
func (s Schedule) String() string { return s.expr }
+2
View File
@@ -23,6 +23,8 @@ type Schedule struct {
expr string
}
func (s Schedule) String() string { return s.expr }
// full returns a bitset with every value in [min,max] set.
func full(min, max int) uint64 {
var m uint64
+7 -42
View File
@@ -99,60 +99,35 @@ func (s *Store) Add(job Job) (Job, error) {
job.Status = StatusActive
}
job.CreatedAt = s.now().UTC()
id, unlock, err := s.reserveID()
id, err := s.allocID()
if err != nil {
return Job{}, err
}
// Held from reservation through initial persistence: without this, Remove
// (which takes the same per-ID lock before deleting) could delete the
// bare reservation directory the instant after Mkdir and report success,
// then writeJob's MkdirAll would silently resurrect it and persist the
// job anyway — or a second Add could re-reserve the freed id while this
// writer is still running, putting two writers on the same metadata path.
defer unlock()
job.ID = id
if err := s.writeReservedJob(job); err != nil {
if err := s.writeJob(job); err != nil {
return Job{}, err
}
return job, nil
}
// reserveID atomically reserves an unused job id (by os.Mkdir-ing its
// directory) and returns the per-ID lock held for that id, still acquired.
// The caller must release it (via the returned unlock) only once the
// reservation has been committed (metadata.json written) or torn down —
// see Add.
func (s *Store) reserveID() (string, func(), error) {
if err := os.MkdirAll(s.root, 0o700); err != nil {
return "", nil, fmt.Errorf("create cron store root: %w", err)
}
func (s *Store) allocID() (string, error) {
base := s.now().UTC().Format("20060102-150405")
for n := 0; n < 100; n++ {
id := base
if n > 0 {
id = fmt.Sprintf("%s-%d", base, n)
}
unlock, err := s.lockJob(id)
if err != nil {
return "", nil, err
if _, err := os.Stat(filepath.Join(s.root, id)); errors.Is(err, os.ErrNotExist) {
return id, nil
}
mkErr := os.Mkdir(s.jobDir(id), 0o700)
if mkErr == nil {
return id, unlock, nil
}
unlock()
if errors.Is(mkErr, os.ErrExist) {
continue
}
return "", nil, fmt.Errorf("reserve cron job id %q: %w", id, mkErr)
}
return "", nil, errors.New("could not allocate a unique cron job id")
return "", errors.New("could not allocate a unique cron job id")
}
func (s *Store) jobDir(id string) string { return filepath.Join(s.root, id) }
// validID rejects ids that could escape the store root (path separators or
// traversal). reserveID-generated timestamp ids always pass; this guards
// traversal). allocID-generated timestamp ids always pass; this guards
// externally-supplied ids (get/update/remove/append).
func validID(id string) bool {
return id != "" && id != "." && id != ".." && !strings.ContainsAny(id, `/\`) && filepath.Base(id) == id
@@ -178,16 +153,6 @@ func (s *Store) writeJob(job Job) error {
return nil
}
func (s *Store) writeReservedJob(job Job) error {
if err := s.writeJob(job); err != nil {
if cleanupErr := os.RemoveAll(s.jobDir(job.ID)); cleanupErr != nil {
return errors.Join(err, fmt.Errorf("remove failed cron job reservation: %w", cleanupErr))
}
return err
}
return nil
}
func (s *Store) Get(id string) (Job, error) {
if !validID(id) {
return Job{}, fmt.Errorf("invalid cron job id %q", id)
-156
View File
@@ -2,11 +2,9 @@ package cron
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
@@ -76,160 +74,6 @@ func TestStoreAddListGetRemove(t *testing.T) {
}
}
func TestStoreAddReservesUniqueIDsAcrossConcurrentStores(t *testing.T) {
root := t.TempDir()
now := func() time.Time { return time.Unix(1000, 0).UTC() }
const adds = 32
start := make(chan struct{})
results := make(chan Job, adds)
errs := make(chan error, adds)
var wg sync.WaitGroup
for i := 0; i < adds; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
store := NewStore(StoreOptions{RootDir: root, Now: now})
<-start
job, err := store.Add(Job{Expr: "* * * * *", Prompt: fmt.Sprintf("job-%d", index)})
if err != nil {
errs <- err
return
}
results <- job
}(i)
}
close(start)
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Errorf("concurrent Add: %v", err)
}
seen := make(map[string]struct{}, adds)
reader := NewStore(StoreOptions{RootDir: root, Now: now})
for job := range results {
if _, duplicate := seen[job.ID]; duplicate {
t.Errorf("concurrent Add returned duplicate ID %q", job.ID)
}
seen[job.ID] = struct{}{}
persisted, err := reader.Get(job.ID)
if err != nil {
t.Errorf("Get(%q): %v", job.ID, err)
continue
}
if persisted.Prompt != job.Prompt {
t.Errorf("Get(%q).Prompt = %q, want %q", job.ID, persisted.Prompt, job.Prompt)
}
}
if len(seen) != adds {
t.Fatalf("unique jobs = %d, want %d", len(seen), adds)
}
}
// TestAddDoesNotLeakReservationToConcurrentRemove reproduces the audit
// finding that Remove could observe and delete an in-flight Add's bare
// reservation directory (Mkdir'd but not yet holding metadata.json) and
// report success — after which Add's writeJob would silently resurrect the
// directory and persist the job anyway. Add now holds the same per-ID lock
// Remove takes, from reservation through initial persistence, so a
// concurrent Remove for that id must wait until the reservation is either
// committed or torn down, never observing the bare reservation in between.
func TestAddDoesNotLeakReservationToConcurrentRemove(t *testing.T) {
store := newTestStore(t)
// Simulate the in-flight window Add now holds the lock across: the id is
// reserved (its directory exists) but metadata.json has not been written.
id, unlock, err := store.reserveID()
if err != nil {
t.Fatalf("reserveID: %v", err)
}
if _, statErr := os.Stat(store.jobDir(id)); statErr != nil {
t.Fatalf("reservation directory missing: %v", statErr)
}
removeErr := make(chan error, 1)
go func() {
removeErr <- store.Remove(id)
}()
// Remove cannot proceed past the shared per-ID lock we're holding, so it's
// safe to commit the job here, before releasing it: if Remove could race
// ahead of this write, it would delete the bare reservation instead of
// waiting to observe (and remove) the committed job.
job := Job{ID: id, Expr: "* * * * *", Prompt: "job", Status: StatusActive}
if err := store.writeJob(job); err != nil {
t.Fatalf("writeJob: %v", err)
}
unlock()
if err := <-removeErr; err != nil {
t.Fatalf("Remove of the committed job: %v", err)
}
if _, err := os.Stat(store.jobDir(id)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("job directory should be gone after Remove: %v", err)
}
if _, err := store.Get(id); !errors.Is(err, ErrJobNotFound) {
t.Fatalf("Get after Remove = %v, want ErrJobNotFound", err)
}
}
// TestReserveIDSerializesOnPerIDLock covers the other half of the audit
// finding: a second Add attempting the SAME candidate id while the first
// reservation is still held must block on the shared per-ID lock (not just
// race the directory Mkdir), so it never ends up writing metadata.json for
// an id whose first writer is still in flight. It moves on to the next
// candidate id instead.
func TestReserveIDSerializesOnPerIDLock(t *testing.T) {
store := newTestStore(t)
id, unlock, err := store.reserveID()
if err != nil {
t.Fatalf("first reserveID: %v", err)
}
type reservation struct {
id string
err error
}
second := make(chan reservation, 1)
go func() {
id2, unlock2, err := store.reserveID()
if err == nil {
unlock2()
}
second <- reservation{id: id2, err: err}
}()
// The second reserveID cannot observe an id until this lock is released:
// it contends on the same per-ID lock file before ever attempting Mkdir.
unlock()
got := <-second
if got.err != nil {
t.Fatalf("second reserveID: %v", got.err)
}
if got.id == id {
t.Fatalf("second reserveID returned the first id %q while its reservation was still held", got.id)
}
}
func TestWriteReservedJobRemovesReservationOnFailure(t *testing.T) {
store := newTestStore(t)
job := Job{ID: "reserved", Expr: "* * * * *", Prompt: "job"}
if err := os.MkdirAll(filepath.Join(store.jobDir(job.ID), "metadata.json.tmp"), 0o700); err != nil {
t.Fatal(err)
}
if err := store.writeReservedJob(job); err == nil {
t.Fatal("writeReservedJob error = nil, want metadata write failure")
}
if _, err := os.Stat(store.jobDir(job.ID)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("failed reservation still exists: %v", err)
}
}
func TestStoreAppendRun(t *testing.T) {
s := newTestStore(t)
job, _ := s.Add(Job{Expr: "* * * * *", Prompt: "x", Status: StatusActive})
+10
View File
@@ -37,6 +37,16 @@ const (
StreamProviderOpenAI = "openai"
)
// BatchProviders lists valid stt.provider values, for config validation.
func BatchProviders() []string {
return []string{ProviderLocal, ProviderGroq, ProviderOpenAI}
}
// StreamProviders lists valid stt.streamProvider values, for config validation.
func StreamProviders() []string {
return []string{StreamProviderLocal, StreamProviderDeepgram, StreamProviderOpenAI}
}
// AudioFormat identifies a recorded container format, sniffed from the bytes —
// desktop recorders produce WAV, Termux's recorder produces M4A/AAC.
type AudioFormat string
-147
View File
@@ -1,147 +0,0 @@
// Package installtxn provides the cross-process filesystem transaction used by
// plugin and skill installation. Callers stage content before taking the lock,
// then commit the content swap and lockfile update together while holding it.
package installtxn
import (
"errors"
"fmt"
"os"
"path/filepath"
)
const lockFileName = ".zero-install.lock"
// Lock takes the per-install-root cross-process lock. It blocks until any other
// installer or remover using dir has completed.
func Lock(dir string) (func(), error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create install dir: %w", err)
}
return lockFile(filepath.Join(dir, lockFileName))
}
// StageDir creates an install workspace on the target filesystem. Content must
// be built and validated in the returned stage directory before CommitDir is
// called. cleanup is always safe to call.
func StageDir(dir string) (stage string, cleanup func(), err error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", func() {}, fmt.Errorf("create install dir: %w", err)
}
workspace, err := os.MkdirTemp(dir, ".zero-install-txn-")
if err != nil {
return "", func() {}, fmt.Errorf("create install staging dir: %w", err)
}
return filepath.Join(workspace, "staged"), func() { cleanupWorkspace(workspace) }, nil
}
// CommitDir replaces target with staged and runs publish while retaining the
// previous target. If either the swap or publish fails, the previous target is
// restored (or the new target is removed for a first install).
//
// The caller must hold the install-root lock returned by Lock.
func CommitDir(target string, staged string, publish func() error) error {
workspace := filepath.Dir(staged)
backup := filepath.Join(workspace, "previous")
hadPrevious := false
if _, err := os.Stat(target); err == nil {
if err := os.Rename(target, backup); err != nil {
return fmt.Errorf("retain previous install: %w", err)
}
hadPrevious = true
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect previous install: %w", err)
}
if err := os.Rename(staged, target); err != nil {
if hadPrevious {
if restoreErr := os.Rename(backup, target); restoreErr != nil {
return errors.Join(fmt.Errorf("publish staged install: %w", err), fmt.Errorf("restore previous install: %w", restoreErr))
}
}
return fmt.Errorf("publish staged install: %w", err)
}
if err := publish(); err != nil {
return rollback(target, backup, hadPrevious, err)
}
if hadPrevious {
_ = os.RemoveAll(backup)
}
cleanupWorkspace(workspace)
return nil
}
// RemoveDir removes target and runs publish while retaining the target until
// publish succeeds. A publish failure restores the directory.
//
// The caller must hold the install-root lock returned by Lock.
func RemoveDir(target string, publish func() error) error {
workspace, err := os.MkdirTemp(filepath.Dir(target), ".zero-install-txn-")
if err != nil {
return fmt.Errorf("create removal staging dir: %w", err)
}
defer cleanupWorkspace(workspace)
backup := filepath.Join(workspace, "previous")
if err := os.Rename(target, backup); err != nil {
return fmt.Errorf("retain removed install: %w", err)
}
if err := publish(); err != nil {
if restoreErr := os.Rename(backup, target); restoreErr != nil {
return errors.Join(err, fmt.Errorf("restore removed install: %w", restoreErr))
}
return err
}
_ = os.RemoveAll(backup)
return nil
}
func rollback(target string, backup string, hadPrevious bool, cause error) error {
if err := os.RemoveAll(target); err != nil {
return errors.Join(cause, fmt.Errorf("remove failed install: %w", err))
}
if hadPrevious {
if err := os.Rename(backup, target); err != nil {
return errors.Join(cause, fmt.Errorf("restore previous install: %w", err))
}
}
return cause
}
// cleanupWorkspace never removes a retained previous install. If rollback was
// unable to restore it (for example because Windows still has a target file
// open), preserving the workspace is safer than turning a recoverable error
// into data loss.
func cleanupWorkspace(workspace string) {
if _, err := os.Stat(filepath.Join(workspace, "previous")); err == nil {
return
}
_ = os.RemoveAll(workspace)
}
// WriteFileAtomically publishes data by renaming a complete sibling temporary
// file over path. The caller is responsible for any surrounding transaction
// lock.
func WriteFileAtomically(path string, data []byte, perm os.FileMode) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".zero-lockfile-")
if err != nil {
return err
}
tempPath := temp.Name()
defer func() { _ = os.Remove(tempPath) }()
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return replaceFile(tempPath, path)
}
-78
View File
@@ -1,78 +0,0 @@
package installtxn
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestCommitDirRestoresPreviousInstallWhenPublishFails(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "demo")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "version"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
staged, cleanup, err := StageDir(root)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := os.MkdirAll(staged, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staged, "version"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
publishErr := errors.New("publish failed")
err = CommitDir(target, staged, func() error { return publishErr })
if !errors.Is(err, publishErr) {
t.Fatalf("CommitDir error = %v, want publish failure", err)
}
data, err := os.ReadFile(filepath.Join(target, "version"))
if err != nil {
t.Fatalf("read restored install: %v", err)
}
if string(data) != "old" {
t.Fatalf("restored content = %q, want old", data)
}
}
func TestCommitDirRemovesFirstInstallWhenPublishFails(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "demo")
staged, cleanup, err := StageDir(root)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := os.MkdirAll(staged, 0o755); err != nil {
t.Fatal(err)
}
err = CommitDir(target, staged, func() error { return errors.New("publish failed") })
if err == nil {
t.Fatal("CommitDir unexpectedly succeeded")
}
if _, statErr := os.Stat(target); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("failed first install remains at target: %v", statErr)
}
}
func TestCleanupWorkspacePreservesRetainedPreviousInstall(t *testing.T) {
workspace := t.TempDir()
previous := filepath.Join(workspace, "previous")
if err := os.MkdirAll(previous, 0o755); err != nil {
t.Fatal(err)
}
cleanupWorkspace(workspace)
if _, err := os.Stat(previous); err != nil {
t.Fatalf("cleanup removed retained previous install: %v", err)
}
}
-29
View File
@@ -1,29 +0,0 @@
//go:build !windows
package installtxn
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func lockFile(path string) (func(), error) {
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("open install lock: %w", err)
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil {
_ = file.Close()
return nil, fmt.Errorf("lock install root: %w", err)
}
return func() {
_ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
_ = file.Close()
}, nil
}
func replaceFile(source string, target string) error {
return os.Rename(source, target)
}
-31
View File
@@ -1,31 +0,0 @@
//go:build windows
package installtxn
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
func lockFile(path string) (func(), error) {
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("open install lock: %w", err)
}
handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped)
if err := windows.LockFileEx(handle, windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped); err != nil {
_ = file.Close()
return nil, fmt.Errorf("lock install root: %w", err)
}
return func() {
_ = windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
_ = file.Close()
}, nil
}
func replaceFile(source string, target string) error {
return windows.MoveFileEx(windows.StringToUTF16Ptr(source), windows.StringToUTF16Ptr(target), windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
}
-14
View File
@@ -1,14 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package lsp
import "os/exec"
// Available reports whether a configured server for the path exists on PATH.
func Available(path string) bool {
cmd, ok := ServerFor(path)
if !ok {
return false
}
_, err := exec.LookPath(cmd[0])
return err == nil
}
+2 -11
View File
@@ -11,18 +11,9 @@ import (
// TestManagerCheckRealGopls drives the exact path the self-correct LSP wiring
// uses (NewManager -> Check) against a REAL gopls and asserts a real diagnostic
// comes back. It is an OPT-IN integration check: an installed-but-unhealthy
// gopls (e.g. an unusable persistent-index cache) exits during initialization
// and would otherwise fail `go test ./...` for every developer who merely has
// gopls on PATH (issue #684). So it runs only when ZERO_GOPLS_INTEGRATION=1,
// matching the repo's other real-server opt-ins (internal/dictation smoke
// tests). Requiring exactly "1" (not merely non-empty) means an explicit
// ZERO_GOPLS_INTEGRATION=0 keeps the integration check OFF, as its value implies.
// The default suite keeps its coverage on the fake server.
// comes back. Skips when gopls is not installed. Temporary manual-verification
// test (the rest of the suite uses a fake server).
func TestManagerCheckRealGopls(t *testing.T) {
if os.Getenv("ZERO_GOPLS_INTEGRATION") != "1" {
t.Skip("set ZERO_GOPLS_INTEGRATION=1 to run the real gopls integration check")
}
if _, err := exec.LookPath("gopls"); err != nil {
t.Skip("gopls not on PATH; skipping real-server check")
}
+11
View File
@@ -1,6 +1,7 @@
package lsp
import (
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -175,6 +176,16 @@ func LanguageID(path string) (string, bool) {
return id, ok
}
// Available reports whether a configured server for the path exists on PATH.
func Available(path string) bool {
cmd, ok := ServerFor(path)
if !ok {
return false
}
_, err := exec.LookPath(cmd[0])
return err == nil
}
func extKey(path string) string {
return strings.ToLower(filepath.Ext(path))
}
+10 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
@@ -288,7 +289,7 @@ func TestRegistryToolIsDeferredEligible(t *testing.T) {
// TestRegistryToolReportsMCPServerName verifies the registryTool reports its true
// configured server name (not the sanitized tool-name token) so the deferred-tools
// discovery label names a multi-token server correctly via tools.DeferredSource.
// reminder labels a multi-token server correctly via tools.DeferredLine.
func TestRegistryToolReportsMCPServerName(t *testing.T) {
client := &fakeToolClient{}
// A server name that sanitizes to a token containing an underscore ("git_hub"):
@@ -304,9 +305,14 @@ func TestRegistryToolReportsMCPServerName(t *testing.T) {
t.Fatalf("MCPServerName() = %q, want %q", tool.MCPServerName(), "git hub")
}
// DeferredSource must prefer the reported server name over the name-derived token.
if source := tools.DeferredSource(tool); source != "git hub" {
t.Fatalf("DeferredSource = %q, want %q via MCPServerName()", source, "git hub")
// DeferredLine must prefer the reported server name over the name-derived token.
line := tools.DeferredLine(tool)
if !strings.Contains(line, "server: git hub") {
t.Fatalf("DeferredLine = %q, want it to label server as %q via MCPServerName()", line, "git hub")
}
// The truncated token-only label ("git") must NOT be the server segment.
if strings.Contains(line, "server: git |") {
t.Fatalf("DeferredLine = %q, mislabeled multi-token server with the truncated token", line)
}
}
-21
View File
@@ -1,21 +0,0 @@
package modelregistry
import "sync"
// resetModelsDevCacheForTest clears the process-level cache memoization and
// disables the overlay.
func resetModelsDevCacheForTest() {
modelsDevOnce = sync.Once{}
modelsDevCached = nil
modelsDevEnabled.Store(false)
}
// Modes returns a copy of the preset catalog, preserving declaration order so
// list output and help text stay stable.
func Modes() []Mode {
modes := make([]Mode, len(defaultModes))
for index, mode := range defaultModes {
modes[index] = cloneMode(mode)
}
return modes
}
+8
View File
@@ -197,6 +197,14 @@ func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
return modelsDevCached
}
// resetModelsDevCacheForTest clears the process-level cache memoization and
// disables the overlay.
func resetModelsDevCacheForTest() {
modelsDevOnce = sync.Once{}
modelsDevCached = nil
modelsDevEnabled.Store(false)
}
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
// call fire-and-forget from startup (use a goroutine); it never affects the
+10
View File
@@ -62,6 +62,16 @@ var defaultModes = []Mode{
},
}
// Modes returns a copy of the preset catalog, preserving declaration order so
// list output and help text stay stable.
func Modes() []Mode {
modes := make([]Mode, len(defaultModes))
for index, mode := range defaultModes {
modes[index] = cloneMode(mode)
}
return modes
}
// LookupMode returns the preset registered under name (case-insensitive,
// whitespace-trimmed). The second result reports whether a preset matched.
func LookupMode(name string) (Mode, bool) {
-7
View File
@@ -1,7 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package notify
// Enabled reports whether mode will ever emit a notification.
func Enabled(mode Mode) bool {
return mode != "" && mode != ModeOff
}
+5
View File
@@ -167,6 +167,11 @@ func sequence(mode Mode, message string) string {
}
}
// Enabled reports whether mode will ever emit a notification.
func Enabled(mode Mode) bool {
return mode != "" && mode != ModeOff
}
// sanitizeMessage drops control bytes (so the message can't break the escape or
// inject terminal control) and clamps to maxMessageLen runes.
func sanitizeMessage(s string) string {
+1 -1
View File
@@ -284,7 +284,7 @@ const skillFileName = "SKILL.md"
// MergedSkills loads the default skills directory, the shared ~/.agents/skills
// root when present, plus the supplied plugin skill roots and returns one
// merged, name-deduplicated list (Content stripped, like skills.ListFromRoots) alongside
// merged, name-deduplicated list (Content stripped, like skills.List) alongside
// the duplicate-name collisions across all roots. Earlier roots win a name
// clash, matching skills.Load's first-wins rule; the default dir is always
// considered first so a user skill shadows agents and plugins. A bad root simply
-62
View File
@@ -1,62 +0,0 @@
package plugins
import (
"errors"
"fmt"
"path/filepath"
"strings"
)
// ErrNotInstalled is returned when Info cannot find a plugin with the requested id.
var ErrNotInstalled = errors.New("plugin is not installed")
// PluginInfo combines a loaded plugin with optional lockfile metadata.
type PluginInfo struct {
Plugin LoadedPlugin `json:"plugin"`
LockSource string `json:"lockSource,omitempty"`
LockHash string `json:"lockHash,omitempty"`
HashDrift bool `json:"hashDrift,omitempty"`
}
// InfoOptions controls plugin lookup and lockfile resolution for Info.
type InfoOptions struct {
LoadOptions LoadOptions
}
// Info returns details for the named plugin after normal discovery precedence.
func Info(options InfoOptions, id string) (PluginInfo, error) {
id = strings.TrimSpace(id)
if id == "" {
return PluginInfo{}, errors.New("plugin id is required")
}
result, err := Load(options.LoadOptions)
if err != nil {
return PluginInfo{}, err
}
var plugin LoadedPlugin
found := false
for _, candidate := range result.Plugins {
if candidate.ID == id {
plugin = candidate
found = true
break
}
}
if !found {
return PluginInfo{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
}
info := PluginInfo{Plugin: plugin}
if lock, readErr := ReadLock(filepath.Dir(plugin.PluginDir)); readErr == nil {
if entry, ok := lock[plugin.ID]; ok {
info.LockSource = entry.Source
info.LockHash = entry.Hash
}
}
if info.LockHash != "" {
if current, hashErr := hashTree(plugin.PluginDir); hashErr == nil {
info.HashDrift = current != info.LockHash
}
}
return info, nil
}
-69
View File
@@ -1,69 +0,0 @@
package plugins
import (
"os"
"path/filepath"
"testing"
)
func TestInfoReturnsLockMetadataAndHashDrift(t *testing.T) {
dir := t.TempDir()
userRoot := filepath.Join(dir, "user")
pluginDir := filepath.Join(userRoot, "zero.demo")
writePluginManifest(t, pluginDir, map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true,
"tools": []any{},
})
hash, err := hashTree(pluginDir)
if err != nil {
t.Fatal(err)
}
if err := writeLock(userRoot, map[string]LockEntry{
"zero.demo": {Source: "./demo-src", Hash: hash},
}); err != nil {
t.Fatal(err)
}
info, err := Info(InfoOptions{
LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}},
}, "zero.demo")
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.Plugin.ID != "zero.demo" || !info.Plugin.Enabled {
t.Fatalf("plugin = %#v", info.Plugin)
}
if info.LockSource != "./demo-src" || info.LockHash != hash {
t.Fatalf("lock metadata = %#v, want source ./demo-src hash %s", info, hash)
}
if info.HashDrift {
t.Fatal("expected no hash drift before manifest edit")
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled":true}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
info, err = Info(InfoOptions{
LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: userRoot}}},
}, "zero.demo")
if err != nil {
t.Fatalf("Info after edit: %v", err)
}
if !info.HashDrift {
t.Fatal("expected hash drift after manifest edit")
}
}
func TestInfoMissingPlugin(t *testing.T) {
_, err := Info(InfoOptions{
LoadOptions: LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}},
}, "missing")
if err == nil {
t.Fatal("expected missing plugin error")
}
}
+17 -46
View File
@@ -19,8 +19,6 @@ import (
"path/filepath"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/installtxn"
)
// manifestFileName is the plugin manifest filename, matching the loader.
@@ -130,32 +128,6 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
return InstallResult{}, fmt.Errorf("hash plugin: %w", err)
}
staged, cleanupStage, err := installtxn.StageDir(dir)
if err != nil {
return InstallResult{}, err
}
defer cleanupStage()
// Copy the whole plugin tree (entry scripts, prompts, skills) into a sibling
// staging area. Copy DATA only — never execute it.
if err := copyTree(pluginDir, staged); err != nil {
return InstallResult{}, fmt.Errorf("stage plugin: %w", err)
}
stagedHash, err := hashTree(staged)
if err != nil {
return InstallResult{}, fmt.Errorf("validate staged plugin: %w", err)
}
if stagedHash != hash {
return InstallResult{}, errors.New("validate staged plugin: copied content hash differs from source")
}
unlock, err := installtxn.Lock(dir)
if err != nil {
return InstallResult{}, err
}
defer unlock()
// Re-read under the cross-process lock. Another install may have updated the
// lockfile while this plugin was fetched and staged.
lock, err := ReadLock(dir)
if err != nil {
return InstallResult{}, err
@@ -166,10 +138,20 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error)
}
target := filepath.Join(dir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return InstallResult{}, fmt.Errorf("create plugins dir: %w", err)
}
if err := os.RemoveAll(target); err != nil {
return InstallResult{}, fmt.Errorf("clear previous plugin: %w", err)
}
// Copy the whole plugin tree (entry scripts, prompts, skills) so the installed
// plugin is runnable through activation. Copy DATA only — never execute it.
if err := copyTree(pluginDir, target); err != nil {
return InstallResult{}, fmt.Errorf("copy plugin: %w", err)
}
lock[id] = LockEntry{Source: source, Hash: hash}
if err := installtxn.CommitDir(target, staged, func() error {
return writeLock(dir, lock)
}); err != nil {
if err := writeLock(dir, lock); err != nil {
return InstallResult{}, err
}
@@ -200,12 +182,6 @@ func Remove(dir string, id string) error {
return fmt.Errorf("invalid plugin id %q", id)
}
unlock, err := installtxn.Lock(dir)
if err != nil {
return err
}
defer unlock()
lock, err := ReadLock(dir)
if err != nil {
return err
@@ -218,16 +194,11 @@ func Remove(dir string, id string) error {
return fmt.Errorf("plugin %q is not installed", id)
}
if present {
if err := installtxn.RemoveDir(target, func() error {
if !locked {
return nil
}
delete(lock, id)
return writeLock(dir, lock)
}); err != nil {
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("remove plugin dir: %w", err)
}
} else if locked {
}
if locked {
delete(lock, id)
if err := writeLock(dir, lock); err != nil {
return err
@@ -268,7 +239,7 @@ func writeLock(dir string, entries map[string]LockEntry) error {
if err != nil {
return fmt.Errorf("encode %s: %w", LockFileName, err)
}
if err := installtxn.WriteFileAtomically(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write %s: %w", LockFileName, err)
}
return nil
-33
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -188,38 +187,6 @@ func TestInstallReinstallShowsHashChange(t *testing.T) {
}
}
func TestConcurrentInstallsPreserveEveryLockEntry(t *testing.T) {
destDir := t.TempDir()
const count = 12
sources := make([]string, count)
for index := range count {
manifest := validManifest()
manifest["id"] = fmt.Sprintf("zero.concurrent.%02d", index)
sources[index] = writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), manifest)
}
errs := make(chan error, count)
for _, source := range sources {
go func() {
_, err := Install(context.Background(), InstallOptions{Source: source, Dir: destDir})
errs <- err
}()
}
for range count {
if err := <-errs; err != nil {
t.Fatalf("concurrent Install: %v", err)
}
}
entries, err := ReadLock(destDir)
if err != nil {
t.Fatalf("ReadLock: %v", err)
}
if len(entries) != count {
t.Fatalf("lockfile has %d entries, want %d: %#v", len(entries), count, entries)
}
}
// TestInstallReinstallDetectsNestedFileChange guards that the recorded hash
// covers the whole installed tree, not just plugin.json. A change to a tool
// script (with the manifest unchanged) must still be reported as an update so
+37 -1
View File
@@ -140,7 +140,6 @@ var descriptors = []Descriptor{
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
openAICompat("atlascloud", "Atlas Cloud", "https://api.atlascloud.ai/v1", "qwen/qwen3.5-flash", []string{"ATLASCLOUD_API_KEY"}, "atlas cloud", "atlas"),
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
@@ -187,6 +186,14 @@ func All() []Descriptor {
return copied
}
func IDs() []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
ids = append(ids, descriptor.ID)
}
return ids
}
func Get(id string) (Descriptor, bool) {
normalized := NormalizeID(id)
for _, descriptor := range descriptors {
@@ -211,6 +218,35 @@ func Require(id string) (Descriptor, error) {
return descriptor, nil
}
func ListByTransport(transport Transport) []Descriptor {
normalized := Transport(NormalizeID(string(transport)))
items := make([]Descriptor, 0)
for _, descriptor := range descriptors {
if descriptor.Transport == normalized {
items = append(items, cloneDescriptor(descriptor))
}
}
return items
}
func ValidTransport(transport Transport) bool {
switch Transport(NormalizeID(string(transport))) {
case TransportOpenAI, TransportAnthropic, TransportGoogle, TransportBedrock, TransportVertex, TransportOpenAICompatible, TransportAnthropicCompatible:
return true
default:
return false
}
}
func ValidAPIFormat(format APIFormat) bool {
switch format {
case APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions, APIFormatAnthropicMessages, APIFormatGoogleGenerateContent, APIFormatBedrockConverse, APIFormatVertexGenerateContent:
return true
default:
return false
}
}
func NormalizeID(id string) string {
var builder strings.Builder
lastDash := false
+1 -25
View File
@@ -24,7 +24,6 @@ var expectedCatalogIDs = []string{
"together",
"dashscope",
"moonshot",
"atlascloud",
"longcat",
"nvidia-nim",
"minimax",
@@ -155,28 +154,6 @@ func TestLongCatDescriptor(t *testing.T) {
}
}
func TestAtlasCloudDescriptor(t *testing.T) {
descriptor, err := Require("atlascloud")
if err != nil {
t.Fatalf("Require(atlascloud) error = %v", err)
}
if descriptor.Name != "Atlas Cloud" {
t.Fatalf("Name = %q, want Atlas Cloud", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.atlascloud.ai/v1" {
t.Fatalf("DefaultBaseURL = %q, want Atlas Cloud OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "qwen/qwen3.5-flash" {
t.Fatalf("DefaultModel = %q, want qwen/qwen3.5-flash", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"ATLASCLOUD_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want ATLASCLOUD_API_KEY", descriptor.AuthEnvVars)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -295,7 +272,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"lm-studio": "lmstudio",
"mini_max": "minimax",
"Moonshot": "moonshot",
"Atlas Cloud": "atlascloud",
"nvidia nim": "nvidia-nim",
"xiaomi mimo": "xiaomi-mimo",
"custom_openai_compatible": "custom-openai-compatible",
@@ -346,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", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "atlascloud", "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 {
-39
View File
@@ -1,39 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package providercatalog
func IDs() []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
ids = append(ids, descriptor.ID)
}
return ids
}
func ListByTransport(transport Transport) []Descriptor {
normalized := Transport(NormalizeID(string(transport)))
items := make([]Descriptor, 0)
for _, descriptor := range descriptors {
if descriptor.Transport == normalized {
items = append(items, cloneDescriptor(descriptor))
}
}
return items
}
func ValidAPIFormat(format APIFormat) bool {
switch format {
case APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions, APIFormatAnthropicMessages, APIFormatGoogleGenerateContent, APIFormatBedrockConverse, APIFormatVertexGenerateContent:
return true
default:
return false
}
}
func ValidTransport(transport Transport) bool {
switch Transport(NormalizeID(string(transport))) {
case TransportOpenAI, TransportAnthropic, TransportGoogle, TransportBedrock, TransportVertex, TransportOpenAICompatible, TransportAnthropicCompatible:
return true
default:
return false
}
}
-4
View File
@@ -133,10 +133,6 @@ var curatedModels = map[string][]Model{
{ID: "kimi-k2-turbo-preview", Description: "fast coding model"},
{ID: "moonshot-v1-128k", Description: "long-context model"},
},
"atlascloud": {
{ID: "qwen/qwen3.5-flash", Description: "catalog default"},
{ID: "deepseek-ai/deepseek-v4-pro", Description: "reasoning model"},
},
"nvidia-nim": {
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
@@ -38,11 +38,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
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: "atlascloud",
want: []string{"qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "openai/gpt-4.1"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
+19 -26
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -36,12 +35,9 @@ type Model struct {
}
type Options struct {
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
OAuthResolver providerio.TokenResolver
CodexAccountResolver openai.CodexAccountResolver
UserAgent string
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
}
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -192,18 +188,6 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
if err != nil {
return nil, err
}
var configure func(*http.Request)
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
configure = func(request *http.Request) {
account := ""
if options.CodexAccountResolver != nil {
if resolved, ok, resolveErr := options.CodexAccountResolver(request.Context()); resolveErr == nil && ok {
account = resolved
}
}
openai.ApplyCodexHeaders(request, account, options.UserAgent)
}
}
return fetchProviderModels(ctx, endpoint, profile, options, providerio.AuthHeaders{
APIKey: profile.APIKey,
DefaultAuthHeader: "Authorization",
@@ -212,7 +196,7 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
}, configure)
}, nil)
}
func discoverAnthropicModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -233,16 +217,25 @@ func discoverAnthropicModels(ctx context.Context, profile config.ProviderProfile
}
func fetchProviderModels(ctx context.Context, endpoint string, profile config.ProviderProfile, options Options, auth providerio.AuthHeaders, configure func(*http.Request)) ([]Model, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
// Authenticate via either an APIKey or a raw auth-header value / custom
// headers, matching how the live providers build their requests
// (internal/providers/providerio). Honoring AuthHeaderValue keeps discovery
// consistent with the credential-present logic elsewhere.
providerio.ApplyAuthHeaders(request, auth)
request.Header.Set("Accept", "application/json")
if configure != nil {
configure(request)
}
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := providerio.SendWithAuthRetry(ctx, client, http.MethodGet, endpoint, nil, auth, options.OAuthResolver, func(request *http.Request) {
request.Header.Set("Accept", "application/json")
if configure != nil {
configure(request)
}
}, 1)
response, err := client.Do(request)
if err != nil {
return nil, redactDiscoveryError(err, profile)
}
@@ -52,63 +52,6 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
}
}
func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
var requests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if got := r.URL.Path; got != "/backend-api/codex/models" {
t.Errorf("path = %q, want Codex models endpoint", got)
}
wantToken := "Bearer old-token"
wantAccount := "old-account"
if requests == 2 {
wantToken = "Bearer refreshed-token"
wantAccount = "refreshed-account"
}
if got := r.Header.Get("Authorization"); got != wantToken {
t.Errorf("request %d Authorization = %q, want %q", requests, got, wantToken)
}
if got := r.Header.Get("chatgpt-account-id"); got != wantAccount {
t.Errorf("request %d chatgpt-account-id = %q, want %q", requests, got, wantAccount)
}
if got := r.Header.Get("originator"); got != "codex_cli_rs" {
t.Errorf("originator = %q", got)
}
if requests == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.4"}]}`))
}))
defer server.Close()
models, err := Discover(context.Background(), config.ProviderProfile{
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
OAuthResolver: func(_ context.Context, force bool) (string, string, bool, error) {
if force {
return "Authorization", "Bearer refreshed-token", true, nil
}
return "Authorization", "Bearer old-token", true, nil
},
CodexAccountResolver: func(context.Context) (string, bool, error) {
if requests == 0 {
return "old-account", true, nil
}
return "refreshed-account", true, nil
},
})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
if requests != 2 || len(models) != 1 || models[0].ID != "gpt-5.4" {
t.Fatalf("requests = %d, models = %#v", requests, models)
}
}
func TestDiscoverAIMLAPIModelsSendsAuthAndCustomHeadersWithoutAttribution(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for header, want := range map[string]string{
+8 -12
View File
@@ -361,6 +361,13 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// login than the bearer — a mismatch the backend rejects.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountForKey(accountKey)
if account == "" {
return "", false, nil
}
return account, true, nil
})
return openai.NewCodexProvider(openai.CodexOptions{
Options: openai.Options{
BaseURL: resolved.baseURL,
@@ -381,7 +388,7 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
// override. The codex provider's constructor derives the
// `/responses` endpoint from BaseURL, so the factory stays out of
// the path.
AccountResolver: CodexAccountResolverForLogin(accountKey),
AccountResolver: resolver,
})
}
@@ -404,14 +411,3 @@ func codexAccountForKey(key string) string {
}
return strings.TrimSpace(token.Account)
}
// CodexAccountResolverForLogin returns the same per-request account resolver
// used by the runtime provider. Auxiliary Codex requests use this rather than
// independently selecting or parsing an OAuth login, which could mismatch the
// bearer selected by oauthLoginForProfile.
func CodexAccountResolverForLogin(key string) openai.CodexAccountResolver {
return func(context.Context) (string, bool, error) {
account := codexAccountForKey(key)
return account, account != "", nil
}
}
+20 -19
View File
@@ -3,6 +3,7 @@ package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -22,19 +23,6 @@ const (
codexOriginatorHeader = "originator"
)
// ApplyCodexHeaders applies the non-bearer headers required by ChatGPT's Codex
// backend. It is shared by runtime completions and auxiliary Codex endpoints
// such as live model discovery so their authentication cannot drift apart.
func ApplyCodexHeaders(req *http.Request, accountID, userAgent string) {
req.Header.Set(codexOriginatorHeader, codexDefaultOriginator)
if accountID = strings.TrimSpace(accountID); accountID != "" {
req.Header.Set(codexAccountHeader, accountID)
}
if userAgent = strings.TrimSpace(userAgent); userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
}
// CodexAccountResolver returns the `chatgpt_account_id` claim for the bearer
// that is about to be sent on a request. It is invoked once per request
// (including the 401-refresh retry) so the value can be re-derived from the
@@ -180,13 +168,15 @@ func (p *CodexProvider) StreamCompletion(ctx context.Context, request zeroruntim
// openai provider. It sets the three Codex-required headers; the bearer is
// applied separately by the openai provider's auth path.
func (p *CodexProvider) injectCodexHeaders(req *http.Request) {
account := ""
if resolved, ok, err := p.resolveAccount(req.Context()); err == nil && ok && resolved != "" {
account = strings.TrimSpace(resolved)
req.Header.Set(codexOriginatorHeader, p.originator)
if account, ok, err := p.resolveAccount(req.Context()); err == nil && ok && account != "" {
req.Header.Set(codexAccountHeader, account)
}
ApplyCodexHeaders(req, account, p.userAgent)
if p.originator != codexDefaultOriginator {
req.Header.Set(codexOriginatorHeader, p.originator)
// Branded User-Agent overrides the openai provider's default. Set last
// so a caller that supplies a different UserAgent in custom-headers is
// still respected (the openai provider's setExtra already ran before us).
if p.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
}
}
@@ -206,3 +196,14 @@ func (p *CodexProvider) resolveAccount(ctx context.Context) (string, bool, error
}
return "", false, nil
}
// ValidateAccount is a convenience for tests/callers that want to confirm the
// account id is the right shape (non-empty, trimmed). It is a no-op helper
// rather than a constructor check so a Codex provider can be built before the
// first login completes.
func ValidateAccount(account string) error {
if strings.TrimSpace(account) == "" {
return errors.New("openai codex: account id is empty")
}
return nil
}
-18
View File
@@ -1,18 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package openai
import (
"errors"
"strings"
)
// ValidateAccount is a convenience for tests/callers that want to confirm the
// account id is the right shape (non-empty, trimmed). It is a no-op helper
// rather than a constructor check so a Codex provider can be built before the
// first login completes.
func ValidateAccount(account string) error {
if strings.TrimSpace(account) == "" {
return errors.New("openai codex: account id is empty")
}
return nil
}
+2 -2
View File
@@ -945,9 +945,9 @@ func TestStreamCompletionSurfacesLengthFinishReason(t *testing.T) {
t.Fatalf("done FinishReason = %q, want %q", doneReason, zeroruntime.FinishReasonLength)
}
// And it round-trips through the runtime collector's FinishReason.
// And it round-trips through the runtime collector as Truncated.
collected := zeroruntime.CollectStream(context.Background(), replay(events))
if collected.FinishReason != zeroruntime.FinishReasonLength {
if !collected.Truncated() || collected.FinishReason != zeroruntime.FinishReasonLength {
t.Fatalf("collected = %+v, want truncated length", collected)
}
}
@@ -1,14 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package providerio
import (
"bufio"
"io"
)
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
return scanSSEPayloads(scanner, handle, nil)
}
@@ -195,6 +195,13 @@ func SendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event
}
}
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
return scanSSEPayloads(scanner, handle, nil)
}
// scanSSEPayloads accumulates SSE "data:" lines into payloads (joined across
// continuation lines, flushed on a blank line or EOF) and forwards each to
// handle. It is the shared core of ScanSSEData and the idle-aware variant.
+147
View File
@@ -0,0 +1,147 @@
// Package reasoning models how each provider's models expose reasoning-effort
// control. Providers disagree on the concept — OpenAI uses a discrete effort
// enum, Anthropic and Gemini 2.5 use a thinking-token budget, some models only
// toggle thinking on/off — so a single flat effort string cannot describe them.
//
// Capability is the typed, per-model description of that control, sourced from a
// community capability catalog (models.dev). It is the data the rest of Zero
// consults to decide which reasoning tiers a model actually supports, replacing
// model-name guessing. This package depends only on the standard library, so the
// model registry and provider adapters can import it without a cycle.
//
// Source of truth: this catalog is intended to become the authoritative reasoning
// -capability source. When it is wired into the live path, modelregistry's
// name-pattern inference (reasoningEffortsForModelName) is demoted to a fallback
// for models the catalog does not cover; the catalog wins where it has an entry.
// This commit is additive groundwork — nothing consumes it yet — so there is no
// behavioral divergence until that wiring lands.
package reasoning
import "strings"
// ControlKind enumerates how a model exposes reasoning control. The values
// mirror the models.dev reasoning_options[].type field.
type ControlKind string
const (
// ControlEffort is a discrete effort enum (OpenAI reasoning_effort, Gemini 3
// thinkingLevel, newer Claude output_config.effort).
ControlEffort ControlKind = "effort"
// ControlBudget is a thinking-token budget (Gemini 2.5 thinkingBudget, legacy
// Claude thinking.budget_tokens).
ControlBudget ControlKind = "budget_tokens"
// ControlToggle is an on/off thinking switch with no levels.
ControlToggle ControlKind = "toggle"
)
// Control is one reasoning control a model accepts. For an effort control,
// Values lists the accepted tiers ordered weakest to strongest. For a budget
// control, Min/Max bound the thinking-token budget; each is nil when the
// provider does not bound that side, kept distinct from an explicit 0 (Gemini
// uses min: 0 to mean "thinking can be disabled", which a plain int would lose).
type Control struct {
Kind ControlKind `json:"type"`
Values []string `json:"values,omitempty"`
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
}
// clone returns a deep copy of the control so the embedded catalog cannot be
// mutated through a returned Control's slice or budget pointers.
func (ctrl Control) clone() Control {
out := Control{Kind: ctrl.Kind}
if ctrl.Values != nil {
out.Values = append([]string(nil), ctrl.Values...)
}
if ctrl.Min != nil {
min := *ctrl.Min
out.Min = &min
}
if ctrl.Max != nil {
max := *ctrl.Max
out.Max = &max
}
return out
}
// Capability is the reasoning capability of a single model: whether it reasons
// at all, and through which controls. A model may carry more than one control
// (e.g. a newer Claude model exposes both an effort enum and a token budget); a
// reasoning model with no controls reasons but exposes no knob (always-on).
type Capability struct {
Reasoning bool `json:"reasoning"`
Controls []Control `json:"reasoning_options,omitempty"`
}
// clone returns a deep copy of the capability, cloning each control, so a caller
// cannot mutate the shared catalog through the returned value's slices.
func (c Capability) clone() Capability {
out := Capability{Reasoning: c.Reasoning}
if c.Controls != nil {
out.Controls = make([]Control, len(c.Controls))
for i, ctrl := range c.Controls {
out.Controls[i] = ctrl.clone()
}
}
return out
}
// Supported reports whether the model performs any reasoning.
func (c Capability) Supported() bool { return c.Reasoning }
// EffortControl returns the model's effort control and whether it has one. The
// returned Control is a deep copy, so a caller cannot mutate the shared catalog
// through its Values slice.
func (c Capability) EffortControl() (Control, bool) {
for _, ctrl := range c.Controls {
if ctrl.Kind == ControlEffort {
return ctrl.clone(), true
}
}
return Control{}, false
}
// EffortValues returns the ordered effort tiers the model accepts, or nil when
// it has no effort control (a budget- or toggle-only model, or a non-reasoning
// model).
func (c Capability) EffortValues() []string {
if ctrl, ok := c.EffortControl(); ok {
return ctrl.Values
}
return nil
}
// SupportsEffort reports whether tier is one of the model's accepted effort
// values (case-insensitive).
func (c Capability) SupportsEffort(tier string) bool {
tier = strings.ToLower(strings.TrimSpace(tier))
for _, v := range c.EffortValues() {
if strings.ToLower(v) == tier {
return true
}
}
return false
}
// BudgetControl returns the model's token-budget control and whether it has one.
// The returned Control is a deep copy: its Min/Max are independent pointers, so a
// caller cannot mutate the shared catalog through them. Min/Max are nil when that
// bound is unspecified (a real 0, e.g. Gemini's min: 0, is a non-nil pointer to 0).
func (c Capability) BudgetControl() (Control, bool) {
for _, ctrl := range c.Controls {
if ctrl.Kind == ControlBudget {
return ctrl.clone(), true
}
}
return Control{}, false
}
// HasControl reports whether the model exposes a control of the given kind.
func (c Capability) HasControl(kind ControlKind) bool {
for _, ctrl := range c.Controls {
if ctrl.Kind == kind {
return true
}
}
return false
}
+99
View File
@@ -0,0 +1,99 @@
package reasoning
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
)
// modelsdev_snapshot.json is a trimmed snapshot of https://models.dev/api.json:
// for a set of first-party providers, each model's api id mapped to its
// reasoning capability (the `reasoning` flag and `reasoning_options`). Routers
// (OpenRouter/Azure/etc.) are intentionally excluded — they carry generic or
// stale option lists, so a lookup resolves to the first-party provider instead.
//
// Provenance is recorded in the snapshot's `_source`/`_fetched` header fields.
// To regenerate: fetch api.json, then for each provider in
// {anthropic, openai, google, xai, groq, deepseek} keep `{reasoning,
// reasoning_options}` per api id, and bump `_fetched`. Runtime auto-refresh is a
// separate change.
//
//go:embed modelsdev_snapshot.json
var snapshotBytes []byte
// snapshotDoc is the on-disk shape of the embedded snapshot.
type snapshotDoc struct {
Providers map[string]map[string]Capability `json:"providers"`
}
// Catalog is a reasoning-capability lookup keyed by provider slug and api model
// id. It is read-only after construction.
type Catalog struct {
byProvider map[string]map[string]Capability
}
// ParseCatalog builds a Catalog from a models.dev-style snapshot document.
func ParseCatalog(data []byte) (Catalog, error) {
var doc snapshotDoc
if err := json.Unmarshal(data, &doc); err != nil {
return Catalog{}, fmt.Errorf("reasoning: parse catalog: %w", err)
}
if len(doc.Providers) == 0 {
return Catalog{}, fmt.Errorf("reasoning: catalog has no providers")
}
return Catalog{byProvider: doc.Providers}, nil
}
// embedded is the catalog parsed from the bundled snapshot. A malformed embed is
// a build/release error, so it panics at init rather than failing silently.
var embedded = mustParseEmbedded()
func mustParseEmbedded() Catalog {
c, err := ParseCatalog(snapshotBytes)
if err != nil {
panic("reasoning: invalid embedded snapshot: " + err.Error())
}
return c
}
// Embedded returns the catalog parsed from the bundled models.dev snapshot.
func Embedded() Catalog { return embedded }
// Lookup returns the reasoning capability for a model identified by its Zero
// provider kind and the API model id — the provider's wire name (e.g.
// "claude-opus-4-1-20250805"), NOT the friendly registry id ("claude-opus-4.1").
// The match is exact: the api model id is only trimmed, not case-folded, and no
// router prefixes ("openai/…") or suffixes (":cloud") are stripped. ok is false
// when the provider or model is not in the snapshot, in which case the caller
// falls back to its next capability source.
func (c Catalog) Lookup(provider, apiModel string) (Capability, bool) {
apiModel = strings.TrimSpace(apiModel)
if apiModel == "" {
return Capability{}, false
}
for _, slug := range providerSlugs(provider) {
models, ok := c.byProvider[slug]
if !ok {
continue
}
if entry, ok := models[apiModel]; ok {
// Deep copy so callers cannot mutate the shared catalog through the
// returned Controls / Values slices or budget pointers.
return entry.clone(), true
}
}
return Capability{}, false
}
// providerSlugs maps a Zero provider kind to the models.dev provider slug to
// look up. Gemini and Google both resolve to "google" (AI Studio), the
// first-party source for Gemini models.
func providerSlugs(provider string) []string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "gemini":
return []string{"google"}
default:
return []string{strings.ToLower(strings.TrimSpace(provider))}
}
}
+249
View File
@@ -0,0 +1,249 @@
package reasoning
import "testing"
func TestEmbeddedCatalogLoads(t *testing.T) {
c := Embedded()
if len(c.byProvider) == 0 {
t.Fatal("embedded catalog is empty")
}
if _, ok := c.Lookup("openai", "gpt-5"); !ok {
t.Fatal("embedded catalog missing a well-known model (gpt-5)")
}
}
func TestParseCatalogRejectsEmpty(t *testing.T) {
if _, err := ParseCatalog([]byte(`{}`)); err == nil {
t.Fatal("expected an error for a catalog with no providers")
}
if _, err := ParseCatalog([]byte(`not json`)); err == nil {
t.Fatal("expected an error for malformed json")
}
}
func TestGroundTruthOpenAI(t *testing.T) {
c := Embedded()
cases := []struct {
api string
reason bool
kind ControlKind
values []string
}{
{"gpt-5", true, ControlEffort, []string{"minimal", "low", "medium", "high"}},
{"gpt-5-codex", true, ControlEffort, []string{"low", "medium", "high"}},
{"gpt-5-pro", true, ControlEffort, []string{"high"}},
{"gpt-5.1", true, ControlEffort, []string{"none", "low", "medium", "high"}},
{"gpt-5.1-codex-max", true, ControlEffort, []string{"low", "medium", "high", "xhigh"}},
{"o3", true, ControlEffort, []string{"low", "medium", "high"}},
}
for _, tc := range cases {
entry, ok := c.Lookup("openai", tc.api)
if !ok {
t.Errorf("%s: not found", tc.api)
continue
}
if entry.Supported() != tc.reason {
t.Errorf("%s: Supported=%v want %v", tc.api, entry.Supported(), tc.reason)
}
if !equalStrings(entry.EffortValues(), tc.values) {
t.Errorf("%s: efforts=%v want %v", tc.api, entry.EffortValues(), tc.values)
}
}
// Non-reasoning models report reasoning:false and expose no effort.
for _, api := range []string{"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4-turbo"} {
entry, ok := c.Lookup("openai", api)
if !ok {
t.Errorf("%s: not found", api)
continue
}
if entry.Supported() {
t.Errorf("%s: Supported=true, want false (non-reasoning)", api)
}
if entry.EffortValues() != nil {
t.Errorf("%s: efforts=%v, want nil", api, entry.EffortValues())
}
}
// Version splits: minimal vs none are not interchangeable.
gpt5, _ := c.Lookup("openai", "gpt-5")
if !gpt5.SupportsEffort("minimal") || gpt5.SupportsEffort("none") {
t.Error("gpt-5 should support minimal, not none")
}
gpt51, _ := c.Lookup("openai", "gpt-5.1")
if gpt51.SupportsEffort("minimal") || !gpt51.SupportsEffort("none") {
t.Error("gpt-5.1 should support none, not minimal")
}
}
func TestGroundTruthAnthropic(t *testing.T) {
c := Embedded()
// Legacy / budget-only models: a thinking-token budget, no effort enum. The
// budget carries an explicit min (1024) and no max — Max stays nil.
for _, api := range []string{
"claude-opus-4-1-20250805", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001",
} {
entry, ok := c.Lookup("anthropic", api)
if !ok {
t.Errorf("%s: not found", api)
continue
}
ctrl, ok := entry.BudgetControl()
if !ok {
t.Errorf("%s: expected a budget control", api)
continue
}
if ctrl.Min == nil || *ctrl.Min != 1024 {
t.Errorf("%s: budget Min=%v, want non-nil 1024", api, ctrl.Min)
}
if ctrl.Max != nil {
t.Errorf("%s: budget Max=%d, want nil (unbounded)", api, *ctrl.Max)
}
if _, ok := entry.EffortControl(); ok {
t.Errorf("%s: did not expect an effort control", api)
}
}
// Newer models: native effort enum with xhigh and the budget control removed.
opus48, ok := c.Lookup("anthropic", "claude-opus-4-8")
if !ok {
t.Fatal("claude-opus-4-8 not found")
}
if !equalStrings(opus48.EffortValues(), []string{"low", "medium", "high", "xhigh", "max"}) {
t.Errorf("opus-4-8 efforts=%v", opus48.EffortValues())
}
if _, ok := opus48.BudgetControl(); ok {
t.Error("opus-4-8 should not carry a budget control (removed)")
}
}
func TestGroundTruthGemini(t *testing.T) {
c := Embedded()
pro, ok := c.Lookup("gemini", "gemini-2.5-pro") // gemini kind -> google slug
if !ok {
t.Fatal("gemini-2.5-pro not found via the gemini provider kind")
}
ctrl, ok := pro.BudgetControl()
if !ok || ctrl.Min == nil || *ctrl.Min != 128 || ctrl.Max == nil || *ctrl.Max != 32768 {
t.Errorf("gemini-2.5-pro budget=%+v ok=%v, want min=128 max=32768", ctrl, ok)
}
if _, ok := pro.EffortControl(); ok {
t.Error("gemini-2.5-pro should be budget-only, no effort control")
}
// gemini-2.5-flash can disable thinking: a toggle plus a budget whose explicit
// min: 0 must survive (a plain int + omitempty would drop it).
flash, _ := c.Lookup("google", "gemini-2.5-flash")
if !flash.HasControl(ControlToggle) {
t.Error("gemini-2.5-flash should expose a toggle (thinking can be disabled)")
}
if fctrl, ok := flash.BudgetControl(); !ok || fctrl.Min == nil || *fctrl.Min != 0 {
t.Errorf("gemini-2.5-flash budget Min=%v, want a non-nil pointer to 0", fctrl.Min)
}
// Gemini 3.x switched to an effort enum.
if g3, ok := c.Lookup("google", "gemini-3-pro-preview"); ok {
if _, ok := g3.EffortControl(); !ok {
t.Error("gemini-3-pro-preview should expose an effort control")
}
}
}
// TestCoversZeroShippedReasoningModels pins that every reasoning model Zero
// currently ships resolves in the catalog (by its api model id), so the
// models.dev fallback actually covers Zero's catalog rather than just well-known
// ids. The api ids mirror internal/modelregistry's curated entries.
func TestCoversZeroShippedReasoningModels(t *testing.T) {
c := Embedded()
shipped := []struct {
provider, api string
wantKind ControlKind
}{
{"anthropic", "claude-opus-4-1-20250805", ControlBudget},
{"anthropic", "claude-sonnet-4-5-20250929", ControlBudget},
{"anthropic", "claude-haiku-4-5-20251001", ControlBudget},
{"google", "gemini-2.5-pro", ControlBudget},
{"google", "gemini-2.5-flash", ControlToggle},
{"google", "gemini-2.5-flash-lite", ControlToggle},
}
for _, m := range shipped {
entry, ok := c.Lookup(m.provider, m.api)
if !ok {
t.Errorf("%s/%s: not covered by the snapshot", m.provider, m.api)
continue
}
if !entry.Supported() {
t.Errorf("%s/%s: Supported=false, want a reasoning model", m.provider, m.api)
}
if !entry.HasControl(m.wantKind) {
t.Errorf("%s/%s: missing expected control %q (controls=%+v)", m.provider, m.api, m.wantKind, entry.Controls)
}
}
}
// TestLookupReturnsDeepCopy pins that a caller cannot corrupt the shared
// embedded catalog by mutating a returned Capability's slices.
func TestLookupReturnsDeepCopy(t *testing.T) {
c := Embedded()
first, ok := c.Lookup("openai", "gpt-5")
if !ok || len(first.Controls) == 0 || len(first.Controls[0].Values) == 0 {
t.Fatal("setup: gpt-5 should have an effort control with values")
}
first.Controls[0].Values[0] = "MUTATED"
first.Controls[0].Kind = "MUTATED"
second, _ := c.Lookup("openai", "gpt-5")
if second.Controls[0].Values[0] == "MUTATED" || second.Controls[0].Kind == "MUTATED" {
t.Error("Lookup leaked a shared reference; a caller's mutation reached the catalog")
}
}
// TestAccessorsReturnDeepCopies pins that EffortControl/BudgetControl hand back
// independent copies, so a caller holding a Capability that shares state with the
// catalog cannot corrupt it by mutating an accessor result's slice or pointers.
func TestAccessorsReturnDeepCopies(t *testing.T) {
min := 100
c := Capability{Reasoning: true, Controls: []Control{
{Kind: ControlEffort, Values: []string{"low", "high"}},
{Kind: ControlBudget, Min: &min},
}}
eff, _ := c.EffortControl()
eff.Values[0] = "MUTATED"
if c.Controls[0].Values[0] != "low" {
t.Error("EffortControl leaked the Values slice")
}
bud, _ := c.BudgetControl()
*bud.Min = 999
if *c.Controls[1].Min != 100 {
t.Error("BudgetControl leaked the Min pointer")
}
}
func TestLookupMissFallsThrough(t *testing.T) {
c := Embedded()
if _, ok := c.Lookup("openai", "totally-made-up-model"); ok {
t.Error("unknown model should miss")
}
if _, ok := c.Lookup("no-such-provider", "gpt-5"); ok {
t.Error("unknown provider should miss")
}
if _, ok := c.Lookup("openai", " "); ok {
t.Error("blank api model should miss")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
File diff suppressed because it is too large. Load diff
+64
View File
@@ -1 +1,65 @@
package reltime
import "fmt"
// RelTime renders a duration in seconds as a human-readable relative-time
// string. This is the ORIGINAL implementation, kept verbatim so
// characterization tests can be pinned against it before refactoring.
func RelTime(d int) string {
if d < 0 {
d = -d
if d < 60 {
if d == 1 {
return "1 second ago"
}
return fmt.Sprintf("%d seconds ago", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
}
x := d / 86400
if x == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", x)
} else if d == 0 {
return "just now"
} else {
if d < 60 {
if d == 1 {
return "in 1 second"
}
return fmt.Sprintf("in %d seconds", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "in 1 minute"
}
return fmt.Sprintf("in %d minutes", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "in 1 hour"
}
return fmt.Sprintf("in %d hours", h)
}
x := d / 86400
if x == 1 {
return "in 1 day"
}
return fmt.Sprintf("in %d days", x)
}
}
-12
View File
@@ -1,12 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package repomap
import (
"io/fs"
"github.com/Gitlawb/zero/internal/workspaceindex"
)
func handleWalkError(cleanRoot string, current string, entry fs.DirEntry, walkErr error, truncated *bool) (bool, error) {
return workspaceindex.HandleWalkError(cleanRoot, current, entry, walkErr, truncated)
}
+5
View File
@@ -2,6 +2,7 @@
package repomap
import (
"io/fs"
"sort"
"strings"
@@ -81,6 +82,10 @@ func Scan(root string, options Options) (Snapshot, error) {
return snapshot, err
}
func handleWalkError(cleanRoot string, current string, entry fs.DirEntry, walkErr error, truncated *bool) (bool, error) {
return workspaceindex.HandleWalkError(cleanRoot, current, entry, walkErr, truncated)
}
func fileDepth(rel string) int {
return workspaceindex.FileDepth(rel)
}
-11
View File
@@ -1,11 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package review
func HasBlockingChecks(checks []Check) bool {
for _, check := range checks {
if IsBlocking(check.Outcome) {
return true
}
}
return false
}
+9
View File
@@ -76,6 +76,15 @@ func BuildChecksFromEnv(env map[string]string) []Check {
return checks
}
func HasBlockingChecks(checks []Check) bool {
for _, check := range checks {
if IsBlocking(check.Outcome) {
return true
}
}
return false
}
func IsBlocking(outcome Outcome) bool {
switch outcome {
case OutcomeFailure, OutcomeCancelled, OutcomeTimedOut, OutcomeActionRequired, OutcomeUnknown:
+2 -99
View File
@@ -27,22 +27,11 @@ var destructivePrograms = map[string]bool{
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
}
var powerShellRemoveItemPrograms = map[string]bool{
"remove-item": true,
"ri": true,
"rd": true,
"rmdir": true,
"del": true,
"erase": true,
"rm": true,
}
// networkPrograms are commands that perform network egress/ingress.
var networkPrograms = map[string]bool{
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
"invoke-restmethod": true,
"ftp": true,
}
var localServerPrograms = map[string]bool{
@@ -76,76 +65,6 @@ func AnalyzeCommand(script string) AnalysisResult {
return result
}
// astCommandFields parses command with the shell parser and returns each simple
// command as its literal field slice (program + args as text), resolving the
// real command positions across quoting, command substitution, subshells, and
// newline separators — the constructs the hand-written splitter in
// safe_command.go mis-handles (issue #473). It returns nil when the command
// cannot be parsed (e.g. a Windows cmd.exe string), so callers fall through to
// the regex path rather than hard-blocking.
func astCommandFields(command string) [][]string {
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil {
return nil
}
var commands [][]string
syntax.Walk(file, func(node syntax.Node) bool {
call, ok := node.(*syntax.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
// EVERY word must be a static literal, not just the program name.
// wordText keeps only the literal/quoted parts of a word and silently
// drops expansions, so a dynamic word anywhere in the call reconstructs
// to something the shell will never run: `$(printf foo)vim` (runs as
// `foovim`) collapses to "vim", and `git $(printf foo)rebase -i` (runs
// as `git foorebase -i`, non-interactive) collapses to `git rebase -i`
// and fabricates an interactive match. Since the runtime value of an
// expansion is unknowable here, skip the whole call rather than classify
// a lossy reconstruction. Skipping is the safe direction: the
// hand-written passes above already ran, and a missed detection falls
// through to the normal permission prompt instead of hard-blocking a
// command the user never wrote.
for _, word := range call.Args {
if !isLiteralWord(word) {
return true
}
}
fields := make([]string, 0, len(call.Args))
for _, word := range call.Args {
fields = append(fields, wordText(word))
}
commands = append(commands, fields)
return true
})
return commands
}
// isLiteralWord reports whether every part of word is a static literal (bare or
// quoted). A word containing a command substitution, parameter/arithmetic
// expansion, process substitution, etc. is dynamic — its runtime value is
// unknown, so its wordText (a partial literal) must not be trusted as a program
// name.
func isLiteralWord(word *syntax.Word) bool {
if word == nil {
return false
}
for _, part := range word.Parts {
switch typed := part.(type) {
case *syntax.Lit, *syntax.SglQuoted:
case *syntax.DblQuoted:
for _, inner := range typed.Parts {
if _, ok := inner.(*syntax.Lit); !ok {
return false
}
}
default:
return false
}
}
return true
}
// analyzeInto parses script and folds its interactive/destructive/network usage
// into result, sharing seen so program names are de-duplicated across recursion.
func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, depth int) {
@@ -183,10 +102,7 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
(prog == "find" && hasFindDelete(rest)) {
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
result.Destructive = true
}
return true
@@ -493,19 +409,6 @@ func hasRecursiveForce(args []*syntax.Word) bool {
return recursive && force
}
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
recursive, force := false, false
for _, arg := range args {
switch strings.ToLower(wordText(arg)) {
case "-recurse", "-r":
recursive = true
case "-force":
force = true
}
}
return recursive && force
}
func hasFindDelete(args []*syntax.Word) bool {
for _, arg := range args {
if wordText(arg) == "-delete" {
-21
View File
@@ -45,27 +45,6 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
{name: "curl", script: "curl https://example.com", network: true},
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
-42
View File
@@ -2,7 +2,6 @@ package sandbox
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
@@ -74,25 +73,6 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
}
}
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
engine := NewEngine(EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: DefaultPolicy(),
})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
}
}
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
@@ -505,28 +485,6 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
}
}
func TestEnginePromptsForOutOfWorkspaceReadWithReadSpecificReason(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "external")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "list_directory",
SideEffect: SideEffectRead,
Permission: PermissionAllow,
WorkspaceRoot: root,
Args: map[string]any{"path": outside},
})
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if decision.Action != ActionPrompt || decision.Block == nil || !decision.Block.Recoverable {
t.Fatalf("outside read decision = %#v, want recoverable prompt", decision)
}
if decision.Reason != want || decision.Block.Reason != want {
t.Fatalf("outside read reasons = decision %q, block %q; want %q", decision.Reason, decision.Block.Reason, want)
}
}
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
-78
View File
@@ -1,78 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package sandbox
import (
"path/filepath"
"strings"
)
func FormatGrantList(grants []Grant) string {
return FormatGrantListWithCommandPrefixes(grants, nil)
}
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
}
// writeRoots returns the full ordered write-root list for command plans:
// the workspace root plus any granted extra roots. The single-root fallback
// only applies to engines built without a workspace root (NewEngine always
// builds a scope otherwise); it is kept as defense in depth.
func (engine *Engine) writeRoots(workspaceRoot string) []string {
var roots []string
if engine.scope != nil {
roots = engine.scope.Roots()
} else {
roots = []string{workspaceRoot}
}
// Reflect the policy's AllowWrite roots in the OS backend write binds so a
// sandboxed shell command may write where the policy grants writes. DenyWrite
// is enforced at the policy gate, and on sandbox-exec additionally as an
// explicit deny rule (see sandboxExecProfile).
policy := engine.effectivePolicy(engine.policy)
if extra := resolveWriteRootPaths(policy.AllowWrite); len(extra) > 0 {
roots = dedupeStrings(append(roots, extra...))
}
return roots
}
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
}
func sandboxExecProfile(writeRoots []string, policy Policy, denialTag string) string {
return seatbeltProfileFromPermissionProfile(seatbeltCompatibilityPermissionProfile(writeRoots, policy), policy, denialTag)
}
func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) PermissionProfile {
fs := FileSystemPolicy{
Kind: FileSystemUnrestricted,
ReadRoots: []string{string(filepath.Separator)},
IncludePlatformRoots: true,
AllowTemp: true,
}
if policy.EnforceWorkspace {
fs.Kind = FileSystemRestricted
fs.WriteRoots = make([]WritableRoot, 0, len(writeRoots))
for _, root := range writeRoots {
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
}
}
fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...))
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
return PermissionProfile{
FileSystem: fs,
Network: NetworkPolicy{Mode: policy.Network},
}
}
// WindowsSandboxSetupPathForRunner derives the setup helper's path from a
// standalone command-runner path (the sibling .exe in the release layout).
// Retained for that layout; self-dispatch callers use
// ResolveWindowsSandboxSetupHelper instead.
func WindowsSandboxSetupPathForRunner(runnerPath string) string {
if strings.TrimSpace(runnerPath) == "" {
return ""
}
return filepath.Join(filepath.Dir(runnerPath), WindowsSandboxSetupName)
}
+4
View File
@@ -427,6 +427,10 @@ func (store *GrantStore) Clear() (int, error) {
return count, nil
}
func FormatGrantList(grants []Grant) string {
return FormatGrantListWithCommandPrefixes(grants, nil)
}
func FormatGrantListWithCommandPrefixes(grants []Grant, prefixes []CommandPrefixGrant) string {
if len(grants) == 0 && len(prefixes) == 0 {
return "No persistent sandbox grants."
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more