Files
zero/internal/cli/provider_detect.go
KRATOS 29fc29a41d feat: add providers detect and sandbox check (wire audit-found unwired features) + coverage (#238)
* feat(providers): add `zero providers detect` for local-runtime onboarding

Wire the previously-unwired provideronboarding detect/advice surface into the
CLI. `zero providers detect` probes for running local OpenAI-compatible runtimes
(Ollama, LM Studio) on their default ports and prints a no-key adopt command for
each, followed by the next-step actions (use / check / set key) for every
configured provider. Supports --json.

This connects DetectLocalRuntimes, LocalRuntimeCandidates,
DetectedLocalRuntime.SetupAction, ProviderActions, MissingCredentialAction,
SetupCommand, and ProviderState.Actions — a fully built, tested feature that had
no caller. Adds an injectable detectLocalRuntimes appDep for hermetic tests.

* feat(sandbox): add `zero sandbox check` to evaluate a decision as JSON

Wire the previously-unwired zerocommands sandbox-snapshot contract into the CLI.
`zero sandbox check <tool>` evaluates what the sandbox engine would decide for a
hypothetical tool action against the resolved policy, and emits the active plan
(policy + backend + restrictions), the decision (allow/prompt/deny with risk and
any violation), and any persistent grant matching the tool. Supports --json;
grant reasons are redacted.

This connects SandboxPlanSnapshotFromPlan, SandboxPolicySnapshotFromPolicy,
SandboxBackendSnapshotFromBackend, SandboxDecisionSnapshotFromDecision,
SandboxRiskSnapshotFromRisk, SandboxViolationSnapshotFromViolation, and
SandboxGrantMatchSnapshotFromLookup — a fully built, tested JSON contract that
had no production consumer. The existing `sandbox policy` output is unchanged.

* test(providermodelcatalog): cover model predicates and remote fetch layer

Add targeted tests for the audit-identified coverage gaps: the pure logic
(LooksLikeCodingModelID, modelMatchesProvider, defaultedOpenGatewayURL,
modelSortLabel/sortModels) and the HTTP fetch layer (fetchJSON, FetchModelsDev,
FetchOpenGateway, FetchRemote routing) via httptest. Package coverage 48.9% ->
79.5%. cmd/zero (main shim) and browser.OpenURL (exec wrapper) are intentionally
left to CI smoke — their only logic seams (openCommand) are already covered.

* fix(sandbox): address CodeRabbit review on `sandbox check`

- Pass the configured write-root scope (NewScope with AdditionalWriteRoots) into
  the check engine, so its decision matches real enforcement and a stale
  extra-root config surfaces here too instead of a workspace-only fallback.
- Validate --side-effect and --autonomy against their advertised value sets and
  return a usage error on a typo, rather than letting the engine normalize it.
- Reword the unmatched-grant line to "no grant matched this action" — Lookup only
  checks this tool/scope/autonomy, not whether any grant exists for the tool.
- Add tests: invalid-flag rejection and a matched-grant reason-redaction
  regression.

* test(sandbox): make the out-of-workspace check test path OS-portable

Smoke (windows-latest) failed: "/etc/passwd" is not absolute on Windows, so the
check joined it into the workspace and returned "prompt" instead of "deny".
Build the out-of-workspace path under a separate temp dir so it is absolute and
outside the workspace on every OS.
2026-06-18 10:20:24 +05:30

179 lines
5.5 KiB
Go

package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/provideronboarding"
)
type providerDetectOptions struct {
json bool
}
// providerDetectAction is the JSON/text shape of a single suggested next step.
type providerDetectAction struct {
Label string `json:"label"`
Command string `json:"command"`
Detail string `json:"detail,omitempty"`
}
type providerDetectRuntime struct {
CatalogID string `json:"catalogID"`
Name string `json:"name"`
BaseURL string `json:"baseURL"`
Models []string `json:"models,omitempty"`
Action providerDetectAction `json:"action"`
}
type providerDetectProvider struct {
Name string `json:"name"`
Active bool `json:"active"`
Actions []providerDetectAction `json:"actions,omitempty"`
}
type providerDetectReport struct {
DetectedRuntimes []providerDetectRuntime `json:"detectedRuntimes"`
Providers []providerDetectProvider `json:"providers"`
}
// runProvidersDetect probes the machine for running local, OpenAI-compatible
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
// one it finds, followed by the next-step actions for every already-configured
// provider. It is the onboarding-advice surface — "what can I do right now?" —
// and never errors on a machine with nothing running locally (it just reports an
// empty detected list).
func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderDetectArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
ctx, stop := signalContext()
defer stop()
detected := deps.detectLocalRuntimes(ctx, provideronboarding.LocalDetectOptions{})
report := buildProviderDetectReport(resolved, detected)
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, formatProviderDetectReport(report)); err != nil {
return exitCrash
}
return exitSuccess
}
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
options := providerDetectOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
default:
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
}
return options, false, nil
}
func buildProviderDetectReport(resolved config.ResolvedConfig, detected []provideronboarding.DetectedLocalRuntime) providerDetectReport {
report := providerDetectReport{
DetectedRuntimes: make([]providerDetectRuntime, 0, len(detected)),
Providers: make([]providerDetectProvider, 0, len(resolved.Providers)),
}
for _, runtime := range detected {
report.DetectedRuntimes = append(report.DetectedRuntimes, providerDetectRuntime{
CatalogID: runtime.CatalogID,
Name: runtime.Name,
BaseURL: runtime.BaseURL,
Models: runtime.Models,
Action: providerDetectActionFrom(runtime.SetupAction()),
})
}
for _, profile := range resolved.Providers {
active := strings.TrimSpace(profile.Name) != "" && profile.Name == resolved.ActiveProvider
state := provideronboarding.ProviderState{Profile: profile, Active: active}
actions := state.Actions()
entry := providerDetectProvider{
Name: profile.Name,
Active: active,
Actions: make([]providerDetectAction, 0, len(actions)),
}
for _, action := range actions {
entry.Actions = append(entry.Actions, providerDetectActionFrom(action))
}
report.Providers = append(report.Providers, entry)
}
return report
}
func providerDetectActionFrom(action provideronboarding.Action) providerDetectAction {
return providerDetectAction{Label: action.Label, Command: action.Command, Detail: action.Detail}
}
func formatProviderDetectReport(report providerDetectReport) string {
lines := []string{"Detected local runtimes:"}
if len(report.DetectedRuntimes) == 0 {
lines = append(lines, " (none running on default ports)")
}
for _, runtime := range report.DetectedRuntimes {
name := strings.TrimSpace(runtime.Name)
if name == "" {
name = runtime.CatalogID
}
lines = append(lines, " "+name+" — "+runtime.BaseURL)
if len(runtime.Models) > 0 {
lines = append(lines, " models: "+strings.Join(runtime.Models, ", "))
}
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
lines = append(lines, " "+runtime.Action.Label+": "+command)
}
}
lines = append(lines, "", "Configured providers:")
if len(report.Providers) == 0 {
lines = append(lines, " (none configured — run `zero setup`)")
}
for _, provider := range report.Providers {
name := strings.TrimSpace(provider.Name)
if name == "" {
name = "(unnamed)"
}
marker := ""
if provider.Active {
marker = " (active)"
}
lines = append(lines, " "+name+marker)
if len(provider.Actions) == 0 {
lines = append(lines, " (ready)")
}
for _, action := range provider.Actions {
line := " " + action.Label
if command := strings.TrimSpace(action.Command); command != "" {
line += ": " + command
}
lines = append(lines, line)
}
}
return strings.Join(lines, "\n")
}