mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c4d65c88 | ||
|
|
bdade37c6c | ||
|
|
729d5ee96c | ||
|
|
9994ad49bb | ||
|
|
2a0229e729 | ||
|
|
f51de6dfc6 | ||
|
|
fcc6c72c34 | ||
|
|
a4a805a6c8 | ||
|
|
9fe02ae52e | ||
|
|
9d5c82d441 | ||
|
|
97ef90d27a | ||
|
|
5c58256abc | ||
|
|
fa8734f179 | ||
|
|
5bb1bc11c3 | ||
|
|
d9b882eb0a | ||
|
|
a50574f673 | ||
|
|
c00a4734b5 | ||
|
|
2479884dea | ||
|
|
ab2f9fbd3e | ||
|
|
baa4be13ac | ||
|
|
ddfdf2878e | ||
|
|
96859c9bd1 | ||
|
|
3524f795e5 | ||
|
|
b1f41735a7 | ||
|
|
4945684fa2 | ||
|
|
5140d4bf7b | ||
|
|
3967d49d64 | ||
|
|
89bdc6719a | ||
|
|
722bb31216 | ||
|
|
a21a052a4a | ||
|
|
f079b90f82 | ||
|
|
b30c3971b4 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.4.0"}
|
||||
{".":"0.5.0"}
|
||||
@@ -6,6 +6,53 @@ 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,6 +67,28 @@ 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,6 +289,7 @@ 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` / `DiscoveryRoots`; single-root `Load` remains for install/write.
|
||||
`LoadFromRoots`; single-root `Load` remains for install/write.
|
||||
- **Hooks** can observe or block tool lifecycle events.
|
||||
|
||||
## End-to-End Data Flow
|
||||
|
||||
+178
-27
@@ -11,6 +11,9 @@ 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"
|
||||
@@ -23,9 +26,10 @@ 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)
|
||||
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)
|
||||
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)
|
||||
// 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.
|
||||
@@ -60,13 +64,16 @@ 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
|
||||
turnMu sync.Mutex
|
||||
modelMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string // override; "" => config default
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string
|
||||
models []SessionConfigOptionValue
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -83,9 +90,6 @@ 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) {
|
||||
@@ -120,7 +124,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionNew(ctx 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")
|
||||
@@ -129,18 +133,23 @@ func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
|
||||
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})
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil)
|
||||
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
Modes: a.modeState(sess),
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionLoad(ctx 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")
|
||||
@@ -161,7 +170,17 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
// 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)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
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)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
@@ -169,7 +188,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
historyErr,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
Modes: a.modeState(sess),
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -354,11 +374,27 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if p.ConfigID != configIDModel {
|
||||
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:
|
||||
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
||||
}
|
||||
sess.setModel(p.Value)
|
||||
return SetSessionConfigOptionResult{}, nil
|
||||
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -370,8 +406,24 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
sess.setModel(p.Model)
|
||||
return ZeroSetModelResult{Model: p.Model}, nil
|
||||
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
|
||||
}
|
||||
|
||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
@@ -398,6 +450,76 @@ 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 {
|
||||
@@ -525,13 +647,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) *acpSession {
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *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, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
}
|
||||
@@ -570,7 +692,18 @@ 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()
|
||||
}
|
||||
@@ -581,6 +714,24 @@ 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,6 +3,7 @@ package acp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ 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"
|
||||
@@ -137,6 +139,12 @@ 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
|
||||
@@ -156,6 +164,201 @@ 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()
|
||||
@@ -181,6 +384,15 @@ 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")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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,10 +103,6 @@ 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
|
||||
@@ -233,10 +229,6 @@ 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"`
|
||||
@@ -337,19 +329,22 @@ type SetSessionModeParams struct {
|
||||
|
||||
type SetSessionModeResult struct{}
|
||||
|
||||
// ---- session config options (model selection) ----
|
||||
// ---- session config options ----
|
||||
|
||||
type SessionConfigOptionValue struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type SessionConfigOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Values []SessionConfigOptionValue `json:"values,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionParams struct {
|
||||
@@ -373,6 +368,11 @@ type ZeroSetModelResult struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
|
||||
// through the standard session/set_config_option method.
|
||||
const configIDModel = "model"
|
||||
const (
|
||||
configIDModel = "model"
|
||||
configIDMode = "mode"
|
||||
|
||||
configOptionTypeSelect = "select"
|
||||
configCategoryModel = "model"
|
||||
configCategoryMode = "mode"
|
||||
)
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
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.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
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.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
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.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
|
||||
@@ -501,15 +501,3 @@ 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,15 +610,6 @@ 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.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
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.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
_, 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.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
// 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.NewReadFileTool(t.TempDir()))
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
|
||||
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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
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.NewReadFileTool(root)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // 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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||
DisabledTools: []string{"read_file"},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||
|
||||
+82
-57
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -266,10 +267,6 @@ 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
|
||||
@@ -1170,6 +1167,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
decisionCommandPrefix = grant.Prefix
|
||||
}
|
||||
}
|
||||
// An explicit additional-permissions request is an elevation only when it
|
||||
// expands the current effective policy. If the same capability was already
|
||||
// approved for this session, reuse that grant across equivalent commands
|
||||
// instead of prompting again merely because their command prefixes differ.
|
||||
if toolFound && !permissionGranted && isShellCommandTool(call.Name) && options.Sandbox != nil {
|
||||
if profile, requested, profileErr := inlineAdditionalPermissionsProfile(args, options.Cwd); profileErr == nil && requested && options.Sandbox.CoversRequestPermissions(profile) {
|
||||
permissionGranted = true
|
||||
decisionAction = PermissionDecisionAllowForSession
|
||||
decisionReason = "requested sandbox capabilities already granted for this session"
|
||||
}
|
||||
}
|
||||
|
||||
var preflightDecision *sandbox.Decision
|
||||
if toolFound && options.Sandbox != nil {
|
||||
@@ -1416,17 +1424,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// the agent loop and the MCP server pass through), so result.Output is
|
||||
// already redacted here and result.Redacted reflects whether it changed.
|
||||
return ToolResult{
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
// the Run turn loop performs the actual provider switch. Empty for every
|
||||
@@ -1546,8 +1555,15 @@ func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, resu
|
||||
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
|
||||
return false
|
||||
}
|
||||
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
||||
return false
|
||||
if result.ExecutionOutcome != nil {
|
||||
denial := result.ExecutionOutcome.Denial
|
||||
if denial == nil || denial.Capability.Kind != execution.CapabilityExternalNetwork {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return false
|
||||
@@ -1600,6 +1616,11 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
|
||||
}
|
||||
|
||||
func sandboxDeniedShellResult(result tools.Result) bool {
|
||||
if result.ExecutionOutcome != nil {
|
||||
// Typed denials must be handled by their exact capability path. Never
|
||||
// turn a structured narrow denial into the legacy unrestricted retry.
|
||||
return false
|
||||
}
|
||||
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
||||
}
|
||||
|
||||
@@ -1728,17 +1749,18 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
}
|
||||
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
RequestedModel: meta["escalate_to_model"],
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
RequestedModel: meta["escalate_to_model"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1998,15 +2020,16 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
Cwd: options.Cwd,
|
||||
})
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
Display: result.Display,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.Display,
|
||||
}
|
||||
}
|
||||
return ToolResult{
|
||||
@@ -2570,7 +2593,8 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
|
||||
scopeText = requestPermissionsScope(profile)
|
||||
}
|
||||
reason = userFacingPermissionReason(call.Name, args, reason)
|
||||
decisionReason := strings.TrimSpace(reason)
|
||||
reason = userFacingPermissionReason(call.Name, args, reason, safety.Reason)
|
||||
|
||||
return PermissionEvent{
|
||||
ToolCallID: call.ID,
|
||||
@@ -2583,6 +2607,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
Autonomy: options.Autonomy,
|
||||
SideEffect: string(safety.SideEffect),
|
||||
Reason: reason,
|
||||
DecisionReason: decisionReason,
|
||||
Scope: scopeText,
|
||||
Risk: risk,
|
||||
Block: block,
|
||||
@@ -2592,18 +2617,31 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}, true
|
||||
}
|
||||
|
||||
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
|
||||
func userFacingPermissionReason(toolName string, args map[string]any, reason string, fallback string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return reason
|
||||
}
|
||||
if justification, ok := firstStringArg(args, "justification"); ok {
|
||||
return justification
|
||||
|
||||
// Policy and sandbox explanations are authoritative. The tool's static
|
||||
// safety copy is only an internal fallback; presenting it as the reason for
|
||||
// every ordinary shell approval makes prompts generic and obscures the cases
|
||||
// where policy has an actionable explanation.
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
if reason != "" && reason != fallback && reason != "tool requires approval before execution" && reason != sandbox.ReasonEscalatedSandboxRequired {
|
||||
return reason
|
||||
}
|
||||
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
|
||||
|
||||
if shellCommandRequiresEscalated(args) {
|
||||
if justification, ok := firstStringArg(args, "justification"); ok {
|
||||
return justification
|
||||
}
|
||||
return "This command needs to run outside the sandbox."
|
||||
}
|
||||
return reason
|
||||
|
||||
// Ordinary shell approvals need no invented explanation. The raw fallback is
|
||||
// retained separately as DecisionReason for traces and policy auditing.
|
||||
return ""
|
||||
}
|
||||
|
||||
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
|
||||
@@ -2824,19 +2862,6 @@ 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.
|
||||
|
||||
+113
-53
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
@@ -26,6 +27,59 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
options := Options{Sandbox: engine}
|
||||
legacyMeta := map[string]string{
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
|
||||
}
|
||||
|
||||
applicationFailure := tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Meta: legacyMeta,
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateFailed,
|
||||
Kind: execution.OutcomeApplicationFailure,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
},
|
||||
}
|
||||
if sandboxDeniedNetworkRetryCandidate(call, nil, applicationFailure, options) || sandboxRestrictedShellRetryCandidate(call, nil, applicationFailure, options) {
|
||||
t.Fatal("typed application failure must not be retried from legacy sandbox-like text")
|
||||
}
|
||||
|
||||
protectedDenial := applicationFailure
|
||||
protectedDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
|
||||
Source: execution.DenialSourceConfiguredPolicy,
|
||||
Reason: "protected metadata",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, protectedDenial, options) {
|
||||
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
networkDenial := protectedDenial
|
||||
networkDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityExternalNetwork},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "external network denied",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if !sandboxDeniedNetworkRetryCandidate(call, nil, networkDenial, options) {
|
||||
t.Fatal("typed external-network denial should use the narrow network approval path")
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests = append(provider.requests, request)
|
||||
|
||||
@@ -809,9 +863,6 @@ 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")
|
||||
}
|
||||
@@ -829,7 +880,7 @@ func TestRunNormalCompletionIsNotTruncated(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Truncated() || result.TruncationNotice() != "" {
|
||||
if result.TruncationNotice() != "" {
|
||||
t.Fatalf("normal completion reported as truncated: reason=%q", result.FinishReason)
|
||||
}
|
||||
}
|
||||
@@ -906,7 +957,7 @@ func TestRunEmitsUsageEvents(t *testing.T) {
|
||||
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1113,9 +1164,9 @@ func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewGrepTool(root))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedGrepTool(root, nil))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1143,7 +1194,7 @@ func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1180,7 +1231,7 @@ func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
func TestRunRejectsToolCallsOutsideEnabledList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1218,7 +1269,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.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1302,7 +1353,7 @@ func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
|
||||
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1370,7 +1421,7 @@ func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1417,8 +1468,8 @@ func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1493,7 +1544,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.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1550,7 +1601,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1593,7 +1644,7 @@ func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1649,7 +1700,7 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1698,7 +1749,7 @@ func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing
|
||||
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1746,7 +1797,7 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write should not continue")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1842,7 +1893,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
@@ -1914,7 +1965,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1995,7 +2046,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2197,7 +2248,7 @@ func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
|
||||
@@ -2304,7 +2355,7 @@ func TestRunPersistentCommandPrefixStillPromptsForNetwork(t *testing.T) {
|
||||
t.Fatalf("seed command prefix: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2353,7 +2404,11 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "PATH=.:$PATH curl https://example.com"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
if windowsTestUsesPowerShell() {
|
||||
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
|
||||
} else {
|
||||
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)
|
||||
@@ -2365,7 +2420,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2432,7 +2487,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2474,13 +2529,17 @@ 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.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2544,7 +2603,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.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2576,6 +2635,12 @@ 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 {
|
||||
@@ -2608,7 +2673,7 @@ func TestRunCancellationPreservesContextCanceledIdentity(t *testing.T) {
|
||||
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2660,7 +2725,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2839,7 +2904,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2888,7 +2953,7 @@ func TestRunStopsAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
@@ -2918,7 +2983,7 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3109,22 +3174,17 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
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 Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
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")
|
||||
}
|
||||
// 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"`} {
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
|
||||
t.Fatalf("expected selected Windows shell guidance to mention %q, 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)
|
||||
}
|
||||
@@ -3177,7 +3237,7 @@ func TestBuildSystemPromptAllowsSpecModeOverride(t *testing.T) {
|
||||
func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
specmode.RegisterDraftTools(registry, root, nil)
|
||||
@@ -3214,7 +3274,7 @@ func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
provider := providerCallingWriteFileThenAnswer("done")
|
||||
|
||||
result, err := Run(context.Background(), "draft", provider, Options{
|
||||
@@ -3247,7 +3307,7 @@ func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3388,7 +3448,7 @@ func TestRunSurfacesDroppedToolCallAlongsideValidCall(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
|
||||
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.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // 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"})
|
||||
|
||||
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "npm install",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
|
||||
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
|
||||
t.Fatal("an already-covered capability must not trigger another prompt")
|
||||
}
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("covered capability should still produce an auditable allow event")
|
||||
}
|
||||
if event.Action != PermissionActionAllow {
|
||||
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
|
||||
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "make test"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("generic shell approval must produce an event")
|
||||
}
|
||||
if event.Reason != "" {
|
||||
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
|
||||
}
|
||||
if event.DecisionReason != "runs a shell command" {
|
||||
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
|
||||
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "curl https://example.test",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
|
||||
"justification": "Run without sandboxing.",
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("policy approval must produce an event")
|
||||
}
|
||||
if event.Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
|
||||
// the auto-mode advertised set, including exec_command: auto mode must expose both
|
||||
// process creation and write_stdin process interaction or the latter is unusable
|
||||
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
maxEagerToolSchemaTokens = 3550
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -310,11 +311,7 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("<environment>\n")
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\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")
|
||||
}
|
||||
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||
b.WriteString("Git branch: " + branch + "\n")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -71,7 +72,10 @@ type ToolResult struct {
|
||||
Meta map[string]string
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
Display tools.Display
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
Display tools.Display
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
@@ -423,13 +427,6 @@ 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,10 +31,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -3,11 +3,10 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
||||
@@ -22,10 +21,7 @@ var (
|
||||
// the negative PID (the group), so any process the child forks dies with it
|
||||
// instead of being orphaned. Must be called before cmd.Start.
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
execution.ConfigureProcessGroup(cmd)
|
||||
}
|
||||
|
||||
// terminateProcess stops a background process. It first asks politely with
|
||||
@@ -41,71 +37,5 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
// back to signalling only the individual PID, which also avoids reporting a false
|
||||
// success when a non-leader group-signal returns ESRCH.
|
||||
func terminateProcess(pid int) error {
|
||||
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
|
||||
// every process we can signal. A real child PID is always > 1, so never let a
|
||||
// bogus 0/1 expand into either.
|
||||
if pid <= 1 {
|
||||
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
|
||||
}
|
||||
|
||||
// Signal the whole group only when pid leads its own group; otherwise the
|
||||
// individual process. See the doc comment for why a non-leader is signalled
|
||||
// directly rather than via its (foreign) group.
|
||||
target := pid
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if processGoneError(err) {
|
||||
return nil // already gone
|
||||
}
|
||||
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if processGoneError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Poll liveness so we return promptly once it exits, rather than always
|
||||
// waiting out the full grace period.
|
||||
deadline := time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Still alive after the grace period: force-kill.
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
|
||||
// so this helper only reports success once the target is actually gone —
|
||||
// otherwise the caller gets nil while descendants are still racing.
|
||||
deadline = time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processGoneError reports whether an error means the process group has already
|
||||
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
|
||||
// no process in the target group remains.
|
||||
func processGoneError(err error) bool {
|
||||
return errors.Is(err, syscall.ESRCH)
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
@@ -3,36 +3,16 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
|
||||
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
||||
// setup is required (the POSIX build sets Setpgid here instead).
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
|
||||
|
||||
func terminateProcess(pid int) error {
|
||||
taskkill := taskkillPath()
|
||||
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = os.Getenv("windir")
|
||||
}
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
||||
return execution.TerminateProcessTree(pid, 0, 0)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -9,6 +10,7 @@ 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"
|
||||
)
|
||||
@@ -43,6 +45,9 @@ 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.
|
||||
|
||||
+34
-16
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
@@ -58,7 +59,11 @@ 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)
|
||||
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
|
||||
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
|
||||
@@ -118,6 +123,7 @@ 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 {
|
||||
@@ -368,6 +374,8 @@ 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":
|
||||
@@ -678,6 +686,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
|
||||
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
@@ -712,6 +740,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
|
||||
PermissionStore: mcpPermissionStore,
|
||||
Autonomy: mcp.AutonomyLow,
|
||||
Execution: executionRunner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
@@ -738,7 +768,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// The interactive TUI is not worktree-reassigned, so the trust root is the
|
||||
// launch directory itself.
|
||||
trustRoot := workspaceRoot
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
||||
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
||||
// apply_patch) would never be offered to the model — the TUI could neither edit
|
||||
@@ -760,19 +790,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// unchanged. The interactive surface applies no operator tool filters, so
|
||||
// enabled/disabled are nil — matching the AgentOptions below.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
@@ -789,7 +806,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
|
||||
// report can be combined with the plugin activation's, and emit at most one
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
@@ -1157,6 +1174,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -252,6 +253,115 @@ 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 {
|
||||
@@ -567,6 +677,18 @@ 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,6 +177,15 @@ 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()
|
||||
|
||||
+35
-29
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -206,6 +207,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
executionRunner := execution.NewRunner(nil)
|
||||
// Register the escalate_model tool only when the run opted into mid-run
|
||||
// escalation. It is registered before --list-tools formatting and
|
||||
// validateExecToolFilters so the tool is both listable and filter-validatable.
|
||||
@@ -238,16 +240,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
permissionMode = agent.PermissionModeSpecDraft
|
||||
}
|
||||
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
var mcpRuntime mcpToolRuntime
|
||||
var mcpSkip trustSkip
|
||||
// Make local plugins live for this run: register their declared tools into the
|
||||
// registry and collect their hooks + skill roots for the dispatcher and skill
|
||||
// tool below. Done before --list-tools and filter validation so plugin tools
|
||||
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
var pluginActivation pluginActivation
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
@@ -290,6 +289,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
if notice, err := sandboxEngine.ConsumeGrantMigrationNotice(); err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", "migrate sandbox grants: "+err.Error())
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
@@ -357,28 +379,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
images = nil
|
||||
}
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
// Re-register the core tools with the run scope, OVERWRITING the nil-scope
|
||||
// instances registered before config resolve (the registry must exist that
|
||||
// early for --list-tools and tool-filter validation, which run without a
|
||||
// provider). This is safe only while two invariants hold:
|
||||
// 1. Registry.Register replaces by NAME, so every path-confining core
|
||||
// tool is swapped wholesale; and
|
||||
// 2. nothing between the initial registration and this point captures a
|
||||
// core-tool INSTANCE (tool_search holds the *Registry* and resolves
|
||||
// names lazily; --list-tools and filter validation use names only).
|
||||
// A new wrapper that snapshots a core tool before this line would silently
|
||||
// ship nil-scope enforcement — add it below this re-registration instead.
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
runReasoningEffort := options.reasoningEffort
|
||||
if options.useSpec && options.specReasoningEffort != "" {
|
||||
runReasoningEffort = options.specReasoningEffort
|
||||
@@ -624,7 +624,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Build the hooks dispatcher out of the struct literal so its trust skip report
|
||||
// can be combined with the plugin activation's, and emit at most one notice when
|
||||
// project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
@@ -939,6 +939,12 @@ 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:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -114,7 +115,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
|
||||
// repo trusted. Emit at most one notice when project hooks or the project MCP
|
||||
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
||||
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
|
||||
@@ -78,6 +78,8 @@ 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:
|
||||
@@ -561,6 +563,7 @@ 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
|
||||
`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
@@ -56,8 +57,8 @@ func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool
|
||||
//
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment); the project layer loads only when that root is trusted.
|
||||
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
|
||||
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
|
||||
}
|
||||
|
||||
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
|
||||
@@ -72,7 +73,7 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
|
||||
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
|
||||
// returned trustSkip lets the caller emit one combined notice; the notice itself
|
||||
// is NOT emitted here.
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
|
||||
@@ -106,10 +107,15 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
|
||||
}
|
||||
config.Hooks = merged
|
||||
}
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
return hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: config,
|
||||
Audit: audit,
|
||||
Cwd: workspaceRoot,
|
||||
Config: config,
|
||||
Audit: audit,
|
||||
Cwd: workspaceRoot,
|
||||
Execution: runner,
|
||||
}), skip
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
@@ -29,7 +30,7 @@ type mcpToolListItem struct {
|
||||
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
|
||||
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
|
||||
// otherwise a workspace whose only project config is MCP would be gated silently.
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
|
||||
@@ -46,9 +47,15 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
|
||||
if err != nil {
|
||||
return nil, skip, err
|
||||
}
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
||||
PermissionStore: store,
|
||||
Autonomy: autonomy,
|
||||
Execution: runner,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
})
|
||||
return runtime, skip, err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -42,7 +43,7 @@ type pluginActivation struct {
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment). The returned activation carries the skip report so the caller can
|
||||
// emit one combined notice; the notice itself is NOT emitted here.
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string, runners ...*execution.Runner) pluginActivation {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
|
||||
@@ -65,7 +66,11 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
writePluginActivationWarning(stderr, formatLoadDiagnostic(diagnostic))
|
||||
}
|
||||
|
||||
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot})
|
||||
var runner *execution.Runner
|
||||
if len(runners) > 0 {
|
||||
runner = runners[0]
|
||||
}
|
||||
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot, Execution: runner})
|
||||
for _, warning := range result.Warnings {
|
||||
writePluginActivationWarning(stderr, warning)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 {
|
||||
@@ -137,7 +138,12 @@ 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) {
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
|
||||
resolver, loginKey := oauthLoginForProfile(profile)
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{
|
||||
OAuthResolver: resolver,
|
||||
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
|
||||
UserAgent: userAgent(),
|
||||
})
|
||||
}
|
||||
|
||||
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
|
||||
|
||||
@@ -74,11 +74,18 @@ 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 {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
payload := map[string]any{
|
||||
"activeProvider": cfg.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
}); err != nil {
|
||||
}
|
||||
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 {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -86,9 +93,32 @@ 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,6 +75,102 @@ 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,3 +1001,18 @@ 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,16 +42,20 @@ var errProviderCommandTimeout = errors.New("provider command timeout")
|
||||
|
||||
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
|
||||
cmd := shellCommand(command)
|
||||
configureCommandProcess(cmd)
|
||||
// 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
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
proc, err := startCommandProcess(cmd)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer proc.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -63,9 +67,21 @@ 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:
|
||||
terminateCommandProcess(cmd)
|
||||
proc.Terminate()
|
||||
<-done
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -67,7 +69,8 @@ func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10})
|
||||
pidFile := filepath.Join(t.TempDir(), "sleep.pid")
|
||||
command := writeCommand(t, commandScript{SleepSeconds: 10, PidFile: pidFile})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
@@ -85,6 +88,102 @@ 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) {
|
||||
@@ -118,6 +217,14 @@ 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 {
|
||||
@@ -128,7 +235,11 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
path := filepath.Join(dir, "provider.cmd")
|
||||
lines := []string{"@echo off"}
|
||||
if script.SleepSeconds > 0 {
|
||||
lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"")
|
||||
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+"\"")
|
||||
}
|
||||
if script.Stdout != "" {
|
||||
lines = append(lines, "echo "+script.Stdout)
|
||||
@@ -136,6 +247,15 @@ 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)
|
||||
@@ -146,6 +266,9 @@ 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 != "" {
|
||||
@@ -154,6 +277,10 @@ 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)
|
||||
@@ -161,6 +288,19 @@ 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"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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,18 +3,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
type commandProcess struct {
|
||||
groupID int
|
||||
anchor *exec.Cmd
|
||||
anchorInput io.WriteCloser
|
||||
}
|
||||
|
||||
func terminateCommandProcess(cmd *exec.Cmd) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
// 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
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = cmd.Process.Kill()
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//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,23 +3,165 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func configureCommandProcess(cmd *exec.Cmd) {}
|
||||
// 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 terminateCommandProcess(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}
|
||||
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
|
||||
}
|
||||
taskkill := taskkillPath()
|
||||
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
|
||||
_ = cmd.Process.Kill()
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//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,6 +93,16 @@ 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)
|
||||
}
|
||||
|
||||
@@ -216,6 +226,9 @@ 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
|
||||
}
|
||||
@@ -273,6 +286,10 @@ 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
|
||||
@@ -688,6 +705,9 @@ 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,6 +1744,85 @@ 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,6 +62,12 @@ 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
|
||||
@@ -316,14 +322,6 @@ 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,7 +3,6 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Issue is a single structured problem found while validating a config file.
|
||||
@@ -13,28 +12,6 @@ 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,39 +431,6 @@ 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.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
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.CoreTools(root) {
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package cron
|
||||
|
||||
func (s Schedule) String() string { return s.expr }
|
||||
@@ -23,8 +23,6 @@ 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
|
||||
|
||||
+42
-7
@@ -99,35 +99,60 @@ func (s *Store) Add(job Job) (Job, error) {
|
||||
job.Status = StatusActive
|
||||
}
|
||||
job.CreatedAt = s.now().UTC()
|
||||
id, err := s.allocID()
|
||||
id, unlock, err := s.reserveID()
|
||||
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.writeJob(job); err != nil {
|
||||
if err := s.writeReservedJob(job); err != nil {
|
||||
return Job{}, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) allocID() (string, error) {
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.root, id)); errors.Is(err, os.ErrNotExist) {
|
||||
return id, nil
|
||||
unlock, err := s.lockJob(id)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
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 "", errors.New("could not allocate a unique cron job id")
|
||||
return "", nil, 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). allocID-generated timestamp ids always pass; this guards
|
||||
// traversal). reserveID-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
|
||||
@@ -153,6 +178,16 @@ 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,9 +2,11 @@ package cron
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -74,6 +76,160 @@ 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,16 +37,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxObservedFiles = 20_000
|
||||
maxHashedFileBytes = 1 << 20
|
||||
maxTotalHashedBytes = 32 << 20
|
||||
)
|
||||
|
||||
type ChangeKind string
|
||||
|
||||
const (
|
||||
ChangeCreated ChangeKind = "created"
|
||||
ChangeModified ChangeKind = "modified"
|
||||
ChangeDeleted ChangeKind = "deleted"
|
||||
)
|
||||
|
||||
type Change struct {
|
||||
Path string `json:"path"`
|
||||
Kind ChangeKind `json:"kind"`
|
||||
Aggregated bool `json:"aggregated,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type fileFingerprint struct {
|
||||
Mode fs.FileMode
|
||||
Size int64
|
||||
ModTime int64
|
||||
Digest [sha256.Size]byte
|
||||
Hashed bool
|
||||
// Aggregated marks a bounded fingerprint for a generated directory. The
|
||||
// observer never enumerates that tree into individual changes.
|
||||
Aggregated bool
|
||||
Count int
|
||||
}
|
||||
|
||||
// ChangeObserver records a bounded workspace snapshot around a command. It
|
||||
// deliberately skips Zero/repository control metadata and large generated
|
||||
// dependency trees; those paths must neither be read as command evidence nor
|
||||
// flood the Files panel.
|
||||
type ChangeObserver struct {
|
||||
root string
|
||||
before map[string]fileFingerprint
|
||||
valid bool
|
||||
}
|
||||
|
||||
func NewChangeObserver(root string) *ChangeObserver {
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
before, ok := snapshotWorkspace(root)
|
||||
return &ChangeObserver{root: root, before: before, valid: ok}
|
||||
}
|
||||
|
||||
func (observer *ChangeObserver) Changes() []Change {
|
||||
if observer == nil || !observer.valid {
|
||||
return nil
|
||||
}
|
||||
after, ok := snapshotWorkspace(observer.root)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
changes := make([]Change, 0)
|
||||
for path, current := range after {
|
||||
previous, existed := observer.before[path]
|
||||
if !existed {
|
||||
changes = append(changes, changeFromFingerprint(path, ChangeCreated, current))
|
||||
continue
|
||||
}
|
||||
if previous != current {
|
||||
changes = append(changes, changeFromFingerprint(path, ChangeModified, current))
|
||||
}
|
||||
}
|
||||
for path, previous := range observer.before {
|
||||
if _, exists := after[path]; !exists {
|
||||
changes = append(changes, changeFromFingerprint(path, ChangeDeleted, previous))
|
||||
}
|
||||
}
|
||||
sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path })
|
||||
return changes
|
||||
}
|
||||
|
||||
func snapshotWorkspace(root string) (map[string]fileFingerprint, bool) {
|
||||
if root == "" || root == "." {
|
||||
return nil, false
|
||||
}
|
||||
files := make(map[string]fileFingerprint)
|
||||
hashedBytes := int64(0)
|
||||
ok := true
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if protectedObservationDirectory(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if generatedObservationDirectory(entry.Name()) {
|
||||
files[filepath.ToSlash(relative)+"/"] = generatedTreeFingerprint(path)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !entry.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if len(files) >= maxObservedFiles {
|
||||
ok = false
|
||||
return fs.SkipAll
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fingerprint := fileFingerprint{Mode: info.Mode(), Size: info.Size(), ModTime: info.ModTime().UnixNano()}
|
||||
if info.Size() <= maxHashedFileBytes && hashedBytes+info.Size() <= maxTotalHashedBytes {
|
||||
if digest, err := hashObservedFile(path); err == nil {
|
||||
fingerprint.Digest = digest
|
||||
fingerprint.Hashed = true
|
||||
hashedBytes += info.Size()
|
||||
}
|
||||
}
|
||||
files[filepath.ToSlash(relative)] = fingerprint
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return files, ok
|
||||
}
|
||||
|
||||
func protectedObservationDirectory(name string) bool {
|
||||
switch name {
|
||||
case ".git", ".zero", ".agents":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func generatedObservationDirectory(name string) bool {
|
||||
switch name {
|
||||
case "node_modules", ".pnpm-store", ".yarn", "dist", "coverage", ".cache":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// generatedTreeFingerprint samples only direct children and caps the work. It
|
||||
// is intentionally qualitative: the UI needs to say that a generated tree
|
||||
// changed, not inventory dependency contents.
|
||||
func generatedTreeFingerprint(root string) fileFingerprint {
|
||||
directory, err := os.Open(root)
|
||||
if err != nil {
|
||||
return fileFingerprint{Aggregated: true, Count: -1}
|
||||
}
|
||||
defer directory.Close()
|
||||
const maxEntries = 256
|
||||
entries, err := directory.ReadDir(maxEntries + 1)
|
||||
if err != nil && err != io.EOF {
|
||||
return fileFingerprint{Aggregated: true, Count: -1}
|
||||
}
|
||||
hash := sha256.New()
|
||||
count := len(entries)
|
||||
if count > maxEntries {
|
||||
entries = entries[:maxEntries]
|
||||
count = -1
|
||||
}
|
||||
for _, entry := range entries {
|
||||
_, _ = io.WriteString(hash, entry.Name())
|
||||
if info, infoErr := entry.Info(); infoErr == nil {
|
||||
_, _ = io.WriteString(hash, info.Mode().String())
|
||||
_, _ = io.WriteString(hash, strconv.FormatInt(info.ModTime().UnixNano(), 10))
|
||||
}
|
||||
}
|
||||
var digest [sha256.Size]byte
|
||||
copy(digest[:], hash.Sum(nil))
|
||||
return fileFingerprint{Digest: digest, Hashed: true, Aggregated: true, Count: count}
|
||||
}
|
||||
|
||||
func changeFromFingerprint(path string, kind ChangeKind, fingerprint fileFingerprint) Change {
|
||||
return Change{Path: path, Kind: kind, Aggregated: fingerprint.Aggregated, Count: fingerprint.Count}
|
||||
}
|
||||
|
||||
func hashObservedFile(path string) ([sha256.Size]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
var digest [sha256.Size]byte
|
||||
copy(digest[:], hash.Sum(nil))
|
||||
return digest, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChangeObserverReportsCreateModifyAndDelete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
modified := filepath.Join(root, "modified.txt")
|
||||
deleted := filepath.Join(root, "deleted.txt")
|
||||
if err := os.WriteFile(modified, []byte("before"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(deleted, []byte("delete"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observer := NewChangeObserver(root)
|
||||
if err := os.WriteFile(modified, []byte("after!"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(deleted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "src", "created.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Change{
|
||||
{Path: "deleted.txt", Kind: ChangeDeleted},
|
||||
{Path: "modified.txt", Kind: ChangeModified},
|
||||
{Path: "src/created.ts", Kind: ChangeCreated},
|
||||
}
|
||||
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Changes() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeObserverSkipsControlMetadataAndSummarizesGeneratedTrees(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
observer := NewChangeObserver(root)
|
||||
for _, path := range []string{
|
||||
filepath.Join(root, ".zero", "config.json"),
|
||||
filepath.Join(root, ".agents", "skills", "x"),
|
||||
filepath.Join(root, ".git", "config"),
|
||||
filepath.Join(root, "node_modules", "pkg", "index.js"),
|
||||
filepath.Join(root, "dist", "bundle.js"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := []Change{
|
||||
{Path: "dist/", Kind: ChangeCreated, Aggregated: true, Count: 1},
|
||||
{Path: "node_modules/", Kind: ChangeCreated, Aggregated: true, Count: 1},
|
||||
}
|
||||
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Changes() = %#v, want generated summaries without control metadata %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Package execution defines the platform-neutral command execution contract.
|
||||
// Frontends and tools use these types without depending on native sandbox or
|
||||
// process-management details.
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const PolicyVersion = 1
|
||||
|
||||
type Origin string
|
||||
|
||||
const (
|
||||
OriginInteractiveCommand Origin = "interactive_command"
|
||||
OriginHook Origin = "hook"
|
||||
OriginPlugin Origin = "plugin"
|
||||
OriginMCPServer Origin = "mcp_server"
|
||||
OriginBackgroundTask Origin = "background_task"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeCaptured Mode = "captured"
|
||||
ModeInteractive Mode = "interactive"
|
||||
ModeDurable Mode = "durable"
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
Name string `json:"name"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
type CapabilityKind string
|
||||
|
||||
const (
|
||||
CapabilityRead CapabilityKind = "filesystem_read"
|
||||
CapabilityWorkspaceWrite CapabilityKind = "workspace_write"
|
||||
CapabilityProtectedMetadata CapabilityKind = "protected_metadata"
|
||||
CapabilityExternalNetwork CapabilityKind = "external_network"
|
||||
CapabilityIsolatedLoopback CapabilityKind = "isolated_loopback"
|
||||
CapabilityHostVisibleBinding CapabilityKind = "host_visible_binding"
|
||||
CapabilityProcessSpawn CapabilityKind = "process_spawn"
|
||||
CapabilityProcessControl CapabilityKind = "process_control"
|
||||
CapabilityEnvironment CapabilityKind = "environment"
|
||||
CapabilityUnrestricted CapabilityKind = "unrestricted"
|
||||
)
|
||||
|
||||
type Capability struct {
|
||||
Kind CapabilityKind `json:"kind"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
type ApprovalContext struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
PolicyVersion int `json:"policyVersion"`
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Origin Origin `json:"origin"`
|
||||
Mode Mode `json:"mode"`
|
||||
Command Command `json:"command"`
|
||||
WorkingDirectory string `json:"workingDirectory"`
|
||||
WorkspaceRoots []string `json:"workspaceRoots"`
|
||||
Capabilities []Capability `json:"capabilities,omitempty"`
|
||||
Approval ApprovalContext `json:"approval"`
|
||||
}
|
||||
|
||||
func (request Request) Validate() error {
|
||||
if !validOrigin(request.Origin) {
|
||||
return errors.New("execution request requires a valid origin")
|
||||
}
|
||||
if request.Mode != ModeCaptured && request.Mode != ModeInteractive && request.Mode != ModeDurable {
|
||||
return errors.New("execution request requires a valid mode")
|
||||
}
|
||||
if strings.TrimSpace(request.Command.Name) == "" {
|
||||
return errors.New("execution request requires a command")
|
||||
}
|
||||
if strings.TrimSpace(request.WorkingDirectory) == "" {
|
||||
return errors.New("execution request requires a working directory")
|
||||
}
|
||||
if len(request.WorkspaceRoots) == 0 {
|
||||
return errors.New("execution request requires at least one workspace root")
|
||||
}
|
||||
for _, root := range request.WorkspaceRoots {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return errors.New("execution request contains an empty workspace root")
|
||||
}
|
||||
}
|
||||
for _, capability := range request.Capabilities {
|
||||
if !validCapabilityKind(capability.Kind) {
|
||||
return fmt.Errorf("execution request contains invalid capability %q", capability.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateRequested State = "requested"
|
||||
StateEvaluated State = "evaluated"
|
||||
StateAwaitingApproval State = "awaiting_approval"
|
||||
StatePrepared State = "prepared"
|
||||
StateRunning State = "running"
|
||||
StateRetained State = "retained"
|
||||
StateCompleted State = "completed"
|
||||
StateDenied State = "denied"
|
||||
StateFailed State = "failed"
|
||||
StateCancelled State = "cancelled"
|
||||
)
|
||||
|
||||
type OutcomeKind string
|
||||
|
||||
const (
|
||||
OutcomeRunning OutcomeKind = "running"
|
||||
OutcomeSuccess OutcomeKind = "success"
|
||||
OutcomePolicyDenied OutcomeKind = "policy_denied"
|
||||
OutcomeEnforcementDenied OutcomeKind = "enforcement_denied"
|
||||
OutcomeApplicationFailure OutcomeKind = "application_failure"
|
||||
OutcomeSandboxSetupFailure OutcomeKind = "sandbox_setup_failure"
|
||||
OutcomeExecutableNotFound OutcomeKind = "executable_not_found"
|
||||
OutcomeCancelled OutcomeKind = "cancelled"
|
||||
OutcomeTimedOut OutcomeKind = "timed_out"
|
||||
OutcomeEnforcementDegraded OutcomeKind = "enforcement_degraded"
|
||||
)
|
||||
|
||||
type Exit struct {
|
||||
Code int `json:"code"`
|
||||
Signal string `json:"signal,omitempty"`
|
||||
}
|
||||
|
||||
type DenialSource string
|
||||
|
||||
const (
|
||||
DenialSourceConfiguredPolicy DenialSource = "configured_policy"
|
||||
DenialSourcePlatformSandbox DenialSource = "platform_sandbox"
|
||||
DenialSourceApproval DenialSource = "approval"
|
||||
)
|
||||
|
||||
type DenialNextAction string
|
||||
|
||||
const (
|
||||
DenialNextActionNone DenialNextAction = "none"
|
||||
DenialNextActionRequestApproval DenialNextAction = "request_approval"
|
||||
DenialNextActionChangeCommand DenialNextAction = "change_command"
|
||||
DenialNextActionEnableSandbox DenialNextAction = "enable_sandbox"
|
||||
)
|
||||
|
||||
type Denial struct {
|
||||
Capability Capability `json:"capability"`
|
||||
Source DenialSource `json:"source"`
|
||||
Reason string `json:"reason"`
|
||||
Recoverable bool `json:"recoverable"`
|
||||
NextAction DenialNextAction `json:"nextAction"`
|
||||
}
|
||||
|
||||
type Enforcement struct {
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Degraded bool `json:"degraded,omitempty"`
|
||||
DowngradeReason string `json:"downgradeReason,omitempty"`
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
State State `json:"state"`
|
||||
Kind OutcomeKind `json:"kind"`
|
||||
ProcessID string `json:"processId,omitempty"`
|
||||
Exit *Exit `json:"exit,omitempty"`
|
||||
Denial *Denial `json:"denial,omitempty"`
|
||||
Enforcement Enforcement `json:"enforcement"`
|
||||
Changes []Change `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
// AdapterReport is the structured, machine-readable result emitted by a
|
||||
// platform enforcement adapter. It is separate from stdout and stderr so
|
||||
// command text cannot impersonate a policy decision.
|
||||
type AdapterReport struct {
|
||||
Denial *Denial `json:"denial,omitempty"`
|
||||
}
|
||||
|
||||
func (outcome Outcome) Validate() error {
|
||||
if outcome.State == "" || outcome.Kind == "" {
|
||||
return errors.New("execution outcome requires state and kind")
|
||||
}
|
||||
switch outcome.State {
|
||||
case StateRetained, StateRunning:
|
||||
if outcome.Kind != OutcomeRunning || strings.TrimSpace(outcome.ProcessID) == "" {
|
||||
return errors.New("running execution outcome requires a process id")
|
||||
}
|
||||
case StateCompleted:
|
||||
if outcome.Kind != OutcomeSuccess || outcome.Exit == nil || outcome.Exit.Code != 0 {
|
||||
return errors.New("completed execution outcome requires a successful exit")
|
||||
}
|
||||
case StateDenied:
|
||||
if (outcome.Kind != OutcomePolicyDenied && outcome.Kind != OutcomeEnforcementDenied) || outcome.Denial == nil {
|
||||
return errors.New("denied execution outcome requires structured denial details")
|
||||
}
|
||||
if err := outcome.Denial.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
case StateFailed:
|
||||
if outcome.Kind == OutcomeRunning || outcome.Kind == OutcomeSuccess || outcome.Exit == nil {
|
||||
return errors.New("failed execution outcome requires a failure kind and exit")
|
||||
}
|
||||
case StateCancelled:
|
||||
if outcome.Kind != OutcomeCancelled {
|
||||
return errors.New("cancelled execution outcome requires cancelled kind")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("execution outcome state %q is not terminal or externally observable", outcome.State)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (denial Denial) Validate() error {
|
||||
if !validCapabilityKind(denial.Capability.Kind) {
|
||||
return errors.New("execution denial requires a valid capability")
|
||||
}
|
||||
if denial.Source == "" || strings.TrimSpace(denial.Reason) == "" || denial.NextAction == "" {
|
||||
return errors.New("execution denial requires source, reason, and next action")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validOrigin(origin Origin) bool {
|
||||
switch origin {
|
||||
case OriginInteractiveCommand, OriginHook, OriginPlugin, OriginMCPServer, OriginBackgroundTask:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validCapabilityKind(kind CapabilityKind) bool {
|
||||
switch kind {
|
||||
case CapabilityRead, CapabilityWorkspaceWrite, CapabilityProtectedMetadata,
|
||||
CapabilityExternalNetwork, CapabilityIsolatedLoopback, CapabilityHostVisibleBinding,
|
||||
CapabilityProcessSpawn, CapabilityProcessControl, CapabilityEnvironment, CapabilityUnrestricted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package execution
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequestValidationRequiresConcreteExecutionContext(t *testing.T) {
|
||||
request := Request{
|
||||
Origin: OriginInteractiveCommand,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "/bin/sh", Args: []string{"-c", "true"}},
|
||||
WorkingDirectory: "/workspace",
|
||||
WorkspaceRoots: []string{"/workspace"},
|
||||
Capabilities: []Capability{{
|
||||
Kind: CapabilityWorkspaceWrite,
|
||||
Scope: "/workspace",
|
||||
}},
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
|
||||
request.Origin = ""
|
||||
if err := request.Validate(); err == nil {
|
||||
t.Fatal("request without origin unexpectedly validated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutcomeValidationPinsTerminalStateContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outcome Outcome
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "retained process",
|
||||
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning, ProcessID: "1000"},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "successful completion",
|
||||
outcome: Outcome{State: StateCompleted, Kind: OutcomeSuccess, Exit: &Exit{Code: 0}},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "structured denial",
|
||||
outcome: Outcome{
|
||||
State: StateDenied,
|
||||
Kind: OutcomePolicyDenied,
|
||||
Denial: &Denial{
|
||||
Capability: Capability{Kind: CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
|
||||
Source: DenialSourceConfiguredPolicy,
|
||||
Reason: "protected workspace metadata cannot be created",
|
||||
NextAction: DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "retained without process id",
|
||||
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning},
|
||||
},
|
||||
{
|
||||
name: "denied without denial",
|
||||
outcome: Outcome{State: StateDenied, Kind: OutcomePolicyDenied},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.outcome.Validate()
|
||||
if test.valid && err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !test.valid && err == nil {
|
||||
t.Fatal("invalid outcome unexpectedly validated")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCompletedRetention = 30 * time.Second
|
||||
defaultMaxProcesses = 64
|
||||
maxPendingOutputBytes = 2 * 1024 * 1024
|
||||
recentOutputBytes = 4096
|
||||
processStopTimeout = 3 * time.Second
|
||||
maxInteractiveYield = 30 * time.Second
|
||||
maxEmptyPollYield = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProcessNotFound = errors.New("execution process not found")
|
||||
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
|
||||
)
|
||||
|
||||
type ProcessManagerOptions struct {
|
||||
CompletedRetention time.Duration
|
||||
MaxProcesses int
|
||||
}
|
||||
|
||||
// ProcessManager owns retained interactive-process identity, transport,
|
||||
// bounded output, continuation, cancellation, completion, and cleanup.
|
||||
type ProcessManager struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
processes map[int]*managedProcess
|
||||
completedRetention time.Duration
|
||||
maxProcesses int
|
||||
startTransport processTransportStarter
|
||||
}
|
||||
|
||||
type ProcessStart struct {
|
||||
Prepared PreparedCommand
|
||||
Request Request
|
||||
CommandText string
|
||||
RelativeCwd string
|
||||
TTY bool
|
||||
Metadata map[string]string
|
||||
// AfterWait may append adapter diagnostics collected by a platform monitor.
|
||||
AfterWait func() []byte
|
||||
}
|
||||
|
||||
type ProcessContinue struct {
|
||||
ProcessID int
|
||||
Input []byte
|
||||
Interrupt bool
|
||||
Wait time.Duration
|
||||
}
|
||||
|
||||
type ProcessResult struct {
|
||||
ProcessID int
|
||||
CommandText string
|
||||
RelativeCwd string
|
||||
TTY bool
|
||||
Output string
|
||||
OutputTruncated bool
|
||||
Exited bool
|
||||
ExitCode int
|
||||
Interrupted bool
|
||||
Request Request
|
||||
Enforcement Enforcement
|
||||
Report AdapterReport
|
||||
ReportErr error
|
||||
Changes []Change
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type ProcessSnapshot struct {
|
||||
ID int
|
||||
Command string
|
||||
Cwd string
|
||||
RelativeCwd string
|
||||
StartedAt time.Time
|
||||
LastUsedAt time.Time
|
||||
TTY bool
|
||||
Status string
|
||||
ExitCode *int
|
||||
RecentOutput string
|
||||
OutputTruncated bool
|
||||
}
|
||||
|
||||
func NewProcessManager(options ProcessManagerOptions) *ProcessManager {
|
||||
retention := options.CompletedRetention
|
||||
if retention == 0 {
|
||||
retention = defaultCompletedRetention
|
||||
}
|
||||
maxProcesses := options.MaxProcesses
|
||||
if maxProcesses == 0 {
|
||||
maxProcesses = defaultMaxProcesses
|
||||
}
|
||||
return &ProcessManager{
|
||||
nextID: 1000,
|
||||
processes: make(map[int]*managedProcess),
|
||||
completedRetention: retention,
|
||||
maxProcesses: maxProcesses,
|
||||
startTransport: startProcessTransport,
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wait time.Duration) (ProcessResult, error) {
|
||||
if manager == nil {
|
||||
return ProcessResult{}, errors.New("execution process manager is not configured")
|
||||
}
|
||||
if err := input.Request.Validate(); err != nil {
|
||||
return ProcessResult{}, fmt.Errorf("invalid execution request: %w", err)
|
||||
}
|
||||
if input.Prepared.Command == nil {
|
||||
return ProcessResult{}, errors.New("prepared execution has no command")
|
||||
}
|
||||
command := input.Prepared.Command
|
||||
buffer := newProcessOutputBuffer()
|
||||
request := input.Request
|
||||
observer := NewChangeObserver(request.WorkspaceRoots[0])
|
||||
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
|
||||
if err != nil {
|
||||
if input.Prepared.Cleanup != nil {
|
||||
input.Prepared.Cleanup()
|
||||
}
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
if tty {
|
||||
request.Mode = ModeInteractive
|
||||
} else {
|
||||
request.Mode = ModeCaptured
|
||||
}
|
||||
now := time.Now()
|
||||
process := &managedProcess{
|
||||
id: manager.allocateID(),
|
||||
commandText: input.CommandText,
|
||||
cwd: request.WorkingDirectory,
|
||||
relativeCwd: input.RelativeCwd,
|
||||
startedAt: now,
|
||||
lastUsedAt: now,
|
||||
tty: tty,
|
||||
command: command,
|
||||
request: request,
|
||||
enforcement: input.Prepared.Enforcement,
|
||||
report: input.Prepared.Report,
|
||||
cleanup: input.Prepared.Cleanup,
|
||||
stdin: stdin,
|
||||
output: buffer,
|
||||
reaped: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
kill: KillProcessTree,
|
||||
metadata: cloneStringMap(input.Metadata),
|
||||
}
|
||||
manager.store(process)
|
||||
manager.removeCompletedLater(process)
|
||||
go func() {
|
||||
waitErr := command.Wait()
|
||||
close(process.reaped)
|
||||
if input.AfterWait != nil {
|
||||
if diagnostic := input.AfterWait(); len(diagnostic) > 0 {
|
||||
_, _ = buffer.Write(diagnostic)
|
||||
}
|
||||
}
|
||||
if transportCleanup != nil {
|
||||
transportCleanup()
|
||||
}
|
||||
adapterReport, reportErr := AdapterReport{}, error(nil)
|
||||
if process.report != nil {
|
||||
adapterReport, reportErr = process.report()
|
||||
}
|
||||
changes := observer.Changes()
|
||||
if process.cleanup != nil {
|
||||
process.cleanup()
|
||||
}
|
||||
process.markDone(waitErr, commandExitCode(waitErr), adapterReport, reportErr, changes)
|
||||
}()
|
||||
result := process.collectResult(ctx, clampInitialProcessWait(wait), false)
|
||||
if ctx != nil && ctx.Err() != nil && !result.Exited {
|
||||
process.terminate()
|
||||
more := process.collectResult(context.Background(), time.Second, true)
|
||||
var mergeTruncated bool
|
||||
result.Output, mergeTruncated = appendBoundedProcessOutputString(result.Output, more.Output)
|
||||
result.OutputTruncated = result.OutputTruncated || more.OutputTruncated
|
||||
result.OutputTruncated = result.OutputTruncated || mergeTruncated
|
||||
result.Exited = more.Exited
|
||||
result.ExitCode = more.ExitCode
|
||||
result.Interrupted = result.Interrupted || more.Interrupted
|
||||
result.Report = more.Report
|
||||
result.ReportErr = more.ReportErr
|
||||
result.Changes = more.Changes
|
||||
}
|
||||
if result.Exited {
|
||||
manager.Remove(process.id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) {
|
||||
process, ok := manager.get(input.ProcessID)
|
||||
if !ok {
|
||||
return ProcessResult{}, ErrProcessNotFound
|
||||
}
|
||||
process.touch()
|
||||
if input.Interrupt {
|
||||
process.terminate()
|
||||
} else if len(input.Input) > 0 {
|
||||
if !process.tty || process.stdin == nil {
|
||||
return ProcessResult{}, ErrProcessStdinDisabled
|
||||
}
|
||||
if _, err := process.stdin.Write(input.Input); err != nil && !process.doneClosed() {
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
}
|
||||
result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt)
|
||||
if result.Exited {
|
||||
manager.Remove(process.id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func clampInitialProcessWait(wait time.Duration) time.Duration {
|
||||
return min(wait, maxInteractiveYield)
|
||||
}
|
||||
|
||||
func clampContinuationWait(wait time.Duration, emptyPoll bool) time.Duration {
|
||||
if emptyPoll {
|
||||
return min(wait, maxEmptyPollYield)
|
||||
}
|
||||
return min(wait, maxInteractiveYield)
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) List() []ProcessSnapshot {
|
||||
manager.mu.Lock()
|
||||
processes := make([]*managedProcess, 0, len(manager.processes))
|
||||
for _, process := range manager.processes {
|
||||
if !process.doneClosed() {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
snapshots := make([]ProcessSnapshot, 0, len(processes))
|
||||
for _, process := range processes {
|
||||
snapshots = append(snapshots, process.snapshot())
|
||||
}
|
||||
sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].ID < snapshots[j].ID })
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Snapshot(id int) (ProcessSnapshot, bool) {
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return ProcessSnapshot{}, false
|
||||
}
|
||||
return process.snapshot(), true
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Stop(id int) bool {
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
process.terminate()
|
||||
return true
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) StopAll() []int {
|
||||
manager.mu.Lock()
|
||||
processes := make([]*managedProcess, 0, len(manager.processes))
|
||||
for _, process := range manager.processes {
|
||||
if !process.doneClosed() {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
ids := make([]int, 0, len(processes))
|
||||
for _, process := range processes {
|
||||
process.terminate()
|
||||
ids = append(ids, process.id)
|
||||
}
|
||||
waitForProcesses(processes, processStopTimeout)
|
||||
sort.Ints(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Remove(id int) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.processes, id)
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) Len() int {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
return len(manager.processes)
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) allocateID() int {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
id := manager.nextID
|
||||
manager.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
process, ok := manager.processes[id]
|
||||
return process, ok
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) store(process *managedProcess) {
|
||||
manager.mu.Lock()
|
||||
var evicted *managedProcess
|
||||
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
|
||||
evicted = manager.processToPruneLocked()
|
||||
if evicted != nil && evicted.doneClosed() {
|
||||
delete(manager.processes, evicted.id)
|
||||
evicted = nil
|
||||
}
|
||||
}
|
||||
manager.processes[process.id] = process
|
||||
manager.mu.Unlock()
|
||||
if evicted != nil {
|
||||
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
|
||||
evicted.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
|
||||
var processes []*managedProcess
|
||||
for _, process := range manager.processes {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
sort.Slice(processes, func(i, j int) bool { return processes[i].lastUsed().Before(processes[j].lastUsed()) })
|
||||
for _, process := range processes {
|
||||
if process.doneClosed() {
|
||||
return process
|
||||
}
|
||||
}
|
||||
if len(processes) > 8 {
|
||||
return processes[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
|
||||
go func() {
|
||||
<-process.done
|
||||
if manager.completedRetention > 0 {
|
||||
timer := time.NewTimer(manager.completedRetention)
|
||||
<-timer.C
|
||||
}
|
||||
manager.Remove(process.id)
|
||||
}()
|
||||
}
|
||||
|
||||
type managedProcess struct {
|
||||
id int
|
||||
commandText string
|
||||
cwd string
|
||||
relativeCwd string
|
||||
startedAt time.Time
|
||||
lastUsedAt time.Time
|
||||
tty bool
|
||||
command *exec.Cmd
|
||||
request Request
|
||||
enforcement Enforcement
|
||||
report func() (AdapterReport, error)
|
||||
cleanup func()
|
||||
stdin io.WriteCloser
|
||||
output *processOutputBuffer
|
||||
reaped chan struct{}
|
||||
doneOnce sync.Once
|
||||
done chan struct{}
|
||||
kill func(int) error
|
||||
mu sync.Mutex
|
||||
exitCode *int
|
||||
waitErr error
|
||||
resultReport AdapterReport
|
||||
reportErr error
|
||||
changes []Change
|
||||
metadata map[string]string
|
||||
}
|
||||
|
||||
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
|
||||
process.mu.Lock()
|
||||
process.waitErr = err
|
||||
process.exitCode = &exitCode
|
||||
process.resultReport = report
|
||||
process.reportErr = reportErr
|
||||
process.changes = append([]Change(nil), changes...)
|
||||
process.mu.Unlock()
|
||||
process.doneOnce.Do(func() { close(process.done) })
|
||||
}
|
||||
|
||||
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
|
||||
output, truncated := process.collect(ctx, wait)
|
||||
process.mu.Lock()
|
||||
exitCode := 0
|
||||
exited := process.exitCode != nil
|
||||
if exited {
|
||||
exitCode = *process.exitCode
|
||||
}
|
||||
result := ProcessResult{
|
||||
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
|
||||
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
|
||||
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
|
||||
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
|
||||
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
|
||||
}
|
||||
process.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
func (process *managedProcess) collect(ctx context.Context, wait time.Duration) (string, bool) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
var output []byte
|
||||
truncated := false
|
||||
finish := func() (string, bool) {
|
||||
truncated = truncated || process.output.consumeTruncated()
|
||||
return string(output), truncated
|
||||
}
|
||||
for {
|
||||
if chunk := process.output.drain(); len(chunk) > 0 {
|
||||
var chunkTruncated bool
|
||||
output, chunkTruncated = appendBoundedProcessOutput(output, chunk)
|
||||
truncated = truncated || chunkTruncated
|
||||
truncated = truncated || process.output.consumeTruncated()
|
||||
if time.Now().After(deadline) {
|
||||
return finish()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if process.doneClosed() {
|
||||
var chunkTruncated bool
|
||||
output, chunkTruncated = appendBoundedProcessOutput(output, process.output.drain())
|
||||
truncated = truncated || chunkTruncated
|
||||
return finish()
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return finish()
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-process.output.notify:
|
||||
case <-process.done:
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return finish()
|
||||
case <-timer.C:
|
||||
return finish()
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendBoundedProcessOutput(output []byte, chunk []byte) ([]byte, bool) {
|
||||
if len(chunk) == 0 {
|
||||
return output, false
|
||||
}
|
||||
if len(chunk) >= maxPendingOutputBytes {
|
||||
bounded := make([]byte, maxPendingOutputBytes)
|
||||
copy(bounded, chunk[len(chunk)-maxPendingOutputBytes:])
|
||||
return bounded, len(output) > 0 || len(chunk) > maxPendingOutputBytes
|
||||
}
|
||||
if len(output)+len(chunk) <= maxPendingOutputBytes {
|
||||
return append(output, chunk...), false
|
||||
}
|
||||
drop := len(output) + len(chunk) - maxPendingOutputBytes
|
||||
copy(output, output[drop:])
|
||||
output = output[:len(output)-drop]
|
||||
return append(output, chunk...), true
|
||||
}
|
||||
|
||||
func appendBoundedProcessOutputString(output string, chunk string) (string, bool) {
|
||||
bounded, truncated := appendBoundedProcessOutput([]byte(output), []byte(chunk))
|
||||
return string(bounded), truncated
|
||||
}
|
||||
|
||||
func (process *managedProcess) doneClosed() bool {
|
||||
select {
|
||||
case <-process.done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (process *managedProcess) touch() {
|
||||
process.mu.Lock()
|
||||
process.lastUsedAt = time.Now()
|
||||
process.mu.Unlock()
|
||||
}
|
||||
func (process *managedProcess) lastUsed() time.Time {
|
||||
process.mu.Lock()
|
||||
defer process.mu.Unlock()
|
||||
return process.lastUsedAt
|
||||
}
|
||||
func (process *managedProcess) terminate() {
|
||||
if process.reapedClosed() || process.command.Process == nil {
|
||||
return
|
||||
}
|
||||
kill := process.kill
|
||||
if kill == nil {
|
||||
kill = KillProcessTree
|
||||
}
|
||||
_ = kill(process.command.Process.Pid)
|
||||
}
|
||||
|
||||
func (process *managedProcess) reapedClosed() bool {
|
||||
if process.reaped == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-process.reaped:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (process *managedProcess) snapshot() ProcessSnapshot {
|
||||
process.mu.Lock()
|
||||
defer process.mu.Unlock()
|
||||
var exit *int
|
||||
if process.exitCode != nil {
|
||||
value := *process.exitCode
|
||||
exit = &value
|
||||
}
|
||||
status := "running"
|
||||
if exit != nil {
|
||||
status = "exited"
|
||||
}
|
||||
return ProcessSnapshot{ID: process.id, Command: process.commandText, Cwd: process.cwd, RelativeCwd: process.relativeCwd,
|
||||
StartedAt: process.startedAt, LastUsedAt: process.lastUsedAt, TTY: process.tty, Status: status,
|
||||
ExitCode: exit, RecentOutput: process.output.recentString(), OutputTruncated: process.output.peekTruncated()}
|
||||
}
|
||||
|
||||
func waitForProcesses(processes []*managedProcess, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for _, process := range processes {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-process.done:
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type processOutputBuffer struct {
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
recent []byte
|
||||
truncated bool
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
func newProcessOutputBuffer() *processOutputBuffer {
|
||||
return &processOutputBuffer{notify: make(chan struct{}, 1)}
|
||||
}
|
||||
func (buffer *processOutputBuffer) Write(data []byte) (int, error) {
|
||||
buffer.mu.Lock()
|
||||
buffer.data = append(buffer.data, data...)
|
||||
if len(buffer.data) > maxPendingOutputBytes {
|
||||
buffer.data = buffer.data[len(buffer.data)-maxPendingOutputBytes:]
|
||||
buffer.truncated = true
|
||||
}
|
||||
buffer.recent = append(buffer.recent, data...)
|
||||
if len(buffer.recent) > recentOutputBytes {
|
||||
buffer.recent = buffer.recent[len(buffer.recent)-recentOutputBytes:]
|
||||
}
|
||||
buffer.mu.Unlock()
|
||||
select {
|
||||
case buffer.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
func (buffer *processOutputBuffer) drain() []byte {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
data := append([]byte(nil), buffer.data...)
|
||||
buffer.data = nil
|
||||
return data
|
||||
}
|
||||
func (buffer *processOutputBuffer) recentString() string {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return string(buffer.recent)
|
||||
}
|
||||
func (buffer *processOutputBuffer) consumeTruncated() bool {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
value := buffer.truncated
|
||||
buffer.truncated = false
|
||||
return value
|
||||
}
|
||||
func (buffer *processOutputBuffer) peekTruncated() bool {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return buffer.truncated
|
||||
}
|
||||
|
||||
func cloneStringMap(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func processManagerRequest(root string, command *exec.Cmd) Request {
|
||||
return Request{
|
||||
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
||||
Command: Command{Name: command.Path, Args: command.Args[1:]},
|
||||
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Second})
|
||||
command := exec.Command("/bin/sh", "-c", "printf first; sleep 0.05; printf second")
|
||||
request := Request{
|
||||
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
||||
Command: Command{Name: "/bin/sh", Args: []string{"-c", "printf first; sleep 0.05; printf second"}},
|
||||
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: request, CommandText: "test",
|
||||
}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if started.Exited || started.ProcessID < 1000 {
|
||||
t.Fatalf("initial result = %#v, want retained process", started)
|
||||
}
|
||||
continued, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Wait: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if !continued.Exited || continued.ProcessID != started.ProcessID {
|
||||
t.Fatalf("continued result = %#v, want same completed process", continued)
|
||||
}
|
||||
combined := started.Output + continued.Output
|
||||
if strings.Count(combined, "first") != 1 || strings.Count(combined, "second") != 1 {
|
||||
t.Fatalf("output was lost or duplicated: %q", combined)
|
||||
}
|
||||
if manager.Len() != 0 {
|
||||
t.Fatalf("completed process still retained: %d", manager.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
||||
request := Request{
|
||||
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
|
||||
Command: Command{Name: "/bin/sh", Args: []string{"-c", "sleep 30"}},
|
||||
WorkingDirectory: root, WorkspaceRoots: []string{root},
|
||||
}
|
||||
started, err := manager.Start(context.Background(), ProcessStart{Prepared: PreparedCommand{Command: command}, Request: request}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
stopped, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Interrupt: true, Wait: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue interrupt: %v", err)
|
||||
}
|
||||
if !stopped.Exited || !stopped.Interrupted {
|
||||
t.Fatalf("interrupt result = %#v", stopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
|
||||
reaped := make(chan struct{})
|
||||
close(reaped)
|
||||
called := false
|
||||
process := &managedProcess{
|
||||
command: &exec.Cmd{Process: &os.Process{Pid: 4242}},
|
||||
reaped: reaped,
|
||||
kill: func(int) error { called = true; return nil },
|
||||
}
|
||||
|
||||
process.terminate()
|
||||
if called {
|
||||
t.Fatal("terminate signaled an already-reaped process")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerCancellationReportsInterrupted(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
result, err := manager.Start(ctx, ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command},
|
||||
Request: processManagerRequest(root, command),
|
||||
}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if !result.Exited || !result.Interrupted {
|
||||
t.Fatalf("cancelled result = %#v, want exited and interrupted", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerObservesChangesMadeDuringTransportStart(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "created.txt")
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
manager.startTransport = func(command *exec.Cmd, output io.Writer, tty bool) (io.WriteCloser, bool, func(), error) {
|
||||
if err := os.WriteFile(target, []byte("created"), 0o600); err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
return startProcessTransport(command, output, tty)
|
||||
}
|
||||
command := exec.Command("/bin/sh", "-c", "true")
|
||||
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command},
|
||||
Request: processManagerRequest(root, command),
|
||||
}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if len(result.Changes) != 1 || result.Changes[0].Path != "created.txt" || result.Changes[0].Kind != ChangeCreated {
|
||||
t.Fatalf("changes = %#v, want created.txt created", result.Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessWaitsClampToEffectiveBounds(t *testing.T) {
|
||||
if got := clampInitialProcessWait(time.Minute); got != maxInteractiveYield {
|
||||
t.Fatalf("initial upper clamp = %v, want %v", got, maxInteractiveYield)
|
||||
}
|
||||
if got := clampInitialProcessWait(time.Millisecond); got != time.Millisecond {
|
||||
t.Fatalf("initial short wait = %v, want caller value", got)
|
||||
}
|
||||
if got := clampContinuationWait(time.Hour, true); got != maxEmptyPollYield {
|
||||
t.Fatalf("empty poll upper clamp = %v, want %v", got, maxEmptyPollYield)
|
||||
}
|
||||
if got := clampContinuationWait(time.Hour, false); got != maxInteractiveYield {
|
||||
t.Fatalf("stdin upper clamp = %v, want %v", got, maxInteractiveYield)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOutputBufferCapsUndrainedData(t *testing.T) {
|
||||
buffer := newProcessOutputBuffer()
|
||||
chunk := []byte(strings.Repeat("x", 1024))
|
||||
for i := 0; i < maxPendingOutputBytes/len(chunk)+10; i++ {
|
||||
if _, err := buffer.Write(chunk); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
buffer.mu.Lock()
|
||||
dataLen := len(buffer.data)
|
||||
buffer.mu.Unlock()
|
||||
if dataLen > maxPendingOutputBytes {
|
||||
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxPendingOutputBytes)
|
||||
}
|
||||
if got := buffer.drain(); !strings.HasSuffix(string(got), string(chunk)) {
|
||||
t.Fatal("drained output should retain the newest bytes")
|
||||
}
|
||||
if !buffer.peekTruncated() || !buffer.consumeTruncated() || buffer.consumeTruncated() {
|
||||
t.Fatal("truncation state was not preserved and consumed exactly once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
|
||||
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
|
||||
stop := make(chan struct{})
|
||||
var writers sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
chunk := []byte(strings.Repeat("x", 256))
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
_, _ = process.output.Write(chunk)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
t.Cleanup(func() { close(stop); writers.Wait() })
|
||||
|
||||
wait := 200 * time.Millisecond
|
||||
started := time.Now()
|
||||
_, _ = process.collect(context.Background(), wait)
|
||||
if elapsed := time.Since(started); elapsed > 5*wait {
|
||||
t.Fatalf("collect took %v under continuous output, want close to %v", elapsed, wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessCollectCapsCumulativeOutput(t *testing.T) {
|
||||
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
|
||||
chunk := []byte(strings.Repeat("x", maxPendingOutputBytes/2))
|
||||
go func() {
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = process.output.Write(chunk)
|
||||
for {
|
||||
process.output.mu.Lock()
|
||||
drained := len(process.output.data) == 0
|
||||
process.output.mu.Unlock()
|
||||
if drained {
|
||||
break
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
close(process.done)
|
||||
}()
|
||||
|
||||
output, truncated := process.collect(context.Background(), time.Second)
|
||||
if len(output) > maxPendingOutputBytes {
|
||||
t.Fatalf("collected %d bytes, want <= %d", len(output), maxPendingOutputBytes)
|
||||
}
|
||||
if !truncated {
|
||||
t.Fatal("cumulative output cap must report truncation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPruningDoesNotRaceTouch(t *testing.T) {
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
for id := 1000; id < 1012; id++ {
|
||||
manager.processes[id] = &managedProcess{id: id, lastUsedAt: time.Now(), done: make(chan struct{})}
|
||||
}
|
||||
target := manager.processes[1000]
|
||||
stop := make(chan struct{})
|
||||
var writer sync.WaitGroup
|
||||
writer.Add(1)
|
||||
go func() {
|
||||
defer writer.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
target.touch()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2000; i++ {
|
||||
manager.mu.Lock()
|
||||
_ = manager.processToPruneLocked()
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
close(stop)
|
||||
writer.Wait()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//go:build !windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigureProcessGroup makes cmd the leader of a process group so lifecycle
|
||||
// operations cover descendants instead of orphaning them.
|
||||
func ConfigureProcessGroup(cmd *exec.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
}
|
||||
|
||||
// KillProcessTree immediately kills pid and, when it is a group leader, its
|
||||
// descendant process group.
|
||||
func KillProcessTree(pid int) error {
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminateProcessTree requests graceful termination, then force-kills the
|
||||
// process tree after grace. Callers retain their distinct persistence models;
|
||||
// this function owns only the OS lifecycle primitive.
|
||||
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if poll <= 0 {
|
||||
poll = 50 * time.Millisecond
|
||||
}
|
||||
if grace < 0 {
|
||||
grace = 0
|
||||
}
|
||||
deadline := time.Now().Add(grace)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(poll)
|
||||
}
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return err
|
||||
}
|
||||
deadline = time.Now().Add(grace)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(poll)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processSignalTarget(pid int) (int, error) {
|
||||
if pid <= 1 {
|
||||
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
|
||||
}
|
||||
target := pid
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if errors.Is(err, syscall.ESRCH) {
|
||||
// Preserve the individual target; the signal call below treats ESRCH as
|
||||
// already gone, which is a successful lifecycle outcome.
|
||||
return pid, nil
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ConfigureProcessGroup(cmd *exec.Cmd) {}
|
||||
|
||||
func KillProcessTree(pid int) error {
|
||||
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
|
||||
func TerminateProcessTree(pid int, _, _ time.Duration) error { return KillProcessTree(pid) }
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = os.Getenv("windir")
|
||||
}
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
|
||||
return nil, nil, errors.New("pty transport is unavailable on this platform")
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build linux
|
||||
|
||||
package tools
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -14,41 +14,25 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func startPTYProcess(command *exec.Cmd, output *execOutputBuffer) (io.WriteCloser, func(), error) {
|
||||
func startPTYProcess(command *exec.Cmd, output io.Writer) (io.WriteCloser, func(), error) {
|
||||
master, slave, err := openPTY()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = master.Close()
|
||||
_ = slave.Close()
|
||||
}
|
||||
command.Stdin = slave
|
||||
command.Stdout = slave
|
||||
command.Stderr = slave
|
||||
hardenPTYProcessLifetime(command, slave)
|
||||
cleanup := func() { _ = master.Close(); _ = slave.Close() }
|
||||
command.Stdin, command.Stdout, command.Stderr = slave, slave, slave
|
||||
hardenPTYProcess(command)
|
||||
if err := command.Start(); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
_ = slave.Close()
|
||||
copied := make(chan struct{})
|
||||
go func() {
|
||||
defer close(copied)
|
||||
_, _ = io.Copy(output, master)
|
||||
}()
|
||||
go func() { defer close(copied); _, _ = io.Copy(output, master) }()
|
||||
return master, func() {
|
||||
// cleanup runs in the Wait goroutine AFTER command.Wait() returns, so the
|
||||
// child has exited and its slave fds are closed — the master then EOFs once
|
||||
// io.Copy drains the remaining PTY output into the buffer. Wait for that copy
|
||||
// to finish BEFORE closing the master and letting the caller mark the session
|
||||
// done + remove it; otherwise a command's final output chunk (e.g. a test
|
||||
// runner's last PASS/FAIL line) can be lost on exit. Closing the master first
|
||||
// would truncate the unread tail, so the join precedes the Close. Bounded so a
|
||||
// stuck copy can't hang teardown. (AUDIT-M14)
|
||||
select {
|
||||
case <-copied:
|
||||
case <-time.After(bashWaitDelay):
|
||||
case <-time.After(processWaitDelay):
|
||||
}
|
||||
_ = master.Close()
|
||||
}, nil
|
||||
@@ -75,18 +59,17 @@ func openPTY() (*os.File, *os.File, error) {
|
||||
_ = master.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
slave := os.NewFile(uintptr(slaveFD), slaveName)
|
||||
return master, slave, nil
|
||||
return master, os.NewFile(uintptr(slaveFD), slaveName), nil
|
||||
}
|
||||
|
||||
func hardenPTYProcessLifetime(command *exec.Cmd, slave *os.File) {
|
||||
func hardenPTYProcess(command *exec.Cmd) {
|
||||
if command.SysProcAttr == nil {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
command.SysProcAttr.Setsid = true
|
||||
command.SysProcAttr.Setctty = true
|
||||
command.SysProcAttr.Ctty = 0
|
||||
command.WaitDelay = bashWaitDelay
|
||||
command.WaitDelay = processWaitDelay
|
||||
command.Cancel = func() error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build linux
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPTYFallbackRestoresOriginalProcessAttributes(t *testing.T) {
|
||||
original := &syscall.SysProcAttr{Setpgid: true}
|
||||
command := exec.Command("/bin/true")
|
||||
command.SysProcAttr = original
|
||||
saved := cloneSysProcAttr(command.SysProcAttr)
|
||||
hardenPTYProcess(command)
|
||||
resetCommandAfterPTYFallback(command, saved)
|
||||
|
||||
if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid {
|
||||
t.Fatalf("fallback attributes = %#v, want original Setpgid", command.SysProcAttr)
|
||||
}
|
||||
if command.SysProcAttr.Setsid || command.SysProcAttr.Setctty {
|
||||
t.Fatalf("fallback retained PTY attributes: %#v", command.SysProcAttr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const maxCapturedStreamBytes = 1 << 20
|
||||
|
||||
// Preparer is the platform-adapter seam. It turns a platform-neutral request
|
||||
// into one enforceable OS command while keeping native sandbox mechanics out of
|
||||
// every caller.
|
||||
type Preparer interface {
|
||||
PrepareExecution(context.Context, Request) (PreparedCommand, error)
|
||||
}
|
||||
|
||||
type PreparedCommand struct {
|
||||
Command *exec.Cmd
|
||||
Enforcement Enforcement
|
||||
Report func() (AdapterReport, error)
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
type CapturedRequest struct {
|
||||
Request Request
|
||||
Stdin []byte
|
||||
}
|
||||
|
||||
type CapturedResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Truncated bool
|
||||
Outcome Outcome
|
||||
Err error
|
||||
}
|
||||
|
||||
// Runner is the deep execution module used by Zero-owned subprocess launchers.
|
||||
// The preparer may be installed after tool registration, but execution fails
|
||||
// closed until an adapter is present.
|
||||
type Runner struct {
|
||||
mu sync.RWMutex
|
||||
preparer Preparer
|
||||
}
|
||||
|
||||
func NewRunner(preparer Preparer) *Runner {
|
||||
return &Runner{preparer: preparer}
|
||||
}
|
||||
|
||||
func (runner *Runner) SetPreparer(preparer Preparer) {
|
||||
if runner == nil {
|
||||
return
|
||||
}
|
||||
runner.mu.Lock()
|
||||
runner.preparer = preparer
|
||||
runner.mu.Unlock()
|
||||
}
|
||||
|
||||
func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest) CapturedResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := input.Request.Validate(); err != nil {
|
||||
return capturedSetupFailure("invalid execution request: "+err.Error(), err, Enforcement{})
|
||||
}
|
||||
if input.Request.Mode != ModeCaptured {
|
||||
err := fmt.Errorf("captured execution requires mode %q", ModeCaptured)
|
||||
return capturedSetupFailure(err.Error(), err, Enforcement{})
|
||||
}
|
||||
prepared, err := runner.Prepare(ctx, input.Request)
|
||||
if err != nil {
|
||||
return capturedSetupFailure("prepare execution: "+err.Error(), err, prepared.Enforcement)
|
||||
}
|
||||
if prepared.Cleanup != nil {
|
||||
defer prepared.Cleanup()
|
||||
}
|
||||
if prepared.Command == nil {
|
||||
err := errors.New("execution adapter returned no command")
|
||||
return capturedSetupFailure(err.Error(), err, prepared.Enforcement)
|
||||
}
|
||||
if len(input.Stdin) > 0 {
|
||||
prepared.Command.Stdin = bytes.NewReader(input.Stdin)
|
||||
}
|
||||
stdout := &capturedBuffer{limit: maxCapturedStreamBytes}
|
||||
stderr := &capturedBuffer{limit: maxCapturedStreamBytes}
|
||||
prepared.Command.Stdout = stdout
|
||||
prepared.Command.Stderr = stderr
|
||||
runErr := prepared.Command.Run()
|
||||
report, reportErr := AdapterReport{}, error(nil)
|
||||
if prepared.Report != nil {
|
||||
report, reportErr = prepared.Report()
|
||||
}
|
||||
result := CapturedResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Truncated: stdout.Truncated() || stderr.Truncated(),
|
||||
Err: runErr,
|
||||
Outcome: Outcome{
|
||||
Enforcement: prepared.Enforcement,
|
||||
},
|
||||
}
|
||||
exitCode := commandExitCode(runErr)
|
||||
result.Outcome.Exit = &Exit{Code: exitCode}
|
||||
switch {
|
||||
case reportErr != nil:
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeSandboxSetupFailure
|
||||
result.Err = reportErr
|
||||
case report.Denial != nil:
|
||||
denial := *report.Denial
|
||||
result.Outcome.State = StateDenied
|
||||
result.Outcome.Kind = OutcomeEnforcementDenied
|
||||
result.Outcome.Denial = &denial
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeTimedOut
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
result.Outcome.State = StateCancelled
|
||||
result.Outcome.Kind = OutcomeCancelled
|
||||
case runErr == nil:
|
||||
result.Outcome.State = StateCompleted
|
||||
result.Outcome.Kind = OutcomeSuccess
|
||||
case executableNotFound(runErr):
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeExecutableNotFound
|
||||
default:
|
||||
result.Outcome.State = StateFailed
|
||||
result.Outcome.Kind = OutcomeApplicationFailure
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Prepare evaluates and prepares a typed request for callers that require
|
||||
// streaming pipes or protocol-specific lifecycle handling, such as stdio MCP.
|
||||
// The returned cleanup remains mandatory; ordinary captured commands should use
|
||||
// ExecuteCaptured so the Runner owns it automatically.
|
||||
func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCommand, error) {
|
||||
if runner == nil {
|
||||
return PreparedCommand{}, errors.New("execution runner is not configured")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := request.Validate(); err != nil {
|
||||
return PreparedCommand{}, err
|
||||
}
|
||||
runner.mu.RLock()
|
||||
preparer := runner.preparer
|
||||
runner.mu.RUnlock()
|
||||
if preparer == nil {
|
||||
return PreparedCommand{}, errors.New("execution adapter is not configured")
|
||||
}
|
||||
return preparer.PrepareExecution(ctx, request)
|
||||
}
|
||||
|
||||
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
|
||||
return CapturedResult{
|
||||
Stderr: message,
|
||||
Err: err,
|
||||
Outcome: Outcome{
|
||||
State: StateFailed,
|
||||
Kind: OutcomeSandboxSetupFailure,
|
||||
Exit: &Exit{Code: -1},
|
||||
Enforcement: enforcement,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func commandExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func executableNotFound(err error) bool {
|
||||
var execErr *exec.Error
|
||||
return errors.As(err, &execErr)
|
||||
}
|
||||
|
||||
type capturedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
limit int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (buffer *capturedBuffer) Write(data []byte) (int, error) {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
remaining := buffer.limit - buffer.buffer.Len()
|
||||
if remaining > 0 {
|
||||
_, _ = buffer.buffer.Write(data[:min(len(data), remaining)])
|
||||
}
|
||||
if len(data) > remaining {
|
||||
buffer.truncated = true
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (buffer *capturedBuffer) String() string {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return buffer.buffer.String()
|
||||
}
|
||||
|
||||
func (buffer *capturedBuffer) Truncated() bool {
|
||||
buffer.mu.Lock()
|
||||
defer buffer.mu.Unlock()
|
||||
return buffer.truncated
|
||||
}
|
||||
|
||||
var _ io.Writer = (*capturedBuffer)(nil)
|
||||
@@ -0,0 +1,77 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type capturedTestPreparer struct {
|
||||
request Request
|
||||
}
|
||||
|
||||
func (preparer *capturedTestPreparer) PrepareExecution(_ context.Context, request Request) (PreparedCommand, error) {
|
||||
preparer.request = request
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestCapturedRunnerHelperProcess$")
|
||||
command.Env = append(os.Environ(), "ZERO_CAPTURED_RUNNER_HELPER=1")
|
||||
return PreparedCommand{
|
||||
Command: command,
|
||||
Enforcement: Enforcement{
|
||||
Backend: "test-adapter",
|
||||
Level: "native",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCapturedRunnerHelperProcess(t *testing.T) {
|
||||
if os.Getenv("ZERO_CAPTURED_RUNNER_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(os.Stdout, "stdout")
|
||||
fmt.Fprint(os.Stderr, "stderr")
|
||||
os.Exit(7)
|
||||
}
|
||||
|
||||
func TestRunnerExecutesCapturedRequestThroughAdapter(t *testing.T) {
|
||||
preparer := &capturedTestPreparer{}
|
||||
runner := NewRunner(preparer)
|
||||
request := Request{
|
||||
Origin: OriginHook,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "ignored-by-test-adapter"},
|
||||
WorkingDirectory: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}
|
||||
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: request})
|
||||
|
||||
if preparer.request.Origin != OriginHook {
|
||||
t.Fatalf("adapter origin = %q, want hook", preparer.request.Origin)
|
||||
}
|
||||
if result.Stdout != "stdout" || result.Stderr != "stderr" {
|
||||
t.Fatalf("captured output = stdout %q stderr %q", result.Stdout, result.Stderr)
|
||||
}
|
||||
if result.Outcome.Kind != OutcomeApplicationFailure || result.Outcome.Exit == nil || result.Outcome.Exit.Code != 7 {
|
||||
t.Fatalf("outcome = %#v, want application failure exit 7", result.Outcome)
|
||||
}
|
||||
if result.Outcome.Enforcement.Backend != "test-adapter" {
|
||||
t.Fatalf("enforcement = %#v", result.Outcome.Enforcement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerWithoutAdapterFailsClosed(t *testing.T) {
|
||||
runner := NewRunner(nil)
|
||||
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
|
||||
Origin: OriginPlugin,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "true"},
|
||||
WorkingDirectory: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
}})
|
||||
if result.Outcome.Kind != OutcomeSandboxSetupFailure || !strings.Contains(result.Stderr, "adapter") {
|
||||
t.Fatalf("result = %#v, want fail-closed missing-adapter result", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const processWaitDelay = 2 * time.Second
|
||||
|
||||
type processTransportStarter func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error)
|
||||
|
||||
func startProcessTransport(command *exec.Cmd, output io.Writer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
|
||||
if ttyRequested {
|
||||
original := cloneSysProcAttr(command.SysProcAttr)
|
||||
if stdin, cleanup, err := startPTYProcess(command, output); err == nil {
|
||||
return stdin, true, cleanup, nil
|
||||
}
|
||||
resetCommandAfterPTYFallback(command, original)
|
||||
}
|
||||
stdin, err := command.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
command.Stdout = output
|
||||
command.Stderr = output
|
||||
ConfigureProcessGroup(command)
|
||||
command.WaitDelay = processWaitDelay
|
||||
if err := command.Start(); err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
return stdin, false, func() {}, nil
|
||||
}
|
||||
|
||||
func cloneSysProcAttr(attributes *syscall.SysProcAttr) *syscall.SysProcAttr {
|
||||
if attributes == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *attributes
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func resetCommandAfterPTYFallback(command *exec.Cmd, original *syscall.SysProcAttr) {
|
||||
command.Stdin = nil
|
||||
command.Stdout = nil
|
||||
command.Stderr = nil
|
||||
command.SysProcAttr = original
|
||||
command.Cancel = nil
|
||||
command.WaitDelay = 0
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// defaultHookTimeout bounds a single hook command so a hung or slow hook cannot
|
||||
@@ -51,13 +53,14 @@ type commandRunner func(ctx context.Context, command string, args []string, stdi
|
||||
|
||||
// DispatcherOptions configures a Dispatcher.
|
||||
type DispatcherOptions struct {
|
||||
Config Config
|
||||
Audit *AuditStore // optional; when set, every run is recorded
|
||||
Cwd string // working directory for hook commands
|
||||
Env []string // extra environment entries appended to the process env
|
||||
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
|
||||
now func() time.Time
|
||||
run commandRunner
|
||||
Config Config
|
||||
Audit *AuditStore // optional; when set, every run is recorded
|
||||
Cwd string // working directory for hook commands
|
||||
Env []string // extra environment entries appended to the process env
|
||||
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
|
||||
Execution *execution.Runner // routes hook subprocesses through sandbox policy
|
||||
now func() time.Time
|
||||
run commandRunner
|
||||
}
|
||||
|
||||
// Dispatcher selects and runs the hooks configured for a lifecycle event,
|
||||
@@ -87,7 +90,11 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
|
||||
}
|
||||
run := options.run
|
||||
if run == nil {
|
||||
run = execCommandRunner
|
||||
if options.Execution != nil {
|
||||
run = executionCommandRunner(options.Execution)
|
||||
} else {
|
||||
run = execCommandRunner
|
||||
}
|
||||
}
|
||||
return &Dispatcher{
|
||||
config: options.Config,
|
||||
@@ -100,6 +107,42 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
func executionCommandRunner(runner *execution.Runner) commandRunner {
|
||||
return func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
||||
result := runner.ExecuteCaptured(ctx, execution.CapturedRequest{
|
||||
Request: execution.Request{
|
||||
Origin: execution.OriginHook,
|
||||
Mode: execution.ModeCaptured,
|
||||
Command: execution.Command{Name: command, Args: append([]string(nil), args...), Env: append([]string(nil), env...)},
|
||||
WorkingDirectory: cwd,
|
||||
WorkspaceRoots: []string{cwd},
|
||||
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
|
||||
},
|
||||
Stdin: stdin,
|
||||
})
|
||||
exitCode := -1
|
||||
if result.Outcome.Exit != nil {
|
||||
exitCode = result.Outcome.Exit.Code
|
||||
}
|
||||
stderr := result.Stderr
|
||||
if stderr == "" && result.Outcome.Denial != nil {
|
||||
stderr = result.Outcome.Denial.Reason
|
||||
}
|
||||
commandErr := error(nil)
|
||||
switch result.Outcome.Kind {
|
||||
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound:
|
||||
commandErr = result.Err
|
||||
}
|
||||
return commandResult{
|
||||
ExitCode: exitCode,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: stderr,
|
||||
Err: commandErr,
|
||||
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blocksOn reports whether a non-zero exit for this event should veto the action.
|
||||
// Only beforeTool gates the tool; other events are observational.
|
||||
func blocksOn(event Event) bool {
|
||||
|
||||
@@ -2,13 +2,25 @@ package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
type hookExecutionPreparer struct {
|
||||
request execution.Request
|
||||
}
|
||||
|
||||
func (preparer *hookExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
|
||||
preparer.request = request
|
||||
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
|
||||
}
|
||||
|
||||
func beforeToolConfig(hooks ...Definition) Config {
|
||||
return Config{Enabled: true, Hooks: hooks}
|
||||
}
|
||||
@@ -186,6 +198,25 @@ func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherRoutesHookThroughTypedExecutionOrigin(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses /bin/sh")
|
||||
}
|
||||
preparer := &hookExecutionPreparer{}
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "typed", Event: EventBeforeTool, Command: "/bin/sh", Args: []string{"-c", "cat"}, Enabled: true}),
|
||||
Cwd: t.TempDir(),
|
||||
Execution: execution.NewRunner(preparer),
|
||||
})
|
||||
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", Payload: map[string]any{"ok": true}})
|
||||
if outcome.Blocked || outcome.Ran != 1 {
|
||||
t.Fatalf("dispatch outcome = %#v", outcome)
|
||||
}
|
||||
if preparer.request.Origin != execution.OriginHook || preparer.request.Mode != execution.ModeCaptured {
|
||||
t.Fatalf("execution request = %#v", preparer.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
|
||||
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
|
||||
if result.Err == nil {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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,9 +11,18 @@ 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. Skips when gopls is not installed. Temporary manual-verification
|
||||
// test (the rest of the suite uses a fake server).
|
||||
// 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.
|
||||
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,7 +1,6 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -176,16 +175,6 @@ 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))
|
||||
}
|
||||
+56
-10
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
type RemoteTool struct {
|
||||
@@ -46,6 +48,7 @@ type Client struct {
|
||||
mu sync.Mutex
|
||||
closeMu sync.Mutex
|
||||
nextID int
|
||||
cleanup func()
|
||||
|
||||
// dispatchMu guards the response-dispatch state shared with the single
|
||||
// reader goroutine. It is never held across a blocking read.
|
||||
@@ -72,9 +75,18 @@ const (
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, server Server) (ToolClient, error) {
|
||||
return ConnectWithOptions(ctx, server, ConnectOptions{})
|
||||
}
|
||||
|
||||
type ConnectOptions struct {
|
||||
Execution *execution.Runner
|
||||
WorkspaceRoot string
|
||||
}
|
||||
|
||||
func ConnectWithOptions(ctx context.Context, server Server, options ConnectOptions) (ToolClient, error) {
|
||||
switch server.Type {
|
||||
case ServerTypeStdio:
|
||||
return connectStdio(ctx, server)
|
||||
return connectStdio(ctx, server, options)
|
||||
case ServerTypeHTTP:
|
||||
return connectNetwork(ctx, server)
|
||||
case ServerTypeSSE:
|
||||
@@ -120,9 +132,37 @@ func (b *boundedBuffer) String() string {
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func connectStdio(ctx context.Context, server Server) (*Client, error) {
|
||||
cmd := exec.CommandContext(ctx, server.Command, server.Args...)
|
||||
cmd.Env = mergeProcessEnv(server.Env)
|
||||
func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) {
|
||||
var cmd *exec.Cmd
|
||||
var cleanup func()
|
||||
cleanupTransferred := false
|
||||
defer func() {
|
||||
if cleanup != nil && !cleanupTransferred {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
if options.Execution != nil {
|
||||
workspaceRoot := strings.TrimSpace(options.WorkspaceRoot)
|
||||
if workspaceRoot == "" {
|
||||
return nil, fmt.Errorf("start MCP server %s: execution workspace root is required", server.Name)
|
||||
}
|
||||
prepared, err := options.Execution.Prepare(ctx, execution.Request{
|
||||
Origin: execution.OriginMCPServer,
|
||||
Mode: execution.ModeDurable,
|
||||
Command: execution.Command{Name: server.Command, Args: append([]string(nil), server.Args...), Env: mergeProcessEnv(server.Env)},
|
||||
WorkingDirectory: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
|
||||
}
|
||||
cmd = prepared.Command
|
||||
cleanup = prepared.Cleanup
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
|
||||
cmd.Env = mergeProcessEnv(server.Env)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open MCP stdin for %s: %w", server.Name, err)
|
||||
@@ -138,13 +178,15 @@ func connectStdio(ctx context.Context, server Server) (*Client, error) {
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
server: server,
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
reader: newMessageReader(stdout),
|
||||
writer: newMessageWriter(stdin),
|
||||
nextID: 1,
|
||||
server: server,
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
reader: newMessageReader(stdout),
|
||||
writer: newMessageWriter(stdin),
|
||||
nextID: 1,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
cleanupTransferred = true
|
||||
if err := client.initialize(ctx); err != nil {
|
||||
_ = client.Close()
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
@@ -242,6 +284,10 @@ func (client *Client) Close() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if client.cleanup != nil {
|
||||
client.cleanup()
|
||||
client.cleanup = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -16,8 +17,21 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
type mcpExecutionPreparer struct {
|
||||
request execution.Request
|
||||
}
|
||||
|
||||
func (preparer *mcpExecutionPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
|
||||
preparer.request = request
|
||||
command := exec.CommandContext(ctx, request.Command.Name, request.Command.Args...)
|
||||
command.Dir = request.WorkingDirectory
|
||||
command.Env = request.Command.Env
|
||||
return execution.PreparedCommand{Command: command}, nil
|
||||
}
|
||||
|
||||
func TestStdioClientListsAndCallsTools(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -65,6 +79,34 @@ func TestStdioClientListsAndCallsTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientUsesTypedMCPExecutionOrigin(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preparer := &mcpExecutionPreparer{}
|
||||
workspace := t.TempDir()
|
||||
client, err := ConnectWithOptions(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeStdio,
|
||||
Command: executable,
|
||||
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
|
||||
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
|
||||
}, ConnectOptions{Execution: execution.NewRunner(preparer), WorkspaceRoot: workspace})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectWithOptions() error = %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if preparer.request.Origin != execution.OriginMCPServer || preparer.request.Mode != execution.ModeDurable {
|
||||
t.Fatalf("execution request = %#v", preparer.request)
|
||||
}
|
||||
if preparer.request.WorkingDirectory != workspace {
|
||||
t.Fatalf("working directory = %q, want %q", preparer.request.WorkingDirectory, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientCloseAllowsConcurrentCallers(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,8 @@ type RegisterOptions struct {
|
||||
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
|
||||
// defaultConnectTimeout.
|
||||
ConnectTimeout time.Duration
|
||||
Execution *execution.Runner
|
||||
WorkspaceRoot string
|
||||
}
|
||||
|
||||
// SkippedServer records an MCP server that was not registered because it could
|
||||
@@ -80,7 +83,7 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
|
||||
factory := options.ClientFactory
|
||||
if factory == nil {
|
||||
factory = func(ctx context.Context, server Server) (ToolClient, error) {
|
||||
return Connect(ctx, server)
|
||||
return ConnectWithOptions(ctx, server, ConnectOptions{Execution: options.Execution, WorkspaceRoot: options.WorkspaceRoot})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -289,7 +288,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
|
||||
// reminder labels a multi-token server correctly via tools.DeferredLine.
|
||||
// discovery label names a multi-token server correctly via tools.DeferredSource.
|
||||
func TestRegistryToolReportsMCPServerName(t *testing.T) {
|
||||
client := &fakeToolClient{}
|
||||
// A server name that sanitizes to a token containing an underscore ("git_hub"):
|
||||
@@ -305,14 +304,9 @@ func TestRegistryToolReportsMCPServerName(t *testing.T) {
|
||||
t.Fatalf("MCPServerName() = %q, want %q", tool.MCPServerName(), "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)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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,14 +197,6 @@ 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,16 +62,6 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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,11 +167,6 @@ 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 {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/secrets"
|
||||
@@ -113,6 +114,10 @@ type ActivateOptions struct {
|
||||
// Env supplies the base environment for plugin tool commands. nil uses the
|
||||
// current process environment (os.Environ()).
|
||||
Env []string
|
||||
// Execution routes plugin tool subprocesses through the shared sandbox and
|
||||
// process execution seam. Production callers provide it before any tool can
|
||||
// be invoked; tests may continue to inject runTool directly.
|
||||
Execution *execution.Runner
|
||||
|
||||
runTool toolRunner
|
||||
}
|
||||
@@ -141,8 +146,14 @@ func Activate(registry *tools.Registry, loaded []LoadedPlugin, options ActivateO
|
||||
if timeout <= 0 {
|
||||
timeout = defaultPluginToolTimeout
|
||||
}
|
||||
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
|
||||
return execPluginCommand(ctx, command, timeout)
|
||||
if options.Execution != nil {
|
||||
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
|
||||
return execPluginCommandWithExecution(ctx, options.Execution, command, timeout)
|
||||
}
|
||||
} else {
|
||||
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
|
||||
return execPluginCommand(ctx, command, timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,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.List) alongside
|
||||
// merged, name-deduplicated list (Content stripped, like skills.ListFromRoots) 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
|
||||
@@ -689,3 +700,32 @@ func execPluginCommand(ctx context.Context, command pluginCommand, timeout time.
|
||||
output.Err = err
|
||||
return output
|
||||
}
|
||||
|
||||
func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runner, command pluginCommand, timeout time.Duration) commandOutput {
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
result := runner.ExecuteCaptured(runCtx, execution.CapturedRequest{
|
||||
Request: execution.Request{
|
||||
Origin: execution.OriginPlugin,
|
||||
Mode: execution.ModeCaptured,
|
||||
Command: execution.Command{Name: command.Command, Args: append([]string(nil), command.Args...), Env: append([]string(nil), command.Env...)},
|
||||
WorkingDirectory: command.Cwd,
|
||||
WorkspaceRoots: []string{command.Cwd},
|
||||
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
|
||||
},
|
||||
Stdin: command.Stdin,
|
||||
})
|
||||
exitCode := -1
|
||||
if result.Outcome.Exit != nil {
|
||||
exitCode = result.Outcome.Exit.Code
|
||||
}
|
||||
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
|
||||
switch result.Outcome.Kind {
|
||||
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
|
||||
output.Err = result.Err
|
||||
}
|
||||
if output.Stderr == "" && result.Outcome.Denial != nil {
|
||||
output.Stderr = result.Outcome.Denial.Reason
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -3,15 +3,27 @@ package plugins
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type pluginExecutionPreparer struct {
|
||||
request execution.Request
|
||||
}
|
||||
|
||||
func (preparer *pluginExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
|
||||
preparer.request = request
|
||||
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
|
||||
}
|
||||
|
||||
// fakeToolRunner records the invocation and returns a canned result so tool
|
||||
// activation can be exercised without spawning a real process.
|
||||
type fakeToolRunner struct {
|
||||
@@ -554,6 +566,21 @@ func TestActivateSkipsDisabledPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCommandUsesTypedPluginExecutionOrigin(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("spawns a subprocess")
|
||||
}
|
||||
preparer := &pluginExecutionPreparer{}
|
||||
command := pluginCommand{Command: os.Args[0], Args: []string{"-test.run=^$"}, Cwd: t.TempDir()}
|
||||
output := execPluginCommandWithExecution(context.Background(), execution.NewRunner(preparer), command, time.Second)
|
||||
if output.Err != nil || output.ExitCode != 0 {
|
||||
t.Fatalf("plugin execution output = %#v", output)
|
||||
}
|
||||
if preparer.request.Origin != execution.OriginPlugin || preparer.request.Mode != execution.ModeCaptured {
|
||||
t.Fatalf("execution request = %#v", preparer.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateIsDeterministic(t *testing.T) {
|
||||
dirA := t.TempDir()
|
||||
dirB := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+46
-17
@@ -19,6 +19,8 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/installtxn"
|
||||
)
|
||||
|
||||
// manifestFileName is the plugin manifest filename, matching the loader.
|
||||
@@ -128,6 +130,32 @@ 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
|
||||
@@ -138,20 +166,10 @@ 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 := writeLock(dir, lock); err != nil {
|
||||
if err := installtxn.CommitDir(target, staged, func() error {
|
||||
return writeLock(dir, lock)
|
||||
}); err != nil {
|
||||
return InstallResult{}, err
|
||||
}
|
||||
|
||||
@@ -182,6 +200,12 @@ 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
|
||||
@@ -194,11 +218,16 @@ func Remove(dir string, id string) error {
|
||||
return fmt.Errorf("plugin %q is not installed", id)
|
||||
}
|
||||
if present {
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
if err := installtxn.RemoveDir(target, func() error {
|
||||
if !locked {
|
||||
return nil
|
||||
}
|
||||
delete(lock, id)
|
||||
return writeLock(dir, lock)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("remove plugin dir: %w", err)
|
||||
}
|
||||
}
|
||||
if locked {
|
||||
} else if locked {
|
||||
delete(lock, id)
|
||||
if err := writeLock(dir, lock); err != nil {
|
||||
return err
|
||||
@@ -239,7 +268,7 @@ func writeLock(dir string, entries map[string]LockEntry) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", LockFileName, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil {
|
||||
if err := installtxn.WriteFileAtomically(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", LockFileName, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user