mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8adc625d16 | ||
|
|
e4bc1f65da | ||
|
|
edf660ab85 | ||
|
|
91b413c514 | ||
|
|
021281ebba |
No files matched your search
@@ -308,6 +308,34 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
|
||||
// A connect failure does not come back as err. RegisterTools is deliberately
|
||||
// best-effort so one unreachable server cannot stop Zero launching: it records
|
||||
// the failure on the runtime and returns nil. That is right for startup and
|
||||
// wrong to ignore here, because this is the command a user runs precisely when
|
||||
// a server is misbehaving, and reporting "is reachable" for a server that never
|
||||
// started sends them looking somewhere else.
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
if skipped.Name != serverName {
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("MCP server %s is not reachable", serverName)
|
||||
if skipped.Err != nil {
|
||||
message = fmt.Sprintf("%s: %s", message, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
if options.json {
|
||||
payload := struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}{ServerName: serverName, Status: "unreachable", Error: message}
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitCrash
|
||||
}
|
||||
return writeAppError(stderr, message, exitCrash)
|
||||
}
|
||||
|
||||
items := mcpToolList(registry)
|
||||
if options.json {
|
||||
payload := struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -219,3 +220,81 @@ func TestRunMCPCheckNormalizeError(t *testing.T) {
|
||||
t.Fatalf("want normalize error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPCheckReportsUnreachableServer covers the case the command exists
|
||||
// for: a server that fails to start.
|
||||
//
|
||||
// RegisterTools is best-effort by design, so a connect failure is recorded on
|
||||
// the runtime and registration returns nil. runMCPCheck used to inspect only
|
||||
// that nil error and then print "is reachable", which told the user their
|
||||
// broken server was fine. The command is spawned for real here rather than
|
||||
// stubbed, because the whole failure mode was a mismatch between what
|
||||
// registration returns and what it records.
|
||||
func TestRunMCPCheckReportsUnreachableServer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "broken"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatalf("mcp check must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
|
||||
}
|
||||
if got := out.String(); strings.Contains(got, "is reachable") {
|
||||
t.Fatalf("mcp check reported a failed server as reachable: %q", got)
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "not reachable") {
|
||||
t.Fatalf("want an unreachable error naming the server, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The --json contract for an unreachable server, which the text case above does
|
||||
// not touch.
|
||||
//
|
||||
// The JSON branch is a separate return path with its own payload, so a caller
|
||||
// parsing it would otherwise be relying on a shape nothing pins: reverting the
|
||||
// check leaves it emitting status "ok" with a tool count, which is valid JSON
|
||||
// and completely wrong.
|
||||
func TestRunMCPCheckReportsUnreachableServerJSON(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"broken": {Type: "stdio", Command: "zero-definitely-not-a-real-binary-xyz"},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "broken", "--json"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatalf("mcp check --json must fail for a server that did not start; stdout=%q stderr=%q", out.String(), errBuf.String())
|
||||
}
|
||||
var payload struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
ToolCount int `json:"toolCount"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode mcp check --json output %q: %v", out.String(), err)
|
||||
}
|
||||
if payload.ServerName != "broken" {
|
||||
t.Fatalf("serverName = %q, want the server that was checked", payload.ServerName)
|
||||
}
|
||||
if payload.Status != "unreachable" {
|
||||
t.Fatalf("status = %q, want %q", payload.Status, "unreachable")
|
||||
}
|
||||
if !strings.Contains(payload.Error, "not reachable") {
|
||||
t.Fatalf("error = %q, want it to say the server is not reachable", payload.Error)
|
||||
}
|
||||
// A reachable payload carries a tool count; an unreachable one must not
|
||||
// imply zero tools were found, which reads as a server that simply exposes
|
||||
// none.
|
||||
if payload.ToolCount != 0 {
|
||||
t.Fatalf("toolCount = %d, want it absent from an unreachable payload", payload.ToolCount)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -386,6 +387,127 @@ func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSand
|
||||
}
|
||||
}
|
||||
|
||||
// The write jail must hold on a path whose DACL grants Everyone write access.
|
||||
//
|
||||
// A WRITE_RESTRICTED token runs TWO checks for a write and needs both to pass:
|
||||
// the normal one against its enabled SIDs, and a second against its RESTRICTED
|
||||
// SID list. The jail is built on the second check only succeeding where Zero has
|
||||
// explicitly ACL'd a capability SID. Putting the World SID (S-1-1-0) in the
|
||||
// restricted list breaks that globally — every principal is a member of
|
||||
// Everyone, so on a DACL that grants Everyone write, the restricted half passes
|
||||
// for free and confinement collapses back to the ordinary user's own
|
||||
// permissions, which is precisely the boundary the sandbox exists to be
|
||||
// stricter than.
|
||||
//
|
||||
// The runner already states this rule for the SIDs it refuses to add ("None of
|
||||
// the granted SIDs can be added to the restricted list without collapsing the
|
||||
// write jail"); Everyone was in the list anyway, since the original sandbox
|
||||
// baseline.
|
||||
//
|
||||
// Realistic rather than theoretical: administrators open share roots with
|
||||
// Everyone:F, and third-party installers ship loose ACLs. It needs no privilege,
|
||||
// no symlink and no race. Checked and ruled out: C:\Users\Public\Documents
|
||||
// grants BATCH, not Everyone, so the common "public folder" case is not this.
|
||||
func TestWindowsRestrictedTokenDeniesWritesToEveryoneWritablePaths(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||
}
|
||||
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
everyoneDir := filepath.Join(outside, "everyone-writable")
|
||||
if err := os.MkdirAll(everyoneDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll everyone-writable: %v", err)
|
||||
}
|
||||
// Granted through the production ACL applier, so the hostile DACL is built
|
||||
// the same way a real one is rather than by a test-only shortcut.
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(windowsACLPathGroup{
|
||||
Path: everyoneDir,
|
||||
Entries: []WindowsACLEntry{{
|
||||
Action: WindowsACLAllowWrite,
|
||||
Path: everyoneDir,
|
||||
Capability: "S-1-1-0",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("grant Everyone write: %v", err)
|
||||
}
|
||||
if !applied {
|
||||
t.Fatal("precondition: the Everyone grant did not apply, so there is no bypass to test for")
|
||||
}
|
||||
t.Cleanup(func() { _ = rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}) })
|
||||
|
||||
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||
// No DenyRead on purpose: that is what makes the runner choose the
|
||||
// WRITE_RESTRICTED token, which is the default posture and the one whose
|
||||
// restricted-SID list this test is about.
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
// Granted root first. A jail that denies everything would satisfy the two
|
||||
// assertions below while being useless, and this separates the two outcomes.
|
||||
insideMarker := filepath.Join(root, "write-ok.txt")
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", "echo ok>" + insideMarker,
|
||||
}, 0)
|
||||
if contents, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(contents)) != "ok" {
|
||||
t.Fatalf("sandboxed write to a granted root = %q, %v; want ok", contents, err)
|
||||
}
|
||||
|
||||
// Control: an ordinary directory outside every granted root. This is the
|
||||
// behaviour the Everyone case must match.
|
||||
controlMarker := filepath.Join(outside, "control-denied.txt")
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, deniedWriteCommand(controlMarker), deniedWriteExitCode)
|
||||
if _, err := os.Stat(controlMarker); err == nil {
|
||||
t.Fatal("precondition: the sandbox allowed a write to an ordinary path outside every granted root, so this test cannot measure the Everyone case")
|
||||
}
|
||||
|
||||
// The assertion. Same as the control in every respect except the DACL.
|
||||
everyoneMarker := filepath.Join(everyoneDir, "everyone-denied.txt")
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, deniedWriteCommand(everyoneMarker), deniedWriteExitCode)
|
||||
if _, err := os.Stat(everyoneMarker); err == nil {
|
||||
t.Error("the sandbox wrote outside every granted root because the path grants Everyone write; the restricted-SID list is satisfied by a SID every principal carries")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Errorf("stat the Everyone-writable marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// deniedWriteExitCode is chosen so a denial cannot be confused with the runner
|
||||
// failing to start the command at all.
|
||||
//
|
||||
// The obvious spelling of these assertions is `echo leaked>path` expecting exit
|
||||
// 1, but the runner itself exits 1 for its own errors — a failed marker
|
||||
// validation, a bad argument, a token it could not build. A test written that
|
||||
// way passes both when the sandbox denies the write and when nothing ever ran,
|
||||
// and the second case proves nothing while looking identical to success. On a
|
||||
// test whose whole job is catching a confinement regression, that is a failure
|
||||
// mode to design out rather than hope about.
|
||||
//
|
||||
// 77 is arbitrary beyond being outside the range the runner produces for itself.
|
||||
const deniedWriteExitCode = 77
|
||||
|
||||
// deniedWriteCommand attempts a write and reports deniedWriteExitCode when the
|
||||
// redirect is refused, so the exit code also proves cmd.exe actually ran.
|
||||
func deniedWriteCommand(marker string) []string {
|
||||
return []string{"cmd.exe", "/d", "/s", "/c", "echo leaked>" + marker + " || exit " + strconv.Itoa(deniedWriteExitCode)}
|
||||
}
|
||||
|
||||
func powershellSingleQuote(value string) string {
|
||||
out := "'"
|
||||
for _, r := range value {
|
||||
|
||||
@@ -88,19 +88,55 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create world SID: %w", err)
|
||||
}
|
||||
|
||||
entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+2)
|
||||
for _, sid := range capabilitySIDs {
|
||||
entries = append(entries, windows.SIDAndAttributes{Sid: sid.sid})
|
||||
}
|
||||
entries = append(entries,
|
||||
windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)},
|
||||
windows.SIDAndAttributes{Sid: worldSID},
|
||||
)
|
||||
entries = append(entries, windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)})
|
||||
|
||||
// The World SID (S-1-1-0, Everyone) is added ONLY to the token that does not
|
||||
// carry WRITE_RESTRICTED, and putting it back unconditionally would reopen a
|
||||
// write-jail bypass.
|
||||
//
|
||||
// A restricted SID is a key to every object whose DACL names it. That is why
|
||||
// the runner refuses to add the user SID, Administrators or SYSTEM — "each
|
||||
// has write access nearly everywhere". Everyone is the broadest of the lot:
|
||||
// every principal carries it, so under WRITE_RESTRICTED the second
|
||||
// (restricted-SID) check passes for free on any path whose DACL grants
|
||||
// Everyone write, and confinement falls back to the ordinary user's own
|
||||
// permissions — the exact boundary this token exists to be stricter than. It
|
||||
// needs no privilege, no symlink and no race: an Everyone-writable directory
|
||||
// is enough, and share roots opened with Everyone:F and loose installer ACLs
|
||||
// supply them. It was present from the original sandbox baseline,
|
||||
// uncommented, and under WRITE_RESTRICTED nothing depends on it, because that
|
||||
// flag already exempts reads from the restricted-SID check.
|
||||
//
|
||||
// Without the flag it IS load-bearing and cannot simply be dropped. The
|
||||
// restricted-SID check then applies to READS too, and default Windows DACLs
|
||||
// grant BUILTIN\Users rather than anything in this list, so a token without
|
||||
// Everyone cannot open cmd.exe — the process dies at launch with
|
||||
// STATUS_ACCESS_DENIED (0xC0000022) before it runs anything. That path is
|
||||
// only taken when the profile configures DenyRead, which is already the
|
||||
// posture that trades capability for read-deny enforcement (#612).
|
||||
//
|
||||
// So the bypass survives for DenyRead profiles, deliberately and narrowly,
|
||||
// rather than being traded for a sandbox that cannot start a command. Closing
|
||||
// it there needs a different mechanism (a read-side grant that is not a
|
||||
// universal group), tracked separately.
|
||||
//
|
||||
// The logon SID above stays in both modes: it is this token's own rather than
|
||||
// a broad group, and broadenWindowsRestrictedTokenDefaultDacl depends on it so
|
||||
// the process can use pipes and events it creates for itself.
|
||||
//
|
||||
// Anything added here needs the same scrutiny — Authenticated Users, Users,
|
||||
// INTERACTIVE and BATCH would each produce this bypass on a DACL naming them.
|
||||
if !writeRestricted {
|
||||
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create world SID: %w", err)
|
||||
}
|
||||
entries = append(entries, windows.SIDAndAttributes{Sid: worldSID})
|
||||
}
|
||||
|
||||
// WRITE_RESTRICTED scopes the restricted-SID check to write-type accesses:
|
||||
// reads use only the normal token identity, so the sandboxed process can
|
||||
|
||||
@@ -8,6 +8,12 @@ import (
|
||||
|
||||
const commandReducerMinPassingLines = 12
|
||||
|
||||
type commandOutputReducer struct {
|
||||
kind string
|
||||
label string
|
||||
reduce func(string) (string, int)
|
||||
}
|
||||
|
||||
// reduceCommandOutput removes repetitive success-only lines from recognized
|
||||
// simple commands. Unsupported or compound shell input passes through exactly,
|
||||
// and every changed result retains the pre-reduction output as an artifact.
|
||||
@@ -16,11 +22,15 @@ func reduceCommandOutput(toolName string, args map[string]any, result Result) Re
|
||||
return result
|
||||
}
|
||||
command, err := commandTextForReducer(toolName, args)
|
||||
if err != nil || !isSimpleGoTestCommand(command) {
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
reducer, ok := commandOutputReducerFor(command)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
|
||||
reduced, omitted := reduceGoTestPassingPackages(result.Output)
|
||||
reduced, omitted := reducer.reduce(result.Output)
|
||||
if omitted < commandReducerMinPassingLines || len(reduced) >= len(result.Output) {
|
||||
return result
|
||||
}
|
||||
@@ -31,13 +41,14 @@ func reduceCommandOutput(toolName string, args map[string]any, result Result) Re
|
||||
return result
|
||||
}
|
||||
|
||||
notice := fmt.Sprintf("[zero] compacted %d passing package lines; exact output saved to %s", omitted, spillPath)
|
||||
notice := fmt.Sprintf("[zero] compacted %d %s; exact output saved to %s", omitted, reducer.label, spillPath)
|
||||
result.Output = strings.TrimRight(reduced, "\n") + "\n" + notice
|
||||
result.Truncated = true
|
||||
if result.Meta == nil {
|
||||
result.Meta = map[string]string{}
|
||||
}
|
||||
result.Meta["command_output_reduced"] = "true"
|
||||
result.Meta["command_output_reducer"] = reducer.kind
|
||||
result.Meta["command_output_omitted_lines"] = strconv.Itoa(omitted)
|
||||
result.Meta["command_output_original_bytes"] = strconv.Itoa(originalBytes)
|
||||
result.Meta["command_output_original_tokens"] = strconv.Itoa(originalTokens)
|
||||
@@ -54,23 +65,207 @@ func commandTextForReducer(toolName string, args map[string]any) (string, error)
|
||||
return execCommandArg(args)
|
||||
}
|
||||
|
||||
func isSimpleGoTestCommand(command string) bool {
|
||||
func simpleCommandFields(command string) ([]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if separator := strings.LastIndexAny(command, " \t"); separator >= 0 && command[separator+1:] == "2>&1" {
|
||||
command = strings.TrimSpace(command[:separator])
|
||||
}
|
||||
if command == "" || strings.ContainsAny(command, "|;&<>\n\r`") || strings.Contains(command, "$(") {
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
fields := strings.Fields(command)
|
||||
return len(fields) >= 2 && fields[0] == "go" && fields[1] == "test"
|
||||
if len(fields) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return fields, true
|
||||
}
|
||||
|
||||
func commandOutputReducerFor(command string) (commandOutputReducer, bool) {
|
||||
fields, ok := simpleCommandFields(command)
|
||||
if !ok {
|
||||
return commandOutputReducer{}, false
|
||||
}
|
||||
if len(fields) >= 2 && fields[0] == "go" && fields[1] == "test" {
|
||||
return commandOutputReducer{kind: "go_test", label: "passing package lines", reduce: reduceGoTestPassingPackages}, true
|
||||
}
|
||||
if len(fields) >= 2 && fields[0] == "cargo" {
|
||||
switch fields[1] {
|
||||
case "test":
|
||||
return commandOutputReducer{kind: "cargo_test", label: "passing Rust test lines", reduce: reduceCargoTestPassingLines}, true
|
||||
case "build", "check":
|
||||
return commandOutputReducer{kind: "cargo_build", label: "successful Rust build progress lines", reduce: reduceCargoBuildProgressLines}, true
|
||||
}
|
||||
}
|
||||
if isPytestCommand(fields) {
|
||||
return commandOutputReducer{kind: "pytest", label: "passing pytest progress lines", reduce: reducePytestPassingLines}, true
|
||||
}
|
||||
if isJavaScriptTestCommand(fields) {
|
||||
return commandOutputReducer{kind: "javascript_test", label: "passing JavaScript test-file lines", reduce: reduceJavaScriptTestPassingLines}, true
|
||||
}
|
||||
return commandOutputReducer{}, false
|
||||
}
|
||||
|
||||
func isPytestCommand(fields []string) bool {
|
||||
if fields[0] == "pytest" || fields[0] == "py.test" {
|
||||
return true
|
||||
}
|
||||
return len(fields) >= 3 && (fields[0] == "python" || fields[0] == "python3") && fields[1] == "-m" && fields[2] == "pytest"
|
||||
}
|
||||
|
||||
func isJavaScriptTestCommand(fields []string) bool {
|
||||
switch fields[0] {
|
||||
case "jest", "vitest":
|
||||
return true
|
||||
case "npx":
|
||||
return len(fields) >= 2 && (fields[1] == "jest" || fields[1] == "vitest")
|
||||
case "npm", "pnpm", "yarn":
|
||||
if len(fields) >= 2 && fields[1] == "test" {
|
||||
return true
|
||||
}
|
||||
if len(fields) >= 3 && fields[1] == "run" && fields[2] == "test" {
|
||||
return true
|
||||
}
|
||||
return len(fields) >= 3 && fields[1] == "exec" && (fields[2] == "jest" || fields[2] == "vitest")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func reduceGoTestPassingPackages(output string) (string, int) {
|
||||
return reduceRepeatedOutputLines(output, "passing package lines", func(line string) bool {
|
||||
packageSuccess := strings.HasPrefix(line, "ok \t") || strings.HasPrefix(line, "? \t") ||
|
||||
strings.HasPrefix(line, "ok ") || strings.HasPrefix(line, "? ")
|
||||
return packageSuccess && !strings.Contains(line, "coverage:") && !strings.Contains(line, "[no test files]")
|
||||
})
|
||||
}
|
||||
|
||||
func reduceCargoTestPassingLines(output string) (string, int) {
|
||||
if !strings.Contains(output, "test result:") {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "passing Rust test lines", func(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return strings.HasPrefix(trimmed, "test ") && strings.HasSuffix(trimmed, " ... ok")
|
||||
})
|
||||
}
|
||||
|
||||
func reduceCargoBuildProgressLines(output string) (string, int) {
|
||||
if !outputHasCargoFinishedSummary(output) {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "successful Rust build progress lines", func(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return strings.HasPrefix(trimmed, "Checking ") || strings.HasPrefix(trimmed, "Compiling ")
|
||||
})
|
||||
}
|
||||
|
||||
func reducePytestPassingLines(output string) (string, int) {
|
||||
if !outputHasPytestSummary(output) {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "passing pytest progress lines", isPassingPytestProgressLine)
|
||||
}
|
||||
|
||||
func isPassingPytestProgressLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
open := strings.LastIndexByte(trimmed, '[')
|
||||
if open < 0 || !strings.HasSuffix(trimmed, "%]") {
|
||||
return false
|
||||
}
|
||||
progress := strings.TrimSpace(trimmed[:open])
|
||||
fields := strings.Fields(progress)
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
marks := fields[len(fields)-1]
|
||||
return marks != "" && strings.Trim(marks, ".") == ""
|
||||
}
|
||||
|
||||
func reduceJavaScriptTestPassingLines(output string) (string, int) {
|
||||
if !outputHasJavaScriptTestSummary(output) {
|
||||
return output, 0
|
||||
}
|
||||
return reduceRepeatedOutputLines(output, "passing JavaScript test-file lines", func(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return strings.HasPrefix(line, "PASS ") || strings.HasPrefix(trimmed, "✓ ") || strings.HasPrefix(trimmed, "✔ ")
|
||||
})
|
||||
}
|
||||
|
||||
func outputHasCargoFinishedSummary(output string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "Finished `") && strings.Contains(trimmed, "` profile ") &&
|
||||
strings.Contains(trimmed, " target(s) in ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func outputHasPytestSummary(output string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
summary := strings.Trim(strings.TrimSpace(line), "= ")
|
||||
if summary == "short test summary info" || isPytestResultSummary(summary) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPytestResultSummary(summary string) bool {
|
||||
fields := strings.Fields(summary)
|
||||
if len(fields) < 4 {
|
||||
return false
|
||||
}
|
||||
first := strings.Trim(fields[0], ",;:()")
|
||||
if _, err := strconv.Atoi(first); err != nil || !summaryHasCountedStatus(summary, "passed") {
|
||||
return false
|
||||
}
|
||||
for index := 1; index+1 < len(fields); index++ {
|
||||
if fields[index] != "in" {
|
||||
continue
|
||||
}
|
||||
duration := strings.TrimSuffix(strings.Trim(fields[index+1], ",;:()"), "s")
|
||||
if _, err := strconv.ParseFloat(duration, 64); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func outputHasJavaScriptTestSummary(output string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "Test Suites:") && summaryHasCountedStatus(trimmed, "passed") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Test Files ") && summaryHasCountedStatus(trimmed, "passed") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func summaryHasCountedStatus(summary string, status string) bool {
|
||||
fields := strings.Fields(summary)
|
||||
for index := 1; index < len(fields); index++ {
|
||||
if strings.Trim(fields[index], ",;:()") != status {
|
||||
continue
|
||||
}
|
||||
count := strings.Trim(fields[index-1], ",;:()")
|
||||
if _, err := strconv.Atoi(count); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func reduceRepeatedOutputLines(output string, label string, omit func(string) bool) (string, int) {
|
||||
lines := strings.Split(output, "\n")
|
||||
kept := make([]string, 0, len(lines))
|
||||
omitted := 0
|
||||
for _, line := range lines {
|
||||
packageSuccess := strings.HasPrefix(line, "ok \t") || strings.HasPrefix(line, "? \t") ||
|
||||
strings.HasPrefix(line, "ok ") || strings.HasPrefix(line, "? ")
|
||||
if packageSuccess && !strings.Contains(line, "coverage:") && !strings.Contains(line, "[no test files]") {
|
||||
if omit(line) {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
@@ -87,7 +282,7 @@ func reduceGoTestPassingPackages(output string) (string, int) {
|
||||
break
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("[zero] %d passing package lines omitted", omitted)
|
||||
summary := fmt.Sprintf("[zero] %d %s omitted", omitted, label)
|
||||
kept = append(kept, "")
|
||||
copy(kept[insertAt+1:], kept[insertAt:])
|
||||
kept[insertAt] = summary
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type commandReducerCorpusCase struct {
|
||||
name string
|
||||
command string
|
||||
output string
|
||||
want string
|
||||
}
|
||||
|
||||
func commandReducerCorpus() []commandReducerCorpusCase {
|
||||
goLines := []string{"output:"}
|
||||
cargoTestLines := []string{"output:", "running 24 tests"}
|
||||
cargoCheckLines := []string{"output:"}
|
||||
pytestLines := []string{"output:"}
|
||||
vitestLines := []string{"output:", " RUN v3.2.4 /workspace"}
|
||||
jestLines := []string{"output:"}
|
||||
for index := 0; index < 24; index++ {
|
||||
goLines = append(goLines, fmt.Sprintf("ok \texample.test/pkg%d\t0.01s", index))
|
||||
cargoTestLines = append(cargoTestLines, fmt.Sprintf("test module::case_%02d ... ok", index))
|
||||
cargoCheckLines = append(cargoCheckLines, fmt.Sprintf(" Checking dependency_%02d v1.0.%d", index, index))
|
||||
pytestLines = append(pytestLines, fmt.Sprintf("tests/test_module_%02d.py ........ [%3d%%]", index, (index+1)*100/24))
|
||||
vitestLines = append(vitestLines, fmt.Sprintf(" ✓ src/module_%02d.test.ts (8 tests) 12ms", index))
|
||||
jestLines = append(jestLines, fmt.Sprintf("PASS src/module_%02d.test.ts", index))
|
||||
}
|
||||
goLines = append(goLines, "exit_code: 0")
|
||||
cargoTestLines = append(cargoTestLines,
|
||||
"test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
cargoCheckLines = append(cargoCheckLines,
|
||||
" Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.41s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
pytestLines = append(pytestLines,
|
||||
"============================= 192 passed in 3.21s =============================",
|
||||
"exit_code: 0",
|
||||
)
|
||||
vitestLines = append(vitestLines,
|
||||
" Test Files 24 passed (24)",
|
||||
" Tests 192 passed (192)",
|
||||
" Duration 3.21s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
jestLines = append(jestLines,
|
||||
"Test Suites: 24 passed, 24 total",
|
||||
"Tests: 192 passed, 192 total",
|
||||
"Time: 3.21 s",
|
||||
"exit_code: 0",
|
||||
)
|
||||
return []commandReducerCorpusCase{
|
||||
{name: "go_test", command: "go test ./...", output: strings.Join(goLines, "\n"), want: "exit_code: 0"},
|
||||
{name: "cargo_test", command: "cargo test --workspace", output: strings.Join(cargoTestLines, "\n"), want: "24 passed"},
|
||||
{name: "cargo_check", command: "cargo check --workspace", output: strings.Join(cargoCheckLines, "\n"), want: "Finished `dev` profile"},
|
||||
{name: "pytest", command: "pytest -q", output: strings.Join(pytestLines, "\n"), want: "192 passed"},
|
||||
{name: "npm_vitest", command: "npm test", output: strings.Join(vitestLines, "\n"), want: "192 passed"},
|
||||
{name: "pnpm_vitest", command: "pnpm test", output: strings.Join(vitestLines, "\n"), want: "192 passed"},
|
||||
{name: "yarn_jest", command: "yarn test", output: strings.Join(jestLines, "\n"), want: "192 passed"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandOutputReductionCorpusMetrics(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
totalRawTokens := 0
|
||||
totalModelTokens := 0
|
||||
for _, testCase := range commandReducerCorpus() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{
|
||||
Status: StatusOK,
|
||||
Output: testCase.output,
|
||||
})
|
||||
if !strings.Contains(result.Output, testCase.want) {
|
||||
t.Fatalf("decisive summary %q was lost: %q", testCase.want, result.Output)
|
||||
}
|
||||
if spillPath := result.Meta["spill_path"]; spillPath != "" {
|
||||
spilled, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw artifact: %v", err)
|
||||
}
|
||||
if string(spilled) != testCase.output {
|
||||
t.Fatal("raw artifact differs from original output")
|
||||
}
|
||||
}
|
||||
rawTokens := estimateOutputTokens(testCase.output)
|
||||
modelTokens := estimateOutputTokens(result.Output)
|
||||
totalRawTokens += rawTokens
|
||||
totalModelTokens += modelTokens
|
||||
reduction := 100 * (rawTokens - modelTokens) / rawTokens
|
||||
t.Logf("raw_bytes=%d model_bytes=%d raw_tokens=%d model_tokens=%d reduction_pct=%d reduced=%t",
|
||||
len(testCase.output), len(result.Output), rawTokens, modelTokens, reduction, result.Meta["command_output_reduced"] == "true")
|
||||
})
|
||||
}
|
||||
if totalRawTokens == 0 {
|
||||
t.Fatal("empty reducer corpus")
|
||||
}
|
||||
if totalModelTokens >= totalRawTokens {
|
||||
t.Fatalf("aggregate reducer output did not save tokens: raw=%d model=%d", totalRawTokens, totalModelTokens)
|
||||
}
|
||||
t.Logf("aggregate raw_tokens=%d model_tokens=%d reduction_pct=%d",
|
||||
totalRawTokens, totalModelTokens, 100*(totalRawTokens-totalModelTokens)/totalRawTokens)
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type commandReducerFixtureTool struct {
|
||||
name string
|
||||
output string
|
||||
}
|
||||
|
||||
func (tool commandReducerFixtureTool) Name() string { return tool.name }
|
||||
func (tool commandReducerFixtureTool) Description() string {
|
||||
return "returns command output for reducer integration tests"
|
||||
}
|
||||
func (tool commandReducerFixtureTool) Parameters() Schema {
|
||||
return Schema{Type: "object", AdditionalProperties: true}
|
||||
}
|
||||
func (tool commandReducerFixtureTool) Safety() Safety {
|
||||
return Safety{SideEffect: SideEffectRead, Permission: PermissionAllow, Reason: "test output"}
|
||||
}
|
||||
func (tool commandReducerFixtureTool) Run(context.Context, map[string]any) Result {
|
||||
return Result{Status: StatusOK, Output: tool.output}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputCompactsPassingGoPackagesAndKeepsRawArtifact(t *testing.T) {
|
||||
var lines []string
|
||||
lines = append(lines, "output:")
|
||||
@@ -131,3 +151,167 @@ func TestSelfManagedBudgetPreservesRawSpillWhenReducedOutputAlsoTruncates(t *tes
|
||||
t.Fatalf("raw spill was not preserved: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputCompactsRecognizedTestAndBuildNoise(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
for _, testCase := range commandReducerCorpus() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{
|
||||
Status: StatusOK,
|
||||
Output: testCase.output,
|
||||
})
|
||||
if result.Meta["command_output_reduced"] != "true" {
|
||||
t.Fatalf("recognized command output was not reduced: %#v", result)
|
||||
}
|
||||
if !strings.Contains(result.Output, testCase.want) || !strings.Contains(result.Output, "exit_code: 0") {
|
||||
t.Fatalf("decisive summary or exit status was lost: %q", result.Output)
|
||||
}
|
||||
spillPath := result.Meta["spill_path"]
|
||||
spilled, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw artifact: %v", err)
|
||||
}
|
||||
if string(spilled) != testCase.output {
|
||||
t.Fatal("raw artifact differs from original output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputPreservesFailureEvidence(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
passing := make([]string, 0, commandReducerMinPassingLines+2)
|
||||
for index := 0; index < commandReducerMinPassingLines+2; index++ {
|
||||
passing = append(passing, fmt.Sprintf("test module::case_%02d ... ok", index))
|
||||
}
|
||||
original := strings.Join(append(passing,
|
||||
"test module::broken ... FAILED",
|
||||
"failures:",
|
||||
"---- module::broken stdout ----",
|
||||
"assertion failed: expected zero",
|
||||
"test result: FAILED. 14 passed; 1 failed; finished in 0.08s",
|
||||
"exit_code: 101",
|
||||
), "\n")
|
||||
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": "cargo test --workspace"}, Result{
|
||||
Status: StatusError,
|
||||
Output: original,
|
||||
})
|
||||
for _, evidence := range []string{"module::broken ... FAILED", "assertion failed: expected zero", "14 passed; 1 failed", "exit_code: 101"} {
|
||||
if !strings.Contains(result.Output, evidence) {
|
||||
t.Fatalf("failure evidence %q was lost: %q", evidence, result.Output)
|
||||
}
|
||||
}
|
||||
if result.Meta["command_output_reduced"] != "true" {
|
||||
t.Fatalf("repetitive passing output was not reduced: %#v", result)
|
||||
}
|
||||
spillPath := result.Meta["spill_path"]
|
||||
if spillPath == "" {
|
||||
t.Fatal("reduced failure output did not retain a raw artifact")
|
||||
}
|
||||
spilled, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw artifact: %v", err)
|
||||
}
|
||||
if string(spilled) != original {
|
||||
t.Fatal("raw artifact differs from original output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputPassesThroughUnsupportedAndCompoundCommands(t *testing.T) {
|
||||
outputs := make(map[string]string)
|
||||
for _, fixture := range commandReducerCorpus() {
|
||||
outputs[fixture.name] = fixture.output
|
||||
}
|
||||
testCases := []struct {
|
||||
command string
|
||||
output string
|
||||
}{
|
||||
{command: "npm test | tee test.log", output: outputs["npm_vitest"]},
|
||||
{command: "cargo test && echo done", output: outputs["cargo_test"]},
|
||||
{command: "pytest; notify-send done", output: outputs["pytest"]},
|
||||
{command: "cargo test 1>test.log", output: outputs["cargo_test"]},
|
||||
{command: "cargo test 2>>errors.log", output: outputs["cargo_test"]},
|
||||
{command: "git status --short", output: outputs["npm_vitest"]},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{Status: StatusOK, Output: testCase.output})
|
||||
if result.Output != testCase.output || result.Meta != nil {
|
||||
t.Fatalf("command %q should pass through exactly, got %#v", testCase.command, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputAllowsTrailingStderrMerge(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
fixture := commandReducerCorpus()[1]
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": fixture.command + " 2>&1"}, Result{
|
||||
Status: StatusOK,
|
||||
Output: fixture.output,
|
||||
})
|
||||
if result.Meta["command_output_reducer"] != "cargo_test" {
|
||||
t.Fatalf("trailing stderr merge prevented reduction: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputRequiresAuthoritativeRunnerSummary(t *testing.T) {
|
||||
lookalikes := map[string]string{
|
||||
"cargo test": "test user::message ... ok",
|
||||
"cargo check": "Checking a custom deployment state",
|
||||
"pytest": "application log ........ [100%]",
|
||||
"npm test": "PASS user-provided log message",
|
||||
}
|
||||
for command, line := range lookalikes {
|
||||
original := strings.Repeat(line+"\n", commandReducerMinPassingLines+2) + "exit_code: 0"
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": command}, Result{Status: StatusOK, Output: original})
|
||||
if result.Output != original || result.Meta != nil {
|
||||
t.Fatalf("summary-free output for %q should pass through, got %#v", command, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceCommandOutputRejectsLookalikeRunnerSummaries(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
testCases := []struct {
|
||||
command string
|
||||
line string
|
||||
summary string
|
||||
}{
|
||||
{command: "cargo check", line: "Compiling dependency v1.0.0", summary: "Finished generating bindings"},
|
||||
{command: "pytest", line: "tests/test_module.py ........ [100%]", summary: "application log: 14 passed validation in staging"},
|
||||
{command: "npm test", line: "PASS src/example.test.ts", summary: "Test Files discovered: 14"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
original := strings.Repeat(testCase.line+"\n", commandReducerMinPassingLines+2) + testCase.summary + "\nexit_code: 1"
|
||||
result := reduceCommandOutput(ExecCommandToolName, map[string]any{"cmd": testCase.command}, Result{Status: StatusError, Output: original})
|
||||
if result.Output != original || result.Meta != nil {
|
||||
t.Fatalf("lookalike summary for %q should pass through exactly, got %#v", testCase.command, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAppliesCommandReducerToExecAndBashTools(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
fixture := commandReducerCorpus()[1]
|
||||
for _, toolName := range []string{ExecCommandToolName, "bash"} {
|
||||
t.Run(toolName, func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(commandReducerFixtureTool{name: toolName, output: fixture.output})
|
||||
args := map[string]any{"cmd": fixture.command}
|
||||
if toolName == "bash" {
|
||||
args = map[string]any{"command": fixture.command}
|
||||
}
|
||||
result := registry.Run(context.Background(), toolName, args)
|
||||
if result.Status != StatusOK || result.Meta["command_output_reducer"] != "cargo_test" {
|
||||
t.Fatalf("registry result was not reduced: %#v", result)
|
||||
}
|
||||
if !strings.Contains(result.Output, fixture.want) {
|
||||
t.Fatalf("registry reduction lost summary: %q", result.Output)
|
||||
}
|
||||
spilled, err := os.ReadFile(result.Meta["spill_path"])
|
||||
if err != nil || string(spilled) != fixture.output {
|
||||
t.Fatalf("registry raw artifact mismatch: err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+9
-17
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -2788,23 +2789,14 @@ func (m model) View() tea.View {
|
||||
// only when the value changes, so gating this costs nothing (§10).
|
||||
view.KeyboardEnhancements.ReportEventTypes = m.dictation.voiceModeEnabled
|
||||
if m.wantsMouseCapture() {
|
||||
if isRunningUnderPRoot() {
|
||||
// Under PRoot the AllMotion (1003) sequence doesn't work
|
||||
// reliably, breaking touch-gesture scrolling. Fall back to
|
||||
// CellMotion which still delivers wheel events, clicks, and
|
||||
// drag — the only thing lost is hover-highlighting.
|
||||
view.MouseMode = tea.MouseModeCellMotion
|
||||
} else {
|
||||
// AllMotion (not CellMotion) is required for hover highlighting:
|
||||
// it reports cursor movement even with no button pressed.
|
||||
// CellMotion only reports motion while a button is held (drag) —
|
||||
// see bubbletea's MouseMode docs. AllMotion has marginally worse
|
||||
// terminal compatibility but is well supported by the terminals
|
||||
// this app targets; the existing 15ms mouse-event throttle
|
||||
// (mouseEventThrottleInterval) already bounds the redraw rate
|
||||
// from the extra motion events.
|
||||
view.MouseMode = tea.MouseModeAllMotion
|
||||
}
|
||||
// AllMotion (1003) is what hover highlighting needs: it reports cursor
|
||||
// movement with no button pressed, where CellMotion (1002) reports motion
|
||||
// only while a button is held. mouseModeFor decides which is safe here,
|
||||
// and documents why one is not simply better than the other: a terminal
|
||||
// that does not implement 1003 sends nothing at all rather than degrading.
|
||||
// The 15ms throttle (mouseEventThrottleInterval) already bounds the redraw
|
||||
// rate from AllMotion's extra motion events.
|
||||
view.MouseMode = mouseModeFor(runtime.GOOS, os.Getenv, isRunningUnderPRoot())
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -8,6 +8,70 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// mouseModeEnv forces a mouse mode: "all", "cell", or "off"/"none". It exists
|
||||
// because the failure it guards against is invisible from here and total for the
|
||||
// user: a terminal we guessed wrong about delivers no mouse events at all, and
|
||||
// there is no in-app way to recover. Anything unrecognised is ignored rather
|
||||
// than treated as "off", so a typo cannot silently kill the mouse.
|
||||
const mouseModeEnv = "ZERO_MOUSE_MODE"
|
||||
|
||||
// mouseModeFor picks the mouse reporting mode, preferring the widest support
|
||||
// over the nicest behaviour.
|
||||
//
|
||||
// The trade is asymmetric. AllMotion (1003) buys exactly one thing, hover
|
||||
// highlighting, because wheel, click and drag all arrive under CellMotion
|
||||
// (1002). But when a terminal does not implement 1003, it does not degrade to
|
||||
// 1002: no mouse event arrives at all. The app holds the alternate screen, so
|
||||
// the terminal's own scrollback and wheel are gone too, and the user is left
|
||||
// with no way to scroll by mouse. A long answer then looks like a hang rather
|
||||
// than like a missing highlight, which is how #870 was reported.
|
||||
//
|
||||
// So AllMotion is asked for only where it is known to work.
|
||||
func mouseModeFor(goos string, env func(string) string, underPRoot bool) tea.MouseMode {
|
||||
switch strings.ToLower(strings.TrimSpace(env(mouseModeEnv))) {
|
||||
case "all":
|
||||
return tea.MouseModeAllMotion
|
||||
case "cell":
|
||||
return tea.MouseModeCellMotion
|
||||
case "off", "none":
|
||||
return tea.MouseModeNone
|
||||
}
|
||||
if underPRoot {
|
||||
return tea.MouseModeCellMotion
|
||||
}
|
||||
if goos == "windows" && !windowsTerminalReportsAllMotion(env) {
|
||||
return tea.MouseModeCellMotion
|
||||
}
|
||||
return tea.MouseModeAllMotion
|
||||
}
|
||||
|
||||
// windowsTerminalReportsAllMotion reports whether the Windows terminal in use is
|
||||
// one that handles 1003.
|
||||
//
|
||||
// Identified terminals are trusted: Windows Terminal sets WT_SESSION, and hosts
|
||||
// like VS Code and mintty set TERM_PROGRAM. The legacy console host sets
|
||||
// neither, and it is what a user gets by double-clicking zero.exe or running it
|
||||
// from an old cmd window, so an unidentified Windows terminal is assumed to be
|
||||
// that one. Guessing wrong in this direction costs a hover highlight; guessing
|
||||
// wrong in the other direction costs every mouse event.
|
||||
// KNOWN LIMIT: these variables are INHERITED, so they answer for the process
|
||||
// that set them rather than for the console host attached right now. A shell
|
||||
// launched from Windows Terminal, or from Git Bash, that later runs zero.exe
|
||||
// against the legacy console still carries them, and this returns true for a
|
||||
// host that may drop every mouse event.
|
||||
//
|
||||
// Left as is on purpose. Deciding it properly means asking the console host
|
||||
// rather than the environment, and erring the other way costs every Windows
|
||||
// Terminal user their hover highlighting for a case that needs an unusual
|
||||
// launch path to reach. ZERO_MOUSE_MODE=cell is the recourse in the meantime,
|
||||
// which is the main reason that override exists.
|
||||
// TestInheritedWindowsTerminalEnvStillAsksForAllMotion pins this, so it is a
|
||||
// documented limit rather than a surprise.
|
||||
func windowsTerminalReportsAllMotion(env func(string) string) bool {
|
||||
return strings.TrimSpace(env("WT_SESSION")) != "" ||
|
||||
strings.TrimSpace(env("TERM_PROGRAM")) != ""
|
||||
}
|
||||
|
||||
// parseTracerPid returns true when /proc/self/status contains a non-zero
|
||||
// TracerPid. This detects any ptrace tracer (PRoot, gdb, strace, dlv, etc.),
|
||||
// not PRoot specifically. That's intentional: under any ptrace tracer the
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func envFrom(pairs map[string]string) func(string) string {
|
||||
return func(key string) string { return pairs[key] }
|
||||
}
|
||||
|
||||
// AllMotion (1003) buys hover highlighting and nothing else: wheel, click and
|
||||
// drag all arrive under CellMotion (1002). When a terminal does not support
|
||||
// 1003 the cost is not a missing highlight, it is that NO mouse event arrives
|
||||
// at all, and because the app holds the alternate screen the terminal's own
|
||||
// scrollback is gone too. The user cannot scroll by any means, and a screen of
|
||||
// cut-off output reads as a hang (#870).
|
||||
//
|
||||
// So the rule is: ask for AllMotion only where it is known to work.
|
||||
func TestMouseModeFallsBackWhereAllMotionIsUnreliable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
env map[string]string
|
||||
underPRoot bool
|
||||
want tea.MouseMode
|
||||
}{
|
||||
{
|
||||
name: "linux terminal",
|
||||
goos: "linux",
|
||||
want: tea.MouseModeAllMotion,
|
||||
},
|
||||
{
|
||||
name: "macOS terminal",
|
||||
goos: "darwin",
|
||||
want: tea.MouseModeAllMotion,
|
||||
},
|
||||
{
|
||||
// Pre-existing carve-out: under PRoot the 1003 sequence is unreliable
|
||||
// and breaks touch-gesture scrolling.
|
||||
name: "under PRoot",
|
||||
goos: "linux",
|
||||
underPRoot: true,
|
||||
want: tea.MouseModeCellMotion,
|
||||
},
|
||||
{
|
||||
// #870. The legacy Windows console does not handle 1003 reliably, and
|
||||
// it is what a user gets by running zero.exe from anywhere that is not
|
||||
// Windows Terminal.
|
||||
name: "legacy Windows console",
|
||||
goos: "windows",
|
||||
want: tea.MouseModeCellMotion,
|
||||
},
|
||||
{
|
||||
name: "Windows Terminal",
|
||||
goos: "windows",
|
||||
env: map[string]string{"WT_SESSION": "d1c2-session"},
|
||||
want: tea.MouseModeAllMotion,
|
||||
},
|
||||
{
|
||||
name: "VS Code terminal on Windows",
|
||||
goos: "windows",
|
||||
env: map[string]string{"TERM_PROGRAM": "vscode"},
|
||||
want: tea.MouseModeAllMotion,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := mouseModeFor(test.goos, envFrom(test.env), test.underPRoot)
|
||||
if got != test.want {
|
||||
t.Fatalf("mouseModeFor(%q) = %v, want %v", test.goos, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A KNOWN LIMIT, pinned deliberately rather than left to be rediscovered.
|
||||
//
|
||||
// Windows children inherit the parent environment, so WT_SESSION or
|
||||
// TERM_PROGRAM can outlive the terminal that set them: a shell started from
|
||||
// Windows Terminal, or from Git Bash, that later runs zero.exe against a
|
||||
// different console host still carries them, and mouseModeFor then asks for
|
||||
// AllMotion on a host that may drop every mouse event. That is the #870 failure
|
||||
// class arriving by a narrower door.
|
||||
//
|
||||
// It is not fixed here, and this test says so out loud. Telling an inherited
|
||||
// variable from a live one needs the actual console host rather than the
|
||||
// environment, and guessing wrong in the other direction costs every Windows
|
||||
// Terminal user their hover highlighting. ZERO_MOUSE_MODE=cell is the recourse
|
||||
// until that host check exists, which is why the override is not a nicety.
|
||||
//
|
||||
// If someone later adds real host detection, this test SHOULD fail. That is the
|
||||
// point of it.
|
||||
func TestInheritedWindowsTerminalEnvStillAsksForAllMotion(t *testing.T) {
|
||||
for _, env := range []map[string]string{
|
||||
{"WT_SESSION": "inherited-from-an-ancestor"},
|
||||
{"TERM_PROGRAM": "vscode"},
|
||||
} {
|
||||
if got := mouseModeFor("windows", envFrom(env), false); got != tea.MouseModeAllMotion {
|
||||
t.Fatalf("mouseModeFor(windows, %v) = %v; if this now returns CellMotion the "+
|
||||
"inherited-environment limit has been fixed, so delete this test and its note", env, got)
|
||||
}
|
||||
// The escape hatch has to keep working for exactly this case, since it is
|
||||
// the only recourse a user has when the guess is wrong.
|
||||
withOverride := map[string]string{mouseModeEnv: "cell"}
|
||||
for key, value := range env {
|
||||
withOverride[key] = value
|
||||
}
|
||||
if got := mouseModeFor("windows", envFrom(withOverride), false); got != tea.MouseModeCellMotion {
|
||||
t.Fatalf("ZERO_MOUSE_MODE=cell did not override inherited %v, so an affected user has no recourse", env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The override exists so a user who hits an unsupported terminal can fix it
|
||||
// themselves without waiting for a release, and so a maintainer can ask a bug
|
||||
// reporter to test one specific mode.
|
||||
func TestMouseModeOverrideWins(t *testing.T) {
|
||||
tests := []struct {
|
||||
value string
|
||||
want tea.MouseMode
|
||||
}{
|
||||
{"cell", tea.MouseModeCellMotion},
|
||||
{"CELL", tea.MouseModeCellMotion},
|
||||
{" cell ", tea.MouseModeCellMotion},
|
||||
{"all", tea.MouseModeAllMotion},
|
||||
{"off", tea.MouseModeNone},
|
||||
{"none", tea.MouseModeNone},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.value, func(t *testing.T) {
|
||||
// windows + PRoot would otherwise force CellMotion, so "all" proves the
|
||||
// override beats the fallbacks rather than agreeing with them.
|
||||
got := mouseModeFor("windows", envFrom(map[string]string{mouseModeEnv: test.value}), true)
|
||||
if got != test.want {
|
||||
t.Fatalf("%s=%q gave %v, want %v", mouseModeEnv, test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable override must not silently disable the mouse: fall through to
|
||||
// the normal decision rather than guessing.
|
||||
func TestMouseModeIgnoresAnUnknownOverride(t *testing.T) {
|
||||
got := mouseModeFor("linux", envFrom(map[string]string{mouseModeEnv: "sideways"}), false)
|
||||
if got != tea.MouseModeAllMotion {
|
||||
t.Fatalf("an unknown override changed the mode to %v, want the normal AllMotion", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user