mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* feat(workspacetrust): add exact-match trust store for project config gating * feat(hooks): add ExcludeProject option to LoadConfig * feat(plugins): add ExcludeProject option to Load * feat(cli): gate project hooks and plugins behind workspace trust Trust check lives inside newHookDispatcherWithExtra and activatePlugins (fail-closed by construction); trustRoot is the original launch dir so a --worktree run inherits the source repo's trust. Callers OR the skip reports and emit one combined stderr notice. * feat(cli): add zero trust command (trust/list/remove) * fix(review): key spec-draft trust on the original launch dir; cover trust CLI edge branches Spec-draft runs (zero exec --use-spec --worktree) passed the reassigned worktree path as trustRoot, dropping a trusted repo's project hooks (fail-closed, but breaks worktree-inheritance). Thread the captured pre-worktree trustRoot into execSpecDraftRun. Add tests for the trust 'remove' too-many-args and --help branches, and a resolveTrust comment noting the per-chokepoint check must not be hoisted to callers. * test(cli): end-to-end trust-gate coverage through agent.Run and exec --worktree Drives the real agent loop with a fake tool-calling provider so a project beforeTool hook actually fires (or is gated away) through production code: gate blocks a tool call via agent.Run, and --worktree / --use-spec --worktree key trust on the original launch dir. All three are load-bearing (RED when the gate or the trustRoot threading regresses). * style(cli): gofmt struct alignment in exec_spec.go * fix(workspacetrust): compare stored trust entries literally, no symlink re-resolution External adversarial review (codex) found a false positive: IsTrusted re-normalized stored entries on every check (Abs+EvalSymlinks), so a trusted path later replaced by a symlink to attacker content re-resolved to the new target and matched. Stored entries are already canonical (normalized once at Trust time); compare them as literals and only normalize the incoming query. Add a regression test for the symlink-retarget case. * feat(mcp): gate project-scoped MCP servers behind workspace trust Closes the P0 an external adversarial review (codex) found: project ./.zero/config.json stdio MCP servers spawned before the hook/plugin gate, an ungated arbitrary-execution path that could also self-trust the workspace. Add ExcludeProject to config.ResolveMCP (skips the project layer) and thread a fail-closed trust check into every MCP spawn site (exec, TUI setup+refresh, mcp tools list, mcp check); reporting sites stay ungated. Verified the sole server-command exec (mcp/client.go) is reachable only through the now-gated RegisterTools paths. * test(cli): cover default-mode beforeTool firing through the trust gate Assert a project beforeTool hook fires in the default auto and ask permission modes (not only under unsafe), and is blocked when the workspace is untrusted, so the gate is verified in the mode a normal run actually uses. * docs(cli): note MCP servers in zero trust help text and doc comment The gate covers project hooks, plugins, and MCP servers, but the help string and runTrust doc comment listed only hooks and plugins. * test(cli): make workspace-trust tests portable on macOS and Windows setTrustConfigRoot set HOME on darwin, but config.UserConfigDir is XDG-first there (=$HOME/.config), so tests wrote trust.json and user hooks.json where the code never read them; set XDG_CONFIG_HOME on every non-Windows platform instead. writeMarkerHook shells out to /bin/sh, absent on Windows, so its trusted-case marker never appeared; skip it there like writeMarkerHookScript already does. TestRunTrustAddThenList compared `trust list` output (canonicalized via EvalSymlinks) against the raw cwd, which diverges on the Windows runner's 8.3 short names (RUNNER~1 -> runneradmin); compare the resolved path, and add TestRunTrustListShowsCanonicalPath to exercise that via a symlink on any platform. * feat(cli): surface the project MCP skip in the workspace-trust notice Project MCP servers were dropped silently in an untrusted workspace: the trust notice covered hooks and plugins but not MCP, and app.go discarded the resolveTrust error. registerMCPToolsForWorkspace now returns a trustSkip, gated on a new projectMCPConfigExists check plus the previously-dropped store-read error, and emitTrustNotice is variadic so the exec, spec-draft, interactive, and `mcp tools list` paths all fold in the MCP skip. The skip drives the notice only; the gate stays on resolveTrust/excludeProject, so a trusted workspace is unaffected and an untrusted one still fails closed. * fix(cli): print zero trust --help to stdout with a success exit zero trust -h/--help/help routed usage to stderr with the usage exit code, unlike every other subcommand (mcp, sandbox, skills, cron, ...) which prints help to stdout and returns success. Align it; the unknown-subcommand path still writes to stderr with the usage exit code. * fix(cli): gate oauth MCP login and surface mcp-check trust notice Two project-scoped MCP activation sites were left outside the workspace trust gate. zero mcp oauth login loaded a project-defined OAuth server from an untrusted workspace and ran the full discovery/registration/token-exchange flow against its configured URL. Gate resolveOAuthServer with resolveTrust so an untrusted clone's ./.zero/config.json OAuth server is dropped before login, and emit the same one-line trust notice the other spawn sites use. zero mcp check already gated the project layer but discarded the trust-check result and never emitted the notice, so a gated project server read as a bare "not configured". Emit the notice on the not-found branch. The three remaining ungated resolveMCPConfig(cwd, false) sites (backends doctor/snapshot and the extensions listing) are enumeration-only and stay ungated. * test(cli): adapt MCP startup-skip tests to two-arg resolveMCPConfig --------- Co-authored-by: Kevin Codex <[email protected]>
925 lines
32 KiB
Go
925 lines
32 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
func TestRunExecHelpDocumentsProtocolFlags(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--help"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d", exitSuccess, exitCode)
|
|
}
|
|
for _, want := range []string{
|
|
"--auto",
|
|
"--enabled-tools",
|
|
"--disabled-tools",
|
|
"--list-tools",
|
|
"--input-format text|stream-json",
|
|
"--output-format text|json|stream-json",
|
|
"--resume",
|
|
"--fork",
|
|
} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("expected exec help to contain %q, got %q", want, stdout.String())
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
providerBuilt := false
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "read_file,grep"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerBuilt = true
|
|
return nil, errors.New("provider should not be constructed for --list-tools")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if providerBuilt {
|
|
t.Fatal("provider should not be constructed for --list-tools")
|
|
}
|
|
for _, want := range []string{"Tools visible to model", "read_file", "grep"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("expected tool list to contain %q, got %q", want, stdout.String())
|
|
}
|
|
}
|
|
if strings.Contains(stdout.String(), "bash") {
|
|
t.Fatalf("expected filtered tool list to hide bash, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
providerBuilt := false
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--output-format", "stream-json", "--enabled-tools", "read_file"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerBuilt = true
|
|
return nil, errors.New("provider should not be constructed for --list-tools")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if providerBuilt {
|
|
t.Fatal("provider should not be constructed for --list-tools")
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 3 {
|
|
t.Fatalf("expected run_start, final, run_end events, got %#v", events)
|
|
}
|
|
if events[0]["type"] != "run_start" || events[1]["type"] != "final" || events[2]["type"] != "run_end" {
|
|
t.Fatalf("unexpected stream-json tool list events: %#v", events)
|
|
}
|
|
text, _ := events[1]["text"].(string)
|
|
if !strings.Contains(text, "Tools visible to model") || !strings.Contains(text, "read_file") {
|
|
t.Fatalf("expected final event to contain tool list, got %#v", events[1])
|
|
}
|
|
for _, name := range []string{"bash", "grep", "write_file"} {
|
|
if strings.Contains(text, name) {
|
|
t.Fatalf("unexpected non-enabled tool %q leaked into stream-json output: %#v", name, events[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
providerBuilt := false
|
|
closed := false
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerBuilt = true
|
|
return nil, errors.New("provider should not be constructed for --list-tools")
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
registry.Register(cliFakeMCPRegistryTool{})
|
|
return closeFunc(func() error {
|
|
closed = true
|
|
return nil
|
|
}), nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if providerBuilt {
|
|
t.Fatal("provider should not be constructed for --list-tools")
|
|
}
|
|
if !closed {
|
|
t.Fatal("MCP runtime was not closed after --list-tools")
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") {
|
|
t.Fatalf("expected MCP tool in list output, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecLogsMCPRuntimeCloseError(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return nil, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
registry.Register(cliFakeMCPRegistryTool{})
|
|
return closeFunc(func() error {
|
|
return errors.New("close failed")
|
|
}), nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "mcp_close_error: close failed") {
|
|
t.Fatalf("stderr = %q, want MCP close error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsInvalidProtocolOptionsBeforeRuntime(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "auto", args: []string{"exec", "--auto", "chaos", "hello"}, want: "Invalid autonomy level"},
|
|
{name: "enabled", args: []string{"exec", "--enabled-tools", "missing_tool", "hello"}, want: "Unknown tool"},
|
|
{name: "overlap", args: []string{"exec", "--enabled-tools", "read_file", "--disabled-tools", "read_file", "hello"}, want: "both enabled and disabled"},
|
|
{name: "input", args: []string{"exec", "--input-format", "yaml", "hello"}, want: "Invalid input format"},
|
|
{name: "resume-fork", args: []string{"exec", "--resume", "abc", "--fork", "def", "hello"}, want: "Use either --resume or --fork, not both"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run(tc.args, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, tc.want) {
|
|
t.Fatalf("expected stderr to contain %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsFlagShapedMissingValues(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--model", "--output-format", "json", "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--model requires a value") {
|
|
t.Fatalf("expected missing model value error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONUsageErrorsStayInProtocol(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--input-format", "stream-json", "--output-format", "stream-json"}, &stdout, &stderr, appDeps{
|
|
stdin: strings.NewReader("{bad\n"),
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider should not be resolved for invalid stream-json input")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 2 || events[0]["type"] != "error" || events[1]["type"] != "run_end" {
|
|
t.Fatalf("expected stream-json error and run_end, got %#v", events)
|
|
}
|
|
if events[0]["code"] != "usage_error" || !strings.Contains(events[0]["message"].(string), "Invalid stream-json input") {
|
|
t.Fatalf("expected usage error event, got %#v", events[0])
|
|
}
|
|
if events[1]["exitCode"] != float64(exitUsage) {
|
|
t.Fatalf("expected usage exit code, got %#v", events[1])
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "persist this"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout.String())
|
|
types := jsonEventTypes(events)
|
|
for _, want := range []string{"run_start", "text", "final", "run_end"} {
|
|
if !slices.Contains(types, want) {
|
|
t.Fatalf("expected event %q in %v; output %q", want, types, stdout.String())
|
|
}
|
|
}
|
|
runStart := events[0]
|
|
sessionID, ok := runStart["sessionId"].(string)
|
|
if !ok || sessionID == "" {
|
|
t.Fatalf("expected run_start sessionId, got %#v", runStart)
|
|
}
|
|
if runStart["provider"] != "openai-compatible" || runStart["apiModel"] != "echo-model" {
|
|
t.Fatalf("expected resolved runtime metadata in run_start, got %#v", runStart)
|
|
}
|
|
if got := events[len(events)-1]["type"]; got != "run_end" {
|
|
t.Fatalf("expected last event run_end, got %#v", events[len(events)-1])
|
|
}
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{
|
|
RootDir: filepath.Join(dataHome, "zero", "sessions"),
|
|
})
|
|
recorded, err := store.ReadEvents(sessionID)
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
if len(recorded) != 2 || recorded[0].Type != sessions.EventMessage || recorded[1].Type != sessions.EventMessage {
|
|
t.Fatalf("recorded events = %#v", recorded)
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "write notes"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return toolCallingExecProvider{
|
|
toolCallID: "call_write",
|
|
toolName: "write_file",
|
|
arguments: `{"path":"notes.txt","content":"hello"}`,
|
|
answer: "write allowed",
|
|
}, nil
|
|
},
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout.String())
|
|
eventTypes := jsonEventTypes(events)
|
|
if slices.Contains(eventTypes, "permission_request") {
|
|
t.Fatalf("workspace write should not request permission in %v; output %q", eventTypes, stdout.String())
|
|
}
|
|
permissionEvent := findJSONEvent(t, events, "permission_decision")
|
|
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "allow" {
|
|
t.Fatalf("unexpected permission event: %#v", permissionEvent)
|
|
}
|
|
if permissionEvent["permission"] != "prompt" || permissionEvent["permissionMode"] != "auto" || permissionEvent["sideEffect"] != "write" {
|
|
t.Fatalf("unexpected permission metadata: %#v", permissionEvent)
|
|
}
|
|
if permissionEvent["permissionGranted"] == true {
|
|
t.Fatalf("workspace allow should not be recorded as user-granted permission: %#v", permissionEvent)
|
|
}
|
|
if permissionEvent["reason"] != "workspace write is allowed" {
|
|
t.Fatalf("unexpected workspace permission reason: %#v", permissionEvent)
|
|
}
|
|
risk, ok := permissionEvent["risk"].(map[string]any)
|
|
if !ok || risk["level"] == "" {
|
|
t.Fatalf("expected permission risk payload, got %#v", permissionEvent)
|
|
}
|
|
|
|
sessionID, ok := events[0]["sessionId"].(string)
|
|
if !ok || sessionID == "" {
|
|
t.Fatalf("expected run_start sessionId, got %#v", events[0])
|
|
}
|
|
store := sessions.NewStore(sessions.StoreOptions{
|
|
RootDir: filepath.Join(dataHome, "zero", "sessions"),
|
|
})
|
|
recorded, err := store.ReadEvents(sessionID)
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionDecision)
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(permissionRecord.Payload, &payload); err != nil {
|
|
t.Fatalf("decode permission payload: %v", err)
|
|
}
|
|
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "allow" {
|
|
t.Fatalf("unexpected recorded permission payload: %#v", payload)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"exec", "--skip-permissions-unsafe", "--output-format", "stream-json", "write approved"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return toolCallingExecProvider{
|
|
toolCallID: "call_write_approved",
|
|
toolName: "write_file",
|
|
arguments: `{"path":"approved.txt","content":"hello"}`,
|
|
answer: "write approved",
|
|
}, nil
|
|
},
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String())
|
|
}
|
|
if stderr.String() != "" {
|
|
t.Fatalf("approved exec wrote stderr = %q", stderr.String())
|
|
}
|
|
approvedEvents := decodeJSONLines(t, stdout.String())
|
|
approvedPermissionEvent := findJSONEvent(t, approvedEvents, "permission_decision")
|
|
if approvedPermissionEvent["id"] != "call_write_approved" || approvedPermissionEvent["action"] != "allow" {
|
|
t.Fatalf("unexpected approved permission event: %#v", approvedPermissionEvent)
|
|
}
|
|
if approvedPermissionEvent["permissionGranted"] != true {
|
|
t.Fatalf("approved permission event did not preserve permissionGranted: %#v", approvedPermissionEvent)
|
|
}
|
|
approvedSessionID, ok := approvedEvents[0]["sessionId"].(string)
|
|
if !ok || approvedSessionID == "" {
|
|
t.Fatalf("expected approved run_start sessionId, got %#v", approvedEvents[0])
|
|
}
|
|
approvedRecorded, err := store.ReadEvents(approvedSessionID)
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents approved session returned error: %v", err)
|
|
}
|
|
approvedPermissionRecord := findSessionEvent(t, approvedRecorded, sessions.EventPermissionDecision)
|
|
payload = map[string]any{}
|
|
if err := json.Unmarshal(approvedPermissionRecord.Payload, &payload); err != nil {
|
|
t.Fatalf("decode approved permission payload: %v", err)
|
|
}
|
|
if payload["permissionGranted"] != true {
|
|
t.Fatalf("approved recorded permission payload did not preserve permissionGranted: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "work",
|
|
Provider: config.ProviderProfile{
|
|
Name: "work",
|
|
Model: "sonnet-4.5",
|
|
},
|
|
MaxTurns: 2,
|
|
}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
runStart := events[0]
|
|
if runStart["provider"] != "anthropic" {
|
|
t.Fatalf("expected resolved provider kind anthropic, got %#v", runStart)
|
|
}
|
|
if runStart["model"] != "sonnet-4.5" {
|
|
t.Fatalf("expected logical model alias, got %#v", runStart)
|
|
}
|
|
if runStart["apiModel"] != "claude-sonnet-4-5-20250929" {
|
|
t.Fatalf("expected resolved API model, got %#v", runStart)
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return reasoningExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
|
|
if reasoning["delta"] != "Thinking. " {
|
|
t.Fatalf("unexpected reasoning event: %#v", reasoning)
|
|
}
|
|
textIdx, text := findJSONEventIndex(t, events, "text")
|
|
if text["delta"] != "done" {
|
|
t.Fatalf("unexpected text event: %#v", text)
|
|
}
|
|
if reasoningIdx >= textIdx {
|
|
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
|
|
}
|
|
final := findJSONEvent(t, events, "final")
|
|
if final["text"] != "done" {
|
|
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
|
|
}
|
|
}
|
|
|
|
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
writer := execEventWriter{
|
|
stdout: &stdout,
|
|
format: execOutputStreamJSON,
|
|
runID: "run_test",
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
writer.toolResult(agent.ToolResult{
|
|
ToolCallID: "call_1",
|
|
Name: "read_file",
|
|
Status: tools.StatusOK,
|
|
Output: strings.Repeat("x", streamJSONToolResultOutputLimit+100),
|
|
Meta: map[string]string{
|
|
"sandbox_backend": "bubblewrap",
|
|
"sandbox_wrapped": "true",
|
|
},
|
|
})
|
|
if writer.err != nil {
|
|
t.Fatalf("toolResult returned writer error: %v", writer.err)
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected one tool_result event, got %#v", events)
|
|
}
|
|
if events[0]["truncated"] != true {
|
|
t.Fatalf("expected truncated=true, got %#v", events[0])
|
|
}
|
|
if events[0]["name"] != "read_file" {
|
|
t.Fatalf("expected tool_result name to be read_file, got %#v", events[0])
|
|
}
|
|
meta := events[0]["meta"].(map[string]any)
|
|
if meta["sandbox_backend"] != "bubblewrap" || meta["sandbox_wrapped"] != "true" {
|
|
t.Fatalf("expected sandbox metadata, got %#v", meta)
|
|
}
|
|
output := events[0]["output"].(string)
|
|
if len(output) >= streamJSONToolResultOutputLimit+100 || !strings.Contains(output, "[truncated]") {
|
|
t.Fatalf("expected shortened output with marker, got len=%d", len(output))
|
|
}
|
|
}
|
|
|
|
func TestExecEventWriterEmitsPermissionDecisionReason(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
writer := execEventWriter{
|
|
stdout: &stdout,
|
|
format: execOutputStreamJSON,
|
|
runID: "run_test",
|
|
sessionID: "session_test",
|
|
streamedText: &strings.Builder{},
|
|
}
|
|
|
|
writer.permission(agent.PermissionEvent{
|
|
ToolCallID: "call_1",
|
|
ToolName: "write_file",
|
|
Action: agent.PermissionActionAllow,
|
|
Permission: string(tools.PermissionPrompt),
|
|
PermissionGranted: true,
|
|
PermissionMode: agent.PermissionModeAuto,
|
|
SideEffect: string(tools.SideEffectWrite),
|
|
Reason: "write access required",
|
|
DecisionReason: "approved by operator",
|
|
})
|
|
|
|
if writer.err != nil {
|
|
t.Fatalf("permission returned writer error: %v", writer.err)
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected one permission event, got %#v", events)
|
|
}
|
|
if events[0]["type"] != "permission_decision" {
|
|
t.Fatalf("expected permission_decision event, got %#v", events[0])
|
|
}
|
|
if events[0]["decisionReason"] != "approved by operator" {
|
|
t.Fatalf("expected decisionReason to be preserved, got %#v", events[0])
|
|
}
|
|
}
|
|
|
|
func TestRunExecStreamJSONProviderErrorEmitsErrorAndRunEnd(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider failed")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) != 2 {
|
|
t.Fatalf("expected error and run_end events, got %#v", events)
|
|
}
|
|
if events[0]["type"] != "error" || events[0]["code"] != "provider_error" {
|
|
t.Fatalf("expected provider error event, got %#v", events[0])
|
|
}
|
|
if events[1]["type"] != "run_end" || events[1]["exitCode"] != float64(exitProvider) {
|
|
t.Fatalf("expected run_end provider exit, got %#v", events[1])
|
|
}
|
|
if events[0]["runId"] != events[1]["runId"] {
|
|
t.Fatalf("expected matching runId, got %#v", events)
|
|
}
|
|
}
|
|
|
|
func TestRunExecJSONInterruptedEmitsTerminalEvents(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "json", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return canceledExecProvider{}, nil },
|
|
})
|
|
|
|
if exitCode != exitInterrupted {
|
|
t.Fatalf("expected interrupted exit %d, got %d: %s", exitInterrupted, exitCode, stderr.String())
|
|
}
|
|
// -o json must end the stream with a terminal error+done, not just print
|
|
// "Interrupted." to stderr (which the stream-json path already avoided).
|
|
events := decodeJSONLines(t, stdout.String())
|
|
if len(events) < 2 {
|
|
t.Fatalf("expected a terminal error+done on -o json interruption, got %#v", events)
|
|
}
|
|
errEvent := events[len(events)-2]
|
|
doneEvent := events[len(events)-1]
|
|
if errEvent["type"] != "error" || errEvent["code"] != "interrupted" {
|
|
t.Fatalf("expected interrupted error event, got %#v", errEvent)
|
|
}
|
|
if doneEvent["type"] != "done" || doneEvent["exit_code"] != float64(exitInterrupted) {
|
|
t.Fatalf("expected done event with interrupted exit code, got %#v", doneEvent)
|
|
}
|
|
}
|
|
|
|
func TestRunExecWarnsAboutScratchFileWhenProviderErrors(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
runGitForScratchTest(t, cwd, "init")
|
|
if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGitForScratchTest(t, cwd, "add", "README.md")
|
|
runGitForScratchTest(t, cwd, "commit", "-m", "init")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "write scratch then fail"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return toolThenErrorExecProvider{
|
|
toolCallID: "call_scratch",
|
|
toolName: "write_file",
|
|
arguments: `{"path":"_debug.py","content":"print('debug')"}`,
|
|
err: errors.New("provider failed after tool"),
|
|
}, nil
|
|
},
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
|
},
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider exit %d, got %d: stdout=%q stderr=%q", exitProvider, exitCode, stdout.String(), stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
warning := findJSONEvent(t, events, "warning")
|
|
message, _ := warning["message"].(string)
|
|
if !strings.Contains(message, "_debug.py") {
|
|
t.Fatalf("expected scratch warning to mention _debug.py, got %#v", warning)
|
|
}
|
|
}
|
|
|
|
func TestRunExecReadsStreamJSONPromptFromStdin(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--input-format", "stream-json", "--output-format", "stream-json"}, &stdout, &stderr, appDeps{
|
|
stdin: strings.NewReader(`{"schemaVersion":2,"type":"prompt","content":"from stdin"}` + "\n"),
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
events := decodeJSONLines(t, stdout.String())
|
|
final := events[len(events)-2]
|
|
if final["type"] != "final" || final["text"] != "from stdin" {
|
|
t.Fatalf("expected final event from stdin, got %#v", final)
|
|
}
|
|
}
|
|
|
|
type cliFakeMCPRegistryTool struct{}
|
|
|
|
func (cliFakeMCPRegistryTool) Name() string {
|
|
return "mcp_docs_lookup"
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Description() string {
|
|
return "Lookup documentation"
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Parameters() tools.Schema {
|
|
return tools.Schema{
|
|
Type: "object",
|
|
AdditionalProperties: false,
|
|
Properties: map[string]tools.PropertySchema{
|
|
"query": {Type: "string"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Safety() tools.Safety {
|
|
return tools.Safety{
|
|
SideEffect: tools.SideEffectNetwork,
|
|
Permission: tools.PermissionAllow,
|
|
Reason: "MCP test tool",
|
|
}
|
|
}
|
|
|
|
func (cliFakeMCPRegistryTool) Run(context.Context, map[string]any) tools.Result {
|
|
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
|
}
|
|
|
|
type closeFunc func() error
|
|
|
|
func (fn closeFunc) Close() error {
|
|
return fn()
|
|
}
|
|
|
|
func (fn closeFunc) Skipped() []mcp.SkippedServer {
|
|
return nil
|
|
}
|
|
|
|
type toolCallingExecProvider struct {
|
|
toolCallID string
|
|
toolName string
|
|
arguments string
|
|
answer string
|
|
}
|
|
|
|
type toolThenErrorExecProvider struct {
|
|
toolCallID string
|
|
toolName string
|
|
arguments string
|
|
err error
|
|
}
|
|
|
|
type reasoningExecProvider struct{}
|
|
|
|
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
ch := make(chan zeroruntime.StreamEvent, 3)
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
for _, message := range request.Messages {
|
|
if message.Role == zeroruntime.MessageRoleTool {
|
|
ch := make(chan zeroruntime.StreamEvent, 2)
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: provider.answer}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
}
|
|
ch := make(chan zeroruntime.StreamEvent, 4)
|
|
select {
|
|
case <-ctx.Done():
|
|
close(ch)
|
|
return ch, ctx.Err()
|
|
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: provider.toolCallID, ToolName: provider.toolName}:
|
|
}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: provider.toolCallID, ArgumentsFragment: provider.arguments}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: provider.toolCallID}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func (provider toolThenErrorExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
for _, message := range request.Messages {
|
|
if message.Role == zeroruntime.MessageRoleTool {
|
|
return nil, provider.err
|
|
}
|
|
}
|
|
ch := make(chan zeroruntime.StreamEvent, 4)
|
|
select {
|
|
case <-ctx.Done():
|
|
close(ch)
|
|
return ch, ctx.Err()
|
|
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: provider.toolCallID, ToolName: provider.toolName}:
|
|
}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: provider.toolCallID, ArgumentsFragment: provider.arguments}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: provider.toolCallID}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
|
t.Helper()
|
|
_, event := findJSONEventIndex(t, events, eventType)
|
|
return event
|
|
}
|
|
|
|
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
|
|
t.Helper()
|
|
for idx, event := range events {
|
|
if event["type"] == eventType {
|
|
return idx, event
|
|
}
|
|
}
|
|
t.Fatalf("event %q not found in %#v", eventType, events)
|
|
return -1, nil
|
|
}
|
|
|
|
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
|
t.Helper()
|
|
for _, event := range events {
|
|
if event.Type == eventType {
|
|
return event
|
|
}
|
|
}
|
|
t.Fatalf("session event %q not found in %#v", eventType, events)
|
|
return sessions.Event{}
|
|
}
|