Compare commits

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

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

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

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

Configure UTF-8 output, preserve restricted-sandbox MSYS safeguards, and add Windows coverage for pipelines, workspace writes, and interactive stdin sessions.
2026-07-24 19:35:42 +05:30
Anandan a4a805a6c8 fix(tui): reset quiet clock for new runs (#799)
New turns inherited lastStreamActivity from the prior turn, so a fresh request could immediately look stalled. Initialize stream activity from the same timestamp as turnStartedAt and cover the stale-clock case with a deterministic regression test.
2026-07-24 17:38:10 +05:30
Anandan 9fe02ae52e fix(sandbox): clarify scoped permission prompts (#798) 2026-07-24 17:37:32 +05:30
40 changed files with 1006 additions and 2124 deletions

No files matched your search

+23 -14
View File
@@ -2404,7 +2404,11 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
root := t.TempDir()
command := "PATH=.:$PATH curl https://example.com"
if runtime.GOOS == "windows" {
command = "set PATH=.;%PATH% && curl https://example.com"
if windowsTestUsesPowerShell() {
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
} else {
command = "set PATH=.;%PATH% && curl https://example.com"
}
fakeCurl := filepath.Join(root, "curl.cmd")
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
t.Fatal(err)
@@ -2525,13 +2529,17 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
root := t.TempDir()
command := "echo rm -rf /"
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
command = `Write-Output 'rm -rf /'`
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2627,6 +2635,12 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
}
}
func windowsTestUsesPowerShell() bool {
executable, _ := tools.HostShellCommand("")
name := strings.ToLower(filepath.Base(executable))
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
}
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
for _, decision := range decisions {
if decision == want {
@@ -3160,22 +3174,17 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
wants := []string{"workdir/cwd", "MSYS binaries"}
if windowsTestUsesPowerShell() {
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
} else {
wants = append(wants, "cmd.exe", "double quotes")
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
for _, want := range wants {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
}
+2 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -310,11 +311,7 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
+29 -2
View File
@@ -27,11 +27,22 @@ var destructivePrograms = map[string]bool{
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
}
var powerShellRemoveItemPrograms = map[string]bool{
"remove-item": true,
"ri": true,
"rd": true,
"rmdir": true,
"del": true,
"erase": true,
"rm": true,
}
// networkPrograms are commands that perform network egress/ingress.
var networkPrograms = map[string]bool{
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
"ftp": true,
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
"invoke-restmethod": true,
}
var localServerPrograms = map[string]bool{
@@ -172,7 +183,10 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
(prog == "find" && hasFindDelete(rest)) {
result.Destructive = true
}
return true
@@ -479,6 +493,19 @@ func hasRecursiveForce(args []*syntax.Word) bool {
return recursive && force
}
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
recursive, force := false, false
for _, arg := range args {
switch strings.ToLower(wordText(arg)) {
case "-recurse", "-r":
recursive = true
case "-force":
force = true
}
}
return recursive && force
}
func hasFindDelete(args []*syntax.Word) bool {
for _, arg := range args {
if wordText(arg) == "-delete" {
+21
View File
@@ -45,6 +45,27 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
{name: "curl", script: "curl https://example.com", network: true},
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
+42
View File
@@ -2,6 +2,7 @@ package sandbox
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
@@ -73,6 +74,25 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
}
}
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
engine := NewEngine(EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: DefaultPolicy(),
})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
}
}
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
@@ -485,6 +505,28 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
}
}
func TestEnginePromptsForOutOfWorkspaceReadWithReadSpecificReason(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "external")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "list_directory",
SideEffect: SideEffectRead,
Permission: PermissionAllow,
WorkspaceRoot: root,
Args: map[string]any{"path": outside},
})
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if decision.Action != ActionPrompt || decision.Block == nil || !decision.Block.Recoverable {
t.Fatalf("outside read decision = %#v, want recoverable prompt", decision)
}
if decision.Reason != want || decision.Block.Reason != want {
t.Fatalf("outside read reasons = decision %q, block %q; want %q", decision.Reason, decision.Block.Reason, want)
}
}
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
@@ -266,6 +266,74 @@ func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
}
}
// TestWindowsRestrictedTokenPowerShell exercises the default Windows shell
// shape end to end under the write-restricted token. It covers PowerShell
// initialization, a native pipeline (which creates IPC objects), UTF-8 output,
// and workspace write enforcement.
func TestWindowsRestrictedTokenPowerShell(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)
powerShell := realSmokePowerShell(t)
root := t.TempDir()
outside := t.TempDir()
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
insideMarker := filepath.Join(root, "powershell-ok.txt")
script := "$ErrorActionPreference='Stop'; " +
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " +
"$values = 1,2,3 | ForEach-Object { $_ * 2 }; " +
"[IO.File]::WriteAllText(" + powershellSingleQuote(insideMarker) + ", (($values -join ',') + ' ✓'))"
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
powerShell, "-NoLogo", "-NoProfile", "-Command", script,
}, 0)
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "2,4,6 ✓" {
t.Fatalf("PowerShell sandbox marker = %q, %v; want pipeline output", bytes, err)
}
outsideMarker := filepath.Join(outside, "powershell-denied.txt")
deniedScript := "$ErrorActionPreference='Stop'; " +
"[IO.File]::WriteAllText(" + powershellSingleQuote(outsideMarker) + ", 'leaked')"
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
powerShell, "-NoLogo", "-NoProfile", "-Command", deniedScript,
}, 1)
if _, err := os.Stat(outsideMarker); err == nil {
t.Fatal("sandboxed PowerShell wrote outside every granted root")
} else if !os.IsNotExist(err) {
t.Fatalf("stat outside PowerShell marker: %v", err)
}
}
func realSmokePowerShell(t *testing.T) string {
t.Helper()
for _, candidate := range []string{"pwsh.exe", "pwsh", "powershell.exe", "powershell"} {
if path, err := exec.LookPath(candidate); err == nil {
return path
}
}
t.Skip("PowerShell is unavailable")
return ""
}
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
t.Helper()
if path := os.Getenv(envKey); path != "" {
+59 -14
View File
@@ -430,25 +430,70 @@ func shellDashCPayload(program string, fields []string) string {
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
// leading command-substitution markers, removes any directory prefix (so
// /usr/bin/vim and C:\tools\vim.exe match "vim"), and lowercases. This closes
// path/quote/substitution evasions of the detector.
// /usr/bin/vim and C:\tools\vim.exe match "vim"), removes Windows executable
// suffixes, and lowercases. This closes path/quote/substitution evasions of the
// detector and keeps curl.exe/npm.cmd equivalent to curl/npm for risk analysis.
func normalizeProgramToken(field string) string {
token := strings.TrimSpace(field)
token = strings.TrimLeft(token, "$(")
token = strings.TrimRight(token, ")")
// Strip shell quoting/escaping characters (", ', `, \) wherever they appear
// in the token — surrounding, embedded, or as a mid-word escape — so
// "vim", v"i"m, 'v'im, and vi\m all collapse to the program name. This is
// done BEFORE the directory-prefix trim so an escape can't masquerade as a
// path separator (e.g. vi\m must become vim, not m).
token = stripChars(token, "\"'`\\")
// Strip a directory prefix so /usr/bin/vim reduces to the basename. (A
// Windows-style backslash path separator is already removed above, so only
// the POSIX separator remains to split on.)
if i := strings.LastIndex(token, "/"); i >= 0 {
token = token[i+1:]
// PowerShell commonly invokes drive-letter, UNC, and relative paths. Extract
// their basename while backslashes still carry path-separator meaning;
// removing them first would turn C:\tools\curl.exe into c:toolscurl.exe and
// bypass the canonical curl risk entry.
pathToken := stripChars(token, "\"'`")
if windowsPathBasename, ok := windowsExecutablePathBasename(pathToken); ok {
token = windowsPathBasename
} else {
// Strip shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token — surrounding, embedded, or as a mid-word escape
// — so "vim", v"i"m, 'v'im, and vi\m all collapse to the program name.
token = stripChars(token, "\"'`\\")
// Strip a directory prefix so /usr/bin/vim reduces to the basename.
if i := strings.LastIndex(token, "/"); i >= 0 {
token = token[i+1:]
}
}
return strings.ToLower(token)
token = strings.ToLower(token)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(token, suffix) {
return strings.TrimSuffix(token, suffix)
}
}
return token
}
func windowsExecutablePathBasename(token string) (string, bool) {
drivePath := len(token) >= 3 &&
((token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z')) &&
token[1] == ':'
uncPath := strings.HasPrefix(token, `\\`)
explicitRelativePath := strings.HasPrefix(token, `.\`) || strings.HasPrefix(token, `..\`)
backslashRelativePath := strings.ContainsRune(token, '\\') && hasWindowsExecutableSuffix(token)
if !drivePath && !uncPath && !explicitRelativePath && !backslashRelativePath {
return "", false
}
index := strings.LastIndexAny(token, `\/`)
if index >= 0 {
if index+1 >= len(token) {
return "", false
}
return token[index+1:], true
}
if drivePath && len(token) > 2 {
return token[2:], true
}
return "", false
}
func hasWindowsExecutableSuffix(token string) bool {
token = strings.ToLower(token)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(token, suffix) {
return true
}
}
return false
}
// stripChars returns s with every rune in cutset removed.
+15 -2
View File
@@ -203,11 +203,24 @@ func dedupeScopeRoots(roots []string) []string {
// only on outside_workspace results. The returned block always carries
// the caller's original requestedPath.
func (s *Scope) validate(requestedPath string) *pathBlock {
return s.validateAgainstRoots(requestedPath, s.Roots())
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.Roots()), requestedPath, SideEffectWrite)
}
func (s *Scope) validateRead(requestedPath string) *pathBlock {
return s.validateAgainstRoots(requestedPath, s.ReadRoots())
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.ReadRoots()), requestedPath, SideEffectRead)
}
func withOutsideWorkspaceAccessReason(block *pathBlock, requestedPath string, sideEffect SideEffect) *pathBlock {
if block == nil || block.Code != BlockOutsideWorkspace || !strings.Contains(block.Reason, " is outside the workspace") {
return block
}
switch sideEffect {
case SideEffectRead:
block.Reason = fmt.Sprintf("Reading %s requires access outside the workspace.", requestedPath)
case SideEffectWrite, SideEffectOutOfWorkspace:
block.Reason = fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", requestedPath)
}
return block
}
func (s *Scope) validateAgainstRoots(requestedPath string, roots []string) *pathBlock {
+40
View File
@@ -1,6 +1,7 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -279,6 +280,45 @@ func TestScopeValidateAllowsAnyRootButRelativeOnlyWorkspace(t *testing.T) {
}
}
func TestScopeValidateReadExplainsReadAccessOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
block := scope.validateRead(outside)
if block == nil {
t.Fatal("validateRead(outside workspace) = nil, want block")
}
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if block.Reason != want {
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
}
if strings.Contains(block.Reason, "--add-dir") || strings.Contains(block.Reason, "writes") {
t.Fatalf("read block reason must not suggest write access: %q", block.Reason)
}
}
func TestScopeValidateWriteExplainsWriteAccessOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
block := scope.validate(outside)
if block == nil {
t.Fatal("validate(outside workspace) = nil, want block")
}
want := fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", outside)
if block.Reason != want {
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
}
}
func TestScopeValidateKeepsSymlinkTraversalProtection(t *testing.T) {
workspace := t.TempDir()
outside := outsideDefaultTempPath(workspace, "symlink-target")
-422
View File
@@ -1,422 +0,0 @@
package sessions
import (
"fmt"
"strings"
"unicode/utf8"
)
const (
GoalObjectiveMaxLength = 4_000
GoalMaxConsecutiveContinuations = 20
goalContinuationLimitStatusReason = "automatic continuation limit reached"
)
func validateGoalObjective(objective string) (string, error) {
objective = strings.TrimSpace(objective)
if objective == "" {
return "", fmt.Errorf("goal objective is required")
}
if utf8.RuneCountInString(objective) > GoalObjectiveMaxLength {
return "", fmt.Errorf("goal objective cannot exceed %d characters", GoalObjectiveMaxLength)
}
return objective, nil
}
// CreateGoal persists a new active goal for a session. It refuses to replace an
// existing goal so callers must make replacement an explicit user decision.
func (store *Store) CreateGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
var err error
objective, err = validateGoalObjective(objective)
if err != nil {
return Metadata{}, Event{}, err
}
if tokenBudget < 0 {
return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal != nil {
return Metadata{}, Event{}, fmt.Errorf("session already has a goal")
}
now := store.timestamp()
session.Goal = &Goal{
Objective: objective,
Status: GoalStatusActive,
TokenBudget: tokenBudget,
ContinuationLimit: GoalMaxConsecutiveContinuations,
CreatedAt: now,
UpdatedAt: now,
}
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalCreated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// UpdateGoal changes a goal's lifecycle state. Terminal goals remain available
// for status/history until the user explicitly clears them.
func (store *Store) UpdateGoal(sessionID string, status GoalStatus, reason string) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
if !validGoalStatus(status) {
return Metadata{}, Event{}, fmt.Errorf("invalid goal status %q", status)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
session.Goal.Status = status
session.Goal.StatusReason = strings.TrimSpace(reason)
if status == GoalStatusActive {
session.Goal.ContinuationCount = 0
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
}
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// EditGoal replaces the objective and optional budget without resetting usage or
// lifecycle timestamps. User-facing command handling owns confirmation.
func (store *Store) EditGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
var err error
objective, err = validateGoalObjective(objective)
if err != nil {
return Metadata{}, Event{}, err
}
if tokenBudget < 0 {
return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
session.Goal.Objective = objective
session.Goal.TokenBudget = tokenBudget
session.Goal.Status = GoalStatusActive
session.Goal.StatusReason = ""
session.Goal.ContinuationCount = 0
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
}
if tokenBudget > 0 && session.Goal.TokensUsed >= tokenBudget {
session.Goal.Status = GoalStatusBudgetLimited
session.Goal.StatusReason = "token budget reached"
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// ResetGoalContinuations starts a fresh autonomous-run allowance after explicit
// user input. It makes the safety bound consecutive rather than lifetime-wide.
func (store *Store) ResetGoalContinuations(sessionID string) (Metadata, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil
}
changed := false
if session.Goal.ContinuationCount != 0 {
session.Goal.ContinuationCount = 0
changed = true
}
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
changed = true
}
if !changed {
return session, nil
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, err
}
return session, nil
}
// ReserveGoalContinuation atomically reserves one automatic continuation. Once
// the persisted consecutive-run limit is exhausted it pauses the goal before
// another provider request can start, even when the provider reports no usage.
func (store *Store) ReserveGoalContinuation(sessionID string) (Metadata, *Event, bool, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, false, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil, false, nil
}
limit := session.Goal.ContinuationLimit
if limit <= 0 {
limit = GoalMaxConsecutiveContinuations
session.Goal.ContinuationLimit = limit
}
if session.Goal.ContinuationCount < limit {
session.Goal.ContinuationCount++
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, false, err
}
return session, nil, true, nil
}
session.Goal.Status = GoalStatusPaused
session.Goal.StatusReason = goalContinuationLimitStatusReason
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, false, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, false, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
return loaded, &event, false, nil
}
// AddGoalUsage accounts tokens consumed while a goal is active. Reaching the
// optional budget pauses the goal before another autonomous turn can start.
func (store *Store) AddGoalUsage(sessionID string, tokens int) (Metadata, *Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
if tokens < 0 {
return Metadata{}, nil, fmt.Errorf("goal token usage cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
if session.Goal == nil || tokens == 0 {
return session, nil, nil
}
session.Goal.TokensUsed += tokens
session.Goal.UpdatedAt = store.timestamp()
budgetReached := false
if session.Goal.Status == GoalStatusActive &&
session.Goal.TokenBudget > 0 &&
session.Goal.TokensUsed >= session.Goal.TokenBudget {
session.Goal.Status = GoalStatusBudgetLimited
session.Goal.StatusReason = "token budget reached"
budgetReached = true
}
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, err
}
if !budgetReached {
return session, nil, nil
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
return loaded, &event, nil
}
// PauseGoalIfActive pauses a goal only while it is still active. It is used by
// cancellation paths where an in-flight agent may have completed or blocked the
// goal immediately before the cancellation reached the runtime.
func (store *Store) PauseGoalIfActive(sessionID, reason string) (Metadata, *Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil, nil
}
session.Goal.Status = GoalStatusPaused
session.Goal.StatusReason = strings.TrimSpace(reason)
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
return loaded, &event, nil
}
// ClearGoal removes the current goal while preserving an audit event.
func (store *Store) ClearGoal(sessionID string) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
cleared := cloneGoal(session.Goal)
session.Goal = nil
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalCleared,
Payload: cleared,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
func validGoalStatus(status GoalStatus) bool {
switch status {
case GoalStatusActive, GoalStatusPaused, GoalStatusBlocked, GoalStatusBudgetLimited, GoalStatusUsageLimited, GoalStatusComplete:
return true
default:
return false
}
}
func cloneGoal(goal *Goal) *Goal {
if goal == nil {
return nil
}
copy := *goal
return &copy
}
-382
View File
@@ -1,382 +0,0 @@
package sessions
import (
"os"
"strings"
"testing"
"time"
)
func TestGoalLifecyclePersistsInSessionMetadata(t *testing.T) {
now := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC)
store := NewStore(StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Second)
return now
},
})
session, err := store.Create(CreateInput{SessionID: "goal_session"})
if err != nil {
t.Fatal(err)
}
created, event, err := store.CreateGoal(session.SessionID, "Ship the release", 1_000)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalCreated {
t.Fatalf("create event = %q, want %q", event.Type, EventGoalCreated)
}
if created.Goal == nil || created.Goal.Objective != "Ship the release" ||
created.Goal.Status != GoalStatusActive || created.Goal.TokenBudget != 1_000 {
t.Fatalf("created goal = %#v", created.Goal)
}
accounted, _, err := store.AddGoalUsage(session.SessionID, 250)
if err != nil {
t.Fatal(err)
}
if accounted.Goal.TokensUsed != 250 || accounted.Goal.Status != GoalStatusActive {
t.Fatalf("accounted goal = %#v", accounted.Goal)
}
paused, event, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user interrupted")
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalUpdated || paused.Goal.Status != GoalStatusPaused ||
paused.Goal.StatusReason != "user interrupted" {
t.Fatalf("paused goal/event = %#v / %#v", paused.Goal, event)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.Goal == nil || loaded.Goal.Status != GoalStatusPaused {
t.Fatalf("reloaded session = %#v", loaded)
}
cleared, event, err := store.ClearGoal(session.SessionID)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalCleared || cleared.Goal != nil {
t.Fatalf("cleared goal/event = %#v / %#v", cleared.Goal, event)
}
}
func TestGoalBudgetPausesAtLimit(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "budget_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded", 100); err != nil {
t.Fatal(err)
}
updated, event, err := store.AddGoalUsage(session.SessionID, 100)
if err != nil {
t.Fatal(err)
}
if updated.Goal.Status != GoalStatusBudgetLimited || updated.Goal.StatusReason != "token budget reached" {
t.Fatalf("budgeted goal = %#v", updated.Goal)
}
if event == nil || event.Type != EventGoalUpdated {
t.Fatalf("budget transition event = %#v", event)
}
}
func TestGoalContinuationLimitStopsWithoutProviderUsage(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "continuation_limit"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded without usage events", 0); err != nil {
t.Fatal(err)
}
for continuation := 1; continuation <= GoalMaxConsecutiveContinuations; continuation++ {
updated, event, reserved, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil {
t.Fatal(err)
}
if !reserved || event != nil {
t.Fatalf("continuation %d: reserved=%v event=%#v", continuation, reserved, event)
}
if updated.Goal.ContinuationCount != continuation {
t.Fatalf("continuation count = %d, want %d", updated.Goal.ContinuationCount, continuation)
}
if updated.Goal.TokensUsed != 0 {
t.Fatalf("provider-independent guard unexpectedly recorded tokens: %#v", updated.Goal)
}
}
stopped, event, reserved, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reserved || event == nil || stopped.Goal.Status != GoalStatusPaused ||
stopped.Goal.StatusReason != goalContinuationLimitStatusReason {
t.Fatalf("unbounded continuation was not stopped: goal=%#v event=%#v reserved=%v", stopped.Goal, event, reserved)
}
reloaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reloaded.Goal.ContinuationCount != GoalMaxConsecutiveContinuations ||
reloaded.Goal.Status != GoalStatusPaused {
t.Fatalf("continuation guard did not persist: %#v", reloaded.Goal)
}
resumed, _, err := store.UpdateGoal(session.SessionID, GoalStatusActive, "")
if err != nil {
t.Fatal(err)
}
if resumed.Goal.ContinuationCount != 0 {
t.Fatalf("explicit resume did not reset consecutive continuations: %#v", resumed.Goal)
}
}
func TestResetGoalContinuationsPersistsOnlyActiveChanges(t *testing.T) {
newStore := func(t *testing.T) (*Store, Metadata) {
t.Helper()
now := time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
store := NewStore(StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Second)
return now
},
})
session, err := store.Create(CreateInput{SessionID: "reset_goal"})
if err != nil {
t.Fatal(err)
}
return store, session
}
pinMetadataTime := func(t *testing.T, store *Store, sessionID string) time.Time {
t.Helper()
path := store.metadataPath(sessionID)
sentinel := time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC)
if err := os.Chtimes(path, sentinel, sentinel); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return info.ModTime()
}
assertMetadataTime := func(t *testing.T, store *Store, sessionID string, want time.Time) {
t.Helper()
info, err := os.Stat(store.metadataPath(sessionID))
if err != nil {
t.Fatal(err)
}
if !info.ModTime().Equal(want) {
t.Fatalf("metadata was rewritten: modtime=%s want=%s", info.ModTime(), want)
}
}
t.Run("no goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal != nil {
t.Fatalf("no-goal reset created a goal: %#v", updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("inactive goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
if _, _, err := store.CreateGoal(session.SessionID, "Pause safely", 0); err != nil {
t.Fatal(err)
}
if _, _, reserved, err := store.ReserveGoalContinuation(session.SessionID); err != nil || !reserved {
t.Fatalf("reserve continuation: reserved=%v err=%v", reserved, err)
}
paused, _, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user paused")
if err != nil {
t.Fatal(err)
}
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 1 || updated.Goal.UpdatedAt != paused.Goal.UpdatedAt {
t.Fatalf("inactive goal changed during reset: before=%#v after=%#v", paused.Goal, updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("already reset active goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
created, _, err := store.CreateGoal(session.SessionID, "Stay reset", 0)
if err != nil {
t.Fatal(err)
}
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 0 ||
updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations ||
updated.Goal.UpdatedAt != created.Goal.UpdatedAt {
t.Fatalf("already-reset goal changed: before=%#v after=%#v", created.Goal, updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("active goal reset persists", func(t *testing.T) {
store, session := newStore(t)
if _, _, err := store.CreateGoal(session.SessionID, "Reset progress", 0); err != nil {
t.Fatal(err)
}
reserved, _, ok, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil || !ok {
t.Fatalf("reserve continuation: reserved=%v err=%v", ok, err)
}
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 0 ||
updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations ||
updated.Goal.UpdatedAt == reserved.Goal.UpdatedAt {
t.Fatalf("active reset did not persist: before=%#v after=%#v", reserved.Goal, updated.Goal)
}
reloaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reloaded.Goal == nil || *reloaded.Goal != *updated.Goal {
t.Fatalf("active reset was not durable: updated=%#v reloaded=%#v", updated.Goal, reloaded.Goal)
}
})
}
func TestGoalObjectiveLengthIsBounded(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "objective_limit"})
if err != nil {
t.Fatal(err)
}
tooLong := strings.Repeat("g", GoalObjectiveMaxLength+1)
if _, _, err := store.CreateGoal(session.SessionID, tooLong, 0); err == nil ||
!strings.Contains(err.Error(), "cannot exceed") {
t.Fatalf("oversized objective error = %v", err)
}
}
func TestEditGoalUpdatesStateAndRejectsInvalidInputWithoutMutation(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "edit_goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Original objective", 1_000); err != nil {
t.Fatal(err)
}
if _, _, err := store.AddGoalUsage(session.SessionID, 250); err != nil {
t.Fatal(err)
}
edited, event, err := store.EditGoal(session.SessionID, "Updated objective", 500)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalUpdated {
t.Fatalf("edit event = %q, want %q", event.Type, EventGoalUpdated)
}
if edited.Goal == nil ||
edited.Goal.Objective != "Updated objective" ||
edited.Goal.TokenBudget != 500 ||
edited.Goal.TokensUsed != 250 ||
edited.Goal.Status != GoalStatusActive ||
edited.Goal.StatusReason != "" {
t.Fatalf("edited goal = %#v", edited.Goal)
}
limited, _, err := store.EditGoal(session.SessionID, "Stay within budget", 200)
if err != nil {
t.Fatal(err)
}
if limited.Goal == nil ||
limited.Goal.Status != GoalStatusBudgetLimited ||
limited.Goal.StatusReason != "token budget reached" ||
limited.Goal.TokensUsed != 250 {
t.Fatalf("budget-limited goal = %#v", limited.Goal)
}
before := *limited.Goal
eventsBefore, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatal(err)
}
if _, _, err := store.EditGoal(session.SessionID, " ", 200); err == nil {
t.Fatal("EditGoal should reject an empty objective")
}
if _, _, err := store.EditGoal(session.SessionID, "Invalid budget", -1); err == nil {
t.Fatal("EditGoal should reject a negative token budget")
}
after, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if after == nil || after.Goal == nil || *after.Goal != before {
t.Fatalf("invalid edit mutated goal: before=%#v after=%#v", before, after)
}
eventsAfter, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatal(err)
}
if len(eventsAfter) != len(eventsBefore) {
t.Fatalf("invalid edit appended events: before=%d after=%d", len(eventsBefore), len(eventsAfter))
}
}
func TestCreateGoalRefusesImplicitReplacement(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "replace_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "First", 0); err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Second", 0); err == nil {
t.Fatal("CreateGoal should require an explicit clear before replacement")
}
}
func TestPauseGoalIfActiveDoesNotOverwriteTerminalState(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "terminal_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Finish", 0); err != nil {
t.Fatal(err)
}
if _, _, err := store.UpdateGoal(session.SessionID, GoalStatusComplete, ""); err != nil {
t.Fatal(err)
}
updated, event, err := store.PauseGoalIfActive(session.SessionID, "cancelled")
if err != nil {
t.Fatal(err)
}
if event != nil || updated.Goal.Status != GoalStatusComplete {
t.Fatalf("terminal goal changed during cancellation: goal=%#v event=%#v", updated.Goal, event)
}
}
-29
View File
@@ -45,9 +45,6 @@ const (
EventSpecDraft EventType = "spec_draft"
EventSpecApproved EventType = "spec_approved"
EventSpecRejected EventType = "spec_rejected"
EventGoalCreated EventType = "goal_created"
EventGoalUpdated EventType = "goal_updated"
EventGoalCleared EventType = "goal_cleared"
)
type SessionKind string
@@ -68,29 +65,6 @@ const (
SpecStatusRejected SpecStatus = "rejected"
)
type GoalStatus string
const (
GoalStatusActive GoalStatus = "active"
GoalStatusPaused GoalStatus = "paused"
GoalStatusBlocked GoalStatus = "blocked"
GoalStatusBudgetLimited GoalStatus = "budget_limited"
GoalStatusUsageLimited GoalStatus = "usage_limited"
GoalStatusComplete GoalStatus = "complete"
)
type Goal struct {
Objective string `json:"objective"`
Status GoalStatus `json:"status"`
StatusReason string `json:"statusReason,omitempty"`
TokenBudget int `json:"tokenBudget,omitempty"`
TokensUsed int `json:"tokensUsed,omitempty"`
ContinuationCount int `json:"continuationCount,omitempty"`
ContinuationLimit int `json:"continuationLimit,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type Metadata struct {
SessionID string `json:"sessionId"`
SessionKind SessionKind `json:"sessionKind,omitempty"`
@@ -117,7 +91,6 @@ type Metadata struct {
SpecRejectReason string `json:"specRejectReason,omitempty"`
SpecSourceSessionID string `json:"specSourceSessionId,omitempty"`
SpecImplSessionID string `json:"specImplSessionId,omitempty"`
Goal *Goal `json:"goal,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
EventCount int `json:"eventCount"`
@@ -150,7 +123,6 @@ type CreateInput struct {
SpecRejectReason string
SpecSourceSessionID string
SpecImplSessionID string
Goal *Goal
}
type ForkInput struct {
@@ -307,7 +279,6 @@ func (store *Store) Create(input CreateInput) (Metadata, error) {
SpecRejectReason: strings.TrimSpace(input.SpecRejectReason),
SpecSourceSessionID: strings.TrimSpace(input.SpecSourceSessionID),
SpecImplSessionID: strings.TrimSpace(input.SpecImplSessionID),
Goal: cloneGoal(input.Goal),
CreatedAt: timestamp,
UpdatedAt: timestamp,
EventCount: 0,
+9 -27
View File
@@ -106,7 +106,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
@@ -293,23 +293,19 @@ func shellIssueBlockResult(issue shellIssue) Result {
}
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
// commandText. On Windows, when the command is not wrapped by the sandbox
// engine (plan.Wrapped == false), it also overrides the child's raw command
// line so commandText reaches cmd.exe unescaped; see
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
// gets the same treatment inside the sandboxed runner process itself
// (internal/sandbox/windows_process_windows.go), since that command line is
// built there, not here.
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
// the fallback. Only cmd.exe needs the raw command-line override.
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
hostShell := detectShellRuntime(runtime.GOOS)
spec := zeroSandbox.CommandSpec{
Name: shellExecutable(),
Args: shellArguments(commandText),
Name: hostShell.Executable,
Args: hostShell.arguments(commandText),
Dir: absoluteCwd,
}
if engine != nil {
command, plan, err := engine.CommandContext(ctx, spec)
if err == nil {
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
}
return command, plan, err
}
@@ -333,7 +329,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
return command, plan, nil
}
@@ -385,20 +381,6 @@ func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Resu
}
}
func shellExecutable() string {
if runtime.GOOS == "windows" {
return "cmd.exe"
}
return "/bin/sh"
}
func shellArguments(command string) []string {
if runtime.GOOS == "windows" {
return zeroSandbox.WindowsShellArgs(command)
}
return []string{"-c", command}
}
func commandExitCode(err error) int {
if err == nil {
return 0
@@ -621,7 +603,7 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+3 -3
View File
@@ -36,6 +36,6 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
// cmd.exe command-line-parsing quirk to work around here.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
// applyWindowsShellCommandLine is a no-op outside Windows.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
}
+6 -9
View File
@@ -31,15 +31,12 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
// is true: the sandbox engine then routes execution through a separate
// zero-windows-command-runner process, which builds its own child command
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
// inheriting whatever this outer exec.Cmd is configured with.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
if wrapped {
// applyWindowsShellCommandLine overrides the cmd.exe fallback's raw child
// command line so commandText reaches cmd.exe unescaped. PowerShell consumes
// ordinary argv quoting and must not take this path. Wrapped commands are
// handled after unwrapping by the Windows sandbox runner.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
if wrapped || !cmdFallback {
return
}
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
+31 -12
View File
@@ -124,11 +124,15 @@ func TestBashToolDescribesHostShellSyntax(t *testing.T) {
}
if runtime.GOOS == "windows" {
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in bash description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in bash description, got %q", description)
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
t.Fatalf("expected Windows PowerShell guidance %q in bash description, got %q", want, description)
}
}
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe fallback guidance in bash description, got %q", description)
}
return
}
@@ -455,13 +459,14 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
wantExitCode := strconv.Itoa(helperFailureExitCode())
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: " + wantExitCode} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
}
}
if result.Meta["exit_code"] != "7" {
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
if result.Meta["exit_code"] != wantExitCode {
t.Fatalf("expected exit_code metadata %s, got %q", wantExitCode, result.Meta["exit_code"])
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
@@ -578,10 +583,16 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
}
registry := NewRegistry()
registry.Register(NewScopedBashTool(root, nil))
msysCommand := "cat somefile.txt"
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
// cat is a native Get-Content alias in PowerShell. Use a name that
// still resolves to an incompatible Git-for-Windows MSYS executable.
msysCommand = "grep pattern somefile.txt"
}
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
"command": msysCommand,
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
@@ -594,7 +605,7 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": "cat somefile.txt",
"command": msysCommand,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -648,8 +659,9 @@ func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T)
"command": command,
})
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
wantExitCode := strconv.Itoa(helperFailureExitCode())
if result.Status != StatusError || result.Meta["exit_code"] != wantExitCode {
t.Fatalf("expected the helper failure exit %s, got %s: %#v", wantExitCode, result.Status, result)
}
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
@@ -860,6 +872,9 @@ func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
}
if detectShellRuntime(runtime.GOOS).Kind != shellKindCmd {
t.Skip("cmd.exe fallback is not selected")
}
root := t.TempDir()
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
@@ -881,6 +896,10 @@ func helperCommand(name string) string {
return executable + " --zero-bash-helper " + name
}
func helperFailureExitCode() int {
return 7
}
func shellQuote(value string) string {
if runtime.GOOS == "windows" {
return value
+2 -2
View File
@@ -173,7 +173,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
@@ -459,7 +459,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
+21 -10
View File
@@ -76,11 +76,15 @@ func TestExecCommandToolDescribesHostShellSyntax(t *testing.T) {
description := strings.ToLower(strings.Join(descriptionParts, " "))
if runtime.GOOS == "windows" {
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in exec_command description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in exec_command description, got %q", description)
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
t.Fatalf("expected Windows PowerShell guidance %q in exec_command description, got %q", want, description)
}
}
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe fallback guidance in exec_command description, got %q", description)
}
}
}
@@ -192,9 +196,15 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
msysCommand := "cat somefile.txt"
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
// cat is a native Get-Content alias in PowerShell, so use an executable
// name that still exercises the MSYS guard.
msysCommand = "grep pattern somefile.txt"
}
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
"cmd": "cat somefile.txt",
"cmd": msysCommand,
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -204,8 +214,8 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// bypassed, the command actually runs, and its real, PATH-dependent output
// could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
@@ -284,8 +294,9 @@ func TestExecCommandApplicationFailureHasTypedOutcome(t *testing.T) {
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != 7 {
t.Fatalf("execution exit = %#v, want code 7", result.ExecutionOutcome.Exit)
wantExitCode := helperFailureExitCode()
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != wantExitCode {
t.Fatalf("execution exit = %#v, want code %d", result.ExecutionOutcome.Exit, wantExitCode)
}
}
-187
View File
@@ -1,187 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/sessions"
)
type goalTool struct {
baseTool
store *sessions.Store
sessionID string
action string
}
// NewGoalTools returns goal lifecycle tools bound to one captured session. The
// binding prevents a late tool call from mutating a different session after a
// resume or session switch.
func NewGoalTools(store *sessions.Store, sessionID string) []Tool {
return []Tool{
newGetGoalTool(store, sessionID),
newCreateGoalTool(store, sessionID),
newUpdateGoalTool(store, sessionID),
}
}
func newGetGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "get_goal",
description: "Get the current persistent goal and its status, token budget, and usage for this session.",
parameters: Schema{
Type: "object",
AdditionalProperties: false,
},
safety: readOnlySafety("Reads goal state for the current session."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true},
},
store: store,
sessionID: sessionID,
action: "get",
}
}
func newCreateGoalTool(store *sessions.Store, sessionID string) Tool {
minimum := 0
maximum := 1_000_000_000
maxObjectiveLength := sessions.GoalObjectiveMaxLength
return &goalTool{
baseTool: baseTool{
name: "create_goal",
description: "Create one persistent goal for this session when the user explicitly asks to pursue an ongoing objective. " +
"Do not infer a goal from an ordinary one-turn task. This fails while any goal is still stored.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"objective": {
Type: "string",
Description: "The concrete objective to keep pursuing.",
MaxLength: &maxObjectiveLength,
},
"token_budget": {
Type: "integer",
Description: "Optional maximum total tokens. Zero means no goal-specific limit.",
Minimum: &minimum,
Maximum: &maximum,
},
},
Required: []string{"objective"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
Reason: "Creates persistent goal state and enables automatic follow-up runs for the current session.",
AdvertiseInAuto: true,
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "create",
}
}
func newUpdateGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "update_goal",
description: "Finish the current goal or mark it blocked. Use complete only when the objective is achieved and no required work remains. " +
"Use blocked only when progress cannot continue without user input or an external state change.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"status": {
Type: "string",
Description: "The terminal state to apply.",
Enum: []string{string(sessions.GoalStatusComplete), string(sessions.GoalStatusBlocked)},
},
"reason": {
Type: "string",
Description: "Why the goal is blocked. Optional for completed goals.",
},
},
Required: []string{"status"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectNone,
Permission: PermissionAllow,
Reason: "Updates persistent goal state for the current session.",
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "update",
}
}
func (tool *goalTool) Run(_ context.Context, args map[string]any) Result {
if tool.store == nil || !sessions.ValidSessionID(tool.sessionID) {
return errorResult("Error: Goal state is unavailable for this run.")
}
switch tool.action {
case "get":
session, err := tool.store.Get(tool.sessionID)
if err != nil {
return errorResult("Error: Read goal: " + err.Error())
}
if session == nil || session.Goal == nil {
return okResult("No goal is set for this session.")
}
return goalResult(session.Goal)
case "create":
objective, err := stringArg(args, "objective", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
tokenBudget, err := intArg(args, "token_budget", 0, 0, 1_000_000_000)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
session, _, err := tool.store.CreateGoal(tool.sessionID, objective, tokenBudget)
if err != nil {
return errorResult("Error: Create goal: " + err.Error())
}
return goalResult(session.Goal)
case "update":
statusText, err := stringArg(args, "status", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
status := sessions.GoalStatus(strings.ToLower(statusText))
if status != sessions.GoalStatusComplete && status != sessions.GoalStatusBlocked {
return errorResult(`Error: Invalid arguments for update_goal: status must be "complete" or "blocked"`)
}
reason, err := stringArgWithEmpty(args, "reason", "", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
if status == sessions.GoalStatusBlocked && strings.TrimSpace(reason) == "" {
return errorResult("Error: Invalid arguments for update_goal: blocked goals require a reason")
}
session, _, err := tool.store.UpdateGoal(tool.sessionID, status, reason)
if err != nil {
return errorResult("Error: Update goal: " + err.Error())
}
return goalResult(session.Goal)
default:
return errorResult("Error: Unknown goal action.")
}
}
func goalResult(goal *sessions.Goal) Result {
if goal == nil {
return okResult("No goal is set for this session.")
}
data, err := json.MarshalIndent(goal, "", " ")
if err != nil {
return errorResult(fmt.Sprintf("Error: Encode goal: %v", err))
}
return okResult(string(data))
}
-76
View File
@@ -1,76 +0,0 @@
package tools
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestGoalToolsAreBoundToTheirSession(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
first, err := store.Create(sessions.CreateInput{SessionID: "first"})
if err != nil {
t.Fatal(err)
}
second, err := store.Create(sessions.CreateInput{SessionID: "second"})
if err != nil {
t.Fatal(err)
}
goalTools := NewGoalTools(store, first.SessionID)
result := goalTools[1].Run(context.Background(), map[string]any{"objective": "Finish first"})
if result.Status != StatusOK {
t.Fatalf("create result = %#v", result)
}
loadedFirst, _ := store.Get(first.SessionID)
loadedSecond, _ := store.Get(second.SessionID)
if loadedFirst.Goal == nil || loadedFirst.Goal.Objective != "Finish first" {
t.Fatalf("first goal = %#v", loadedFirst.Goal)
}
if loadedSecond.Goal != nil {
t.Fatalf("second goal unexpectedly changed = %#v", loadedSecond.Goal)
}
}
func TestCreateGoalToolDeclaresTokenBudgetMaximum(t *testing.T) {
create := NewGoalTools(nil, "goal")[1]
maximum := create.Parameters().Properties["token_budget"].Maximum
if maximum == nil || *maximum != 1_000_000_000 {
t.Fatalf("token_budget maximum = %v, want 1000000000", maximum)
}
maxLength := create.Parameters().Properties["objective"].MaxLength
if maxLength == nil || *maxLength != sessions.GoalObjectiveMaxLength {
t.Fatalf("objective maxLength = %v, want %d", maxLength, sessions.GoalObjectiveMaxLength)
}
safety := create.Safety()
if safety.SideEffect != SideEffectWrite || safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
t.Fatalf("create_goal safety = %#v, want prompted persistent write advertised in auto mode", safety)
}
}
func TestUpdateGoalToolRestrictsAgentTransitions(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(sessions.CreateInput{SessionID: "goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Finish it", 0); err != nil {
t.Fatal(err)
}
update := NewGoalTools(store, session.SessionID)[2]
result := update.Run(context.Background(), map[string]any{"status": "paused"})
if result.Status != StatusError || !strings.Contains(result.Output, "complete") {
t.Fatalf("paused result = %#v", result)
}
result = update.Run(context.Background(), map[string]any{"status": "blocked"})
if result.Status != StatusError || !strings.Contains(result.Output, "require a reason") {
t.Fatalf("reasonless blocked result = %#v", result)
}
result = update.Run(context.Background(), map[string]any{"status": "complete"})
if result.Status != StatusOK {
t.Fatalf("complete result = %#v", result)
}
}
+301 -14
View File
@@ -1,15 +1,33 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"unicode"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
type shellKind string
const (
shellKindPOSIX shellKind = "posix"
shellKindPowerShell shellKind = "powershell"
shellKindCmd shellKind = "cmd"
)
type shellRuntime struct {
GOOS string
Executable string
Syntax string
Kind shellKind
}
type shellIssue struct {
@@ -20,7 +38,18 @@ type shellIssue struct {
const windowsMsysSandboxKind = "windows_msys_sandbox"
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
const windowsMsysSandboxSuggestion = "MSYS/Cygwin executables and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer native PowerShell cmdlets or Zero tools (grep, read_file with offset/limit, list_directory, glob). If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
const windowsPowerShellUTF8Prefix = "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}\n"
const windowsPowerShellFailurePrefix = "$ErrorActionPreference = 'Stop'\n"
const windowsPowerShellExitSuffix = "\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\n"
var (
hostShellOnce sync.Once
hostShell shellRuntime
)
// windowsMsysProneNames is the single source of truth for POSIX coreutil and
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
@@ -54,24 +83,139 @@ var (
)
func detectShellRuntime(goos string) shellRuntime {
if goos == "windows" {
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe"}
if goos != runtime.GOOS {
return detectShellRuntimeWithLookup(goos, exec.LookPath, os.Getenv)
}
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh"}
hostShellOnce.Do(func() {
hostShell = detectShellRuntimeWithProbe(goos, exec.LookPath, os.Getenv, powerShellExecutableUsable)
})
return hostShell
}
func detectShellRuntimeWithLookup(goos string, lookPath func(string) (string, error), getenv func(string) string) shellRuntime {
return detectShellRuntimeWithProbe(goos, lookPath, getenv, func(string) bool { return true })
}
func detectShellRuntimeWithProbe(goos string, lookPath func(string) (string, error), getenv func(string) string, usable func(string) bool) shellRuntime {
if goos == "windows" {
for _, candidate := range windowsPowerShellCandidates(getenv) {
if path, err := lookPath(candidate); err == nil && strings.TrimSpace(path) != "" && usable(path) {
return shellRuntime{
GOOS: goos,
Executable: path,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
}
}
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
}
func powerShellExecutableUsable(path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
command := exec.CommandContext(ctx, path, "-NoLogo", "-NoProfile", "-Command", "exit 0")
return command.Run() == nil
}
func windowsPowerShellCandidates(getenv func(string) string) []string {
candidates := []string{"pwsh.exe", "pwsh"}
if programFiles := strings.TrimSpace(getenv("ProgramFiles")); programFiles != "" {
candidates = append(candidates, filepath.Join(programFiles, "PowerShell", "7", "pwsh.exe"))
}
candidates = append(candidates, "powershell.exe", "powershell")
if systemRoot := strings.TrimSpace(getenv("SystemRoot")); systemRoot != "" {
candidates = append(candidates, filepath.Join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
}
return candidates
}
func (shell shellRuntime) arguments(command string) []string {
switch shell.Kind {
case shellKindPowerShell:
script := windowsPowerShellUTF8Prefix + windowsPowerShellFailurePrefix + command + windowsPowerShellExitSuffix
return []string{"-NoLogo", "-NoProfile", "-Command", script}
case shellKindCmd:
return zeroSandbox.WindowsShellArgs(command)
default:
return []string{"-c", command}
}
}
// HostShellCommand returns the shell executable and argv used for a command on
// this host. Keeping this in one place makes agent tools and the user-typed TUI
// shell escape use the same Windows shell and fallback behavior.
func HostShellCommand(command string) (string, []string) {
shell := detectShellRuntime(runtime.GOOS)
return shell.Executable, shell.arguments(command)
}
// NewHostShellCommandContext constructs a direct host-shell command. The
// cmd.exe fallback needs a Windows-specific raw command line, while PowerShell
// and POSIX shells use normal argv handling.
func NewHostShellCommandContext(ctx context.Context, commandText string) *exec.Cmd {
shell := detectShellRuntime(runtime.GOOS)
command := exec.CommandContext(ctx, shell.Executable, shell.arguments(commandText)...)
applyWindowsShellCommandLine(command, commandText, false, shell.Kind == shellKindCmd)
return command
}
func shellGuidanceForGOOS(goos string) string {
runtime := detectShellRuntime(goos)
if goos == "windows" {
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
return shellGuidanceForRuntime(detectShellRuntime(goos))
}
func shellGuidanceForRuntime(shell shellRuntime) string {
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
guidance := "Uses PowerShell syntax on Windows; prefer cwd/workdir over Set-Location when changing directories. Examples: Get-ChildItem -Force; Get-ChildItem -Recurse -Filter *.go; Get-ChildItem -Recurse | Select-String -Pattern 'TODO'; Get-Process | Where-Object { $_.ProcessName -like '*node*' }; $env:NAME='value'; @'\nprint('hello')\n'@ | python -. Do not invoke Git-for-Windows MSYS/Cygwin executables (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; prefer PowerShell cmdlets or native Zero tools."
return guidance + legacyPowerShellChainGuidance(shell)
}
guidance := "Uses " + runtime.Syntax + " syntax."
if goos == "darwin" {
if shell.GOOS == "windows" {
return "Uses " + shell.Syntax + " syntax on Windows because PowerShell was unavailable; prefer cwd/workdir over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
}
guidance := "Uses " + shell.Syntax + " syntax."
if shell.GOOS == "darwin" {
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
}
return guidance
}
// HostShellEnvironmentGuidance returns the concise, model-facing shell rule
// for the current host.
func HostShellEnvironmentGuidance() string {
return hostShellEnvironmentGuidanceForRuntime(detectShellRuntime(runtime.GOOS))
}
func hostShellEnvironmentGuidanceForRuntime(shell shellRuntime) string {
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
guidance := "Shell syntax: PowerShell for exec_command/bash tools. Use PowerShell cmdlets and pipelines (Get-ChildItem, Get-Content, Select-String, Select-Object), use $env:NAME='value' for environment variables, and prefer the workdir/cwd argument over Set-Location. Do not invoke Git-for-Windows MSYS binaries (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; use native PowerShell cmdlets or Zero tools instead, or sandbox_permissions require_escalated only when host-level execution is truly required."
return guidance + legacyPowerShellChainGuidance(shell)
}
if shell.GOOS == "windows" {
return "Shell syntax: Windows cmd.exe syntax for exec_command/bash tools because PowerShell is unavailable. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes. Do not invoke Git-for-Windows MSYS binaries inside the restricted sandbox; use native Zero tools instead. Prefer the workdir/cwd argument over cd."
}
return "Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories."
}
func legacyPowerShellChainGuidance(shell shellRuntime) string {
if !shell.isWindowsPowerShell() {
return ""
}
return " This host uses Windows PowerShell 5.1, which does not support && or ||. Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining."
}
func (shell shellRuntime) isWindowsPowerShell() bool {
if shell.Kind != shellKindPowerShell {
return false
}
executable := strings.TrimSpace(shell.Executable)
if index := strings.LastIndexAny(executable, `\/`); index >= 0 {
executable = executable[index+1:]
}
return strings.EqualFold(executable, "powershell") || strings.EqualFold(executable, "powershell.exe")
}
// MsysProneCommandName reports whether a bare command name commonly resolves to
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
func MsysProneCommandName(name string) bool {
@@ -172,7 +316,15 @@ func msysProneCommandWord(word string) bool {
}
func detectShellCommandIssue(command string, goos string) *shellIssue {
if goos != "windows" {
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
if goos == "windows" {
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return detectShellCommandIssueForRuntime(command, shell)
}
func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shellIssue {
if shell.GOOS != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
@@ -184,7 +336,24 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
// cat), so an escaped metachar can't stand in as a fake segment boundary
// either.
unquoted := stripCmdCaretEscapes(stripDoubleQuotedSpans(trimmed))
if shell.Kind == shellKindPowerShell {
unquoted = stripPowerShellQuotedSpans(trimmed)
if shell.isWindowsPowerShell() && (strings.Contains(unquoted, "&&") || strings.Contains(unquoted, "||")) {
return &shellIssue{
Kind: "windows_powershell_version",
Message: "Command uses && or ||, but this host runs Windows PowerShell 5.1, which does not support those operators.",
Suggestion: "Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining.",
}
}
}
if windowsBashStyleCDPattern.MatchString(unquoted) {
if shell.Kind == shellKindPowerShell {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs PowerShell commands on this Windows host.",
Suggestion: "Use the cwd/workdir argument instead of cd and use native PowerShell syntax or Zero tools such as list_directory, read_file, grep, and glob.",
}
}
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
@@ -198,18 +367,128 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
// Being segment/word anchored rather than a whole-string regex or scan,
// neither check matches text that only appears inside a quoted argument
// (e.g. a commit message or PR comment body discussing head.exe).
for _, segment := range windowsCommandSegments(trimmed) {
segments := windowsCommandSegments(trimmed)
if shell.Kind == shellKindPowerShell {
segments = powerShellCommandSegments(trimmed)
}
for _, segment := range segments {
word := firstCommandWord(segment)
if windowsMsysBinaryPathPattern.MatchString(word) {
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
}
if msysProneCommandWord(word) {
if shell.Kind == shellKindPowerShell && powerShellAliasWord(word) {
continue
}
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
}
}
return nil
}
// powerShellCommandSegments splits the subset of PowerShell syntax needed for
// command-position checks. It respects single/double quoted strings and the
// backtick escape, then splits on pipeline/statement operators. Complex script
// interpretation remains PowerShell's job; this scanner only determines
// whether a known-incompatible MSYS executable is actually invoked.
func powerShellCommandSegments(command string) []string {
var segments []string
var current strings.Builder
var quote rune
escaped := false
for _, c := range command {
if escaped {
current.WriteRune(c)
escaped = false
continue
}
if c == '`' {
current.WriteRune(c)
escaped = true
continue
}
if quote != 0 {
current.WriteRune(c)
if c == quote {
quote = 0
}
continue
}
if c == '\'' || c == '"' {
quote = c
current.WriteRune(c)
continue
}
if c == ';' || c == '|' || c == '&' {
if segment := strings.TrimSpace(current.String()); segment != "" {
segments = append(segments, segment)
}
current.Reset()
continue
}
current.WriteRune(c)
}
if segment := strings.TrimSpace(current.String()); segment != "" {
segments = append(segments, segment)
}
return segments
}
func powerShellAliasWord(word string) bool {
trimmed := strings.Trim(word, `"'`)
if strings.ContainsAny(trimmed, `\/`) || strings.HasSuffix(strings.ToLower(trimmed), ".exe") {
return false
}
switch strings.ToLower(trimmed) {
case "cat", "ls":
return true
default:
return false
}
}
func stripPowerShellQuotedSpans(command string) string {
var b strings.Builder
b.Grow(len(command))
var quote rune
escaped := false
runes := []rune(command)
for index := 0; index < len(runes); index++ {
c := runes[index]
if escaped {
b.WriteRune(' ')
escaped = false
continue
}
if c == '`' {
b.WriteRune(' ')
escaped = true
continue
}
if quote != 0 {
b.WriteRune(' ')
if c == quote {
// PowerShell escapes a single quote inside a single-quoted
// string by doubling it.
if quote == '\'' && index+1 < len(runes) && runes[index+1] == '\'' {
index++
b.WriteRune(' ')
continue
}
quote = 0
}
continue
}
if c == '\'' || c == '"' {
quote = c
b.WriteRune(' ')
continue
}
b.WriteRune(c)
}
return b.String()
}
// stripDoubleQuotedSpans replaces the contents of every double-quoted span
// (quotes included) with spaces, preserving the string's length and the
// position of unquoted text. cmd.exe treats a double-quoted span as a single
@@ -256,7 +535,15 @@ func stripCmdCaretEscapes(command string) string {
// quoted-text false positives the preflight command-position check exists to
// avoid, just after execution instead of before it.
func detectShellOutputIssue(output string, goos string) *shellIssue {
if goos != "windows" {
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
if goos == "windows" {
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return detectShellOutputIssueForRuntime(output, shell)
}
func detectShellOutputIssueForRuntime(output string, shell shellRuntime) *shellIssue {
if shell.GOOS != "windows" {
return nil
}
lower := strings.ToLower(output)
@@ -266,8 +553,8 @@ func detectShellOutputIssue(output string, goos string) *shellIssue {
if wslServiceDeniedInOutput(lower) {
return windowsMsysSandboxIssue("WSL bash could not connect to the WSL service under Zero's Windows sandbox (Bash/Service/CreateInstance/E_ACCESSDENIED).")
}
if strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command") {
if shell.Kind == shellKindCmd && (strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command")) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
+208
View File
@@ -1,10 +1,218 @@
package tools
import (
"errors"
"os/exec"
"runtime"
"strings"
"testing"
)
func TestDetectShellRuntimePrefersPowerShellSevenOnWindows(t *testing.T) {
lookPath := func(name string) (string, error) {
if name == "pwsh.exe" {
return `C:\Program Files\PowerShell\7\pwsh.exe`, nil
}
return "", errors.New("not found")
}
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\pwsh.exe`) {
t.Fatalf("shell = %#v, want PowerShell 7", shell)
}
}
func TestDetectShellRuntimeFallsBackThroughWindowsPowerShellToCmd(t *testing.T) {
t.Run("windows powershell", func(t *testing.T) {
lookPath := func(name string) (string, error) {
if name == "powershell.exe" {
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
}
return "", errors.New("not found")
}
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\powershell.exe`) {
t.Fatalf("shell = %#v, want Windows PowerShell", shell)
}
})
t.Run("cmd", func(t *testing.T) {
shell := detectShellRuntimeWithLookup(
"windows",
func(string) (string, error) { return "", errors.New("not found") },
func(string) string { return "" },
)
if shell.Kind != shellKindCmd || shell.Executable != "cmd.exe" {
t.Fatalf("shell = %#v, want cmd.exe fallback", shell)
}
})
}
func TestDetectShellRuntimeSkipsUnusablePowerShell(t *testing.T) {
lookPath := func(name string) (string, error) {
switch name {
case "pwsh.exe":
return `C:\broken\pwsh.exe`, nil
case "powershell.exe":
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
default:
return "", errors.New("not found")
}
}
shell := detectShellRuntimeWithProbe(
"windows",
lookPath,
func(string) string { return "" },
func(path string) bool { return !strings.Contains(path, `\broken\`) },
)
if shell.Kind != shellKindPowerShell || strings.Contains(shell.Executable, `\broken\`) {
t.Fatalf("shell = %#v, want usable Windows PowerShell fallback", shell)
}
}
func TestPowerShellArgumentsDisableProfilesAndRequestUTF8(t *testing.T) {
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
args := shell.arguments("Write-Output 'hello'")
joined := strings.Join(args, "\n")
for _, want := range []string{
"-NoLogo",
"-NoProfile",
"-Command",
windowsPowerShellUTF8Prefix,
"$ErrorActionPreference = 'Stop'",
"Write-Output 'hello'",
"exit $LASTEXITCODE",
} {
if !strings.Contains(joined, want) {
t.Fatalf("PowerShell args missing %q: %#v", want, args)
}
}
script := args[len(args)-1]
preferenceIndex := strings.Index(script, "$ErrorActionPreference")
commandIndex := strings.Index(script, "Write-Output 'hello'")
exitIndex := strings.Index(script, "exit $LASTEXITCODE")
if preferenceIndex < 0 || commandIndex <= preferenceIndex || exitIndex <= commandIndex {
t.Fatalf("PowerShell failure handling is not ordered around the command: %q", script)
}
}
func TestWindowsPowerShellArgumentsPropagateFailures(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows PowerShell integration test")
}
executable, err := exec.LookPath("powershell.exe")
if err != nil {
t.Skipf("Windows PowerShell unavailable: %v", err)
}
shell := shellRuntime{
GOOS: "windows",
Executable: executable,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
t.Run("native exit code after cmdlet", func(t *testing.T) {
command := exec.Command(executable, shell.arguments("cmd /c exit 5; Write-Output done")...)
output, err := command.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 5 {
t.Fatalf("exit = %v, output = %q; want exact native exit code 5", err, output)
}
if !strings.Contains(string(output), "done") {
t.Fatalf("output = %q, want command output before propagated failure", output)
}
})
t.Run("cmdlet error stops script", func(t *testing.T) {
missing := strings.ReplaceAll(t.TempDir()+`\missing`, `'`, `''`)
commandText := "Get-Item -LiteralPath '" + missing + "'; Write-Output after"
command := exec.Command(executable, shell.arguments(commandText)...)
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("cmdlet failure reported success: %q", output)
}
if strings.Contains(string(output), "after") {
t.Fatalf("script continued after cmdlet failure: %q", output)
}
})
}
func TestWindowsPowerShellGuidanceAvoidsUnsupportedChainOperators(t *testing.T) {
shell := shellRuntime{
GOOS: "windows",
Executable: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
for _, guidance := range []string{
shellGuidanceForRuntime(shell),
hostShellEnvironmentGuidanceForRuntime(shell),
} {
for _, want := range []string{"Windows PowerShell 5.1", "&&", "||", "if ($?)"} {
if !strings.Contains(guidance, want) {
t.Fatalf("legacy PowerShell guidance missing %q: %q", want, guidance)
}
}
}
}
func TestWindowsPowerShellCommandIssueFlagsUnsupportedChainOperators(t *testing.T) {
legacy := shellRuntime{
GOOS: "windows",
Executable: "powershell.exe",
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
for _, command := range []string{
"Write-Output one && Write-Output two",
"Write-Output one || Write-Output two",
} {
issue := detectShellCommandIssueForRuntime(command, legacy)
if issue == nil || issue.Kind != "windows_powershell_version" {
t.Fatalf("legacy PowerShell command %q issue = %#v, want version issue", command, issue)
}
}
for _, command := range []string{
`Write-Output "one && two"`,
"Write-Output 'one || two'",
"Write-Output one `&`& Write-Output two",
} {
if issue := detectShellCommandIssueForRuntime(command, legacy); issue != nil {
t.Fatalf("literal legacy PowerShell operators in %q produced issue %#v", command, issue)
}
}
modern := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
if issue := detectShellCommandIssueForRuntime("Write-Output one && Write-Output two", modern); issue != nil {
t.Fatalf("PowerShell 7 chain operator produced issue %#v", issue)
}
}
func TestPowerShellCommandIssueAllowsAliasesAndBlocksMsysExecutables(t *testing.T) {
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
for _, command := range []string{
`ls -Force`,
`cat README.md`,
`Get-ChildItem | Select-Object -First 10`,
`Write-Output 'grep README.md; head file.txt'`,
`Write-Output 'cd /tmp'`,
} {
if issue := detectShellCommandIssueForRuntime(command, shell); issue != nil {
t.Fatalf("PowerShell-native command %q was blocked: %#v", command, issue)
}
}
for _, command := range []string{
`grep README.md`,
`Get-Content README.md | head -10`,
`cat.exe README.md`,
`bash -lc "make test"`,
`Write-Output ok; sed -n '1,5p' README.md`,
} {
if issue := detectShellCommandIssueForRuntime(command, shell); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("MSYS-prone command %q was not blocked: %#v", command, issue)
}
}
}
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
-1
View File
@@ -83,7 +83,6 @@ type PropertySchema struct {
Minimum *int `json:"minimum,omitempty"`
Maximum *int `json:"maximum,omitempty"`
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
MinItems *int `json:"minItems,omitempty"`
// Properties/Required describe nested object fields (for Type "object" or an
// object-typed Items).
+2 -6
View File
@@ -83,7 +83,6 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
})
}
parent.btw = btwState{}
parent.goalContinuationsSuspended = true
// A scrollback print that was already scheduled may acknowledge after the
// side surface is active. The hidden model does not receive that unscoped
// acknowledgement, so clear its print latch and rebuild on return.
@@ -176,7 +175,6 @@ func (m model) leaveBTW() (model, tea.Cmd) {
}
m, _ = m.clearLoopsForSessionSwitch()
parent := *m.btw.parent
parent.goalContinuationsSuspended = false
parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID)
parent.btw = btwState{}
// A hidden parent completion may have scheduled an unscoped git-sweep result
@@ -198,15 +196,13 @@ func (m model) leaveBTW() (model, tea.Cmd) {
text: "Returned from the isolated BTW conversation. Its messages were not added to this session.",
})
parent.resetFlushFrontier("· returned from btw ·")
var goalCmd tea.Cmd
parent, goalCmd = parent.launchGoalContinuationIfReady()
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd)
return parent, batchCommands(sweepCmd, spinnerCmd)
}
func btwCommandUnavailable(command parsedCommand) bool {
arg := strings.ToLower(strings.TrimSpace(command.text))
switch command.kind {
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal,
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop,
commandRewind, commandCompact, commandSTTModel, commandMCP:
return true
case commandModel:
-45
View File
@@ -135,51 +135,6 @@ func TestBTWCanOpenWhileParentRunContinues(t *testing.T) {
}
}
func TestBTWHiddenParentDoesNotLaunchGoalContinuation(t *testing.T) {
m := newBTWTestModel(t)
goalSession, _, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, "Stay visible to the user", 0)
if err != nil {
t.Fatal(err)
}
m.activeSession = goalSession
m.provider = &fakeProvider{}
m.pending = true
m.runID = 7
m.activeRunID = 7
side, _ := m.handleBTWCommand("")
routed, _, ok := side.routeBTWParentMessage(agentResponseMsg{
runID: 7,
goalAware: true,
rows: []transcriptRow{{kind: rowAssistant, text: "main turn finished", final: true}},
})
if !ok || routed.btw.parent == nil {
t.Fatal("parent completion was not routed while BTW was active")
}
parent := routed.btw.parent
if parent.pending {
t.Fatal("hidden parent launched an automatic goal continuation")
}
if parent.activeSession.Goal == nil || parent.activeSession.Goal.ContinuationCount != 0 {
t.Fatalf("hidden parent consumed a continuation: %#v", parent.activeSession.Goal)
}
returned, cmd := routed.leaveBTW()
if cmd == nil || !returned.pending {
t.Fatalf("returning from BTW did not resume the deferred goal: pending=%v cmd=%v", returned.pending, cmd)
}
if returned.goalContinuationsSuspended {
t.Fatal("goal continuations remained suspended after leaving BTW")
}
if returned.activeSession.Goal == nil || returned.activeSession.Goal.ContinuationCount != 1 {
t.Fatalf("returning from BTW reserved an unexpected continuation: %#v", returned.activeSession.Goal)
}
again, duplicateCmd := returned.launchGoalContinuationIfReady()
if duplicateCmd != nil || again.activeSession.Goal.ContinuationCount != 1 {
t.Fatalf("returning from BTW launched more than one continuation: goal=%#v cmd=%v", again.activeSession.Goal, duplicateCmd)
}
}
func TestBTWInlineQuestionStartsSideRun(t *testing.T) {
m := newBTWTestModel(t)
m.provider = &fakeProvider{}
+6 -12
View File
@@ -2,12 +2,11 @@ package tui
import (
"context"
"os/exec"
"runtime"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
@@ -30,8 +29,7 @@ func runBashEscape(cwd, command string) tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), bashEscapeTimeout)
defer cancel()
name, shellArgs := escapeShell(command)
cmd := exec.CommandContext(ctx, name, shellArgs...)
cmd := tools.NewHostShellCommandContext(ctx, command)
if strings.TrimSpace(cwd) != "" {
cmd.Dir = cwd
}
@@ -51,15 +49,11 @@ func runBashEscape(cwd, command string) tea.Cmd {
}
}
// escapeShell returns the platform shell and arguments for a "!cmd" escape,
// matching the agent bash tool: cmd.exe on Windows, /bin/sh elsewhere. Hardcoding
// "bash" broke the escape on stock Windows (no bash on PATH) and anywhere bash is
// not installed at a predictable path.
// escapeShell returns the platform shell and arguments for tests and display.
// Execution itself uses tools.NewHostShellCommandContext so the cmd.exe
// fallback receives its required raw Windows command line.
func escapeShell(command string) (string, []string) {
if runtime.GOOS == "windows" {
return "cmd.exe", []string{"/d", "/s", "/c", command}
}
return "/bin/sh", []string{"-c", command}
return tools.HostShellCommand(command)
}
func appendNote(text, note string) string {
-8
View File
@@ -50,7 +50,6 @@ const (
commandBTW
commandSkills
commandLoop
commandGoal
commandVoice
commandSTTModel
commandUnknown
@@ -339,13 +338,6 @@ var commandDefinitions = []commandDefinition{
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
kind: commandLoop,
},
{
name: "/goal",
usage: "/goal [--tokens N] <objective> | status | pause | resume | edit | clear",
group: commandGroupSession,
description: "Create and pursue one persistent objective for this session.",
kind: commandGoal,
},
{
name: "/help",
usage: "/help",
-358
View File
@@ -1,358 +0,0 @@
package tui
import (
"context"
"fmt"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const goalContinuationPrompt = "Continue pursuing the active goal. Review the existing session context, make concrete progress, and use update_goal when the objective is complete or genuinely blocked."
func (m model) handleGoalCommand(args string) (model, tea.Cmd) {
action, rest := splitGoalCommand(args)
if rest != "" && (action == "status" || action == "pause" || action == "resume" || action == "clear") {
return m.appendGoalError("Usage: /goal " + action), nil
}
switch action {
case "status":
return m.appendGoalStatus(), nil
case "pause":
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
m.cancelRun()
return m, nil
}
return m.setGoalStatus(sessions.GoalStatusPaused, "paused by user"), nil
case "resume":
if m.pending {
return m.appendGoalError("A run is already in progress."), nil
}
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.activeSession.Goal.Status == sessions.GoalStatusBudgetLimited &&
m.activeSession.Goal.TokenBudget > 0 &&
m.activeSession.Goal.TokensUsed >= m.activeSession.Goal.TokenBudget {
return m.appendGoalError("The token budget is exhausted. Increase it with /goal edit --tokens N <objective>."), nil
}
m = m.setGoalStatus(sessions.GoalStatusActive, "")
return m.launchGoalContinuationIfReady()
case "clear":
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
m.cancelRun()
}
updated, event, err := m.sessionStore.ClearGoal(m.activeSession.SessionID)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal cleared."})
return m, nil
case "edit":
objective, budget, err := parseGoalObjective(rest)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
return m.appendGoalError("Pause the current run before editing its goal."), nil
}
if !goalBudgetSpecified(rest) {
budget = m.activeSession.Goal.TokenBudget
}
updated, event, err := m.sessionStore.EditGoal(m.activeSession.SessionID, objective, budget)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
message := "Goal updated and resumed: " + objective
if updated.Goal.Status == sessions.GoalStatusBudgetLimited {
message = "Goal updated, but its token budget is still exhausted. Increase the budget or use --tokens 0 for no goal-specific limit."
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message})
return m.launchGoalContinuationIfReady()
case "create":
if m.pending {
return m.appendGoalError("A run is already in progress."), nil
}
objective, budget, err := parseGoalObjective(rest)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
if m.activeSession.Goal != nil {
return m.appendGoalError("This session already has a goal. Use /goal edit <objective> or /goal clear first."), nil
}
m, err = m.ensureActiveSession(objective)
if err != nil {
return m.appendGoalError("session create error: " + err.Error()), nil
}
updated, event, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, objective, budget)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
return m.launchPrompt(objective)
default:
return m.appendGoalError("Unknown action. Use /goal, /goal pause, /goal resume, /goal edit <objective>, or /goal clear."), nil
}
}
func splitGoalCommand(args string) (string, string) {
trimmed := strings.TrimSpace(args)
if trimmed == "" || strings.EqualFold(trimmed, "status") {
return "status", ""
}
first, rest, _ := strings.Cut(trimmed, " ")
switch strings.ToLower(first) {
case "status", "pause", "resume", "clear":
return strings.ToLower(first), strings.TrimSpace(rest)
case "edit":
return "edit", strings.TrimSpace(rest)
default:
return "create", trimmed
}
}
func parseGoalObjective(input string) (string, int, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", 0, fmt.Errorf("goal objective is required")
}
budget := 0
if strings.HasPrefix(input, "--tokens") {
fields := strings.Fields(input)
if len(fields) < 3 || fields[0] != "--tokens" {
return "", 0, fmt.Errorf("usage: /goal [--tokens N] <objective>")
}
value, err := strconv.Atoi(fields[1])
if err != nil || value < 0 {
return "", 0, fmt.Errorf("goal token budget must be a non-negative integer")
}
budget = value
input = strings.TrimSpace(strings.Join(fields[2:], " "))
}
if input == "" {
return "", 0, fmt.Errorf("goal objective is required")
}
return input, budget, nil
}
func goalBudgetSpecified(input string) bool {
return strings.HasPrefix(strings.TrimSpace(input), "--tokens")
}
func (m model) appendGoalStatus() model {
goal := m.activeSession.Goal
if goal == nil {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Goal\nstatus: none\nStart one with /goal <objective>.",
})
return m
}
budget := "unlimited"
if goal.TokenBudget > 0 {
budget = fmt.Sprintf("%d / %d tokens", goal.TokensUsed, goal.TokenBudget)
} else if goal.TokensUsed > 0 {
budget = fmt.Sprintf("%d tokens used", goal.TokensUsed)
}
lines := []string{
"Goal",
"status: " + string(goal.Status),
"objective: " + goal.Objective,
"budget: " + budget,
fmt.Sprintf(
"automatic continuations: %d / %d",
goal.ContinuationCount,
goalContinuationLimit(goal),
),
}
if goal.StatusReason != "" {
lines = append(lines, "reason: "+goal.StatusReason)
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: strings.Join(lines, "\n")})
return m
}
func goalContinuationLimit(goal *sessions.Goal) int {
if goal != nil && goal.ContinuationLimit > 0 {
return goal.ContinuationLimit
}
return sessions.GoalMaxConsecutiveContinuations
}
func (m model) goalFooterSummary() string {
if m.activeSession.Goal == nil {
return ""
}
return "goal " + string(m.activeSession.Goal.Status)
}
func (m model) appendGoalError(message string) model {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: " + message})
return m
}
func (m model) setGoalStatus(status sessions.GoalStatus, reason string) model {
if m.sessionStore == nil || m.activeSession.SessionID == "" || m.activeSession.Goal == nil {
return m.appendGoalError("Goal state is unavailable.")
}
updated, event, err := m.sessionStore.UpdateGoal(m.activeSession.SessionID, status, reason)
if err != nil {
return m.appendGoalError(err.Error())
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Goal " + string(status) + ".",
})
return m
}
func (m model) goalRegistry() *tools.Registry {
registry := cloneToolRegistry(m.registry)
if m.activeSession.SessionID == "" {
return registry
}
for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) {
registry.Register(tool)
}
return registry
}
func (m model) goalSystemPrompt(base string) string {
goal := m.activeSession.Goal
if goal == nil {
return base
}
instruction := fmt.Sprintf(
"Persistent goal for this session:\nObjective: %s\nStatus: %s\n"+
"When the status is active, keep pursuing this objective across turns. "+
"Call update_goal with complete only after the objective is genuinely achieved, "+
"or blocked with a concrete reason when progress requires user input or an external change.",
goal.Objective,
goal.Status,
)
if strings.TrimSpace(base) == "" {
return instruction
}
return base + "\n\n" + instruction
}
func (m model) launchGoalContinuationIfReady() (model, tea.Cmd) {
goal := m.activeSession.Goal
if goal == nil || goal.Status != sessions.GoalStatusActive || m.pending ||
m.compactInFlight || m.exiting || m.provider == nil ||
m.goalContinuationsSuspended {
return m, nil
}
updated, event, reserved, err := m.sessionStore.ReserveGoalContinuation(m.activeSession.SessionID)
if err != nil {
return m.appendGoalError("reserve automatic continuation: " + err.Error()), nil
}
m.activeSession = updated
if event != nil {
m.sessionEvents = append(m.sessionEvents, *event)
}
if !reserved {
if event != nil && updated.Goal != nil && updated.Goal.Status == sessions.GoalStatusPaused {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: fmt.Sprintf(
"Goal paused after %d automatic continuations. Review progress, then use /goal resume to continue.",
goalContinuationLimit(updated.Goal),
),
})
}
return m, nil
}
goal = updated.Goal
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Continuing goal: " + goal.Objective,
})
prompt := m.sessionPrompt(goalContinuationPrompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "goal",
"content": goalContinuationPrompt,
})
if err != nil {
return m.appendGoalError("session record error: " + err.Error()), nil
}
runCtx, cancel := context.WithCancel(m.ctx)
m = m.beginRun(cancel)
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, nil), m.spinner.Tick)
}
func (m model) reconcileGoalAfterRun(usageEvents []zeroruntime.Usage, runErr error) model {
if m.sessionStore == nil || m.activeSession.SessionID == "" {
return m
}
loaded, err := m.sessionStore.Get(m.activeSession.SessionID)
if err != nil || loaded == nil {
if err != nil {
return m.appendGoalError("reload after run: " + err.Error())
}
return m
}
if loaded.Goal == nil {
return m
}
tokens := 0
for _, event := range usageEvents {
tokens += event.TotalTokens()
}
if tokens > 0 {
updated, _, addErr := m.sessionStore.AddGoalUsage(loaded.SessionID, tokens)
if addErr != nil {
return m.appendGoalError("account usage: " + addErr.Error())
}
loaded = &updated
}
if runErr != nil && loaded.Goal.Status == sessions.GoalStatusActive {
status := sessions.GoalStatusPaused
reason := "run stopped after an error"
if errhint.Classify(runErr) == errhint.RateLimit {
status = sessions.GoalStatusUsageLimited
reason = "provider usage limit reached"
}
updated, _, updateErr := m.sessionStore.UpdateGoal(
loaded.SessionID,
status,
reason,
)
if updateErr != nil {
return m.appendGoalError("pause after error: " + updateErr.Error())
}
loaded = &updated
message := "Goal paused because the run stopped with an error. Use /goal resume to continue."
if status == sessions.GoalStatusUsageLimited {
message = "Goal stopped at the provider usage limit. Use /goal resume when capacity is available."
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message})
}
m.activeSession = *loaded
if events, readErr := m.sessionStore.ReadEvents(loaded.SessionID); readErr == nil {
m.sessionEvents = events
}
return m
}
-362
View File
@@ -1,362 +0,0 @@
package tui
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestGoalCommandCreatesPersistentGoalAndStartsRun(t *testing.T) {
store := testSessionStore(t)
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
next, cmd := m.handleGoalCommand("--tokens 500 Ship the release")
if cmd == nil || !next.pending {
t.Fatal("creating a goal should start its first run")
}
if next.activeSession.Goal == nil ||
next.activeSession.Goal.Objective != "Ship the release" ||
next.activeSession.Goal.TokenBudget != 500 ||
next.activeSession.Goal.Status != sessions.GoalStatusActive {
t.Fatalf("active goal = %#v", next.activeSession.Goal)
}
loaded, err := store.Get(next.activeSession.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.Goal == nil || loaded.Goal.Objective != "Ship the release" {
t.Fatalf("persisted session = %#v", loaded)
}
}
func TestGoalCommandDoesNotCreateGoalDuringActiveRun(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_pending"})
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.pending = true
next, cmd := m.handleGoalCommand("Do not replace the active run")
if cmd != nil {
t.Fatal("goal creation during an active run returned a command")
}
if next.activeSession.Goal != nil {
t.Fatalf("goal was created during an active run: %#v", next.activeSession.Goal)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded.Goal != nil {
t.Fatalf("goal was persisted during an active run: %#v", loaded.Goal)
}
if !transcriptContains(next.transcript, "A run is already in progress.") {
t.Fatalf("missing active-run explanation: %#v", next.transcript)
}
}
func TestGoalRunRegistryContainsSessionBoundTools(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_tools"})
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
registry := m.goalRegistry()
for _, name := range []string{"get_goal", "create_goal", "update_goal"} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("goal registry missing %q", name)
}
}
if _, ok := m.registry.Get("get_goal"); ok {
t.Fatal("goal tools should not mutate the shared base registry")
}
}
func TestLoopRunExcludesGoalToolsAndInstructions(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep this out of loops", 0)
if err != nil {
t.Fatal(err)
}
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "Loop iteration complete."},
{Type: zeroruntime.StreamEventDone},
}}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.activeLoopID = "loop-1"
_ = execCmd(m.runAgentWithOptions(1, context.Background(), "run loop", nil, tuiAgentRunOptions{}))
if len(provider.requests) != 1 {
t.Fatalf("provider requests = %d, want 1", len(provider.requests))
}
request := provider.requests[0]
for _, definition := range request.Tools {
switch definition.Name {
case "get_goal", "create_goal", "update_goal":
t.Fatalf("loop request exposed goal tool %q", definition.Name)
}
}
for _, message := range request.Messages {
if strings.Contains(message.Content, "Persistent goal for this session:") {
t.Fatalf("loop request included goal instructions: %q", message.Content)
}
}
}
func TestLoopRunDoesNotConsumeGoalBudgetOrLaunchContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop_usage"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep loop usage separate", 10)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.pending = true
m.activeRunID = 1
m.activeLoopID = "loop-1"
updated, _ := m.Update(agentResponseMsg{
runID: 1,
goalAware: false,
usageEvents: []zeroruntime.Usage{{InputTokens: 8, OutputTokens: 4}},
rows: []transcriptRow{{kind: rowAssistant, text: "loop result", final: true}},
})
next := updated.(model)
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded.Goal.TokensUsed != 0 || loaded.Goal.ContinuationCount != 0 {
t.Fatalf("loop run mutated goal accounting: %#v", loaded.Goal)
}
if next.pending {
t.Fatal("loop completion launched a goal continuation")
}
}
func TestAgentCanCompleteGoalWithoutAnotherContinuation(t *testing.T) {
store := testSessionStore(t)
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "goal_done", ToolName: "update_goal"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "goal_done", ArgumentsFragment: `{"status":"complete"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "goal_done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "The goal is complete."},
{Type: zeroruntime.StreamEventDone},
},
}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: store,
})
running, cmd := m.handleGoalCommand("Finish the task")
response := execCmd(cmd)
if response == nil {
t.Fatal("goal run did not return an agent response")
}
updated, nextCmd := running.Update(response)
settled := updated.(model)
if settled.activeSession.Goal == nil || settled.activeSession.Goal.Status != sessions.GoalStatusComplete {
t.Fatalf("completed goal = %#v", settled.activeSession.Goal)
}
if settled.pending {
t.Fatal("completed goal started another continuation")
}
// Background title/recap/sweep commands may still be returned; none should
// have changed the settled goal back to active.
_ = nextCmd
}
func TestGoalBudgetStopsAutomaticContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_budget"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Stay bounded", 20)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m = m.reconcileGoalAfterRun([]zeroruntime.Usage{{InputTokens: 12, OutputTokens: 8}}, nil)
if m.activeSession.Goal.Status != sessions.GoalStatusBudgetLimited {
t.Fatalf("budgeted goal status = %q", m.activeSession.Goal.Status)
}
next, cmd := m.launchGoalContinuationIfReady()
if cmd != nil || next.pending {
t.Fatal("a budget-paused goal must not launch another run")
}
}
func TestActiveGoalLaunchesContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_continue"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
next, cmd := m.launchGoalContinuationIfReady()
if cmd == nil || !next.pending {
t.Fatal("active goal should launch a continuation while idle")
}
if !transcriptContains(next.transcript, "Continuing goal: Keep going") {
t.Fatalf("continuation was not surfaced: %#v", next.transcript)
}
}
func TestGoalContinuationChainStopsAtPersistedLimit(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_hard_stop"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Never run forever", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
for continuation := 1; continuation <= sessions.GoalMaxConsecutiveContinuations; continuation++ {
next, cmd := m.launchGoalContinuationIfReady()
if cmd == nil || !next.pending {
t.Fatalf("continuation %d did not launch", continuation)
}
if next.runCancel != nil {
next.runCancel()
}
next.runCancel = nil
next.pending = false
next.activeRunID = 0
m = next
}
stopped, cmd := m.launchGoalContinuationIfReady()
if cmd != nil || stopped.pending {
t.Fatal("goal exceeded its automatic continuation limit")
}
if stopped.activeSession.Goal.Status != sessions.GoalStatusPaused ||
!transcriptContains(stopped.transcript, "Goal paused after") {
t.Fatalf("hard stop was not surfaced: goal=%#v transcript=%#v", stopped.activeSession.Goal, stopped.transcript)
}
}
func TestGoalActionsRejectTrailingArguments(t *testing.T) {
for _, action := range []string{"status extra", "pause extra", "resume extra", "clear extra"} {
m := newModel(context.Background(), Options{})
next, cmd := m.handleGoalCommand(action)
if cmd != nil || !transcriptContains(next.transcript, "Usage: /goal") {
t.Fatalf("%q silently accepted trailing arguments: %#v", action, next.transcript)
}
}
}
func TestCancelRunPausesActiveGoal(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_cancel"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.activeSession = session
m.pending = true
m.activeRunID = 1
m.cancelRun()
if m.activeSession.Goal.Status != sessions.GoalStatusPaused {
t.Fatalf("cancelled goal status = %q", m.activeSession.Goal.Status)
}
if !transcriptContains(m.transcript, "Goal paused") {
t.Fatalf("cancel did not explain goal pause: %#v", m.transcript)
}
}
func TestParseGoalObjective(t *testing.T) {
objective, budget, err := parseGoalObjective("--tokens 1200 finish the migration")
if err != nil {
t.Fatal(err)
}
if objective != "finish the migration" || budget != 1200 {
t.Fatalf("parsed objective/budget = %q/%d", objective, budget)
}
if _, _, err := parseGoalObjective("--tokens nope task"); err == nil ||
!strings.Contains(err.Error(), "non-negative integer") {
t.Fatalf("invalid budget error = %v", err)
}
}
func TestGoalStatusIsVisibleInNarrowFooter(t *testing.T) {
m := newModel(context.Background(), Options{})
m.activeSession.Goal = &sessions.Goal{Objective: "Ship", Status: sessions.GoalStatusActive}
status := plainRender(t, m.statusLine(51))
if !strings.Contains(status, "goal active") {
t.Fatalf("narrow status omitted active goal: %q", status)
}
}
+22 -61
View File
@@ -245,13 +245,10 @@ type model struct {
loopCounter int
loopTicking bool
loopLeavePrompt commandKind
// goalContinuationsSuspended keeps a hidden parent from launching autonomous
// work while the user is in an isolated BTW conversation.
goalContinuationsSuspended bool
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
// to the session they were recording into AT CANCEL TIME. Each cancelled
// agent goroutine keeps running to completion and returns its accumulated
@@ -588,7 +585,6 @@ type agentResponseMsg struct {
sessionEvents []pendingSessionEvent
specReview *pendingSpecReviewPrompt
err error
goalAware bool
// Turn metadata for settled rows that do not otherwise carry it.
turnTools int
turnElapsed time.Duration
@@ -2216,7 +2212,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptEvent := permissionEventFromRequest(msg.request)
promptRow := permissionTranscriptRow(promptEvent)
// The focused modal owns the actionable reason while the decision is
// pending. Keep only structured block context in the transcript row so
// the same explanation is not shown immediately above the card.
if promptEvent.Block == nil {
promptRow.detail = ""
} else {
promptRow.detail = permissionBlockDetail(promptEvent)
}
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
m.pendingPermission = &pendingPermissionPrompt{
@@ -2410,9 +2415,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingText = nil
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
if msg.goalAware {
m = m.reconcileGoalAfterRun(msg.usageEvents, msg.err)
}
// Roll the completed run's wall-time into the session's rolling average so
// /context can surface typical turn latency, not just token counts.
if msg.turnElapsed > 0 {
@@ -2467,13 +2469,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// other loops remain.
m, loopTickCmd = m.ensureLoopTick()
}
hadQueuedMessage := strings.TrimSpace(m.queuedMessage) != ""
next, queuedCmd := m.launchQueuedMessageIfReady()
var goalCmd tea.Cmd
if msg.goalAware && !hadQueuedMessage && msg.specReview == nil {
next, goalCmd = next.launchGoalContinuationIfReady()
}
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd, goalCmd)
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -4349,8 +4346,6 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
return m.handleBTWCommand(command.text)
case commandLoop:
return m.handleLoopCommand(command.text)
case commandGoal:
return m.handleGoalCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
@@ -4755,14 +4750,6 @@ func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
text: "session create error: " + err.Error(),
})
} else {
if m.activeLoopID == "" && sessions.IsResumableKind(m.activeSession.SessionKind) &&
m.activeSession.Goal != nil && m.activeSession.Goal.Status == sessions.GoalStatusActive {
if updated, resetErr := m.sessionStore.ResetGoalContinuations(m.activeSession.SessionID); resetErr != nil {
m = m.appendGoalError("reset automatic continuation count: " + resetErr.Error())
} else {
m.activeSession = updated
}
}
agentPrompt := m.sessionPrompt(prompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "user",
@@ -4829,6 +4816,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
// suppressed by a stale preference.
m.sidebarHidden = false
m.turnStartedAt = m.now()
m.lastStreamActivity = m.turnStartedAt
m.turnStreamedRunes = 0
m.spinnerTicking = true
return m
@@ -4905,8 +4893,6 @@ func (m *model) rememberInput(value string) {
}
func (m *model) cancelRun() {
goalWasActive := m.pending && m.activeSession.Goal != nil &&
m.activeSession.Goal.Status == sessions.GoalStatusActive
if m.runCancel != nil {
m.runCancel()
}
@@ -4957,18 +4943,6 @@ func (m *model) cancelRun() {
*m = next
}
}
if goalWasActive && m.sessionStore != nil && m.activeSession.SessionID != "" {
updated, event, err := m.sessionStore.PauseGoalIfActive(m.activeSession.SessionID, "run cancelled by user")
if err != nil {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: pause after cancellation: " + err.Error()})
} else {
m.activeSession = updated
if event != nil {
m.sessionEvents = append(m.sessionEvents, *event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal paused. Use /goal resume to continue."})
}
}
}
m.pending = false
m.runCancel = nil
m.activeRunID = 0
@@ -5018,19 +4992,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
usageModelID := m.modelName
var specReview *pendingSpecReviewPrompt
options := m.agentOptions
options.Registry = cloneToolRegistry(m.registry)
goalAwareRun := !runOptions.specDraft && m.activeLoopID == "" &&
sessions.IsResumableKind(m.activeSession.SessionKind)
if goalAwareRun {
options.Registry = m.goalRegistry()
}
options.Registry = m.registry
if runOptions.registry != nil {
options.Registry = cloneToolRegistry(runOptions.registry)
if goalAwareRun && m.activeSession.SessionID != "" {
for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) {
options.Registry.Register(tool)
}
}
options.Registry = runOptions.registry
}
options.PermissionMode = m.permissionMode
if runOptions.permissionMode != "" {
@@ -5039,9 +5003,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
if runOptions.systemPrompt != "" {
options.SystemPrompt = runOptions.systemPrompt
}
if goalAwareRun {
options.SystemPrompt = m.goalSystemPrompt(options.SystemPrompt)
}
options.SessionID = m.activeSession.SessionID
options.ProviderName = m.providerName
options.Model = m.modelName
@@ -5450,7 +5411,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
@@ -5460,10 +5421,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
@@ -5488,7 +5449,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
}
}
+31
View File
@@ -1606,6 +1606,9 @@ func TestPermissionRequestShowsFocusedPrompt(t *testing.T) {
if strings.Contains(view, "risk:") || strings.Contains(view, "risk=") {
t.Fatalf("focused permission prompt must not render risk labels, got %q", view)
}
if count := strings.Count(view, request.Reason); count != 1 {
t.Fatalf("focused permission reason rendered %d times, want once:\n%s", count, view)
}
}
func TestPermissionPromptChoicesResolveDecision(t *testing.T) {
@@ -1720,6 +1723,34 @@ func TestPermissionRowRendersSandboxBlocks(t *testing.T) {
}
}
func TestPermissionRowRendersIdenticalEventAndBlockReasonOnce(t *testing.T) {
const reason = "Reading /home/dev/.nvm requires access outside the workspace."
event := agent.PermissionEvent{
ToolCallID: "call_read",
ToolName: "list_directory",
Action: agent.PermissionActionDeny,
DecisionAction: agent.PermissionDecisionDeny,
SideEffect: "read",
Reason: reason,
Scope: "/home/dev/.nvm",
Block: &sandbox.Block{
Code: sandbox.BlockOutsideWorkspace,
Path: "/home/dev/.nvm",
Reason: reason,
},
}
row := permissionTranscriptRow(event)
if count := strings.Count(event.Reason+"\n"+row.detail, reason); count != 1 {
t.Fatalf("permission reason represented %d times, want once: detail=%q", count, row.detail)
}
for _, want := range []string{"denied by user", "outside workspace", "path: /home/dev/.nvm"} {
if !strings.Contains(row.detail, want) {
t.Fatalf("permission detail missing %q: %q", want, row.detail)
}
}
}
func TestAppendTranscriptRowDedupesRuntimeRowsByID(t *testing.T) {
event := agent.PermissionEvent{
ToolCallID: "call_1",
+23
View File
@@ -126,6 +126,29 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
}
}
func TestPermissionPromptWrapsLongReasonWithoutTruncating(t *testing.T) {
request := agent.PermissionRequest{
ToolName: "list_directory",
SideEffect: "read",
Reason: "Reading /home/dev/projects/a-very-long-directory-name/with/nested/configuration requires access outside the workspace.",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
},
}
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 60)
got := plainRender(t, card)
for _, want := range []string{"Reading", "/home/dev/projects", "requires access outside the workspace."} {
if !strings.Contains(got, want) {
t.Fatalf("permission card truncated reason; missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "…") {
t.Fatalf("permission card must wrap the decision reason instead of truncating it:\n%s", got)
}
}
func TestPermissionOptionsExposePersistentCommandPrefixApproval(t *testing.T) {
request := agent.PermissionRequest{
ToolName: "bash",
+7 -1
View File
@@ -1131,7 +1131,13 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
}
lines := []string{top, body}
if reason := permissionDisplayReason(request.Reason); reason != "" {
lines = append(lines, fill(zeroTheme.muted).Render(reason))
reasonWidth := width
if widthTier(width) != tierTiny {
reasonWidth = maxInt(1, width-4)
}
for _, line := range wrapPlainText(reason, reasonWidth) {
lines = append(lines, fill(zeroTheme.muted).Render(line))
}
}
// Surface exactly what the grant covers (file/dir/host) so "always" is a
// clear, bounded choice rather than a blind tool-wide yes.
+7 -15
View File
@@ -317,26 +317,18 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st
if recorded := strings.TrimSpace(session.Provider); recorded != "" && !strings.EqualFold(recorded, m.providerName) {
providerLine += " (recorded: " + recorded + ")"
}
lines := []string{
"id: " + session.SessionID,
"title: " + displayValue(session.Title, "untitled"),
modelLine,
providerLine,
fmt.Sprintf("events: %d", eventCount),
}
if session.Goal != nil {
goalLine := "goal: " + string(session.Goal.Status) + " — " + session.Goal.Objective
if session.Goal.Status == sessions.GoalStatusActive {
goalLine += " (run /goal resume to continue)"
}
lines = append(lines, goalLine)
}
return renderCommandOutput(commandOutput{
Title: "Resumed Zero session",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "Session",
Lines: lines,
Lines: []string{
"id: " + session.SessionID,
"title: " + displayValue(session.Title, "untitled"),
modelLine,
providerLine,
fmt.Sprintf("events: %d", eventCount),
},
}},
})
}
-29
View File
@@ -1056,35 +1056,6 @@ func TestResumedPromptIncludesSessionContext(t *testing.T) {
}
}
func TestResumeActiveGoalDoesNotStartAutomaticRun(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "resume_active_goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Wait for explicit resume", 0); err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.input.SetValue("/resume " + session.SessionID)
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil || next.pending {
t.Fatal("resuming a session unexpectedly started a billed goal run")
}
if next.activeSession.Goal == nil || next.activeSession.Goal.Status != sessions.GoalStatusActive {
t.Fatalf("active goal was not restored: %#v", next.activeSession.Goal)
}
if !transcriptContains(next.transcript, "run /goal resume to continue") {
t.Fatalf("resume did not explain how to continue the goal: %#v", next.transcript)
}
}
func TestResumeCommandReportsMissingSession(t *testing.T) {
m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)})
m.input.SetValue("/resume missing_session")
+7 -3
View File
@@ -398,8 +398,10 @@ func permissionDetailText(event agent.PermissionEvent) string {
if event.GrantMatched {
parts = append(parts, "approved by saved permission")
}
if event.Reason != "" {
parts = append(parts, permissionDisplayReason(event.Reason))
if event.Action == agent.PermissionActionPrompt {
if reason := permissionDisplayReason(event.Reason); reason != "" {
parts = append(parts, reason)
}
}
if event.Block != nil {
parts = append(parts, permissionBlockDetail(event))
@@ -438,7 +440,9 @@ func permissionBlockDetail(event agent.PermissionEvent) string {
if path := strings.TrimSpace(event.Block.Path); path != "" {
parts = append(parts, "path: "+path)
}
if reason := permissionDisplayReason(event.Block.Reason); reason != "" {
reason := permissionDisplayReason(event.Block.Reason)
eventReason := permissionDisplayReason(event.Reason)
if reason != "" && reason != eventReason {
parts = append(parts, reason)
}
return strings.Join(parts, " ")
+6 -6
View File
@@ -42,19 +42,19 @@ func TestBashEscapeGatedByPermissionMode(t *testing.T) {
}
}
// The "!" escape must use the platform shell (cmd.exe on Windows, /bin/sh
// elsewhere), not a hardcoded "bash" that is absent on stock Windows.
// The "!" escape must use the same detected host shell as the agent tools.
func TestEscapeShellWrapsCommandForPlatform(t *testing.T) {
name, args := escapeShell("echo hi")
if name == "" {
t.Fatal("escapeShell returned an empty executable")
}
if len(args) == 0 || args[len(args)-1] != "echo hi" {
t.Fatalf("escapeShell args = %v, want the command as the final arg", args)
if len(args) == 0 || !strings.Contains(args[len(args)-1], "echo hi") {
t.Fatalf("escapeShell args = %v, want the command in the final arg", args)
}
if runtime.GOOS == "windows" {
if name != "cmd.exe" || args[0] != "/d" {
t.Fatalf("windows shell = %q %v, want cmd.exe /d /s /c", name, args)
lowerName := strings.ToLower(filepath.Base(name))
if lowerName != "pwsh.exe" && lowerName != "powershell.exe" && lowerName != "cmd.exe" {
t.Fatalf("windows shell = %q %v, want PowerShell or cmd.exe fallback", name, args)
}
} else if name != "/bin/sh" || args[0] != "-c" {
t.Fatalf("unix shell = %q %v, want /bin/sh -c", name, args)
-6
View File
@@ -211,9 +211,6 @@ func (m model) statusLine(width int) string {
if dictation := m.dictationStatusChip(); dictation != "" {
return fitStyledLine(prefix+btwChip+dictation, width)
}
if goalSummary := m.goalFooterSummary(); goalSummary != "" {
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary)
}
return fitStyledLine(left, width)
}
@@ -243,9 +240,6 @@ func (m model) statusLine(width int) string {
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
// running loop is always visible (hidden during an exit/cancel confirm above).
if !m.exitConfirmActive && !m.cancelConfirmActive {
if goalSummary := m.goalFooterSummary(); goalSummary != "" {
left += separator + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary)
}
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
}
+14
View File
@@ -106,6 +106,20 @@ func TestQuietGenerationHint(t *testing.T) {
}
}
func TestBeginRunResetsQuietGenerationClock(t *testing.T) {
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
m := model{
now: func() time.Time { return now },
lastStreamActivity: now.Add(-time.Minute),
}
m = m.beginRun(nil)
if got := m.quietGenerationHint(); got != "" {
t.Fatalf("new run inherited a stale quiet-generation warning: %q", got)
}
}
// TestQuietGenerationHintEscalatesPastHalfIdleTimeout: a heartbeating-but-
// silent stream (chatgpt/gpt-5.x, ollama reasoning models — see
// providerio.ErrStreamStalled) and a genuinely healthy-but-slow one look
+1 -1
View File
@@ -52,7 +52,7 @@ case "$(go env GOOS)" in
ZERO_SANDBOX_REAL_SMOKE=1 \
ZERO_WINDOWS_COMMAND_RUNNER_EXE="$tmpdir/windows-zero-windows-command-runner.exe" \
ZERO_WINDOWS_SANDBOX_SETUP_EXE="$tmpdir/windows-zero-windows-sandbox-setup.exe" \
go test ./internal/sandbox -run TestWindowsRestrictedTokenRealSandboxSmoke -count=1
go test ./internal/sandbox -run 'TestWindowsRestrictedTokenRealSandboxSmoke|TestWindowsRestrictedTokenPowerShell' -count=1
;;
*)
echo "No real sandbox smoke is defined for this host platform."