mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5068a3840f | ||
|
|
2a5b2af277 | ||
|
|
b5a78dca6f | ||
|
|
952788f72d | ||
|
|
f915f70e5a | ||
|
|
cdf9d839ae | ||
|
|
960db9660e | ||
|
|
b1ccb6d9c1 | ||
|
|
0bc8074c97 | ||
|
|
72eed06b4f | ||
|
|
33dc7ae2cc | ||
|
|
292e1edea5 | ||
|
|
39e7100674 | ||
|
|
027a8f2768 | ||
|
|
bd89a1f451 |
No files matched your search
@@ -112,6 +112,12 @@ jobs:
|
||||
needs: package
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Manual approval gate: this environment carries a required-reviewer rule
|
||||
# (Settings -> Environments -> npm-publish), so the job pauses until a
|
||||
# maintainer approves it in the Actions UI before anything reaches npm.
|
||||
environment:
|
||||
name: npm-publish
|
||||
url: https://www.npmjs.com/package/@gitlawb/zero
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token exchange for npm trusted publishing + provenance
|
||||
|
||||
@@ -51,6 +51,27 @@ The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
### Bun
|
||||
|
||||
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
Install with Bun, then fetch the binary in one of two ways:
|
||||
|
||||
```bash
|
||||
# Option A: run the installer manually
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
|
||||
# Option B: allow the postinstall to run on install
|
||||
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
|
||||
global install path, or prefer the install scripts below.
|
||||
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
+19
-1
@@ -54,8 +54,26 @@ const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
||||
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||
'which did not run (or was skipped) for this install.\n' +
|
||||
'\n' +
|
||||
'Fix it now by running the installer manually:\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. To let it run on future installs, add zero to your\n' +
|
||||
'project\'s trustedDependencies:\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'then reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,38 @@ Requirements:
|
||||
- Node.js 18+
|
||||
- network access to npm and GitHub Releases
|
||||
|
||||
## Bun
|
||||
|
||||
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
To install with Bun, either run the installer manually after installing:
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Or allow the postinstall to run by adding the package to your project's
|
||||
`trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
"trustedDependencies": ["@gitlawb/zero"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
|
||||
against the global install path, or use the install scripts below.
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
@@ -2159,6 +2159,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}
|
||||
}
|
||||
|
||||
// A command that explicitly requests additional sandbox permissions
|
||||
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
|
||||
// must consent to, even when the base command's sandbox decision was Allow.
|
||||
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
|
||||
// so the event MUST be a prompt — otherwise it carried Action=allow while the
|
||||
// loop waited on a decision, and a UI that renders only prompts (the TUI)
|
||||
// dropped it, deadlocking the run. Keep this consistent with
|
||||
// shouldRequestPermission's own condition.
|
||||
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||||
action = PermissionActionPrompt
|
||||
}
|
||||
|
||||
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
||||
return PermissionEvent{}, false
|
||||
}
|
||||
|
||||
@@ -37,6 +37,35 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type recordingWebSearchTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
|
||||
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
|
||||
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"query": {Type: "string"},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *recordingWebSearchTool) Safety() tools.Safety {
|
||||
return tools.Safety{
|
||||
SideEffect: tools.SideEffectNetwork,
|
||||
Permission: tools.PermissionPrompt,
|
||||
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||
AdvertiseInAuto: true,
|
||||
}
|
||||
}
|
||||
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
|
||||
}
|
||||
|
||||
type sandboxDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
@@ -865,7 +894,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreNetworkTools() {
|
||||
@@ -896,6 +925,52 @@ func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
search := &recordingWebSearchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(search)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "search", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected one permission request, got %#v", requests)
|
||||
}
|
||||
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
|
||||
t.Fatalf("unexpected permission request: %#v", requests[0])
|
||||
}
|
||||
if len(search.calls) != 0 {
|
||||
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||
// standing in for bash so buildPermissionEvent can be exercised directly.
|
||||
type promptShellTool struct{}
|
||||
|
||||
func (promptShellTool) Name() string { return "bash" }
|
||||
func (promptShellTool) Description() string { return "run a shell command" }
|
||||
func (promptShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (promptShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
|
||||
}
|
||||
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A command whose sandbox decision is Allow but that explicitly requests
|
||||
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
|
||||
// prompt, never Action=allow. Previously it carried allow while the loop still
|
||||
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
|
||||
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
|
||||
// folder-creation shape captured in the reported hang.
|
||||
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "mkdir foo && cd foo && go mod init foo",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
}
|
||||
// Base command allowed by the sandbox…
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
|
||||
}
|
||||
if event.Action != PermissionActionPrompt {
|
||||
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
|
||||
}
|
||||
// Sanity: shouldRequestPermission agrees the loop blocks on this.
|
||||
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
|
||||
// prompt (shouldRequestPermission is false; it just runs) — the override must
|
||||
// not over-reach and prompt for ordinary allowed commands.
|
||||
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "ls"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if event.Action == PermissionActionPrompt {
|
||||
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
|
||||
}
|
||||
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
+57
-29
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -53,32 +54,33 @@ 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)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
exportActiveProvider func(providerName string)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -134,8 +136,9 @@ func defaultAppDeps() appDeps {
|
||||
OAuthLoginKey: loginKey,
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
discoverProviderModels: defaultDiscoverProviderModels,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -402,6 +405,14 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
||||
return 1
|
||||
}
|
||||
// First-run users reach for `zero login`/`zero logout` (reported in the
|
||||
// wild); point them at the real command instead of a bare usage pointer.
|
||||
switch strings.ToLower(args[0]) {
|
||||
case "login", "logout":
|
||||
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
||||
return 1
|
||||
}
|
||||
@@ -432,6 +443,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.probeProviderHealth == nil {
|
||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||
}
|
||||
if deps.discoverProviderModels == nil {
|
||||
deps.discoverProviderModels = defaults.discoverProviderModels
|
||||
}
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
@@ -530,7 +544,21 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
// A resolve failure the setup wizard can FIX is not fatal for the
|
||||
// interactive TUI: drop into the wizard with an empty config so the user
|
||||
// can onboard/repair, instead of exiting with an error they can only fix
|
||||
// by hand-editing config.json. That covers a missing/unresolvable active
|
||||
// provider and an active provider without a model (custom endpoints have
|
||||
// no catalog default) — previously the second shape bricked bare `zero`
|
||||
// and `zero setup`, the exact commands that could have fixed it. Any
|
||||
// other error (malformed JSON, directory conflict, etc.) is still fatal,
|
||||
// and headless commands (zero config / zero exec) still fail with the
|
||||
// actionable message.
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -157,6 +158,80 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
|
||||
}
|
||||
|
||||
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider should not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
if launchedOptions.Provider != nil {
|
||||
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
t.Fatal("TUI must not launch on a non-provider resolve error")
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero for fatal config error")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid config JSON") {
|
||||
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -1401,3 +1476,75 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A custom endpoint saved without a model previously bricked bare `zero` and
|
||||
// `zero setup` — the exact commands that could have fixed it (the resolve
|
||||
// error escaped before the wizard could open). The interactive TUI now treats
|
||||
// the requires-model failure as "needs onboarding", same as a missing active
|
||||
// provider. The error comes from a REAL Resolve over a real config file, so
|
||||
// this exercises the production sentinel wrapping, not a hand-built error.
|
||||
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
brokenConfig := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(brokenConfig, []byte(`{
|
||||
"activeProvider": "gw",
|
||||
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launched := false
|
||||
var launchedOptions tui.Options
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider must not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
|
||||
// users try them (reported in the wild), so the unknown-command error points at
|
||||
// the real command.
|
||||
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"zero auth login"`) {
|
||||
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
|
||||
}
|
||||
// An unrelated unknown command gets no misleading suggestion.
|
||||
stderr.Reset()
|
||||
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
|
||||
if strings.Contains(stderr.String(), "did you mean") {
|
||||
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "detect" {
|
||||
return runProvidersDetect(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "models" {
|
||||
return runProvidersModels(args, stdout, stderr, deps)
|
||||
}
|
||||
if command != "list" && command != "current" && command != "catalog" {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||
}
|
||||
@@ -465,9 +468,11 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers use <name> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
zero providers models [name] [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -65,7 +66,7 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
|
||||
report := doctor.Run(doctor.Options{
|
||||
Now: deps.now,
|
||||
Runtime: "go",
|
||||
Runtime: runtime.Version(),
|
||||
UserConfig: userConfig,
|
||||
ProjectConfig: projectConfig,
|
||||
Provider: provider,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
type providerModelsOptions struct {
|
||||
name string
|
||||
json bool
|
||||
}
|
||||
|
||||
// runProvidersModels lists the models a saved provider actually serves by probing
|
||||
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
|
||||
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
|
||||
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
|
||||
// models no longer needs a config object per model — configure the provider once,
|
||||
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
|
||||
// model ids through to the provider).
|
||||
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderModelsArgs(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
|
||||
}
|
||||
profile, err := selectProviderForCheck(resolved, options.name)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
|
||||
if options.json {
|
||||
items := make([]map[string]any, 0, len(models))
|
||||
for _, model := range models {
|
||||
entry := map[string]any{"id": model.ID}
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
entry["description"] = description
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"provider": profile.Name,
|
||||
"count": len(items),
|
||||
"models": items,
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
name = "provider"
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
for _, model := range models {
|
||||
line := strings.TrimSpace(model.ID)
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
line += " — " + description
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
suffix := "s"
|
||||
if len(models) == 1 {
|
||||
suffix = ""
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if len(models) > 0 {
|
||||
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// discoveryCredentialProfile resolves the profile's API key the same way the
|
||||
// runtime does — inline, then the stored credential, then the configured env var —
|
||||
// so a `providers models` probe authenticates exactly like a real request. Mirrors
|
||||
// discoveredModelContextWindow's credential resolution.
|
||||
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
|
||||
authed := profile
|
||||
if strings.TrimSpace(authed.APIKey) == "" {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
|
||||
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
|
||||
}
|
||||
return authed
|
||||
}
|
||||
|
||||
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
|
||||
// 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{})
|
||||
}
|
||||
|
||||
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
|
||||
options := providerModelsOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
if options.name != "" {
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
options.name = arg
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
var probed config.ProviderProfile
|
||||
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
probed = profile
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "team/model-a", Description: "first"},
|
||||
{ID: "team/model-b"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
// No name given → the active provider is probed, with its resolved credential.
|
||||
if probed.Name != "work" {
|
||||
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Provider string `json:"provider"`
|
||||
Count int `json:"count"`
|
||||
Models []struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
|
||||
t.Fatalf("model[0] = %#v", payload.Models[0])
|
||||
}
|
||||
// A model with no description omits the field rather than emitting an empty string.
|
||||
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
|
||||
t.Fatalf("model[1] = %#v", payload.Models[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
custom := config.ProviderProfile{
|
||||
Name: "self-hosted",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://models.internal/v1",
|
||||
APIKey: "sk-internal",
|
||||
Model: "house-model",
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
|
||||
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
|
||||
}
|
||||
var probed config.ProviderProfile
|
||||
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
probed = profile
|
||||
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
|
||||
t.Fatalf("probed = %#v, want the named custom provider", probed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
called := false
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("discovery must not run for an unknown provider")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
|
||||
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, errors.New("models endpoint returned 404")
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
|
||||
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestRunSpecialistListShowAndPath(t *testing.T) {
|
||||
@@ -326,7 +328,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
configRoot, err := os.UserConfigDir()
|
||||
configRoot, err := config.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -33,13 +34,32 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
|
||||
}
|
||||
|
||||
func DefaultUserConfigPath() (string, error) {
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
userConfigDir, err := UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve user config directory: %w", err)
|
||||
}
|
||||
return filepath.Join(userConfigDir, "zero", "config.json"), nil
|
||||
}
|
||||
|
||||
// UserConfigDir returns the base directory Zero stores per-user config under.
|
||||
// It mirrors os.UserConfigDir everywhere except macOS: there Go defaults to
|
||||
// ~/Library/Application Support, but Zero deliberately uses ~/.config (XDG-style,
|
||||
// matching Linux and Claude Code) so a single config path works cross-platform.
|
||||
// $XDG_CONFIG_HOME still wins on macOS when set.
|
||||
func UserConfigDir() (string, error) {
|
||||
if runtime.GOOS == "darwin" {
|
||||
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
|
||||
return xdg, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config"), nil
|
||||
}
|
||||
return os.UserConfigDir()
|
||||
}
|
||||
|
||||
func existingConfigFile(path string) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
|
||||
configRoot, err := os.UserConfigDir()
|
||||
configRoot, err := UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
@@ -130,3 +130,21 @@ func mkdirAll(t *testing.T, path string) {
|
||||
t.Fatalf("create directory %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultUserConfigPathUsesXDGConfigOnMacOS(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("macOS-specific config path behavior")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
|
||||
path, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultUserConfigPath() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(home, ".config", "zero", "config.json")
|
||||
if path != want {
|
||||
t.Fatalf("DefaultUserConfigPath() = %q, want %q", path, want)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -14,6 +15,31 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// ErrNoActiveProvider marks a resolve failure caused solely by a missing or
|
||||
// unresolvable active provider. The interactive TUI treats this as "needs
|
||||
// onboarding" (drop into the setup wizard) rather than a fatal config error.
|
||||
var ErrNoActiveProvider = errors.New("no active provider configured")
|
||||
|
||||
// ErrProviderRequiresModel marks a resolve failure caused solely by the active
|
||||
// provider missing a model with no catalog default to fall back on (custom
|
||||
// openai-/anthropic-compatible endpoints — Zero cannot guess a gateway's model).
|
||||
// Like ErrNoActiveProvider, the interactive TUI treats it as "needs onboarding"
|
||||
// and drops into the setup wizard so the user can fix it; headless commands
|
||||
// (zero config, zero exec) still fail with the actionable message.
|
||||
var ErrProviderRequiresModel = errors.New("provider requires model")
|
||||
|
||||
// setupFixableError tags an error with a sentinel for errors.Is WITHOUT
|
||||
// changing its message: the sentinel's own text must not prefix what the user
|
||||
// sees (the wrapped message is already complete and actionable).
|
||||
type setupFixableError struct {
|
||||
err error
|
||||
sentinel error
|
||||
}
|
||||
|
||||
func (e *setupFixableError) Error() string { return e.err.Error() }
|
||||
|
||||
func (e *setupFixableError) Unwrap() []error { return []error{e.err, e.sentinel} }
|
||||
|
||||
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
|
||||
// too low for real multi-step agentic work (agents ran out mid-task before reaching
|
||||
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
|
||||
@@ -220,8 +246,17 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
// 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
|
||||
// are the only config sources for write roots.
|
||||
//
|
||||
// Sandbox.Network from project config may only TIGHTEN (→ "deny"), never
|
||||
// WEAKEN (→ "allow"). A malicious repo must not be able to open network
|
||||
// access for shell commands. Matches the AdditionalWriteRoots posture.
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
// Normalize: anything that isn't literally "allow" counts as deny.
|
||||
if sandbox.NormalizeNetworkMode(sandbox.NetworkMode(network)) != sandbox.NetworkAllow {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
// Silently ignore "allow" from project config — not an error, just
|
||||
// a privilege the project scope does not have.
|
||||
}
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
@@ -370,6 +405,18 @@ func effectiveProviderKind(profile ProviderProfile) ProviderKind {
|
||||
return ""
|
||||
}
|
||||
|
||||
// catalogDefaultModel returns the default model of a catalog provider ("" when
|
||||
// the id is unknown), used to fill a missing profile.Model for the official-API
|
||||
// kinds so a hand-written profile without a model resolves instead of bricking
|
||||
// every command that resolves config up front (zero config, bare zero setup).
|
||||
func catalogDefaultModel(catalogID string) string {
|
||||
descriptor, ok := providercatalog.Get(catalogID)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(descriptor.DefaultModel)
|
||||
}
|
||||
|
||||
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
|
||||
catalogID := providercatalog.NormalizeID(profile.CatalogID)
|
||||
if catalogID == "" {
|
||||
@@ -731,11 +778,16 @@ func unionStrings(base []string, extra []string) []string {
|
||||
}
|
||||
|
||||
type normalizeOptions struct {
|
||||
defaultOpenAIModel bool
|
||||
// defaultModels fills a missing profile.Model for the official-API provider
|
||||
// kinds (openai from the model registry; anthropic/google from their catalog
|
||||
// descriptors). Off for provider-command configs (command.go), which must
|
||||
// surface exactly what the external command returned rather than inventing
|
||||
// a model behind its back.
|
||||
defaultModels bool
|
||||
}
|
||||
|
||||
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
|
||||
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
|
||||
}
|
||||
|
||||
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||
@@ -750,7 +802,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
if activeName != "" {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
}
|
||||
return []ProviderProfile{}, ProviderProfile{}, nil
|
||||
}
|
||||
@@ -782,10 +834,13 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
|
||||
if !activeFound {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
}
|
||||
if active.Model == "" {
|
||||
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
|
||||
return nil, ProviderProfile{}, &setupFixableError{
|
||||
err: providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name),
|
||||
sentinel: ErrProviderRequiresModel,
|
||||
}
|
||||
}
|
||||
|
||||
return normalized, active, nil
|
||||
@@ -832,7 +887,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
||||
case ProviderKindOpenAI:
|
||||
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = OpenAIBaseURL
|
||||
if options.defaultOpenAIModel && profile.Model == "" {
|
||||
if options.defaultModels && profile.Model == "" {
|
||||
profile.Model = modelregistry.DefaultModelID
|
||||
}
|
||||
return profile, nil
|
||||
@@ -849,6 +904,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
||||
case ProviderKindAnthropic:
|
||||
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = AnthropicBaseURL
|
||||
if options.defaultModels && profile.Model == "" {
|
||||
profile.Model = catalogDefaultModel("anthropic")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
|
||||
@@ -863,6 +921,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
||||
case ProviderKindGoogle:
|
||||
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = GoogleBaseURL
|
||||
if options.defaultModels && profile.Model == "" {
|
||||
profile.Model = catalogDefaultModel("google")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -898,6 +899,9 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
|
||||
t.Fatalf("error = %q, want active provider missing message", err.Error())
|
||||
}
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
|
||||
}
|
||||
if resolved.ActiveProvider != "" {
|
||||
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
|
||||
}
|
||||
@@ -1269,6 +1273,45 @@ func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
|
||||
// 1. Project config tries to set network to "allow" (should be ignored).
|
||||
userPath := writeConfig(t, `{}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"sandbox": {"network": "allow"}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Network == "allow" {
|
||||
t.Fatal("resolved.Sandbox.Network = allow, want empty/deny to prevent project config from weakening sandbox")
|
||||
}
|
||||
|
||||
// 2. User config sets network to "allow", project config sets it to "deny" (tightening is allowed).
|
||||
userPath2 := writeConfig(t, `{
|
||||
"sandbox": {"network": "allow"}
|
||||
}`)
|
||||
projectPath2 := writeConfig(t, `{
|
||||
"sandbox": {"network": "deny"}
|
||||
}`)
|
||||
resolved2, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath2,
|
||||
ProjectConfigPath: projectPath2,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved2.Sandbox.Network != "deny" {
|
||||
t.Fatalf("resolved.Sandbox.Network = %q, want deny (project config allowed to tighten)", resolved2.Sandbox.Network)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyValid(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
@@ -1551,3 +1594,97 @@ func TestResolveMaxTurnsZeroFallsBackToDefault(t *testing.T) {
|
||||
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// The reported brick: a hand-written google profile with an apiKey but no
|
||||
// model made EVERY resolving command fail ("provider google requires model"),
|
||||
// including zero config and bare zero setup — the only commands that could
|
||||
// have fixed it. Official-API kinds now fall back to their catalog default
|
||||
// model, exactly like the openai kind always has.
|
||||
func TestResolveDefaultsGoogleModelFromCatalog(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "google",
|
||||
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want model defaulted from the google catalog", err)
|
||||
}
|
||||
if resolved.Provider.Model != "gemini-2.5-pro" {
|
||||
t.Fatalf("Model = %q, want the google catalog default gemini-2.5-pro", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.BaseURL != GoogleBaseURL {
|
||||
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, GoogleBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultsAnthropicModelFromCatalog(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "anthropic",
|
||||
"providers": [{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want model defaulted from the anthropic catalog", err)
|
||||
}
|
||||
if resolved.Provider.Model != "claude-sonnet-4.5" {
|
||||
t.Fatalf("Model = %q, want the anthropic catalog default claude-sonnet-4.5", resolved.Provider.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly configured model must always win over the catalog default.
|
||||
func TestResolveKeepsExplicitGoogleModel(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "google",
|
||||
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x", "model": "gemini-2.5-flash"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.Model != "gemini-2.5-flash" {
|
||||
t.Fatalf("Model = %q, want the explicitly configured gemini-2.5-flash", resolved.Provider.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// Provider-command configs must NOT get invented models: the without-defaults
|
||||
// path surfaces exactly what the external command returned.
|
||||
func TestNormalizeWithoutModelDefaultsStillRequiresGoogleModel(t *testing.T) {
|
||||
_, _, err := normalizeProvidersWithoutModelDefaults(
|
||||
[]ProviderProfile{{Name: "google", ProviderKind: ProviderKindGoogle, APIKey: "AIza-x"}},
|
||||
"google", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("provider-command path must still require an explicit model")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires model") {
|
||||
t.Fatalf("error = %q, want requires-model", err.Error())
|
||||
}
|
||||
// The residual error must tell the user how to fix it.
|
||||
if !strings.Contains(err.Error(), "config.json") {
|
||||
t.Fatalf("error = %q, want an actionable config.json hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// The residual requires-model failure (custom endpoint, no catalog default)
|
||||
// must be tagged setup-fixable WITHOUT changing its message: the interactive
|
||||
// TUI matches the sentinel to drop into the wizard, while headless commands
|
||||
// print the exact actionable text.
|
||||
func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "gw",
|
||||
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||
}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected requires-model error for a custom endpoint without model")
|
||||
}
|
||||
if !errors.Is(err, ErrProviderRequiresModel) {
|
||||
t.Fatalf("error = %v, want errors.Is(err, ErrProviderRequiresModel)", err)
|
||||
}
|
||||
// The sentinel must NOT prefix the user-facing message.
|
||||
if !strings.HasPrefix(err.Error(), "provider gw requires model") {
|
||||
t.Fatalf("message = %q, want it to start with the provider error, not the sentinel text", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "sk-x") {
|
||||
t.Fatalf("error leaked API key: %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,13 @@ func TestValidateFileRedactsSecretInIssue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateFileMissingModelWarns(t *testing.T) {
|
||||
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
|
||||
// on, so a missing model is still a real issue — and the message must tell
|
||||
// the user how to fix it.
|
||||
path := writeValidateFixture(t, `{
|
||||
"activeProvider": "main",
|
||||
"providers": [
|
||||
{"name": "main", "provider_kind": "anthropic"}
|
||||
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
|
||||
]
|
||||
}`)
|
||||
|
||||
@@ -80,6 +83,30 @@ func TestValidateFileMissingModelWarns(t *testing.T) {
|
||||
if !strings.Contains(issues[0].Message, "requires model") {
|
||||
t.Fatalf("expected requires-model issue, got %#v", issues)
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "config.json") {
|
||||
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
|
||||
// Official-API kinds (anthropic/google) fall back to their catalog default
|
||||
// model, so a hand-written model-less profile validates clean instead of
|
||||
// bricking zero config / bare zero setup — the only commands that could
|
||||
// have fixed it (the reported google case).
|
||||
path := writeValidateFixture(t, `{
|
||||
"activeProvider": "google",
|
||||
"providers": [
|
||||
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
|
||||
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, issues := ValidateFile(path)
|
||||
for _, issue := range issues {
|
||||
if strings.Contains(issue.Message, "requires model") {
|
||||
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
|
||||
|
||||
@@ -57,6 +57,11 @@ type Options struct {
|
||||
// LSP-server checks. Nil means exec.LookPath; tests inject a stub so the
|
||||
// checks are deterministic regardless of the host's installed tooling.
|
||||
LookupExecutable func(string) (string, error)
|
||||
// Executable resolves the running Zero binary's path (nil => os.Executable)
|
||||
// and StatExecutable stats it (nil => os.Stat). Injected so the native-binary
|
||||
// runtime check is testable without depending on a real on-disk layout.
|
||||
Executable func() (string, error)
|
||||
StatExecutable func(string) (os.FileInfo, error)
|
||||
}
|
||||
|
||||
func Run(options Options) Report {
|
||||
@@ -65,7 +70,7 @@ func Run(options Options) Report {
|
||||
now = time.Now
|
||||
}
|
||||
checks := []Check{
|
||||
runtimeCheck(options.Runtime),
|
||||
runtimeCheck(options),
|
||||
configFilesCheck(options.UserConfig, options.ProjectConfig),
|
||||
configValidationCheck(options.UserConfig, options.ProjectConfig),
|
||||
}
|
||||
@@ -114,12 +119,48 @@ func Format(report Report) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func runtimeCheck(runtime string) Check {
|
||||
runtime = strings.TrimSpace(runtime)
|
||||
if runtime == "" {
|
||||
runtime = "go"
|
||||
// runtimeCheck verifies the running Zero native binary is actually present and
|
||||
// runnable, and reports its resolved path — instead of the former hardcoded
|
||||
// always-pass, which claimed "Go runtime is available" while checking nothing
|
||||
// (#405). doctor executes INSIDE the native binary, so this only fails if
|
||||
// os.Executable can't resolve or the running file has vanished; the missing-
|
||||
// binary-at-install case is caught earlier by the npm wrapper, which refuses to
|
||||
// launch without the platform binary. Surfacing the binary PATH is the useful
|
||||
// part: it lets a user see which binary answered (e.g. an npm-installed one vs a
|
||||
// stale/shadowing copy on PATH) rather than a meaningless green.
|
||||
func runtimeCheck(options Options) Check {
|
||||
runtimeVersion := strings.TrimSpace(options.Runtime)
|
||||
if runtimeVersion == "" {
|
||||
runtimeVersion = "go"
|
||||
}
|
||||
return check("runtime.go", "Go runtime", StatusPass, fmt.Sprintf("Zero Go runtime is available (%s).", runtime), map[string]any{"runtime": runtime})
|
||||
details := map[string]any{"runtime": runtimeVersion}
|
||||
|
||||
executable := options.Executable
|
||||
if executable == nil {
|
||||
executable = os.Executable
|
||||
}
|
||||
path, err := executable()
|
||||
if err != nil {
|
||||
return check("runtime.go", "Zero native binary", StatusFail,
|
||||
"Could not resolve the running Zero binary: "+err.Error(), details)
|
||||
}
|
||||
details["binaryPath"] = path
|
||||
|
||||
stat := options.StatExecutable
|
||||
if stat == nil {
|
||||
stat = os.Stat
|
||||
}
|
||||
info, err := stat(path)
|
||||
if err != nil {
|
||||
return check("runtime.go", "Zero native binary", StatusFail,
|
||||
fmt.Sprintf("The running Zero binary is not accessible at %s: %s. Reinstall the zero package, or build from source: https://github.com/Gitlawb/zero", path, err.Error()), details)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return check("runtime.go", "Zero native binary", StatusFail,
|
||||
fmt.Sprintf("The resolved Zero binary path %s is a directory, not an executable.", path), details)
|
||||
}
|
||||
return check("runtime.go", "Zero native binary", StatusPass,
|
||||
fmt.Sprintf("Zero native binary is present and runnable at %s (%s).", path, runtimeVersion), details)
|
||||
}
|
||||
|
||||
func configFilesCheck(userPath string, projectPath string) Check {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeFileInfo is a minimal os.FileInfo for the runtime-check stat injection.
|
||||
type fakeFileInfo struct{ dir bool }
|
||||
|
||||
func (f fakeFileInfo) Name() string { return "zero" }
|
||||
func (f fakeFileInfo) Size() int64 { return 1 }
|
||||
func (f fakeFileInfo) Mode() fs.FileMode { return 0o755 }
|
||||
func (f fakeFileInfo) ModTime() time.Time { return time.Unix(0, 0) }
|
||||
func (f fakeFileInfo) IsDir() bool { return f.dir }
|
||||
func (f fakeFileInfo) Sys() any { return nil }
|
||||
|
||||
func runtimeCheckFor(t *testing.T, exe func() (string, error), stat func(string) (os.FileInfo, error)) Check {
|
||||
t.Helper()
|
||||
return runtimeCheck(Options{Runtime: "go1.26", Executable: exe, StatExecutable: stat})
|
||||
}
|
||||
|
||||
// A present, runnable binary passes AND reports its resolved path — the check is
|
||||
// no longer a hardcoded green; it names which binary answered.
|
||||
func TestRuntimeCheckPassesAndReportsBinaryPath(t *testing.T) {
|
||||
c := runtimeCheckFor(t,
|
||||
func() (string, error) { return "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero", nil },
|
||||
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
|
||||
if c.Status != StatusPass {
|
||||
t.Fatalf("status = %s, want pass", c.Status)
|
||||
}
|
||||
if c.Details["binaryPath"] != "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero" {
|
||||
t.Fatalf("binaryPath = %v, want the resolved path in details", c.Details["binaryPath"])
|
||||
}
|
||||
if !strings.Contains(c.Message, "/opt/homebrew/lib/node_modules/@gitlawb/zero/zero") {
|
||||
t.Fatalf("message should surface the binary path, got %q", c.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// The vanished-binary case FAILS loudly instead of the old unconditional pass —
|
||||
// this is the #405 regression: a broken runtime no longer gets a green check.
|
||||
func TestRuntimeCheckFailsWhenBinaryMissing(t *testing.T) {
|
||||
c := runtimeCheckFor(t,
|
||||
func() (string, error) { return "/path/to/zero", nil },
|
||||
func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
if c.Status != StatusFail {
|
||||
t.Fatalf("#405: a missing/inaccessible binary must FAIL, got %s (%q)", c.Status, c.Message)
|
||||
}
|
||||
if !strings.Contains(c.Message, "/path/to/zero") {
|
||||
t.Fatalf("failure message should name the path, got %q", c.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// os.Executable resolution failure fails loudly too.
|
||||
func TestRuntimeCheckFailsWhenExecutableUnresolvable(t *testing.T) {
|
||||
c := runtimeCheckFor(t,
|
||||
func() (string, error) { return "", errors.New("no executable path") },
|
||||
func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
|
||||
if c.Status != StatusFail {
|
||||
t.Fatalf("unresolvable executable must FAIL, got %s", c.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A path that resolves to a directory is not a runnable binary → FAIL.
|
||||
func TestRuntimeCheckFailsWhenPathIsDirectory(t *testing.T) {
|
||||
c := runtimeCheckFor(t,
|
||||
func() (string, error) { return "/some/dir", nil },
|
||||
func(string) (os.FileInfo, error) { return fakeFileInfo{dir: true}, nil })
|
||||
if c.Status != StatusFail {
|
||||
t.Fatalf("a directory path must FAIL, got %s", c.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Default injection (nil Executable/StatExecutable) resolves the real running
|
||||
// test binary — proving the production default path works and passes.
|
||||
func TestRuntimeCheckDefaultsToRealBinary(t *testing.T) {
|
||||
c := runtimeCheck(Options{})
|
||||
if c.Status != StatusPass {
|
||||
t.Fatalf("default runtime check should pass for the running test binary, got %s (%q)", c.Status, c.Message)
|
||||
}
|
||||
if strings.TrimSpace(c.Details["binaryPath"].(string)) == "" {
|
||||
t.Fatal("default runtime check should report the running binary path")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -211,6 +212,85 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/mcp" {
|
||||
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
|
||||
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
|
||||
http.Error(response, "bad request", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q, want secret", got)
|
||||
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
message := readHTTPRPCMessage(t, request)
|
||||
switch message.Method {
|
||||
case "initialize":
|
||||
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
|
||||
case "notifications/initialized":
|
||||
response.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
|
||||
}
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
client, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeHTTP,
|
||||
URL: testServer.URL + "/mcp",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||
t.Errorf("redirect target received X-Api-Key = %q", got)
|
||||
}
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
_, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeHTTP,
|
||||
URL: redirector.URL + "/mcp",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -340,6 +420,53 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
|
||||
}
|
||||
response.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
|
||||
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
|
||||
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
|
||||
http.Error(response, "bad request", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
|
||||
if flusher, ok := response.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}))
|
||||
defer stream.Close()
|
||||
|
||||
_, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeSSE,
|
||||
URL: stream.URL + "/sse",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
|
||||
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -733,7 +733,11 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
|
||||
}
|
||||
return baseURL.ResolveReference(endpointURL).String(), nil
|
||||
resolvedURL := baseURL.ResolveReference(endpointURL)
|
||||
if !sameMCPOrigin(baseURL, resolvedURL) {
|
||||
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
|
||||
}
|
||||
return resolvedURL.String(), nil
|
||||
}
|
||||
|
||||
func rpcResponseKey(id any) string {
|
||||
@@ -873,10 +877,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
||||
|
||||
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
|
||||
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
|
||||
// not declare OAuth get the default client unchanged.
|
||||
// not declare OAuth use the default transport with the same MCP redirect guard.
|
||||
func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
|
||||
return http.DefaultClient, nil
|
||||
return mcpHTTPClient(server, nil), nil
|
||||
}
|
||||
store, err := NewTokenStore(TokenStoreOptions{})
|
||||
if err != nil {
|
||||
@@ -888,5 +892,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
httpClient: http.DefaultClient,
|
||||
now: time.Now,
|
||||
}
|
||||
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
|
||||
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -149,6 +150,141 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
|
||||
source := &fakeTokenSource{}
|
||||
source.access.Store("access-secret")
|
||||
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("redirect target received Authorization = %q", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
client := mcpHTTPClient(
|
||||
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
|
||||
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
|
||||
)
|
||||
resp, err := client.Get(redirector.URL + "/mcp")
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "https default port",
|
||||
left: "https://example.test/mcp",
|
||||
right: "https://EXAMPLE.test:443/other",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "http default port",
|
||||
left: "http://example.test/mcp",
|
||||
right: "http://example.test:80/other",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different scheme",
|
||||
left: "https://example.test/mcp",
|
||||
right: "http://example.test:443/other",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different port",
|
||||
left: "https://example.test/mcp",
|
||||
right: "https://example.test:8443/other",
|
||||
want: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
left, err := url.Parse(tc.left)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := url.Parse(tc.right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sameMCPOrigin(left, right); got != tc.want {
|
||||
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
base string
|
||||
endpoint string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "relative endpoint",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "/messages",
|
||||
want: "https://example.test/messages",
|
||||
},
|
||||
{
|
||||
name: "absolute same origin",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://example.test/messages",
|
||||
want: "https://example.test/messages",
|
||||
},
|
||||
{
|
||||
name: "default port equivalent",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://example.test:443/messages",
|
||||
want: "https://example.test:443/messages",
|
||||
},
|
||||
{
|
||||
name: "cross origin",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://evil.test/messages",
|
||||
wantErr: "differs from configured server origin",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOAuthServerIsUnaffected(t *testing.T) {
|
||||
// Regression: a server without auth must not get a bearer header or any
|
||||
// OAuth round tripper, and must connect normally.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxMCPRedirects = 10
|
||||
|
||||
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: checkMCPRedirect(server),
|
||||
}
|
||||
}
|
||||
|
||||
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
|
||||
return func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxMCPRedirects {
|
||||
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
|
||||
}
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !sameMCPOrigin(via[0].URL, request.URL) {
|
||||
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
effectiveMCPPort(left) == effectiveMCPPort(right)
|
||||
}
|
||||
|
||||
func effectiveMCPPort(value *url.URL) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if port := value.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(value.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mcpOrigin(value *url.URL) string {
|
||||
if value == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
host := value.Hostname()
|
||||
port := effectiveMCPPort(value)
|
||||
if port != "" {
|
||||
host = net.JoinHostPort(host, port)
|
||||
}
|
||||
if value.Scheme == "" {
|
||||
return host
|
||||
}
|
||||
return strings.ToLower(value.Scheme) + "://" + host
|
||||
}
|
||||
@@ -370,7 +370,14 @@ func TestFullFlowStoresTokens(t *testing.T) {
|
||||
redirect := parsed.Query().Get("redirect_uri")
|
||||
go func() {
|
||||
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
|
||||
_, _ = http.Get(cbURL)
|
||||
for i := 0; i < 20; i++ {
|
||||
resp, err := http.Get(cbURL)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ var descriptors = []Descriptor{
|
||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
|
||||
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
|
||||
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
|
||||
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
||||
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
||||
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
||||
|
||||
@@ -34,6 +34,9 @@ var expectedCatalogIDs = []string{
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"kilocode",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
@@ -264,7 +267,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -342,7 +342,7 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
|
||||
declarations = append(declarations, geminiFunctionDeclaration{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
Parameters: sanitizeGeminiSchema(tool.Parameters),
|
||||
})
|
||||
}
|
||||
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
|
||||
@@ -350,6 +350,71 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
// geminiSchemaFields is the subset of JSON Schema keywords Google's
|
||||
// functionDeclarations[].parameters accepts (the Generative AI Schema type).
|
||||
// Anything outside it — most notably OpenAI's `additionalProperties`, which Zero
|
||||
// emits on every tool's parameters and which Gemini 400s on ("Unknown name
|
||||
// \"additionalProperties\" … Cannot find field") — must be dropped before the
|
||||
// request goes out. Kept as an allowlist rather than a denylist so a schema
|
||||
// keyword Zero (or an MCP server) adds later can't silently leak an unsupported
|
||||
// field into the Gemini payload.
|
||||
var geminiSchemaFields = map[string]bool{
|
||||
"type": true, "format": true, "title": true, "description": true,
|
||||
"nullable": true, "enum": true, "items": true, "properties": true,
|
||||
"required": true, "anyOf": true, "propertyOrdering": true, "default": true,
|
||||
"minimum": true, "maximum": true, "minItems": true, "maxItems": true,
|
||||
"minLength": true, "maxLength": true, "minProperties": true,
|
||||
"maxProperties": true, "pattern": true, "example": true,
|
||||
}
|
||||
|
||||
// sanitizeGeminiSchema returns a copy of a JSON-Schema map keeping only the
|
||||
// keywords Gemini supports (geminiSchemaFields), recursing through the nested
|
||||
// schemas under `properties`, `items`, and `anyOf`. Returns nil for a nil input
|
||||
// so a parameterless tool stays parameterless.
|
||||
func sanitizeGeminiSchema(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(schema))
|
||||
for key, value := range schema {
|
||||
if !geminiSchemaFields[key] {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "properties":
|
||||
if props, ok := value.(map[string]any); ok {
|
||||
cleaned := make(map[string]any, len(props))
|
||||
for name, sub := range props {
|
||||
if subMap, ok := sub.(map[string]any); ok {
|
||||
cleaned[name] = sanitizeGeminiSchema(subMap)
|
||||
} else {
|
||||
cleaned[name] = sub
|
||||
}
|
||||
}
|
||||
value = cleaned
|
||||
}
|
||||
case "items":
|
||||
if subMap, ok := value.(map[string]any); ok {
|
||||
value = sanitizeGeminiSchema(subMap)
|
||||
}
|
||||
case "anyOf":
|
||||
if variants, ok := value.([]any); ok {
|
||||
cleaned := make([]any, len(variants))
|
||||
for i, variant := range variants {
|
||||
if subMap, ok := variant.(map[string]any); ok {
|
||||
cleaned[i] = sanitizeGeminiSchema(subMap)
|
||||
} else {
|
||||
cleaned[i] = variant
|
||||
}
|
||||
}
|
||||
value = cleaned
|
||||
}
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
|
||||
systemParts := []geminiPart{}
|
||||
contents := []geminiContent{}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gemini
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -504,3 +505,129 @@ func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.Stream
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
// TestSanitizeGeminiSchemaStripsUnsupportedFields: the sanitizer keeps only
|
||||
// Gemini-supported keywords and recurses into properties/items/anyOf, so no
|
||||
// OpenAI-ism (additionalProperties, $schema, patternProperties) survives at any
|
||||
// depth while legitimate fields (type/description/enum/required/default) stay.
|
||||
func TestSanitizeGeminiSchemaStripsUnsupportedFields(t *testing.T) {
|
||||
in := map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"required": []string{"path"},
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "a path",
|
||||
"default": ".",
|
||||
},
|
||||
"nested": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": false, // must be stripped at depth too
|
||||
"properties": map[string]any{
|
||||
"tags": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string", "additionalProperties": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := sanitizeGeminiSchema(in)
|
||||
|
||||
assertNoAdditionalProps(t, out, "root")
|
||||
if out["additionalProperties"] != nil {
|
||||
t.Fatal("top-level additionalProperties must be stripped")
|
||||
}
|
||||
if out["$schema"] != nil {
|
||||
t.Fatal("$schema must be stripped")
|
||||
}
|
||||
// Legitimate fields survive.
|
||||
if out["type"] != "object" {
|
||||
t.Fatalf("type should survive, got %v", out["type"])
|
||||
}
|
||||
props := out["properties"].(map[string]any)
|
||||
pathProp := props["path"].(map[string]any)
|
||||
if pathProp["description"] != "a path" || pathProp["default"] != "." {
|
||||
t.Fatalf("legitimate property fields must survive: %#v", pathProp)
|
||||
}
|
||||
// nil input stays nil (parameterless tool).
|
||||
if sanitizeGeminiSchema(nil) != nil {
|
||||
t.Fatal("nil schema should map to nil")
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoAdditionalProps fails if additionalProperties appears anywhere in the
|
||||
// (recursively walked) schema.
|
||||
func assertNoAdditionalProps(t *testing.T, node any, path string) {
|
||||
t.Helper()
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if _, ok := v["additionalProperties"]; ok {
|
||||
t.Fatalf("additionalProperties leaked at %s", path)
|
||||
}
|
||||
for k, sub := range v {
|
||||
assertNoAdditionalProps(t, sub, path+"."+k)
|
||||
}
|
||||
case []any:
|
||||
for i, sub := range v {
|
||||
assertNoAdditionalProps(t, sub, fmt.Sprintf("%s[%d]", path, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiRequestOmitsAdditionalPropertiesInToolSchema: end-to-end, the tool
|
||||
// parameters Zero emits (schemaToRuntimeMap always writes additionalProperties)
|
||||
// must not reach Gemini — otherwise every functionDeclaration is 400-rejected
|
||||
// ("Unknown name additionalProperties"), which broke all tool-using exec calls
|
||||
// against Google (issue #373).
|
||||
func TestGeminiRequestOmitsAdditionalPropertiesInToolSchema(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `{"usageMetadata":{"promptTokenCount":1}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{APIKey: "k", BaseURL: server.URL + "/", Model: "models/gemini-2.5-flash"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
Tools: []zeroruntime.ToolDefinition{{
|
||||
Name: "grep",
|
||||
Description: "search",
|
||||
Parameters: map[string]any{ // exactly what schemaToRuntimeMap produces
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": []string{"pattern"},
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "regex"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
tools := gotBody["tools"].([]any)
|
||||
decls := tools[0].(map[string]any)["functionDeclarations"].([]any)
|
||||
params := decls[0].(map[string]any)["parameters"].(map[string]any)
|
||||
if _, ok := params["additionalProperties"]; ok {
|
||||
t.Fatalf("additionalProperties must not be sent to Gemini: %#v", params)
|
||||
}
|
||||
// The tool is still usable: its real schema survived.
|
||||
if params["type"] != "object" {
|
||||
t.Fatalf("tool parameters type should survive, got %v", params["type"])
|
||||
}
|
||||
props := params["properties"].(map[string]any)
|
||||
if _, ok := props["pattern"]; !ok {
|
||||
t.Fatalf("tool property must survive sanitization: %#v", props)
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
|
||||
// sandbox network policy gates sandboxed shell egress, not first-party
|
||||
// in-process web tools.
|
||||
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(t *testing.T) {
|
||||
// TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy verifies
|
||||
// that first-party in-process network tools are governed by their permission
|
||||
// metadata, not by the sandboxed shell egress policy.
|
||||
func TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy(t *testing.T) {
|
||||
base := Policy{Mode: ModeEnforce, Network: NetworkDeny}
|
||||
|
||||
engine := NewEngine(EngineOptions{Policy: base})
|
||||
d := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
|
||||
})
|
||||
if d.Action != ActionAllow || d.Block != nil {
|
||||
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
|
||||
if d.Action != ActionPrompt || d.Block != nil {
|
||||
t.Fatalf("web_search must prompt before permission is granted, got %#v", d)
|
||||
}
|
||||
if d.Reason == ReasonNetworkBlocked {
|
||||
t.Fatalf("web_search prompt should come from tool permission, not shell network policy: %#v", d)
|
||||
}
|
||||
|
||||
allowed := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||
})
|
||||
if allowed.Action != ActionAllow || allowed.Block != nil {
|
||||
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
|
||||
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
|
||||
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
|
||||
}
|
||||
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sandbox
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -128,7 +129,7 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
|
||||
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
|
||||
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
|
||||
}
|
||||
if pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
t.Fatalf("Roots()=%v want default /tmp write root", roots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
@@ -120,7 +121,7 @@ var knownToolNames = map[string]bool{
|
||||
}
|
||||
|
||||
func DefaultPaths(workspaceRoot string) (Paths, error) {
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
userConfigDir, err := config.UserConfigDir()
|
||||
if err != nil {
|
||||
return Paths{}, fmt.Errorf("resolve user config directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,25 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
content := string(contentBytes)
|
||||
occurrences := strings.Count(content, oldString)
|
||||
|
||||
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
|
||||
// the model, so the model's old_string will use \n line endings. When the raw
|
||||
// file uses \r\n (common on Windows), the \n-based old_string won't match.
|
||||
// Detect this and transparently normalize: if the direct match fails, translate
|
||||
// old_string's line endings to \r\n and search again. If found, use the CRLF-translated
|
||||
// old_string and new_string for the replacement to preserve the file's existing EOL style
|
||||
// without rewriting EOLs in unrelated parts of the file.
|
||||
if occurrences == 0 && strings.Contains(content, "\r\n") && !strings.Contains(oldString, "\r\n") {
|
||||
crlfOldString := strings.ReplaceAll(oldString, "\n", "\r\n")
|
||||
normalizedOccurrences := strings.Count(content, crlfOldString)
|
||||
if normalizedOccurrences > 0 {
|
||||
occurrences = normalizedOccurrences
|
||||
oldString = crlfOldString
|
||||
if !strings.Contains(newString, "\r\n") {
|
||||
newString = strings.ReplaceAll(newString, "\n", "\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if occurrences == 0 {
|
||||
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
|
||||
if property, ok := search.Parameters().Properties["query"]; !ok || property.Type != "string" {
|
||||
t.Fatalf("web_search must expose a string query property, got %#v", search.Parameters().Properties["query"])
|
||||
}
|
||||
if safety := search.Safety(); safety.Permission != PermissionAllow || safety.AdvertiseInAuto {
|
||||
t.Fatalf("web_search safety = %#v, want allow without prompt-advertise override", safety)
|
||||
if safety := search.Safety(); safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
|
||||
t.Fatalf("web_search safety = %#v, want prompt and advertised in auto", safety)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,13 +81,14 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
|
||||
Required: []string{"query"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
// Hosted search is a read-only discovery action. It remains marked as
|
||||
// network for metadata, but sandbox network policy is the shell egress
|
||||
// boundary; web_search keeps its own result-domain filtering safeguards.
|
||||
// Hosted search sends model-provided query text to a configured network
|
||||
// backend. Keep it visible in auto mode, but guard execution through the
|
||||
// normal permission flow like web_fetch.
|
||||
safety: Safety{
|
||||
SideEffect: SideEffectNetwork,
|
||||
Permission: PermissionAllow,
|
||||
Reason: "Performs a web search over the network.",
|
||||
SideEffect: SideEffectNetwork,
|
||||
Permission: PermissionPrompt,
|
||||
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
},
|
||||
backend: backend,
|
||||
@@ -95,7 +96,8 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
|
||||
}
|
||||
|
||||
// RunWithSandbox follows the normal web_search path. The sandbox network policy
|
||||
// gates sandboxed shell egress, not this in-process hosted search tool.
|
||||
// gates sandboxed shell egress; this in-process hosted search tool is guarded by
|
||||
// the permission flow plus backend and result-domain safeguards.
|
||||
func (tool webSearchTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
|
||||
return tool.Run(ctx, args)
|
||||
}
|
||||
|
||||
@@ -116,17 +116,52 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchSafetyAllowsHostedSearchWithoutPrompt(t *testing.T) {
|
||||
func TestWebSearchSafetyPromptsForHostedSearchButAdvertisesInAuto(t *testing.T) {
|
||||
tool := newWebSearchToolWithBackend(&fakeSearchBackend{})
|
||||
safety := tool.Safety()
|
||||
if safety.SideEffect != SideEffectNetwork {
|
||||
t.Fatalf("side effect = %s, want network", safety.SideEffect)
|
||||
}
|
||||
if safety.Permission != PermissionAllow {
|
||||
t.Fatalf("permission = %s, want allow", safety.Permission)
|
||||
if safety.Permission != PermissionPrompt {
|
||||
t.Fatalf("permission = %s, want prompt", safety.Permission)
|
||||
}
|
||||
if safety.AdvertiseInAuto {
|
||||
t.Fatal("web_search should not need the prompt-tool auto advertisement override")
|
||||
if !safety.AdvertiseInAuto {
|
||||
t.Fatal("web_search should be advertised in auto mode while still requiring permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchRegistryRequiresPermissionBeforeBackendCall(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
|
||||
registry := NewRegistry()
|
||||
registry.Register(newWebSearchToolWithBackend(backend))
|
||||
|
||||
res := registry.Run(context.Background(), "web_search", map[string]any{"query": "private workspace detail"})
|
||||
|
||||
if res.Status != StatusError {
|
||||
t.Fatalf("expected permission error, got %s: %s", res.Status, res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "Permission required for web_search") {
|
||||
t.Fatalf("expected permission-required output, got %q", res.Output)
|
||||
}
|
||||
if backend.gotQuery != "" {
|
||||
t.Fatalf("backend must not be called before permission, got query %q", backend.gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchRegistryRunsAfterPermissionGranted(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
|
||||
registry := NewRegistry()
|
||||
registry.Register(newWebSearchToolWithBackend(backend))
|
||||
|
||||
res := registry.RunWithOptions(context.Background(), "web_search", map[string]any{"query": "go errors"}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
})
|
||||
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", res.Status, res.Output)
|
||||
}
|
||||
if backend.gotQuery != "go errors" {
|
||||
t.Fatalf("backend query = %q, want %q", backend.gotQuery, "go errors")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,29 @@ func TestEditFileToolReplacesExactStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolReplacesCRLF(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "code.go")
|
||||
writeTestFile(t, path, "const a = 1\r\nconst b = 2\r\n")
|
||||
|
||||
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "code.go",
|
||||
"old_string": "const a = 1\nconst b = 2",
|
||||
"new_string": "const a = 42\nconst b = 24",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "const a = 42\r\nconst b = 24\r\n" {
|
||||
t.Fatalf("unexpected edited content: %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
|
||||
|
||||
+42
-6
@@ -635,7 +635,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
}
|
||||
}
|
||||
|
||||
userConfigDir, _ := os.UserConfigDir()
|
||||
userConfigDir, _ := config.UserConfigDir()
|
||||
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
|
||||
|
||||
registry := options.Registry
|
||||
@@ -1669,17 +1669,38 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// when the loop is already running or there is nothing to animate.
|
||||
return m, m.ensureSpinnerTick()
|
||||
case permissionRequestMsg:
|
||||
// The agent goroutine that raised this request is BLOCKED waiting on the
|
||||
// decision callback, so every branch below must resolve it exactly once —
|
||||
// or store it in pendingPermission, which resolves it on the user's reply.
|
||||
// Dropping a request without resolving parks the run forever (the reported
|
||||
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
|
||||
// nothing will ever signal, with no visible prompt and no network activity.
|
||||
if msg.runID != m.activeRunID {
|
||||
// A superseded/stale run: unblock its parked goroutine now rather than
|
||||
// relying on that run's context cancel to fire first.
|
||||
if msg.decide != nil {
|
||||
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if msg.request.Action != agent.PermissionActionPrompt {
|
||||
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
|
||||
// blocked because it requested additional permissions). The UI has
|
||||
// nothing to ask, so resolve immediately and FAIL CLOSED — never
|
||||
// silently grant access that was never surfaced to the user. (The agent
|
||||
// now marks such elevation requests as prompts, so in practice this is a
|
||||
// defensive backstop; matches the ACP handler's fail-closed contract.)
|
||||
if msg.decide != nil {
|
||||
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
|
||||
promptRow.runID = msg.runID
|
||||
m.transcript = appendTranscriptRow(m.transcript, promptRow)
|
||||
if msg.request.Action == agent.PermissionActionPrompt {
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
}
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
}
|
||||
return m, nil
|
||||
case askUserRequestMsg:
|
||||
@@ -4525,6 +4546,21 @@ func (m model) sendPermissionRequest(runID int, request agent.PermissionRequest,
|
||||
m.runtimeMessageSink(permissionRequestMsg{runID: runID, request: request, decide: decide})
|
||||
}
|
||||
|
||||
// autoResolvedPermissionDecision resolves a permission request the TUI cannot
|
||||
// turn into a user prompt (Action != prompt). The agent is blocked awaiting a
|
||||
// decision, so one must ALWAYS be produced. Only an explicit Cancel is honored
|
||||
// as such; every other non-prompt action — including allow — is DENIED, so the
|
||||
// UI never silently grants access it did not surface for approval.
|
||||
func autoResolvedPermissionDecision(action agent.PermissionAction) agent.PermissionDecision {
|
||||
if action == agent.PermissionActionCancel {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run cancelled"}
|
||||
}
|
||||
return agent.PermissionDecision{
|
||||
Action: agent.PermissionDecisionDeny,
|
||||
Reason: "permission request could not be surfaced for approval",
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) sendAskUserRequest(runID int, request agent.AskUserRequest, answer func([]string)) {
|
||||
if m.runtimeMessageSink == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
// The model picker must title each row with the model NAME, not the models.dev
|
||||
// marketing description. Regression: Ollama Cloud models resolved full-sentence
|
||||
// descriptions from the remote catalog, so rows showed sentences and several
|
||||
// models sharing one description looked like exact duplicates (issue: /model
|
||||
// list rendered descriptions as titles).
|
||||
func TestModelPickerDisplayNamePrefersModelName(t *testing.T) {
|
||||
sentence := "DeepSeek chat model for instruction following, coding, and analysis"
|
||||
cases := []struct {
|
||||
id string
|
||||
description string
|
||||
want string
|
||||
}{
|
||||
{"deepseek-v3.2", sentence, "Deepseek V3.2"},
|
||||
{"deepseek-v3.2-thinking", sentence, "Deepseek V3.2 Thinking"},
|
||||
{"glm-5.2", "GLM flagship model", "GLM 5.2"},
|
||||
{"qwen3-coder:480b", "Qwen coding agent model for repository tasks", "Qwen3 Coder 480b"},
|
||||
{"anthropic/claude-sonnet-4.6", "some blurb", "Claude Sonnet 4.6"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := modelPickerDisplayName(tc.id, tc.description); got != tc.want {
|
||||
t.Errorf("modelPickerDisplayName(%q, %q) = %q, want %q", tc.id, tc.description, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two distinct model ids that share the same catalog description must render as
|
||||
// distinct rows (the "duplicate-looking rows" symptom), never as the shared
|
||||
// sentence.
|
||||
func TestModelPickerDisplayNameDistinctForSharedDescription(t *testing.T) {
|
||||
shared := "Mistral coding agent model for repository tasks and software engineering"
|
||||
a := modelPickerDisplayName("codestral-2", shared)
|
||||
b := modelPickerDisplayName("devstral-2", shared)
|
||||
if a == b {
|
||||
t.Fatalf("shared description collapsed rows to the same title: %q", a)
|
||||
}
|
||||
if a == shared || b == shared {
|
||||
t.Fatalf("row title used the description sentence: a=%q b=%q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// With no id to name the row, a non-generic description is still an acceptable
|
||||
// fallback; a generic placeholder (or nothing) falls back to "model".
|
||||
func TestModelPickerDisplayNameFallsBackToDescriptionOnlyWithoutID(t *testing.T) {
|
||||
if got := modelPickerDisplayName("", "Friendly Name"); got != "Friendly Name" {
|
||||
t.Fatalf("empty id should fall back to the description, got %q", got)
|
||||
}
|
||||
if got := modelPickerDisplayName("", "catalog default"); got != "model" {
|
||||
t.Fatalf("generic description with no id should yield %q, got %q", "model", got)
|
||||
}
|
||||
if got := modelPickerDisplayName("", ""); got != "model" {
|
||||
t.Fatalf("empty id and description should yield %q, got %q", "model", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func permissionModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.width = 96
|
||||
return m
|
||||
}
|
||||
|
||||
// The deadlock: a permission request whose Action is NOT prompt (e.g. a
|
||||
// sandbox-allowed command that still blocked to request additional permissions)
|
||||
// must be resolved immediately — the agent goroutine is parked awaiting a
|
||||
// decision. Before the fix the handler dropped it, and the run hung forever
|
||||
// (the reported "stuck for 33 minutes"). Fail closed: a non-prompt allow denies.
|
||||
func TestPermissionRequestNonPromptResolvesFailClosed(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
updated, _ := m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionAllow},
|
||||
decide: decide,
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("DEADLOCK: decide was never called for a non-prompt request — the agent goroutine would park forever")
|
||||
}
|
||||
if got.Action != agent.PermissionDecisionDeny {
|
||||
t.Fatalf("decision = %q, want deny (fail closed — never silently grant an unsurfaced permission)", got.Action)
|
||||
}
|
||||
if next.pendingPermission != nil {
|
||||
t.Fatal("a non-prompt request must not open a pending prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// A superseded/stale run's request (runID mismatch) must also be resolved
|
||||
// (cancelled) so its parked goroutine unblocks instead of depending on
|
||||
// context-cancel ordering.
|
||||
func TestPermissionRequestStaleRunResolvesCancel(t *testing.T) {
|
||||
m := permissionModel(t) // activeRunID = 7
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
m.Update(permissionRequestMsg{
|
||||
runID: 3, // != activeRunID
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt},
|
||||
decide: decide,
|
||||
})
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("stale-run request was dropped without resolving — its agent goroutine would park")
|
||||
}
|
||||
if got.Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("decision = %q, want cancel for a superseded run", got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit-cancel action is honored as a cancel (not denied).
|
||||
func TestPermissionRequestCancelActionResolvesCancel(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionCancel},
|
||||
decide: decide,
|
||||
})
|
||||
if got == nil || got.Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("cancel action must resolve as cancel, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuine prompt request stores the pending prompt and does NOT resolve yet —
|
||||
// the decision comes from the user's reply. (Regression guard: the fix must not
|
||||
// accidentally auto-resolve real prompts.)
|
||||
func TestPermissionRequestPromptDefersToUser(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
resolved := false
|
||||
decide := func(agent.PermissionDecision) { resolved = true }
|
||||
|
||||
updated, _ := m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{
|
||||
ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt,
|
||||
Reason: "runs a shell command", AvailableDecisions: []agent.PermissionDecisionAction{agent.PermissionDecisionAllow},
|
||||
},
|
||||
decide: decide,
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
if resolved {
|
||||
t.Fatal("a real prompt must not be auto-resolved; it awaits the user")
|
||||
}
|
||||
if next.pendingPermission == nil {
|
||||
t.Fatal("a prompt request must open a pending permission prompt")
|
||||
}
|
||||
}
|
||||
@@ -452,11 +452,17 @@ func registryModelPickerMeta(entry modelregistry.ModelEntry) string {
|
||||
}
|
||||
|
||||
func modelPickerDisplayName(id string, description string) string {
|
||||
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
|
||||
return description
|
||||
}
|
||||
// The row title is the model NAME, not its marketing blurb. Catalog/discovery
|
||||
// descriptions (from models.dev) are full sentences, and several models share
|
||||
// the same one — using them as titles showed sentence-long rows that looked like
|
||||
// exact duplicates and hid the actual id (which only survived in the meta line).
|
||||
// Prefer the (prettified) id; fall back to a non-generic description only when
|
||||
// there is no id to name the row.
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
|
||||
return description
|
||||
}
|
||||
return "model"
|
||||
}
|
||||
name := id
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a",
|
||||
"packages": {
|
||||
".": {
|
||||
"package-name": "@gitlawb/zero",
|
||||
"release-as": "0.1.0"
|
||||
"package-name": "@gitlawb/zero"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user