mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d0bc478b | ||
|
|
b9b96c6f82 | ||
|
|
b8eec2d86e | ||
|
|
1550c4131c |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.5.0"}
|
||||
{".":"0.4.0"}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
¬ifier{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)
|
||||
(¬ifier{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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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
@@ -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"})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user