Compare commits

..
Author SHA1 Message Date
anandh8x 93c4d65c88 fix(sandbox): classify PowerShell removal aliases 2026-07-26 23:41:28 +05:30
anandh8x bdade37c6c fix(windows): harden PowerShell execution 2026-07-26 23:19:20 +05:30
anandh8x 729d5ee96c fix(sandbox): normalize relative Windows paths
Recognize drive-relative, explicit relative, and suffixed backslash executable paths before generic shell escape removal so Windows commands continue to match canonical sandbox risk entries.
2026-07-24 20:22:15 +05:30
anandh8x 9994ad49bb fix(sandbox): normalize absolute Windows command paths
Extract drive-letter and UNC basenames before removing shell escape characters so fully qualified Windows executables continue matching canonical safety and network classifications.

Cover both drive and UNC curl paths with analyzer regressions.
2026-07-24 20:11:47 +05:30
anandh8x 2a0229e729 fix(sandbox): classify Windows executable suffixes
Normalize .exe, .cmd, .bat, and .com command names before safety and network lookup so PowerShell invocations such as curl.cmd receive the same network permission handling as curl.

Add analyzer and engine-level regressions for the Windows PowerShell command shape observed in CI.
2026-07-24 20:02:00 +05:30
anandh8x f51de6dfc6 test(windows): align shell coverage with PowerShell
Make Windows command fixtures and guidance assertions follow the selected host shell, preserve cmd fallback coverage, and use a genuine MSYS executable when PowerShell aliases are available.

Remove the unsupported non-TTY PowerShell stdin assertion; Windows interactive stdin requires a separate PTY transport.
2026-07-24 19:51:03 +05:30
anandh8x fcc6c72c34 fix(windows): prefer PowerShell for command execution
Detect PowerShell 7 or Windows PowerShell before falling back to cmd.exe, and share the selected host shell across agent tools and the interactive shell escape.

Configure UTF-8 output, preserve restricted-sandbox MSYS safeguards, and add Windows coverage for pipelines, workspace writes, and interactive stdin sessions.
2026-07-24 19:35:42 +05:30
Anandan a4a805a6c8 fix(tui): reset quiet clock for new runs (#799)
New turns inherited lastStreamActivity from the prior turn, so a fresh request could immediately look stalled. Initialize stream activity from the same timestamp as turnStartedAt and cover the stale-clock case with a deterministic regression test.
2026-07-24 17:38:10 +05:30
Anandan 9fe02ae52e fix(sandbox): clarify scoped permission prompts (#798) 2026-07-24 17:37:32 +05:30
Anandan 9d5c82d441 test(config): remove Windows startup timing assumptions (#800)
Cold PowerShell startup under concurrent Windows CI can consume most of the provider timeout, so an unsynchronized PID file may never be written and a fixed four-second bound can fail despite correct process-tree cleanup. Treat an absent PID as a helper that never reached startup, retain strict liveness checks for synchronized children, and bound completion by the child's natural lifetime instead.
2026-07-24 15:43:51 +08:00
euxaristiaandClaude Sonnet 5 97ef90d27a chore: remove code unreachable on every supported platform (#706)
* chore: remove code unreachable on every supported platform

The Security and code health job's advisory deadcode step reports 152
unreachable functions on main. Running the same check under GOOS=linux,
windows, and darwin shows 13 of those are false positives whose callers
live in windows build-tagged files, so only functions unreachable on all
three platforms are touched.

Functions with no references anywhere are deleted, including the
internal/reasoning package, which nothing imports. Functions that only
tests reference move into per-package export_test.go files so tests keep
their seams while the production binary sheds the code. Test call sites
of deleted convenience wrappers are ported to the live APIs (scoped tool
constructors, tools.DeferredSource, FinishReason checks).

Kept despite the report: cron.Store.Runs (only reader of runs.jsonl,
which production writes), and agenteval.TraceEventKeys and
MissingTraceEvents (used across packages by the trace contract test).

* docs(config): correct ValidateFile's errors.As claim

Message is a flattened string (.Error() already called on the parse
error), not a wrapped error, so callers can't recover the concrete
json error type via errors.As. Fix the comment to match.

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

* chore(trace,skills): drop obsolete Sink adapters and fix stale doc references

The dead-code pass removed the Emit methods but left Sink, NDJSONSink, and
TextSink exported with a contract nothing implements; remove the types with
their contract. Also remove the stray blank line at EOF of trace.go flagged
by gofmt, and update skills package docs and HOW_ZERO_WORKS.md to stop
directing callers to DiscoveryRoots/Duplicates, which are now test seams.

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

* chore: clean up remaining review nits on dead code removal

Replace a pointless fmt.Errorf(...%w...).Error() with fmt.Sprintf
using %v, since the wrapped error was discarded immediately anyway.
Remove a comment block that was duplicated verbatim.

* docs(skills): drop remaining references to test-only List/Get

Load and MergedSkills comments still named List/Get as production
entry points after they moved to export_test.go. Point callers at
Load and ListFromRoots instead.

* chore: remove now-unreferenced export_test.go seams

execSessionManager.len() and likelySandboxDenied/sandboxDenialKind had
no remaining callers after the sandbox unify-command-execution rebase;
the production code they wrapped for tests no longer exists.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-24 09:04:41 +08:00
PierrunoYTandClaude Sonnet 5 5c58256abc fix(config): kill provider-command process tree via job object on Windows (#690)
* fix(config): kill provider-command process tree via job object on Windows

taskkill /T walks the process tree by parent PID in user space and can
miss descendants, leaving the sleeping child holding the inherited
stdout/stderr pipes so cmd.Wait() blocked until natural completion
(~10s instead of the 5s timeout).

Assign the started command to a job object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and terminate the job on timeout so
the kernel kills the whole tree atomically; taskkill remains as a
fallback. Also set cmd.WaitDelay so Wait can never hang on pipes held
by an escaped orphan, and add a regression assertion that the sleeping
child is actually gone after the timeout returns.

Fixes #683

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

* fix(config): harden provider-command tree termination

Address code review on PR #690: terminate the process tree when
cmd.Wait() returns exec.ErrWaitDelay (a background descendant kept
inherited pipes open, previously left running); start the Windows
process suspended and assign it to the job object before resuming its
main thread, closing the window where a fast child could escape the
job; drop JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so a successful command's
detached helpers survive Close() instead of being reaped; escape
single quotes in generated PID-file paths; and correct a misnamed
Windows exit-code constant in a comment.

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

* fix(config): avoid gofmt smart-quote rewrite in doc comment

gofmt's doc-comment formatter rewrites a trailing '' into a curly
closing quote, which CI's gofmt -l check flags as unformatted. Reword
to avoid the pattern instead of embedding a raw quote pair.

* fix(config): terminate leaked descendants on nonzero exit, avoid PID reuse

Terminate the process tree on any error returned from cmd.Wait, not only

exec.ErrWaitDelay. Go only returns ErrWaitDelay when the shell itself

exited successfully; a nonzero exit (e.g. sleep 600 & exit 7) yields the

bare *ExitError instead, so a detached background child that held the

inherited pipes open was never terminated on that path.

Also stop falling through to the taskkill/PID fallback once a job handle

successfully contained the tree: 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 taskkill /T /PID

against a reused PID would force-kill the wrong tree.

Addresses PR #690 review feedback.

* fix(config): surface suspended-thread resume failures instead of hanging

resumeMainThread now reports whether it actually resumed at least one

thread. If Toolhelp/OpenThread/ResumeThread all fail, the launched

process would otherwise sit suspended forever, never exit on its own,

and get misreported as a plain 5s provider-command timeout instead of

the real resume failure. attachCommandProcess now terminates the

process immediately in that case.

Also documents the known, non-regressive limitation that the taskkill

fallback (used only when job creation/assignment itself fails) can

still miss a descendant that has reparented away from the tree it

walks by PID.

Addresses remaining PR #690 review feedback; PR description corrected

separately to drop the outdated JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE claim.

* fix(config): guard taskkill with retained process identity

* fix(config): retain POSIX process group identity

* test(tui): match provider wizard shortcut

* test(tui): pin credential-step env for deterministic footer assertion

The credential step's footer only shows the /→ shortcut once a key
is present, whether typed or inherited from the provider's
AuthEnvVars. The previous commit changed the assertion to
"Enter/→ continue" without accounting for that, so the test only
passed by coincidence when ANTHROPIC_API_KEY happened to be set in the
environment — failing deterministically on a clean CI runner (as
smoke reported on all three OSes). Pin the env var empty and assert
the no-key footer text so the test is correct regardless of ambient
environment.

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

* fix(config): preserve provider process attributes

* test(config): keep SysProcAttr coverage portable

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-24 09:03:59 +08:00
Felix-Ayush fa8734f179 fix(cli): include plugins info in shell completions (#794)
zero plugins info landed in #773 but the completions tree still only
offered list/add/remove, so generated bash/zsh/fish scripts omitted it.
2026-07-24 08:49:01 +08:00
euxaristiaandClaude Fable 5 5bb1bc11c3 fix(tui): fix rendering corruption over multipass + Windows Terminal (#709)
* fix(tui): disable bubbletea hard scroll optimization unconditionally

Vendors a patched charm.land/bubbletea/v2 (via a local go.mod replace)
with hard scroll optimization (using terminal scroll-region sequences
to shift existing lines instead of repainting) disabled unconditionally,
instead of only on GOOS=="windows".

That GOOS check only catches a Windows terminal driving the program
directly. It misses a Linux process running inside a multipass VM
whose output is ultimately displayed by Windows Terminal over a
remote shell, which corrupts rendering badly (garbled/overlapping
text, worse on resize) the same way the check exists to prevent.
There's no reliable way to detect that remote hop from inside the VM,
so scroll optimization is now off in all cases.

* fix(tui): force redraw on streamed newlines to clear stale caret

The streaming caret (appendStreamingCursor) is appended to whichever
visual line is currently last. Over a remote terminal chain
(multipass VM output displayed by Windows Terminal), the renderer's
incremental cell diff sometimes fails to clear the caret's old cell
when a newline moves it to a new line, leaving ghost carets behind.

Fire tea.ClearScreen when a streamed delta contains a newline: that's
exactly the moment the caret moves and the risk exists, so it forces
a full redraw right then instead of leaving it to the incremental
diff, without doing so on every animation tick.

* chore(tui): trim upstream repo metadata from vendored bubbletea fork

The vendored bubbletea-v2-patched module is ~99% byte-identical to
upstream charm.land/bubbletea/v2 v2.0.8; only cursed_renderer.go
differs (one line disabling hard scroll optimization). The .github
workflows, README.md, UPGRADE_GUIDE_V2.md, Taskfile.yaml and CI config
files are upstream repo metadata that serve no purpose inside this
repo, so remove them to slim the PR. The Go source and testdata golden
files are kept because Go's replace directive requires the full module
to be present and buildable.

Refs #709

* fix(tui): address review round on the rendering workaround and vendored fork

Fork correctness fixes (all flagged by review):

- key.go: KeyMediaRecord repeated the previous const expression and
  decoded record presses as previous-track; map it to uv.KeyMediaRecord.
- mouse.go: MouseButton11 likewise collapsed onto MouseButton10.
- termios_unix.go: BSDLY/BS0 are output-delay bits; read them from Oflag
  instead of Lflag so useBackspace is set correctly.
- cursed_renderer.go: count wrapped rows with (lineWidth-1)/w so an
  exact terminal-width multiple no longer inserts a blank line; compare
  colors by normalized RGBA via a nil-safe helper instead of interface
  equality (which panics on non-comparable implementations); and make
  clearScreen invalidate lastView so the queued erase flushes with the
  next frame instead of deferring until an unrelated view change — the
  reordering that could leave the ghost caret in place.
- exec_test.go: implement reset() on the spy renderer and inject it
  before Run; assert exec preserves renderer state (releaseTerminal
  with reset=false).
- logging_test.go: restore the default logger's output/prefix/flags and
  fail fast on setup errors.
- tea_test.go: tie the Quit-pumping goroutine to a channel closed when
  Run returns instead of leaking it until process exit.

TUI scoping (review P2): the forced redraw on streamed newlines is now
rate-limited to ~10/s so heavy streaming output no longer turns every
newline into a full-screen repaint, and ZERO_NO_STREAM_CLEAR=1 opts a
correctly-rendering terminal back into the fast incremental path.

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

* fix(tui): guarantee deferred stream-clear redraw and other review fixes

A throttled stream-clear could drop the redraw entirely when a
newline arrived in the last delta of a turn, leaving a ghost caret on
screen. Add a one-shot deferred flush timer plus a flush on stream
end. Fix a stale comment and a test that leaked global logger state
in the vendored bubbletea fork.

* fix(tui): use m.now for stream-clear throttle and harden tests

Drive stream-clear rate limiting through the mockable clock and a
shared streamClearThrottle constant so both the agentTextMsg and
flush paths stay in sync. Add an explicit two-newline-within-window
regression and detect ClearScreen by function pointer so tests do not
evaluate Tick side effects.

* fix(tui): relocate bubbletea fork out of third_party

Move the maintained bubbletea/v2 hard-scroll patch from
third_party/bubbletea-v2-patched to patches/bubbletea-v2 and update the
root go.mod replace. third_party/ is immutable vendored content per
AGENTS.md; a maintained fork belongs outside that tree.

* fix(ci,docs): run vendored bubbletea fork tests and document unsupported go install path

patches/bubbletea-v2 is its own Go module so `go test ./...` from the repo
root never touched its 7 test files. Add a CI step (and a make test-patches
target for local use) that runs the fork's own suite across the smoke
matrix.

The root go.mod's local `replace charm.land/bubbletea/v2 =>
./patches/bubbletea-v2` also breaks `go install
github.com/Gitlawb/zero/cmd/zero@<version>`, since a versioned install can't
resolve a replace pointing at a filesystem path. Document that path as
unsupported in README.md and docs/INSTALL.md and point at git clone + go
build instead, per jatmn's review findings on PR #709.

* ci: run vendored bubbletea fork tests with -race

CodeRabbit review on this PR. The fork exists specifically to patch a
concurrency bug (charmbracelet/bubbletea#1690), so its regression test
needs the race detector to actually catch a reintroduced race instead
of just passing on a benign-looking run.

* fix(tui): drop vendored bubbletea fork; keep stream-clear workaround

Maintainers asked not to ship an in-tree bubbletea replace: it breaks
versioned go install and is a large maintenance surface for a narrow
multipass/Windows Terminal scroll bug. Remove patches/bubbletea-v2 and
the replace directive; keep the model.go full-redraw-on-streamed-newline
workaround (ZERO_NO_STREAM_CLEAR to opt out). Restore normal install docs.

Refs Gitlawb/zero#709

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-24 08:48:16 +08:00
PierrunoYTandClaude Sonnet 5 d9b882eb0a fix(cron): reserve job IDs atomically (#686)
* fix(cron): reserve job IDs atomically

* fix(cron): hold the per-ID lock from reservation through persistence

Address code review on PR #686: reserveID made the timestamp directory
visible via os.Mkdir before metadata.json existed, but Remove (which
takes the per-ID lock before deleting) never coordinated with that
window. A concurrent `zero cron rm <id>` could delete the bare
reservation and report success right after Add reserved it; writeJob's
MkdirAll would then silently resurrect the directory and persist the
job anyway. A second Add could also re-reserve the same id while the
first writer was still running, putting two writers on the same
metadata path.

reserveID now acquires the same per-ID lock (used by Get/Update/
Mutate/Remove/AppendRun) for each candidate id before attempting
os.Mkdir, and returns it still held; Add releases it only after
writeReservedJob commits (or tears down) the reservation. A concurrent
Remove for that id now blocks until the reservation is resolved one
way or the other, and a concurrent Add attempting the same candidate
id serializes through the lock rather than only racing the Mkdir.

Adds two regression tests: one proving Remove waits for and removes
the committed job rather than the bare reservation, and one proving a
second reserveID call for the same candidate id blocks on the shared
lock and moves on to the next candidate.

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

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-22 19:28:04 +08:00
github-actions[bot] a50574f673 chore(main): release 0.5.0 (#714)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-22 09:37:36 +08:00
nb213andbinyangzhu000-sudo c00a4734b5 Add Atlas Cloud provider preset (#784)
Co-authored-by: binyangzhu000-sudo <[email protected]>
2026-07-22 08:20:21 +08:00
Felix-Ayush 2479884dea feat(plugins): add zero plugins info command (#773)
* feat(plugins): add zero plugins info command

Add plugins info to inspect manifest state, extension counts, and
lockfile source/hash with optional hash drift detection.

* fix(plugins): derive lock dir from plugin install path for info

Remove LockDir from InfoOptions and read the lockfile from
filepath.Dir(plugin.PluginDir) so lock metadata matches the resolved
plugin location. Consolidate duplicate lock hash output lines.
2026-07-22 08:14:00 +08:00
Anandan ab2f9fbd3e Add shell completion generation (#764)
* Add shell completion generation

Add zero completions for bash, zsh, fish, PowerShell, and Elvish using one shared command tree that covers aliases, nested commands, root flags, and common exec flags.

Validate missing and unsupported shell arguments as usage errors and document safe installation examples in command help.

Refs #499

* Complete shell target suggestions

Expose every supported shell as a completions subcommand candidate and document Elvish activation.

Refs #499

* Fix Elvish completion syntax

Close generated Elvish conditional branches, use value equality, and validate generated script structure plus native Bash and Zsh syntax.

Refs #499
2026-07-22 08:13:14 +08:00
PierrunoYTandAmp baa4be13ac fix: make extension installs transactional (#762)
Co-authored-by: Amp <[email protected]>
2026-07-22 08:12:49 +08:00
ddfdf2878e Fix ChatGPT OAuth model discovery and ACP model selection (#724)
* fix: expose authenticated ChatGPT models to ACP

Co-authored-by: Pierre Bruno <[email protected]>

* fix(acp): persist discovered model selections

* fix(acp): normalize model selections

---------

Co-authored-by: Amp <[email protected]>
2026-07-22 08:11:55 +08:00
Anandan 96859c9bd1 feat(sandbox): unify command execution and enforcement (#781)
* feat(sandbox): unify command execution and enforcement

* test(sandbox): make platform contracts portable

* fix(sandbox): close execution lifecycle races

Bound retained interactive output, preserve interruption state, and start workspace observation before process launch.

Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback.

* fix(sandbox): restore strict macOS isolation

Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces.

Track process reap completion so retained sessions cannot signal a stale numeric PID.
2026-07-21 18:55:13 +05:30
Vasanth T 3524f795e5 fix(cli): warn when ZERO_PROVIDER overrides a providers-use selection (#767)
Closes #721.

`zero providers use <name>` wrote activeProvider to config.json and reported
"Active provider set to <name>" even when ZERO_PROVIDER was set, which applyEnv
makes win over config.json unconditionally. So the switch was reported as a
success while `providers current` still showed the env-pinned provider, a silent
no-op.

Detect the override and say so: the text path prints a note to stderr naming the
env var and the provider that stays active, and the --json path adds
effectiveProvider / overriddenByEnv fields so a machine consumer is not misled
either. The config write still lands unchanged; only the reporting is honest.

The env read is injected via a new appDeps.getenv (os.Getenv in production, left
nil by fillAppDeps like exportActiveProvider) so tests stay hermetic against an
ambient ZERO_PROVIDER. The related env-derived-profile "not found" case the issue
also mentions is already handled by the unpersisted-provider path (#707) and #716.
2026-07-21 13:00:50 +05:30
Vasanth T b1f41735a7 fix(lsp): make the real-gopls check opt-in so a broken gopls can't fail the suite (#684) (#766)
* fix(lsp): make the real-gopls check opt-in so a broken gopls can't fail the suite

Closes #684.

TestManagerCheckRealGopls skipped only when gopls was absent from PATH, so an
installed-but-unhealthy gopls (e.g. an unusable persistent-index cache that makes
the server exit during initialization with EOF) failed `go test ./...` for every
developer who merely had gopls on PATH.

Gate it behind ZERO_GOPLS_INTEGRATION, matching the repo's other real-server
opt-ins (internal/dictation smoke tests). The default hermetic suite keeps its
coverage on the fake server and never touches the developer's global gopls; the
real check still runs on demand with ZERO_GOPLS_INTEGRATION=1.

* test(lsp): require ZERO_GOPLS_INTEGRATION=1 exactly, so =0 keeps the check off
2026-07-21 13:00:41 +05:30
Vasanth T 4945684fa2 fix(sandbox): bind Windows elevated ACL setup to one no-follow handle (#765)
Closes #728.

The Windows ACL setup resolved each target's pathname independently across
os.Stat, GetNamedSecurityInfo, and SetNamedSecurityInfo (and again on rollback),
so during elevated setup a lower-privileged local user could swap a target for a
symlink/junction between operations and redirect the ACL change onto a system
object the process never validated. That is a TOCTOU across a privilege boundary.

Open each target once with a no-follow handle (FILE_FLAG_OPEN_REPARSE_POINT plus
FILE_FLAG_BACKUP_SEMANTICS, READ_CONTROL|WRITE_DAC) and drive both the read
(GetSecurityInfo) and the write (SetSecurityInfo) through that one handle, so they
hit the same kernel object. isDir is read from the same handle rather than a
separate Stat. A target that resolves to a reparse point is refused outright,
since a setup target that is a symlink/junction during elevated setup is the
swap-attack signature. Rollback re-opens no-follow rather than restoring by
pathname; the residual window is small because the target is ACL-restricted by
then, and one caller discards the rollback closure so handles are not retained for
the whole sandbox lifetime.

Tests (windows-tagged, no elevation needed): the handle-based apply plus rollback
round-trip on a user-owned temp dir, the materialize-and-remove path, reparse
rejection via a junction (needs no admin, so it runs in CI), and the not-exist
mapping. The full privilege-boundary race can't be reproduced in CI, so the guard
is verified structurally (one handle for read and write) and by junction rejection.
2026-07-21 13:00:29 +05:30
Vasanth T 5140d4bf7b docs(contributing): add contribution volume and batching guidance (#783)
Documents expectations for keeping contribution flow sustainable during the
stabilization phase: one issue per bug (no bundled multi-finding audits), keep
only a few items open at a time, and hold AI-assisted contributions to the same
per-item verification bar. Complements the existing issue-approved gate, which
already covers PR-to-issue linkage and scope; the gap was submission volume.
2026-07-21 15:16:22 +08:00
KRATOSandKRATOS 3967d49d64 feat(tui): permission prompt takes free-text feedback inline (#780)
* feat(tui): let the permission prompt take free-text feedback inline

"No, and tell Zero what to do differently" now opens an inline input on the
permission card instead of silently cancelling: type an instruction, Enter sends
it, Esc returns to the option list. The text is delivered as a Deny decision whose
Reason is the typed instruction, so the agent surfaces it via deniedPermissionResult
as the tool result and the model adjusts in the SAME turn — where the old cancel
returned an error and aborted the run. Empty input + Enter falls back to a plain
cancel (the prior meaning).

No agent-side change: PermissionDecision.Reason already carries free text to the
model. The UI mirrors the ask_user "type your own answer" pattern — the feedback
field reuses the shared composer input and the card renders it in the transcript
body.

Also: a focused permission prompt now suppresses the composer (and idle
hints/plan panel), keeping only the status line, exactly like the ask_user modal.
Its keys are inert anyway, so a "describe a task for zero…" box below the card was
misleading — and in feedback mode the shared input would otherwise echo in two
places.

Tests cover open / submit-as-Deny-with-text / empty-cancels / Esc-returns, the
composer suppression in both modes, and that the feedback text renders exactly
once.

* fix(tui): preserve the composer draft across permission feedback (#780 review)

anand + CodeRabbit (Major): entering feedback mode cleared the shared composer
input via SetValue(""), and neither submit nor cancel restored it — so a user
with a half-typed or queued next-turn message who chose "tell Zero what to do
differently" lost that draft.

Snapshot the composer value into pendingPermissionPrompt.savedDraft when feedback
mode opens, and restore it on BOTH exits (submit and Esc-cancel). Only the
feedback text is consumed, delivered via the decision Reason. Adds regression
tests for both paths; mutation-verified against the SetValue("") regression.

* fix(tui): guard permission-feedback backspace and clicks (#780 review)

Vasanthdev2004 review, all on the permission path:

1. Real bug: Backspace on an empty feedback field dropped a staged attachment.
   The keyBackspace case runs before the typing branch and, on an empty composer
   (feedback mode clears it), fell to removeLastAttachment — silently dropping a
   staged image/doc that savedDraft does not restore. Route Backspace to the
   shared input while typing so it is a plain edit of the feedback text.

2. Defence-in-depth: the mouse click resolver in handleTranscriptSelectionMouse
   resolved a clicked permission option (Allow included) with no typing check. It
   is safe today only because renderFocusedPermissionPrompt returns nil clickable
   offsets in feedback mode — a single early-return in a function other PRs also
   edit. Add an explicit !typing guard on the click path so the safety is stated,
   not emergent.

3. Test hardening (also raised by CodeRabbit): assert len(got) == 1 before
   indexing got[0] in the draft-preservation test, so a regression fails cleanly
   instead of panicking.

Tests: TestPermissionFeedbackBackspaceKeepsStagedAttachment (mutation-verified
against the missing guard) and TestPermissionFeedbackRendersNoClickableOptionsWhileTyping.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 23:29:10 +05:30
KRATOSandKRATOS 89bdc6719a fix(tools): read_file recovers a backwards line range instead of erroring (#779)
* fix(tools): read_file recovers a backwards line range instead of erroring

renderReadFileRange hard-failed when end_line < start_line, costing the caller a
whole retry for an arithmetic slip. It was also inconsistent: start_line past EOF
returns a friendly note and end_line past EOF is auto-clamped to the last line —
only a backwards range hard-errored.

Recover it the same way: clamp end_line UP to start_line (read just that line) and
surface the adjustment in the output so it is never silent. Valid ranges
(end_line >= start_line) are byte-for-byte unchanged.

The change is in the argument-validation phase, before any file I/O, so there is
no runtime cost; if anything it saves the agent a wasted retry turn.

Tests: TestReadFileToolRecoversBackwardsRange (recovery + note + reads only
start_line, mutation-verified against the old hard error) and
TestReadFileToolValidRangeUnchangedByRecovery (valid ranges carry no note and read
the identical window).

* test(tools): assert full valid-range output in read_file recovery test (#779 review)

anand + CodeRabbit: TestReadFileToolValidRangeUnchangedByRecovery claimed
byte-for-byte preservation but only checked substrings, so an altered
header/separator or a stray out-of-range line would still pass. Compare the
whole result.Output against the exact expected rendering.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 22:57:25 +05:30
KRATOSandKRATOS 722bb31216 fix(tui): stop the permission card clashing on cool themes (#778)
The focused permission prompt was the only prompt card in the TUI that used
brand/warning colours for its interior, and both read badly on cool themes such
as dracula:

1. The selected option ("Yes, proceed") was filled with zeroTheme.badge — the
   brand chip meant for short labels (" 0 ", " ASK ", " SPEC REVIEW "), a
   full-brightness accent fill. Every other selectable list in the TUI (model
   picker, provider list, / palette) highlights its selected row with onSel/selBg
   instead. The permission card was the outlier, painting a saturated accent slab
   across the row and skipping the card tint every other line composed onto.

2. The card BODY was tinted with permBg, an amber-family wash intended to signal
   a warning surface. It works on the warm default palette but renders as a
   brown-yellow slab over cool themes, and it made the permission card the only
   prompt whose body carries a fill — ask_user, spec review and plan all use a
   transparent body (lipgloss.NewStyle()).

Fix both to match the rest of the TUI: the selected row uses onSel(ink) like
every other picker, and the body drops its permBg fill for a transparent one.
Warning identity is unchanged — the amber PERMISSION badge and the amber-mixed
border still mark it as a permission gate; only the clashing interior fills go.

TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip asserts against
palette entries (not hardcoded hex, so it survives a theme retune): the selected
row carries selBg and not the brand accent, the body carries no permBg wash, and
the PERMISSION badge keeps its amber fill so the card still reads as a warning.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 22:39:50 +05:30
KRATOSandKRATOS a21a052a4a feat(sandbox): disable the sandbox via config (#687) (#746)
* feat(sandbox): disable the sandbox via config (#687)

Add a sandbox.enabled field (`"sandbox": {"enabled": false}`). ModeDisabled
already short-circuits the engine but had no config surface.

- SandboxConfig.Enabled *bool (pointer distinguishes an explicit false from an
  omitted key).
- mergeConfig honors it (global config / CLI); applyConfiguredSandboxPolicy maps
  enabled:false to ModeDisabled.
- mergeProjectConfig intentionally does NOT merge it: a cloned repo must not be
  able to disable the sandbox that constrains it — same posture as
  AdditionalWriteRoots and the Network tighten-only rule.

* fix(config): honor sandbox.enabled from the CLI override path (#687 review)

applyOverrides copied BlockUnixSockets/MonitorDenials but not the new Enabled
tri-state pointer, so a CLI/programmatic Overrides{Sandbox:{Enabled:&false}} was
dropped — contradicting the 'global config / CLI can disable' contract. Preserve
the pointer in applyOverrides (runs after all config sources, so an explicit
override wins) and add a resolver regression test: an explicit false override
disables, and an explicit true override wins over a lower-precedence config false.

* fix(config): keep sandbox.enabled out of the provider-command path (#687 review)

mergeConfig now copies Sandbox.Enabled, and it is shared by two callers: the
global-config merge (trusted) and the provider-command merge (not). A provider
command is an arbitrary executable named in config, and LoadProviderCommand
parses its stdout into a full FileConfig -- so one returning a valid provider
plus {"sandbox":{"enabled":false}} could disable the sandbox meant to constrain
it, despite the setting being documented as global-config/CLI only. Confirmed
reproducible in both directions before the fix.

Clear Sandbox.Enabled from the provider-command config before merging, mirroring
the carve-out mergeProjectConfig already applies for the same reason. Done at the
call site rather than inside mergeConfig because that helper must keep honouring
the setting for the trusted global-config merge.

Adds TestResolveSandboxEnabledIgnoredFromProviderCommand covering both
directions (cannot disable, cannot enable) and asserting the provider itself is
still applied, so the guard cannot be satisfied by dropping the command wholesale.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 21:07:04 +05:30
KRATOSandKRATOS f079b90f82 fix(sandbox): AST second opinion for interactive-command bypasses (#473) (#745)
* fix(sandbox): AST second opinion for interactive-command bypasses (#473)

The interactive-command guard split shell commands with a hand-written parser
(splitShellSegments), which mis-handles unusual quoting, command substitution,
subshells, and newline separators — an interactive program hidden by one slips
through and the agent hangs until the per-command timeout.

Add astCommandFields(): mvdan.cc/sh/v3/syntax (already used by analyzer.go) to
re-extract the real simple commands, then apply the SAME per-program checks the
regex path uses (interactivePrograms + hasNonInteractiveFlag). This only ADDS
detections and classifies every program exactly as before — ssh with a trailing
command, python -c, etc. stay allowed — while catching the splitter's bypasses.
Unparseable input (Windows cmd.exe, obfuscation) yields no commands and falls
through to the regex path; the guard never hard-blocks on a parse error.

* fix(sandbox): AST pass runs the full pipeline + guards dynamic programs (#473 review)

Address review on #745:
- The AST second opinion now applies the complete detection pipeline via a new
  inspectCommandFields helper (multi-word interactive segments, sh -c payload
  recursion, then the per-program lookup), so parser-hidden bypasses like
  { git rebase -i HEAD~1; } and { sh -c 'vim file.txt'; } are caught, not just
  bare interactive programs.
- astCommandFields now skips a CallExpr whose program word is not a static
  literal (isLiteralWord), so a substitution concatenated with a literal
  ($(printf foo)vim -> foovim) can't fabricate an interactive program name.

Regression tests added for both boundaries. (The un-braced $(printf foo)vim
false positive is a separate, pre-existing hand-parser behavior — present on
main before this PR — and is out of scope here.)

* fix(sandbox): require literal words for the whole AST call (#473 review)

astCommandFields guarded only the program word, then ran every argument
through wordText, which keeps literal/quoted parts and silently drops
CmdSubst/ParamExp. A dynamic ARGUMENT was therefore reconstructed lossily:
`git $(printf foo)rebase -i HEAD~1` runs as `git foorebase -i` (a
non-existent, non-interactive subcommand) but collapsed to `git rebase -i`
and was blocked -- a false positive on a command the user never wrote, and
new on this branch (the base correctly reports not-interactive).

Extend the literalness guard to every word in the call: if any word carries
an expansion, skip that AST command rather than classify a reconstruction
whose runtime value is unknowable. Skipping is the safe direction -- the
hand-written passes still run first, and a miss falls through to the normal
permission prompt instead of hard-blocking.

Genuine detection is unchanged: `git rebase -i HEAD~1` and the #473 bypass
cases still classify exactly as before.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 21:01:55 +05:30
KRATOSandKRATOS b30c3971b4 fix(tui): model rows labelled by id when the description is prose; keep the sidebar under the / palette (#775)
* fix(tui): label model rows by id when the description is prose

The model picker (provider wizard AND onboarding, which share displayLabel)
labelled each row with the provider's Description, falling back to the ID only
when the description was "generic". That is right when Description holds a short
display name -- "Grok 4.3" for x-ai/grok-4.3, which is what the field is for and
why the row prefers it over an ugly raw ID.

Providers do not all honour that. Aggregators commonly return a prose blurb
instead ("Fast DeepSeek model for efficient chat, coding help, and agent
loops"), which fails as a label twice: it overflows the row, and because such
blurbs are written per model FAMILY rather than per model, several DIFFERENT
models collapse to byte-identical rows. Observed in a live picker with two
identical "Open-weight GPT model for self-hosted reasoning..." rows and two
identical "Kimi multimodal agent model..." rows -- the distinguishing ID was
visible only in the detail line of whichever row happened to be highlighted, so
the list could not be used to pick between them.

providerWizardGenericModelDescription cannot catch these: it rejects vague
descriptions ("live model", "catalog default"), whereas these are specific, just
shared and sentence-shaped. So gate on shape instead -- a display name is short
and few-worded; anything longer is prose and the ID (unique by construction)
makes the better label. Friendly names are still preferred where providers
supply them, so the existing behaviour is unchanged for well-behaved catalogs.

* fix(tui): keep the sidebar open under the / command palette

sidebarAvailable grouped suggestionsActive() with the full-screen overlays
(setup, wizards, pickers), whose stated rationale is that they "take over the
chat column and render at full width". That is not true of the `/` palette:
suggestionOverlay draws it through centerRenderedBlock at
minInt(width, suggestionPaletteMaxWidth), a centred box capped at 76 cells
floating over the chat column.

Treating it as full-screen collapsed the whole layout on every `/`: the sidebar
disappeared, the plan panel dropped out of it and re-rendered inline above the
composer, and the transcript re-wrapped to full width -- a jarring reflow for a
popup, and worst mid-run with a live plan on screen.

It cannot overlap the sidebar either: twoColumnTranscriptView renders the
overlay with width = chatColumnWidth, so the palette is capped at
min(chatColumn, 76) and centred inside the chat column. Mouse hit-testing is
unaffected because sidebarLineAtMouse carries its own suggestionsActive() guard,
so clicks still reach the palette rather than the sidebar rows beneath it.

The genuinely full-width overlays keep suppressing the sidebar, pinned by the
new test alongside the palette case.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 20:58:11 +05:30
211 changed files with 6700 additions and 3355 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.4.0"}
{".":"0.5.0"}
+47
View File
@@ -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)
+22
View File
@@ -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:
+1
View File
@@ -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
```
+1 -1
View File
@@ -749,7 +749,7 @@ flowchart TD
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
`LoadFromRoots` / `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
View File
@@ -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(
&notifier{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)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
}
default:
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)
+212
View File
@@ -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")
+11
View File
@@ -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
View File
@@ -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(
-12
View File
@@ -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
}
-9
View File
@@ -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 {
+4 -4
View File
@@ -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{
+3 -3
View File
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.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"})
+1 -1
View File
@@ -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"},
+41
View 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)
}
+7 -7
View File
@@ -138,7 +138,7 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.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++ {
-17
View File
@@ -267,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
@@ -2866,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.
+59 -53
View File
@@ -863,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")
}
@@ -883,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)
}
}
@@ -960,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"},
@@ -1167,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"},
@@ -1197,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{
{
@@ -1234,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{
{
@@ -1272,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{
{
@@ -1356,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{
{
@@ -1424,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{
{
@@ -1471,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{
@@ -1547,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{
@@ -1604,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
@@ -1647,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
@@ -1703,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
@@ -1752,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
@@ -1800,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
@@ -1896,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()
@@ -1968,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{
{
@@ -2049,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{
{
@@ -2251,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
@@ -2358,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{
{
@@ -2407,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)
@@ -2419,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{
{
@@ -2486,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{
{
@@ -2528,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},
},
@@ -2598,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
@@ -2630,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 {
@@ -2662,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
@@ -2714,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
@@ -2893,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
@@ -2942,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"},
@@ -2972,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{
{
@@ -3163,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)
}
@@ -3231,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)
@@ -3268,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{
@@ -3301,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{
{
@@ -3442,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"})
+1 -1
View File
@@ -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")
}
}
+2 -5
View File
@@ -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")
}
-7
View File
@@ -427,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.
-4
View File
@@ -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
+8
View File
@@ -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)
}
+5
View File
@@ -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.
+9 -1
View File
@@ -59,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
@@ -119,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 {
@@ -369,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":
@@ -1167,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
+404
View File
@@ -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
}
+177
View File
@@ -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)
}
}
}
+122
View File
@@ -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>
+9
View File
@@ -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()
+6
View File
@@ -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:
+3
View File
@@ -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
`)
+7 -1
View File
@@ -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) {
+32 -2
View File
@@ -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 {
+96
View File
@@ -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")
+15
View File
@@ -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")
}
}
+19 -3
View File
@@ -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
}
+142 -2
View File
@@ -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"
+72
View File
@@ -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)
}
+48 -7
View File
@@ -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
}
+66
View File
@@ -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
}
+147 -5
View File
@@ -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 == "" {
+65
View File
@@ -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
}
+20
View File
@@ -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
}
+79
View File
@@ -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{}})
+6 -8
View File
@@ -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"`
-23
View File
@@ -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
-33
View File
@@ -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
+2 -2
View File
@@ -16,7 +16,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
writeTestFile(t, root, "AGENTS.md", strings.Repeat("project rules\n", 200))
registry := tools.NewRegistry()
for _, tool := range tools.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)
}
+4
View File
@@ -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 }
-2
View File
@@ -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
View File
@@ -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)
+156
View File
@@ -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})
-10
View File
@@ -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
+147
View File
@@ -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)
}
+78
View File
@@ -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)
}
}
+29
View File
@@ -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)
}
+31
View File
@@ -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)
}
+14
View File
@@ -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 -2
View File
@@ -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")
}
-11
View File
@@ -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))
}
+4 -10
View File
@@ -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")
}
}
+21
View File
@@ -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
}
-8
View File
@@ -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
-10
View File
@@ -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) {
+7
View File
@@ -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
}
-5
View File
@@ -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 {
+1 -1
View File
@@ -284,7 +284,7 @@ const skillFileName = "SKILL.md"
// MergedSkills loads the default skills directory, the shared ~/.agents/skills
// root when present, plus the supplied plugin skill roots and returns one
// merged, name-deduplicated list (Content stripped, like skills.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
+62
View File
@@ -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
}
+69
View File
@@ -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
View File
@@ -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
+33
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -187,6 +188,38 @@ func TestInstallReinstallShowsHashChange(t *testing.T) {
}
}
func TestConcurrentInstallsPreserveEveryLockEntry(t *testing.T) {
destDir := t.TempDir()
const count = 12
sources := make([]string, count)
for index := range count {
manifest := validManifest()
manifest["id"] = fmt.Sprintf("zero.concurrent.%02d", index)
sources[index] = writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), manifest)
}
errs := make(chan error, count)
for _, source := range sources {
go func() {
_, err := Install(context.Background(), InstallOptions{Source: source, Dir: destDir})
errs <- err
}()
}
for range count {
if err := <-errs; err != nil {
t.Fatalf("concurrent Install: %v", err)
}
}
entries, err := ReadLock(destDir)
if err != nil {
t.Fatalf("ReadLock: %v", err)
}
if len(entries) != count {
t.Fatalf("lockfile has %d entries, want %d: %#v", len(entries), count, entries)
}
}
// TestInstallReinstallDetectsNestedFileChange guards that the recorded hash
// covers the whole installed tree, not just plugin.json. A change to a tool
// script (with the manifest unchanged) must still be reported as an update so
+1 -37
View File
@@ -140,6 +140,7 @@ var descriptors = []Descriptor{
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
openAICompat("atlascloud", "Atlas Cloud", "https://api.atlascloud.ai/v1", "qwen/qwen3.5-flash", []string{"ATLASCLOUD_API_KEY"}, "atlas cloud", "atlas"),
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
@@ -186,14 +187,6 @@ func All() []Descriptor {
return copied
}
func IDs() []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
ids = append(ids, descriptor.ID)
}
return ids
}
func Get(id string) (Descriptor, bool) {
normalized := NormalizeID(id)
for _, descriptor := range descriptors {
@@ -218,35 +211,6 @@ func Require(id string) (Descriptor, error) {
return descriptor, nil
}
func ListByTransport(transport Transport) []Descriptor {
normalized := Transport(NormalizeID(string(transport)))
items := make([]Descriptor, 0)
for _, descriptor := range descriptors {
if descriptor.Transport == normalized {
items = append(items, cloneDescriptor(descriptor))
}
}
return items
}
func ValidTransport(transport Transport) bool {
switch Transport(NormalizeID(string(transport))) {
case TransportOpenAI, TransportAnthropic, TransportGoogle, TransportBedrock, TransportVertex, TransportOpenAICompatible, TransportAnthropicCompatible:
return true
default:
return false
}
}
func ValidAPIFormat(format APIFormat) bool {
switch format {
case APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions, APIFormatAnthropicMessages, APIFormatGoogleGenerateContent, APIFormatBedrockConverse, APIFormatVertexGenerateContent:
return true
default:
return false
}
}
func NormalizeID(id string) string {
var builder strings.Builder
lastDash := false
+25 -1
View File
@@ -24,6 +24,7 @@ var expectedCatalogIDs = []string{
"together",
"dashscope",
"moonshot",
"atlascloud",
"longcat",
"nvidia-nim",
"minimax",
@@ -154,6 +155,28 @@ func TestLongCatDescriptor(t *testing.T) {
}
}
func TestAtlasCloudDescriptor(t *testing.T) {
descriptor, err := Require("atlascloud")
if err != nil {
t.Fatalf("Require(atlascloud) error = %v", err)
}
if descriptor.Name != "Atlas Cloud" {
t.Fatalf("Name = %q, want Atlas Cloud", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.atlascloud.ai/v1" {
t.Fatalf("DefaultBaseURL = %q, want Atlas Cloud OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "qwen/qwen3.5-flash" {
t.Fatalf("DefaultModel = %q, want qwen/qwen3.5-flash", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"ATLASCLOUD_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want ATLASCLOUD_API_KEY", descriptor.AuthEnvVars)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -272,6 +295,7 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"lm-studio": "lmstudio",
"mini_max": "minimax",
"Moonshot": "moonshot",
"Atlas Cloud": "atlascloud",
"nvidia nim": "nvidia-nim",
"xiaomi mimo": "xiaomi-mimo",
"custom_openai_compatible": "custom-openai-compatible",
@@ -322,7 +346,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
+39
View File
@@ -0,0 +1,39 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package providercatalog
func IDs() []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
ids = append(ids, descriptor.ID)
}
return ids
}
func ListByTransport(transport Transport) []Descriptor {
normalized := Transport(NormalizeID(string(transport)))
items := make([]Descriptor, 0)
for _, descriptor := range descriptors {
if descriptor.Transport == normalized {
items = append(items, cloneDescriptor(descriptor))
}
}
return items
}
func ValidAPIFormat(format APIFormat) bool {
switch format {
case APIFormatOpenAIResponses, APIFormatOpenAIChatCompletions, APIFormatAnthropicMessages, APIFormatGoogleGenerateContent, APIFormatBedrockConverse, APIFormatVertexGenerateContent:
return true
default:
return false
}
}
func ValidTransport(transport Transport) bool {
switch Transport(NormalizeID(string(transport))) {
case TransportOpenAI, TransportAnthropic, TransportGoogle, TransportBedrock, TransportVertex, TransportOpenAICompatible, TransportAnthropicCompatible:
return true
default:
return false
}
}
+4
View File
@@ -133,6 +133,10 @@ var curatedModels = map[string][]Model{
{ID: "kimi-k2-turbo-preview", Description: "fast coding model"},
{ID: "moonshot-v1-128k", Description: "long-context model"},
},
"atlascloud": {
{ID: "qwen/qwen3.5-flash", Description: "catalog default"},
{ID: "deepseek-ai/deepseek-v4-pro", Description: "reasoning model"},
},
"nvidia-nim": {
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
@@ -38,6 +38,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"anthropic/claude-sonnet-5", "google/gemini-3.5-flash", "openai/gpt-5.5-2026-04-23", "qwen/qwen-3.7-max", "deepseek/deepseek-v4-pro"},
notWant: []string{"gpt-4o", "openai/gpt-4o", "anthropic/claude-opus-4.8", "x-ai/grok-4-5"},
},
{
provider: "atlascloud",
want: []string{"qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "openai/gpt-4.1"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
+26 -19
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -35,9 +36,12 @@ type Model struct {
}
type Options struct {
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
HTTPClient *http.Client
ModelsDevURL string
OpenGatewayURL string
OAuthResolver providerio.TokenResolver
CodexAccountResolver openai.CodexAccountResolver
UserAgent string
}
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -188,6 +192,18 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
if err != nil {
return nil, err
}
var configure func(*http.Request)
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
configure = func(request *http.Request) {
account := ""
if options.CodexAccountResolver != nil {
if resolved, ok, resolveErr := options.CodexAccountResolver(request.Context()); resolveErr == nil && ok {
account = resolved
}
}
openai.ApplyCodexHeaders(request, account, options.UserAgent)
}
}
return fetchProviderModels(ctx, endpoint, profile, options, providerio.AuthHeaders{
APIKey: profile.APIKey,
DefaultAuthHeader: "Authorization",
@@ -196,7 +212,7 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
}, nil)
}, configure)
}
func discoverAnthropicModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -217,25 +233,16 @@ func discoverAnthropicModels(ctx context.Context, profile config.ProviderProfile
}
func fetchProviderModels(ctx context.Context, endpoint string, profile config.ProviderProfile, options Options, auth providerio.AuthHeaders, configure func(*http.Request)) ([]Model, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
// Authenticate via either an APIKey or a raw auth-header value / custom
// headers, matching how the live providers build their requests
// (internal/providers/providerio). Honoring AuthHeaderValue keeps discovery
// consistent with the credential-present logic elsewhere.
providerio.ApplyAuthHeaders(request, auth)
request.Header.Set("Accept", "application/json")
if configure != nil {
configure(request)
}
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := client.Do(request)
response, err := providerio.SendWithAuthRetry(ctx, client, http.MethodGet, endpoint, nil, auth, options.OAuthResolver, func(request *http.Request) {
request.Header.Set("Accept", "application/json")
if configure != nil {
configure(request)
}
}, 1)
if err != nil {
return nil, redactDiscoveryError(err, profile)
}
@@ -52,6 +52,63 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
}
}
func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
var requests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if got := r.URL.Path; got != "/backend-api/codex/models" {
t.Errorf("path = %q, want Codex models endpoint", got)
}
wantToken := "Bearer old-token"
wantAccount := "old-account"
if requests == 2 {
wantToken = "Bearer refreshed-token"
wantAccount = "refreshed-account"
}
if got := r.Header.Get("Authorization"); got != wantToken {
t.Errorf("request %d Authorization = %q, want %q", requests, got, wantToken)
}
if got := r.Header.Get("chatgpt-account-id"); got != wantAccount {
t.Errorf("request %d chatgpt-account-id = %q, want %q", requests, got, wantAccount)
}
if got := r.Header.Get("originator"); got != "codex_cli_rs" {
t.Errorf("originator = %q", got)
}
if requests == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.4"}]}`))
}))
defer server.Close()
models, err := Discover(context.Background(), config.ProviderProfile{
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
OAuthResolver: func(_ context.Context, force bool) (string, string, bool, error) {
if force {
return "Authorization", "Bearer refreshed-token", true, nil
}
return "Authorization", "Bearer old-token", true, nil
},
CodexAccountResolver: func(context.Context) (string, bool, error) {
if requests == 0 {
return "old-account", true, nil
}
return "refreshed-account", true, nil
},
})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
if requests != 2 || len(models) != 1 || models[0].ID != "gpt-5.4" {
t.Fatalf("requests = %d, models = %#v", requests, models)
}
}
func TestDiscoverAIMLAPIModelsSendsAuthAndCustomHeadersWithoutAttribution(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for header, want := range map[string]string{
+12 -8
View File
@@ -361,13 +361,6 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// login than the bearer — a mismatch the backend rejects.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountForKey(accountKey)
if account == "" {
return "", false, nil
}
return account, true, nil
})
return openai.NewCodexProvider(openai.CodexOptions{
Options: openai.Options{
BaseURL: resolved.baseURL,
@@ -388,7 +381,7 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
// override. The codex provider's constructor derives the
// `/responses` endpoint from BaseURL, so the factory stays out of
// the path.
AccountResolver: resolver,
AccountResolver: CodexAccountResolverForLogin(accountKey),
})
}
@@ -411,3 +404,14 @@ func codexAccountForKey(key string) string {
}
return strings.TrimSpace(token.Account)
}
// CodexAccountResolverForLogin returns the same per-request account resolver
// used by the runtime provider. Auxiliary Codex requests use this rather than
// independently selecting or parsing an OAuth login, which could mismatch the
// bearer selected by oauthLoginForProfile.
func CodexAccountResolverForLogin(key string) openai.CodexAccountResolver {
return func(context.Context) (string, bool, error) {
account := codexAccountForKey(key)
return account, account != "", nil
}
}
+19 -20
View File
@@ -3,7 +3,6 @@ package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -23,6 +22,19 @@ const (
codexOriginatorHeader = "originator"
)
// ApplyCodexHeaders applies the non-bearer headers required by ChatGPT's Codex
// backend. It is shared by runtime completions and auxiliary Codex endpoints
// such as live model discovery so their authentication cannot drift apart.
func ApplyCodexHeaders(req *http.Request, accountID, userAgent string) {
req.Header.Set(codexOriginatorHeader, codexDefaultOriginator)
if accountID = strings.TrimSpace(accountID); accountID != "" {
req.Header.Set(codexAccountHeader, accountID)
}
if userAgent = strings.TrimSpace(userAgent); userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
}
// CodexAccountResolver returns the `chatgpt_account_id` claim for the bearer
// that is about to be sent on a request. It is invoked once per request
// (including the 401-refresh retry) so the value can be re-derived from the
@@ -168,15 +180,13 @@ func (p *CodexProvider) StreamCompletion(ctx context.Context, request zeroruntim
// openai provider. It sets the three Codex-required headers; the bearer is
// applied separately by the openai provider's auth path.
func (p *CodexProvider) injectCodexHeaders(req *http.Request) {
req.Header.Set(codexOriginatorHeader, p.originator)
if account, ok, err := p.resolveAccount(req.Context()); err == nil && ok && account != "" {
req.Header.Set(codexAccountHeader, account)
account := ""
if resolved, ok, err := p.resolveAccount(req.Context()); err == nil && ok && resolved != "" {
account = strings.TrimSpace(resolved)
}
// Branded User-Agent overrides the openai provider's default. Set last
// so a caller that supplies a different UserAgent in custom-headers is
// still respected (the openai provider's setExtra already ran before us).
if p.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
ApplyCodexHeaders(req, account, p.userAgent)
if p.originator != codexDefaultOriginator {
req.Header.Set(codexOriginatorHeader, p.originator)
}
}
@@ -196,14 +206,3 @@ func (p *CodexProvider) resolveAccount(ctx context.Context) (string, bool, error
}
return "", false, nil
}
// ValidateAccount is a convenience for tests/callers that want to confirm the
// account id is the right shape (non-empty, trimmed). It is a no-op helper
// rather than a constructor check so a Codex provider can be built before the
// first login completes.
func ValidateAccount(account string) error {
if strings.TrimSpace(account) == "" {
return errors.New("openai codex: account id is empty")
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package openai
import (
"errors"
"strings"
)
// ValidateAccount is a convenience for tests/callers that want to confirm the
// account id is the right shape (non-empty, trimmed). It is a no-op helper
// rather than a constructor check so a Codex provider can be built before the
// first login completes.
func ValidateAccount(account string) error {
if strings.TrimSpace(account) == "" {
return errors.New("openai codex: account id is empty")
}
return nil
}
+2 -2
View File
@@ -945,9 +945,9 @@ func TestStreamCompletionSurfacesLengthFinishReason(t *testing.T) {
t.Fatalf("done FinishReason = %q, want %q", doneReason, zeroruntime.FinishReasonLength)
}
// And it round-trips through the runtime collector as Truncated.
// And it round-trips through the runtime collector's FinishReason.
collected := zeroruntime.CollectStream(context.Background(), replay(events))
if !collected.Truncated() || collected.FinishReason != zeroruntime.FinishReasonLength {
if collected.FinishReason != zeroruntime.FinishReasonLength {
t.Fatalf("collected = %+v, want truncated length", collected)
}
}
@@ -0,0 +1,14 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package providerio
import (
"bufio"
"io"
)
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
return scanSSEPayloads(scanner, handle, nil)
}
@@ -195,13 +195,6 @@ func SendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event
}
}
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
return scanSSEPayloads(scanner, handle, nil)
}
// scanSSEPayloads accumulates SSE "data:" lines into payloads (joined across
// continuation lines, flushed on a blank line or EOF) and forwards each to
// handle. It is the shared core of ScanSSEData and the idle-aware variant.
-147
View File
@@ -1,147 +0,0 @@
// Package reasoning models how each provider's models expose reasoning-effort
// control. Providers disagree on the concept — OpenAI uses a discrete effort
// enum, Anthropic and Gemini 2.5 use a thinking-token budget, some models only
// toggle thinking on/off — so a single flat effort string cannot describe them.
//
// Capability is the typed, per-model description of that control, sourced from a
// community capability catalog (models.dev). It is the data the rest of Zero
// consults to decide which reasoning tiers a model actually supports, replacing
// model-name guessing. This package depends only on the standard library, so the
// model registry and provider adapters can import it without a cycle.
//
// Source of truth: this catalog is intended to become the authoritative reasoning
// -capability source. When it is wired into the live path, modelregistry's
// name-pattern inference (reasoningEffortsForModelName) is demoted to a fallback
// for models the catalog does not cover; the catalog wins where it has an entry.
// This commit is additive groundwork — nothing consumes it yet — so there is no
// behavioral divergence until that wiring lands.
package reasoning
import "strings"
// ControlKind enumerates how a model exposes reasoning control. The values
// mirror the models.dev reasoning_options[].type field.
type ControlKind string
const (
// ControlEffort is a discrete effort enum (OpenAI reasoning_effort, Gemini 3
// thinkingLevel, newer Claude output_config.effort).
ControlEffort ControlKind = "effort"
// ControlBudget is a thinking-token budget (Gemini 2.5 thinkingBudget, legacy
// Claude thinking.budget_tokens).
ControlBudget ControlKind = "budget_tokens"
// ControlToggle is an on/off thinking switch with no levels.
ControlToggle ControlKind = "toggle"
)
// Control is one reasoning control a model accepts. For an effort control,
// Values lists the accepted tiers ordered weakest to strongest. For a budget
// control, Min/Max bound the thinking-token budget; each is nil when the
// provider does not bound that side, kept distinct from an explicit 0 (Gemini
// uses min: 0 to mean "thinking can be disabled", which a plain int would lose).
type Control struct {
Kind ControlKind `json:"type"`
Values []string `json:"values,omitempty"`
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
}
// clone returns a deep copy of the control so the embedded catalog cannot be
// mutated through a returned Control's slice or budget pointers.
func (ctrl Control) clone() Control {
out := Control{Kind: ctrl.Kind}
if ctrl.Values != nil {
out.Values = append([]string(nil), ctrl.Values...)
}
if ctrl.Min != nil {
min := *ctrl.Min
out.Min = &min
}
if ctrl.Max != nil {
max := *ctrl.Max
out.Max = &max
}
return out
}
// Capability is the reasoning capability of a single model: whether it reasons
// at all, and through which controls. A model may carry more than one control
// (e.g. a newer Claude model exposes both an effort enum and a token budget); a
// reasoning model with no controls reasons but exposes no knob (always-on).
type Capability struct {
Reasoning bool `json:"reasoning"`
Controls []Control `json:"reasoning_options,omitempty"`
}
// clone returns a deep copy of the capability, cloning each control, so a caller
// cannot mutate the shared catalog through the returned value's slices.
func (c Capability) clone() Capability {
out := Capability{Reasoning: c.Reasoning}
if c.Controls != nil {
out.Controls = make([]Control, len(c.Controls))
for i, ctrl := range c.Controls {
out.Controls[i] = ctrl.clone()
}
}
return out
}
// Supported reports whether the model performs any reasoning.
func (c Capability) Supported() bool { return c.Reasoning }
// EffortControl returns the model's effort control and whether it has one. The
// returned Control is a deep copy, so a caller cannot mutate the shared catalog
// through its Values slice.
func (c Capability) EffortControl() (Control, bool) {
for _, ctrl := range c.Controls {
if ctrl.Kind == ControlEffort {
return ctrl.clone(), true
}
}
return Control{}, false
}
// EffortValues returns the ordered effort tiers the model accepts, or nil when
// it has no effort control (a budget- or toggle-only model, or a non-reasoning
// model).
func (c Capability) EffortValues() []string {
if ctrl, ok := c.EffortControl(); ok {
return ctrl.Values
}
return nil
}
// SupportsEffort reports whether tier is one of the model's accepted effort
// values (case-insensitive).
func (c Capability) SupportsEffort(tier string) bool {
tier = strings.ToLower(strings.TrimSpace(tier))
for _, v := range c.EffortValues() {
if strings.ToLower(v) == tier {
return true
}
}
return false
}
// BudgetControl returns the model's token-budget control and whether it has one.
// The returned Control is a deep copy: its Min/Max are independent pointers, so a
// caller cannot mutate the shared catalog through them. Min/Max are nil when that
// bound is unspecified (a real 0, e.g. Gemini's min: 0, is a non-nil pointer to 0).
func (c Capability) BudgetControl() (Control, bool) {
for _, ctrl := range c.Controls {
if ctrl.Kind == ControlBudget {
return ctrl.clone(), true
}
}
return Control{}, false
}
// HasControl reports whether the model exposes a control of the given kind.
func (c Capability) HasControl(kind ControlKind) bool {
for _, ctrl := range c.Controls {
if ctrl.Kind == kind {
return true
}
}
return false
}
-99
View File
@@ -1,99 +0,0 @@
package reasoning
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
)
// modelsdev_snapshot.json is a trimmed snapshot of https://models.dev/api.json:
// for a set of first-party providers, each model's api id mapped to its
// reasoning capability (the `reasoning` flag and `reasoning_options`). Routers
// (OpenRouter/Azure/etc.) are intentionally excluded — they carry generic or
// stale option lists, so a lookup resolves to the first-party provider instead.
//
// Provenance is recorded in the snapshot's `_source`/`_fetched` header fields.
// To regenerate: fetch api.json, then for each provider in
// {anthropic, openai, google, xai, groq, deepseek} keep `{reasoning,
// reasoning_options}` per api id, and bump `_fetched`. Runtime auto-refresh is a
// separate change.
//
//go:embed modelsdev_snapshot.json
var snapshotBytes []byte
// snapshotDoc is the on-disk shape of the embedded snapshot.
type snapshotDoc struct {
Providers map[string]map[string]Capability `json:"providers"`
}
// Catalog is a reasoning-capability lookup keyed by provider slug and api model
// id. It is read-only after construction.
type Catalog struct {
byProvider map[string]map[string]Capability
}
// ParseCatalog builds a Catalog from a models.dev-style snapshot document.
func ParseCatalog(data []byte) (Catalog, error) {
var doc snapshotDoc
if err := json.Unmarshal(data, &doc); err != nil {
return Catalog{}, fmt.Errorf("reasoning: parse catalog: %w", err)
}
if len(doc.Providers) == 0 {
return Catalog{}, fmt.Errorf("reasoning: catalog has no providers")
}
return Catalog{byProvider: doc.Providers}, nil
}
// embedded is the catalog parsed from the bundled snapshot. A malformed embed is
// a build/release error, so it panics at init rather than failing silently.
var embedded = mustParseEmbedded()
func mustParseEmbedded() Catalog {
c, err := ParseCatalog(snapshotBytes)
if err != nil {
panic("reasoning: invalid embedded snapshot: " + err.Error())
}
return c
}
// Embedded returns the catalog parsed from the bundled models.dev snapshot.
func Embedded() Catalog { return embedded }
// Lookup returns the reasoning capability for a model identified by its Zero
// provider kind and the API model id — the provider's wire name (e.g.
// "claude-opus-4-1-20250805"), NOT the friendly registry id ("claude-opus-4.1").
// The match is exact: the api model id is only trimmed, not case-folded, and no
// router prefixes ("openai/…") or suffixes (":cloud") are stripped. ok is false
// when the provider or model is not in the snapshot, in which case the caller
// falls back to its next capability source.
func (c Catalog) Lookup(provider, apiModel string) (Capability, bool) {
apiModel = strings.TrimSpace(apiModel)
if apiModel == "" {
return Capability{}, false
}
for _, slug := range providerSlugs(provider) {
models, ok := c.byProvider[slug]
if !ok {
continue
}
if entry, ok := models[apiModel]; ok {
// Deep copy so callers cannot mutate the shared catalog through the
// returned Controls / Values slices or budget pointers.
return entry.clone(), true
}
}
return Capability{}, false
}
// providerSlugs maps a Zero provider kind to the models.dev provider slug to
// look up. Gemini and Google both resolve to "google" (AI Studio), the
// first-party source for Gemini models.
func providerSlugs(provider string) []string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "gemini":
return []string{"google"}
default:
return []string{strings.ToLower(strings.TrimSpace(provider))}
}
}
-249
View File
@@ -1,249 +0,0 @@
package reasoning
import "testing"
func TestEmbeddedCatalogLoads(t *testing.T) {
c := Embedded()
if len(c.byProvider) == 0 {
t.Fatal("embedded catalog is empty")
}
if _, ok := c.Lookup("openai", "gpt-5"); !ok {
t.Fatal("embedded catalog missing a well-known model (gpt-5)")
}
}
func TestParseCatalogRejectsEmpty(t *testing.T) {
if _, err := ParseCatalog([]byte(`{}`)); err == nil {
t.Fatal("expected an error for a catalog with no providers")
}
if _, err := ParseCatalog([]byte(`not json`)); err == nil {
t.Fatal("expected an error for malformed json")
}
}
func TestGroundTruthOpenAI(t *testing.T) {
c := Embedded()
cases := []struct {
api string
reason bool
kind ControlKind
values []string
}{
{"gpt-5", true, ControlEffort, []string{"minimal", "low", "medium", "high"}},
{"gpt-5-codex", true, ControlEffort, []string{"low", "medium", "high"}},
{"gpt-5-pro", true, ControlEffort, []string{"high"}},
{"gpt-5.1", true, ControlEffort, []string{"none", "low", "medium", "high"}},
{"gpt-5.1-codex-max", true, ControlEffort, []string{"low", "medium", "high", "xhigh"}},
{"o3", true, ControlEffort, []string{"low", "medium", "high"}},
}
for _, tc := range cases {
entry, ok := c.Lookup("openai", tc.api)
if !ok {
t.Errorf("%s: not found", tc.api)
continue
}
if entry.Supported() != tc.reason {
t.Errorf("%s: Supported=%v want %v", tc.api, entry.Supported(), tc.reason)
}
if !equalStrings(entry.EffortValues(), tc.values) {
t.Errorf("%s: efforts=%v want %v", tc.api, entry.EffortValues(), tc.values)
}
}
// Non-reasoning models report reasoning:false and expose no effort.
for _, api := range []string{"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4-turbo"} {
entry, ok := c.Lookup("openai", api)
if !ok {
t.Errorf("%s: not found", api)
continue
}
if entry.Supported() {
t.Errorf("%s: Supported=true, want false (non-reasoning)", api)
}
if entry.EffortValues() != nil {
t.Errorf("%s: efforts=%v, want nil", api, entry.EffortValues())
}
}
// Version splits: minimal vs none are not interchangeable.
gpt5, _ := c.Lookup("openai", "gpt-5")
if !gpt5.SupportsEffort("minimal") || gpt5.SupportsEffort("none") {
t.Error("gpt-5 should support minimal, not none")
}
gpt51, _ := c.Lookup("openai", "gpt-5.1")
if gpt51.SupportsEffort("minimal") || !gpt51.SupportsEffort("none") {
t.Error("gpt-5.1 should support none, not minimal")
}
}
func TestGroundTruthAnthropic(t *testing.T) {
c := Embedded()
// Legacy / budget-only models: a thinking-token budget, no effort enum. The
// budget carries an explicit min (1024) and no max — Max stays nil.
for _, api := range []string{
"claude-opus-4-1-20250805", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001",
} {
entry, ok := c.Lookup("anthropic", api)
if !ok {
t.Errorf("%s: not found", api)
continue
}
ctrl, ok := entry.BudgetControl()
if !ok {
t.Errorf("%s: expected a budget control", api)
continue
}
if ctrl.Min == nil || *ctrl.Min != 1024 {
t.Errorf("%s: budget Min=%v, want non-nil 1024", api, ctrl.Min)
}
if ctrl.Max != nil {
t.Errorf("%s: budget Max=%d, want nil (unbounded)", api, *ctrl.Max)
}
if _, ok := entry.EffortControl(); ok {
t.Errorf("%s: did not expect an effort control", api)
}
}
// Newer models: native effort enum with xhigh and the budget control removed.
opus48, ok := c.Lookup("anthropic", "claude-opus-4-8")
if !ok {
t.Fatal("claude-opus-4-8 not found")
}
if !equalStrings(opus48.EffortValues(), []string{"low", "medium", "high", "xhigh", "max"}) {
t.Errorf("opus-4-8 efforts=%v", opus48.EffortValues())
}
if _, ok := opus48.BudgetControl(); ok {
t.Error("opus-4-8 should not carry a budget control (removed)")
}
}
func TestGroundTruthGemini(t *testing.T) {
c := Embedded()
pro, ok := c.Lookup("gemini", "gemini-2.5-pro") // gemini kind -> google slug
if !ok {
t.Fatal("gemini-2.5-pro not found via the gemini provider kind")
}
ctrl, ok := pro.BudgetControl()
if !ok || ctrl.Min == nil || *ctrl.Min != 128 || ctrl.Max == nil || *ctrl.Max != 32768 {
t.Errorf("gemini-2.5-pro budget=%+v ok=%v, want min=128 max=32768", ctrl, ok)
}
if _, ok := pro.EffortControl(); ok {
t.Error("gemini-2.5-pro should be budget-only, no effort control")
}
// gemini-2.5-flash can disable thinking: a toggle plus a budget whose explicit
// min: 0 must survive (a plain int + omitempty would drop it).
flash, _ := c.Lookup("google", "gemini-2.5-flash")
if !flash.HasControl(ControlToggle) {
t.Error("gemini-2.5-flash should expose a toggle (thinking can be disabled)")
}
if fctrl, ok := flash.BudgetControl(); !ok || fctrl.Min == nil || *fctrl.Min != 0 {
t.Errorf("gemini-2.5-flash budget Min=%v, want a non-nil pointer to 0", fctrl.Min)
}
// Gemini 3.x switched to an effort enum.
if g3, ok := c.Lookup("google", "gemini-3-pro-preview"); ok {
if _, ok := g3.EffortControl(); !ok {
t.Error("gemini-3-pro-preview should expose an effort control")
}
}
}
// TestCoversZeroShippedReasoningModels pins that every reasoning model Zero
// currently ships resolves in the catalog (by its api model id), so the
// models.dev fallback actually covers Zero's catalog rather than just well-known
// ids. The api ids mirror internal/modelregistry's curated entries.
func TestCoversZeroShippedReasoningModels(t *testing.T) {
c := Embedded()
shipped := []struct {
provider, api string
wantKind ControlKind
}{
{"anthropic", "claude-opus-4-1-20250805", ControlBudget},
{"anthropic", "claude-sonnet-4-5-20250929", ControlBudget},
{"anthropic", "claude-haiku-4-5-20251001", ControlBudget},
{"google", "gemini-2.5-pro", ControlBudget},
{"google", "gemini-2.5-flash", ControlToggle},
{"google", "gemini-2.5-flash-lite", ControlToggle},
}
for _, m := range shipped {
entry, ok := c.Lookup(m.provider, m.api)
if !ok {
t.Errorf("%s/%s: not covered by the snapshot", m.provider, m.api)
continue
}
if !entry.Supported() {
t.Errorf("%s/%s: Supported=false, want a reasoning model", m.provider, m.api)
}
if !entry.HasControl(m.wantKind) {
t.Errorf("%s/%s: missing expected control %q (controls=%+v)", m.provider, m.api, m.wantKind, entry.Controls)
}
}
}
// TestLookupReturnsDeepCopy pins that a caller cannot corrupt the shared
// embedded catalog by mutating a returned Capability's slices.
func TestLookupReturnsDeepCopy(t *testing.T) {
c := Embedded()
first, ok := c.Lookup("openai", "gpt-5")
if !ok || len(first.Controls) == 0 || len(first.Controls[0].Values) == 0 {
t.Fatal("setup: gpt-5 should have an effort control with values")
}
first.Controls[0].Values[0] = "MUTATED"
first.Controls[0].Kind = "MUTATED"
second, _ := c.Lookup("openai", "gpt-5")
if second.Controls[0].Values[0] == "MUTATED" || second.Controls[0].Kind == "MUTATED" {
t.Error("Lookup leaked a shared reference; a caller's mutation reached the catalog")
}
}
// TestAccessorsReturnDeepCopies pins that EffortControl/BudgetControl hand back
// independent copies, so a caller holding a Capability that shares state with the
// catalog cannot corrupt it by mutating an accessor result's slice or pointers.
func TestAccessorsReturnDeepCopies(t *testing.T) {
min := 100
c := Capability{Reasoning: true, Controls: []Control{
{Kind: ControlEffort, Values: []string{"low", "high"}},
{Kind: ControlBudget, Min: &min},
}}
eff, _ := c.EffortControl()
eff.Values[0] = "MUTATED"
if c.Controls[0].Values[0] != "low" {
t.Error("EffortControl leaked the Values slice")
}
bud, _ := c.BudgetControl()
*bud.Min = 999
if *c.Controls[1].Min != 100 {
t.Error("BudgetControl leaked the Min pointer")
}
}
func TestLookupMissFallsThrough(t *testing.T) {
c := Embedded()
if _, ok := c.Lookup("openai", "totally-made-up-model"); ok {
t.Error("unknown model should miss")
}
if _, ok := c.Lookup("no-such-provider", "gpt-5"); ok {
t.Error("unknown provider should miss")
}
if _, ok := c.Lookup("openai", " "); ok {
t.Error("blank api model should miss")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
File diff suppressed because it is too large. Load diff
-64
View File
@@ -1,65 +1 @@
package reltime
import "fmt"
// RelTime renders a duration in seconds as a human-readable relative-time
// string. This is the ORIGINAL implementation, kept verbatim so
// characterization tests can be pinned against it before refactoring.
func RelTime(d int) string {
if d < 0 {
d = -d
if d < 60 {
if d == 1 {
return "1 second ago"
}
return fmt.Sprintf("%d seconds ago", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
}
x := d / 86400
if x == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", x)
} else if d == 0 {
return "just now"
} else {
if d < 60 {
if d == 1 {
return "in 1 second"
}
return fmt.Sprintf("in %d seconds", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "in 1 minute"
}
return fmt.Sprintf("in %d minutes", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "in 1 hour"
}
return fmt.Sprintf("in %d hours", h)
}
x := d / 86400
if x == 1 {
return "in 1 day"
}
return fmt.Sprintf("in %d days", x)
}
}
+12
View File
@@ -0,0 +1,12 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package repomap
import (
"io/fs"
"github.com/Gitlawb/zero/internal/workspaceindex"
)
func handleWalkError(cleanRoot string, current string, entry fs.DirEntry, walkErr error, truncated *bool) (bool, error) {
return workspaceindex.HandleWalkError(cleanRoot, current, entry, walkErr, truncated)
}
-5
View File
@@ -2,7 +2,6 @@
package repomap
import (
"io/fs"
"sort"
"strings"
@@ -82,10 +81,6 @@ func Scan(root string, options Options) (Snapshot, error) {
return snapshot, err
}
func handleWalkError(cleanRoot string, current string, entry fs.DirEntry, walkErr error, truncated *bool) (bool, error) {
return workspaceindex.HandleWalkError(cleanRoot, current, entry, walkErr, truncated)
}
func fileDepth(rel string) int {
return workspaceindex.FileDepth(rel)
}
+11
View File
@@ -0,0 +1,11 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package review
func HasBlockingChecks(checks []Check) bool {
for _, check := range checks {
if IsBlocking(check.Outcome) {
return true
}
}
return false
}
-9
View File
@@ -76,15 +76,6 @@ func BuildChecksFromEnv(env map[string]string) []Check {
return checks
}
func HasBlockingChecks(checks []Check) bool {
for _, check := range checks {
if IsBlocking(check.Outcome) {
return true
}
}
return false
}
func IsBlocking(outcome Outcome) bool {
switch outcome {
case OutcomeFailure, OutcomeCancelled, OutcomeTimedOut, OutcomeActionRequired, OutcomeUnknown:
+99 -2
View File
@@ -27,11 +27,22 @@ var destructivePrograms = map[string]bool{
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
}
var powerShellRemoveItemPrograms = map[string]bool{
"remove-item": true,
"ri": true,
"rd": true,
"rmdir": true,
"del": true,
"erase": true,
"rm": true,
}
// networkPrograms are commands that perform network egress/ingress.
var networkPrograms = map[string]bool{
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
"ftp": true,
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
"invoke-restmethod": true,
}
var localServerPrograms = map[string]bool{
@@ -65,6 +76,76 @@ func AnalyzeCommand(script string) AnalysisResult {
return result
}
// astCommandFields parses command with the shell parser and returns each simple
// command as its literal field slice (program + args as text), resolving the
// real command positions across quoting, command substitution, subshells, and
// newline separators — the constructs the hand-written splitter in
// safe_command.go mis-handles (issue #473). It returns nil when the command
// cannot be parsed (e.g. a Windows cmd.exe string), so callers fall through to
// the regex path rather than hard-blocking.
func astCommandFields(command string) [][]string {
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil {
return nil
}
var commands [][]string
syntax.Walk(file, func(node syntax.Node) bool {
call, ok := node.(*syntax.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
// EVERY word must be a static literal, not just the program name.
// wordText keeps only the literal/quoted parts of a word and silently
// drops expansions, so a dynamic word anywhere in the call reconstructs
// to something the shell will never run: `$(printf foo)vim` (runs as
// `foovim`) collapses to "vim", and `git $(printf foo)rebase -i` (runs
// as `git foorebase -i`, non-interactive) collapses to `git rebase -i`
// and fabricates an interactive match. Since the runtime value of an
// expansion is unknowable here, skip the whole call rather than classify
// a lossy reconstruction. Skipping is the safe direction: the
// hand-written passes above already ran, and a missed detection falls
// through to the normal permission prompt instead of hard-blocking a
// command the user never wrote.
for _, word := range call.Args {
if !isLiteralWord(word) {
return true
}
}
fields := make([]string, 0, len(call.Args))
for _, word := range call.Args {
fields = append(fields, wordText(word))
}
commands = append(commands, fields)
return true
})
return commands
}
// isLiteralWord reports whether every part of word is a static literal (bare or
// quoted). A word containing a command substitution, parameter/arithmetic
// expansion, process substitution, etc. is dynamic — its runtime value is
// unknown, so its wordText (a partial literal) must not be trusted as a program
// name.
func isLiteralWord(word *syntax.Word) bool {
if word == nil {
return false
}
for _, part := range word.Parts {
switch typed := part.(type) {
case *syntax.Lit, *syntax.SglQuoted:
case *syntax.DblQuoted:
for _, inner := range typed.Parts {
if _, ok := inner.(*syntax.Lit); !ok {
return false
}
}
default:
return false
}
}
return true
}
// analyzeInto parses script and folds its interactive/destructive/network usage
// into result, sharing seen so program names are de-duplicated across recursion.
func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, depth int) {
@@ -102,7 +183,10 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
(prog == "find" && hasFindDelete(rest)) {
result.Destructive = true
}
return true
@@ -409,6 +493,19 @@ func hasRecursiveForce(args []*syntax.Word) bool {
return recursive && force
}
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
recursive, force := false, false
for _, arg := range args {
switch strings.ToLower(wordText(arg)) {
case "-recurse", "-r":
recursive = true
case "-force":
force = true
}
}
return recursive && force
}
func hasFindDelete(args []*syntax.Word) bool {
for _, arg := range args {
if wordText(arg) == "-delete" {
+21
View File
@@ -45,6 +45,27 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
{name: "curl", script: "curl https://example.com", network: true},
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
+42
View File
@@ -2,6 +2,7 @@ package sandbox
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
@@ -73,6 +74,25 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
}
}
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
engine := NewEngine(EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: DefaultPolicy(),
})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
}
}
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
@@ -485,6 +505,28 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
}
}
func TestEnginePromptsForOutOfWorkspaceReadWithReadSpecificReason(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "external")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "list_directory",
SideEffect: SideEffectRead,
Permission: PermissionAllow,
WorkspaceRoot: root,
Args: map[string]any{"path": outside},
})
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if decision.Action != ActionPrompt || decision.Block == nil || !decision.Block.Recoverable {
t.Fatalf("outside read decision = %#v, want recoverable prompt", decision)
}
if decision.Reason != want || decision.Block.Reason != want {
t.Fatalf("outside read reasons = decision %q, block %q; want %q", decision.Reason, decision.Block.Reason, want)
}
}
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
+78
View File
@@ -0,0 +1,78 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package sandbox
import (
"path/filepath"
"strings"
)
func FormatGrantList(grants []Grant) string {
return FormatGrantListWithCommandPrefixes(grants, nil)
}
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
}
// writeRoots returns the full ordered write-root list for command plans:
// the workspace root plus any granted extra roots. The single-root fallback
// only applies to engines built without a workspace root (NewEngine always
// builds a scope otherwise); it is kept as defense in depth.
func (engine *Engine) writeRoots(workspaceRoot string) []string {
var roots []string
if engine.scope != nil {
roots = engine.scope.Roots()
} else {
roots = []string{workspaceRoot}
}
// Reflect the policy's AllowWrite roots in the OS backend write binds so a
// sandboxed shell command may write where the policy grants writes. DenyWrite
// is enforced at the policy gate, and on sandbox-exec additionally as an
// explicit deny rule (see sandboxExecProfile).
policy := engine.effectivePolicy(engine.policy)
if extra := resolveWriteRootPaths(policy.AllowWrite); len(extra) > 0 {
roots = dedupeStrings(append(roots, extra...))
}
return roots
}
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
}
func sandboxExecProfile(writeRoots []string, policy Policy, denialTag string) string {
return seatbeltProfileFromPermissionProfile(seatbeltCompatibilityPermissionProfile(writeRoots, policy), policy, denialTag)
}
func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) PermissionProfile {
fs := FileSystemPolicy{
Kind: FileSystemUnrestricted,
ReadRoots: []string{string(filepath.Separator)},
IncludePlatformRoots: true,
AllowTemp: true,
}
if policy.EnforceWorkspace {
fs.Kind = FileSystemRestricted
fs.WriteRoots = make([]WritableRoot, 0, len(writeRoots))
for _, root := range writeRoots {
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
}
}
fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...))
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
return PermissionProfile{
FileSystem: fs,
Network: NetworkPolicy{Mode: policy.Network},
}
}
// WindowsSandboxSetupPathForRunner derives the setup helper's path from a
// standalone command-runner path (the sibling .exe in the release layout).
// Retained for that layout; self-dispatch callers use
// ResolveWindowsSandboxSetupHelper instead.
func WindowsSandboxSetupPathForRunner(runnerPath string) string {
if strings.TrimSpace(runnerPath) == "" {
return ""
}
return filepath.Join(filepath.Dir(runnerPath), WindowsSandboxSetupName)
}
-4
View File
@@ -427,10 +427,6 @@ func (store *GrantStore) Clear() (int, error) {
return count, nil
}
func FormatGrantList(grants []Grant) string {
return FormatGrantListWithCommandPrefixes(grants, nil)
}
func FormatGrantListWithCommandPrefixes(grants []Grant, prefixes []CommandPrefixGrant) string {
if len(grants) == 0 && len(prefixes) == 0 {
return "No persistent sandbox grants."
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more