mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7ce57a39 | ||
|
|
20032342e5 | ||
|
|
81a5e6cb71 | ||
|
|
5d1869e7bc | ||
|
|
518d2e53ba | ||
|
|
d82b94defe | ||
|
|
88fb7b6192 | ||
|
|
0125e70d0f | ||
|
|
f5fa4ffc64 | ||
|
|
9fbdc34155 | ||
|
|
583e6530bd | ||
|
|
ac50a5a840 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
*.go text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.sum text eol=lf
|
||||
+33
-12
@@ -30,6 +30,36 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install Make on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: choco install make --no-progress -y
|
||||
|
||||
- name: Validate quality targets with stock macOS Make
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 1
|
||||
GIT_CONFIG_KEY_0: grep.patternType
|
||||
GIT_CONFIG_VALUE_0: fixed
|
||||
run: make -n vulncheck
|
||||
|
||||
- name: Check vulnerabilities through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: cmd
|
||||
run: make vulncheck
|
||||
|
||||
- name: Check dead code through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make deadcode
|
||||
|
||||
- name: Run static lint through native Windows Make
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: cmd
|
||||
run: make lint-static
|
||||
|
||||
- name: Check formatting
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
@@ -100,27 +130,18 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||
# their own modules, which can drop below the version go.mod requires and
|
||||
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||
# so all three run under it.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV"
|
||||
|
||||
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
|
||||
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
|
||||
# published advisory on an unrelated PR — intentional: do not ship known vulns.
|
||||
- name: govulncheck
|
||||
run: go run golang.org/x/vuln/cmd/[email protected] ./...
|
||||
run: make vulncheck
|
||||
|
||||
# Advisory: reports functions unreachable from any cmd/* main so dormant
|
||||
# code is visible in CI. Non-blocking while the dormant subsystems are
|
||||
# still being wired or removed.
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||
run: make deadcode
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
@@ -130,4 +151,4 @@ jobs:
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||
run: make lint-static
|
||||
@@ -51,10 +51,8 @@ request, or completing an implementation task:
|
||||
or run focused tests with `-race`, when concurrency is affected.
|
||||
4. **Build**: `go run ./cmd/zero-release build`.
|
||||
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
|
||||
6. **Advisory lint**:
|
||||
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
7. **Security**:
|
||||
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
6. **Advisory lint**: `make lint-static`.
|
||||
7. **Security**: `make vulncheck`.
|
||||
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
|
||||
tracked changes).
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
# Zero build/test/lint targets. AGENTS.md says to build and run quality checks
|
||||
# with `make` — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
|
||||
GO_VERSION = $(word 2,$(shell git grep -G -h "^go[[:space:]]" -- go.mod))
|
||||
GO_TOOLCHAIN = go$(GO_VERSION)
|
||||
DEADCODE_VERSION := v0.46.0
|
||||
GOLANGCI_LINT_VERSION := v2.12.2
|
||||
GOVULNCHECK_VERSION := v1.3.0
|
||||
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -33,6 +39,22 @@ fmt-check:
|
||||
# Lint = formatting check + vet (no extra tooling required).
|
||||
lint: fmt-check vet
|
||||
|
||||
# Versioned tools select the toolchain from their own modules when invoked with
|
||||
# package@version. Read this module's go directive directly, without invoking
|
||||
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
|
||||
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
|
||||
# The target-specific export is shell-independent.
|
||||
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
|
||||
|
||||
lint-static:
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
|
||||
|
||||
deadcode:
|
||||
go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -test=false ./...
|
||||
|
||||
vulncheck:
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
@@ -55,4 +77,4 @@ baseline: build
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
|
||||
@@ -367,22 +367,16 @@ variable unset and set.
|
||||
|
||||
### Code Quality and Security Checks
|
||||
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. The `make` targets below pin each tool to this module's Go version, so they load correctly even when your default `go` toolchain is older — running the plain `go run ...@version` form yourself can select the tool module's own (older) toolchain and fail to load this module instead.
|
||||
|
||||
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
|
||||
2. **Vetting**: Run `go vet ./...` (or `make vet`).
|
||||
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
1. **Formatting**: Run `make fmt` (or `go fmt ./...`).
|
||||
2. **Vetting**: Run `make vet` (or `go vet ./...`).
|
||||
3. **Linting**: Run `make lint-static`.
|
||||
4. **Vulnerability Scan**: Run `make vulncheck`.
|
||||
|
||||
If you prefer to install these tools globally on your path, you can run:
|
||||
|
||||
```bash
|
||||
# Install golangci-lint
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
|
||||
# Install govulncheck
|
||||
go install golang.org/x/vuln/cmd/[email protected]
|
||||
```
|
||||
Use the repository-managed targets rather than globally installed binaries:
|
||||
the targets apply the module's required Go toolchain as well as the reviewed,
|
||||
pinned tool versions.
|
||||
|
||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||
|
||||
+2
-2
@@ -108,10 +108,10 @@ Discovery roots (earlier wins on name collisions):
|
||||
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
||||
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove/lock target)
|
||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
|
||||
@@ -1617,9 +1617,15 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
|
||||
|
||||
func sandboxDeniedShellResult(result tools.Result) bool {
|
||||
if result.ExecutionOutcome != nil {
|
||||
// Typed denials must be handled by their exact capability path. Never
|
||||
// turn a structured narrow denial into the legacy unrestricted retry.
|
||||
return false
|
||||
// Only a typed platform denial that explicitly requests unrestricted
|
||||
// recovery may enter the legacy retry path. Narrow capability denials
|
||||
// remain on their exact capability path.
|
||||
denial := result.ExecutionOutcome.Denial
|
||||
return denial != nil &&
|
||||
denial.Source == execution.DenialSourcePlatformSandbox &&
|
||||
denial.Capability.Kind == execution.CapabilityUnrestricted &&
|
||||
denial.Recoverable &&
|
||||
denial.NextAction == execution.DenialNextActionRequestApproval
|
||||
}
|
||||
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
||||
}
|
||||
|
||||
+51
-14
@@ -64,6 +64,22 @@ func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
nonRecoverableDenial := protectedDenial
|
||||
nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox enforcement failed closed",
|
||||
Recoverable: false,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) {
|
||||
t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
networkDenial := protectedDenial
|
||||
networkDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
@@ -288,6 +304,18 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any)
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "sandbox blocked command execution",
|
||||
Recoverable: true,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
Meta: map[string]string{
|
||||
"exit_code": "1",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
@@ -2404,7 +2432,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 +2557,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 +2663,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 +3202,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)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
|
||||
if !c.failureTripped && !c.riskTripped && !c.scTripped && !c.uncertainTrip {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
c.escalated = true
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report {
|
||||
if len(task.RequiredTraceEvents) > 0 {
|
||||
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
||||
}
|
||||
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
|
||||
report.Results = append(report.Results, result)
|
||||
}
|
||||
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
|
||||
report.finishSummary()
|
||||
return report
|
||||
}
|
||||
|
||||
@@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
|
||||
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
||||
|
||||
if info.PrimaryLanguage != "" {
|
||||
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
|
||||
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
|
||||
}
|
||||
if langs := topLanguages(info.Languages, 5); langs != "" {
|
||||
b.WriteString("- Languages: " + langs + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
|
||||
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
|
||||
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
||||
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
|
||||
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
|
||||
}
|
||||
if len(info.BuildTools) > 0 {
|
||||
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
||||
|
||||
@@ -149,12 +149,9 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
|
||||
// The forked child must be gone too (poll until reaped by init).
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
break // child no longer exists
|
||||
}
|
||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||
|
||||
@@ -515,9 +515,7 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
|
||||
if task.Agent.Truncated {
|
||||
converted.Truncated = true
|
||||
}
|
||||
for _, failure := range agentEvalFailuresFromTaskReport(task) {
|
||||
converted.Failures = append(converted.Failures, failure)
|
||||
}
|
||||
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ func runSkillInfo(args []string, dir string, stdout io.Writer, stderr io.Writer)
|
||||
}
|
||||
if info.Hash != "" {
|
||||
lines = append(lines, " hash: "+info.Hash)
|
||||
lines = append(lines, fmt.Sprintf(" hash drift: %t", info.HashDrift))
|
||||
}
|
||||
lines = append(lines, " path: "+info.Skill.Path)
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(strings.Join(lines, "\n"), redaction.Options{})); err != nil {
|
||||
|
||||
@@ -83,6 +83,48 @@ func TestRunSkillAddInfoRemove(t *testing.T) {
|
||||
if !strings.Contains(info, src) || !strings.Contains(info, "sha256:") {
|
||||
t.Fatalf("info missing source/hash:\n%s", info)
|
||||
}
|
||||
if !strings.Contains(info, "hash drift: false") {
|
||||
t.Fatalf("info missing hash drift status:\n%s", info)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info --json exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
var beforeJSON map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &beforeJSON); err != nil {
|
||||
t.Fatalf("parse skill info json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if beforeJSON["hashDrift"] != false {
|
||||
t.Fatalf("hashDrift before edit = %#v, want false", beforeJSON["hashDrift"])
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(skillsDir, "confirmation-policy", "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte("---\ndescription: edited\n---\nchanged\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info after edit exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "hash drift: true") {
|
||||
t.Fatalf("info missing hash drift after edit:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if exit := runWithDeps([]string{"skill", "info", "confirmation-policy", "--json"}, &stdout, &stderr, deps); exit != 0 {
|
||||
t.Fatalf("skill info --json after edit exit = %d, stderr = %s", exit, stderr.String())
|
||||
}
|
||||
var afterJSON map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &afterJSON); err != nil {
|
||||
t.Fatalf("parse skill info json after edit: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if afterJSON["hashDrift"] != true {
|
||||
t.Fatalf("hashDrift after edit = %#v, want true", afterJSON["hashDrift"])
|
||||
}
|
||||
|
||||
// remove deletes it
|
||||
stdout.Reset()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,12 +25,47 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
|
||||
var heads, posts atomic.Int64
|
||||
// Closed when the prewarm probe lands, so the turn can be held open until it
|
||||
// does. See the POST handler for why that ordering has to be forced.
|
||||
headSeen := make(chan struct{})
|
||||
var headOnce sync.Once
|
||||
var prewarmWaitTimedOut atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodHead:
|
||||
heads.Add(1)
|
||||
headOnce.Do(func() { close(headSeen) })
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
|
||||
// Hold the turn open until the prewarm probe arrives. The probe is
|
||||
// issued on the run's context, and runExec cancels that context the
|
||||
// moment it returns, because the stop function signalContext hands
|
||||
// back is a CancelFunc. A probe that has not reached the wire by the
|
||||
// end of the run therefore never reaches it at all. That is why the
|
||||
// poll this replaced could not work at any deadline: once the run is
|
||||
// over the probe is cancelled, not merely slow.
|
||||
//
|
||||
// This widens the window, it does not close it. Once the goroutine is
|
||||
// scheduled it still has only prewarmTimeout to dial loopback and
|
||||
// write the request, and that timeout is armed inside the goroutine
|
||||
// itself, so holding the turn here cannot extend it. If this test
|
||||
// ever fails again, prewarmTimeout is where to look, not this hold.
|
||||
//
|
||||
// No self-deadlock: the pinned transport leaves MaxConnsPerHost at 0,
|
||||
// so the HEAD dials its own connection instead of queueing behind
|
||||
// this held POST. Do not set a per-host connection limit there.
|
||||
//
|
||||
// Bounded, so a probe that genuinely never fires fails the assertion
|
||||
// below with a useful message instead of hanging the test.
|
||||
select {
|
||||
case <-headSeen:
|
||||
case <-time.After(10 * time.Second):
|
||||
// Record it. A probe that shows up after this gave up would still
|
||||
// leave heads at 1, so the count alone proves the probe was
|
||||
// eventually received, not that it was ordered before the turn,
|
||||
// which is the whole claim of this test.
|
||||
prewarmWaitTimedOut.Store(true)
|
||||
}
|
||||
posts.Add(1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n"))
|
||||
@@ -75,14 +111,13 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
if got := posts.Load(); got < 1 {
|
||||
t.Fatalf("server saw %d chat-completions POSTs, want >= 1", got)
|
||||
}
|
||||
// The prewarm probe is asynchronous; it fires well before the model turn
|
||||
// finishes, but poll rather than assuming scheduling order. The deadline
|
||||
// exceeds the production prewarmTimeout (3s) so a slow probe cannot flake
|
||||
// this assertion.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for heads.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Checked before the count, because the count cannot tell the two apart: a
|
||||
// probe that arrived only after the guard expired still leaves heads at 1.
|
||||
if prewarmWaitTimedOut.Load() {
|
||||
t.Fatal("the turn was not held until the prewarm probe arrived; the probe did not precede it")
|
||||
}
|
||||
// No polling: the turn above could not complete until the probe landed, so
|
||||
// the count is settled by this point.
|
||||
if got := heads.Load(); got != 1 {
|
||||
t.Fatalf("server saw %d prewarm HEAD probes, want exactly 1", got)
|
||||
}
|
||||
|
||||
@@ -734,14 +734,12 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
|
||||
switch {
|
||||
case legacyFound:
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
case config.IsDefaultMCPServer(name):
|
||||
// A built-in default server isn't written to the file until the user
|
||||
// overrides it. Treat it as present with an empty base so disabling it
|
||||
// writes a minimal {"disabled":true} entry that merges over the default —
|
||||
// letting `zero mcp disable <default>` work even though it lives in code.
|
||||
raw = nil
|
||||
found = true
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
@@ -83,10 +83,10 @@ func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
|
||||
options := providerDetectOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
case "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
|
||||
@@ -244,19 +244,19 @@ func parseUsageArgs(args []string) (usageOptions, bool, error) {
|
||||
func FormatReport(report usage.Report, insertions int, deletions int) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n")
|
||||
builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost"))
|
||||
fmt.Fprintf(&builder, "%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost")
|
||||
for _, bucket := range report.Buckets {
|
||||
builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n",
|
||||
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)))
|
||||
fmt.Fprintf(&builder, "%-12s %10d %14s %14s\n",
|
||||
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost))
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n",
|
||||
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)))
|
||||
fmt.Fprintf(&builder, "\n%-12s %10d %14s %14s\n",
|
||||
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost))
|
||||
|
||||
builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n",
|
||||
insertions, deletions, report.NetLOC))
|
||||
fmt.Fprintf(&builder, "\nnet LOC (estimate): +%d / -%d = %d\n",
|
||||
insertions, deletions, report.NetLOC)
|
||||
if report.NetLOCPositive {
|
||||
builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC))
|
||||
builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)))
|
||||
fmt.Fprintf(&builder, "tokens per net LOC: %.1f\n", report.TokensPerNetLOC)
|
||||
fmt.Fprintf(&builder, "est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC))
|
||||
} else {
|
||||
builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n")
|
||||
builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n")
|
||||
|
||||
@@ -77,7 +77,13 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
|
||||
proc.Terminate()
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
// Wrap rather than replace. A descendant holding the inherited pipes
|
||||
// open and the command never finishing at all are different failures
|
||||
// that both collapsed into the same bare sentinel, which left callers
|
||||
// and tests unable to tell which one happened. Callers that only ask
|
||||
// errors.Is(err, errProviderCommandTimeout) are unaffected, so the
|
||||
// message LoadProviderCommand reports is unchanged.
|
||||
return stdout.Bytes(), stderr.Bytes(), fmt.Errorf("%w: %w", errProviderCommandTimeout, err)
|
||||
}
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
case <-timer.C:
|
||||
|
||||
+126
-27
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -91,6 +92,29 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// providerCommandTestBudget bounds the two background-child tests below, and is
|
||||
// deliberately NOT providerCommandTimeout. Those tests assert which error each
|
||||
// cmd.Wait path yields and that the leftover descendant is killed; neither is a
|
||||
// claim about how fast Windows can start a process. Driving them through
|
||||
// LoadProviderCommand billed the fixture's own startup to the production 5s
|
||||
// budget, so on a loaded runner the outer timer won the select in
|
||||
// runProviderCommand: the nonzero-exit test saw a timeout instead of the exit
|
||||
// status it asserts, and the timer's Terminate killed the background child
|
||||
// before it recorded its PID, putting the PID file permanently out of reach no
|
||||
// matter how long the assertion polled for it.
|
||||
//
|
||||
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
|
||||
// it, since both tests return about one WaitDelay after the fixture exits.
|
||||
const providerCommandTestBudget = 60 * time.Second
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
// returned yet trips the budget first. That is what lets both tests drop their
|
||||
// old elapsed-versus-lifetime bounds. "Still alive when the call returned" is
|
||||
// now true by construction, so a child found dead afterwards can only have been
|
||||
// killed by proc.Terminate.
|
||||
const backgroundChildLifetime = 120 * time.Second
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
|
||||
// exits immediately but leaves a detached child holding the inherited
|
||||
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
|
||||
@@ -98,24 +122,22 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
// leftover child instead of just returning and leaking it.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
t.Fatal("runProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out after 5s") {
|
||||
t.Fatalf("error = %q, want timeout", err.Error())
|
||||
}
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
// Name the exact path. The outer-timer path reports the bare sentinel, so
|
||||
// matching only that, or the "timed out after 5s" text this test used to
|
||||
// match, would also be satisfied by a slow runner taking precisely the path
|
||||
// this test exists to rule out.
|
||||
if !errors.Is(err, errProviderCommandTimeout) || !errors.Is(err, exec.ErrWaitDelay) {
|
||||
t.Fatalf("error = %v, want errProviderCommandTimeout wrapping exec.ErrWaitDelay", err)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
@@ -128,29 +150,56 @@ func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
// be terminated on that path.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "boom",
|
||||
ExitCode: 7,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit")
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
// The shell's own exit status has to survive. A timeout here means the
|
||||
// budget swallowed it, which is exactly what this test kept reporting on CI.
|
||||
if errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the shell's exit status rather than a timeout", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exit status") {
|
||||
t.Fatalf("error = %q, want exit status failure", err.Error())
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error = %v, want *exec.ExitError from the nonzero exit", err)
|
||||
}
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
if exitErr.ExitCode() != 7 {
|
||||
t.Fatalf("exit code = %d, want 7", exitErr.ExitCode())
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func TestAssertProcessTerminatedWaitsForDelayedPIDFile(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "delayed.pid")
|
||||
firstMissing := make(chan struct{})
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
<-firstMissing
|
||||
tempFile := pidFile + ".tmp"
|
||||
if err := os.WriteFile(tempFile, []byte("2147483647\n"), 0o600); err != nil {
|
||||
writeDone <- err
|
||||
return
|
||||
}
|
||||
writeDone <- os.Rename(tempFile, pidFile)
|
||||
}()
|
||||
|
||||
missingObserved := false
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, func() {
|
||||
missingObserved = true
|
||||
close(firstMissing)
|
||||
})
|
||||
if !missingObserved {
|
||||
t.Fatal("PID file existed before the missing-file polling path was exercised")
|
||||
}
|
||||
if err := <-writeDone; err != nil {
|
||||
t.Fatalf("write delayed pid file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -167,16 +216,41 @@ func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
|
||||
func assertProcessTerminated(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(pidFile)
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, nil)
|
||||
}
|
||||
|
||||
func assertProcessTerminatedAfterMissing(t *testing.T, pidFile string, onMissing func()) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var data []byte
|
||||
var err error
|
||||
missingObserved := false
|
||||
for time.Now().Before(deadline) {
|
||||
data, err = os.ReadFile(pidFile)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
}
|
||||
if !missingObserved {
|
||||
missingObserved = true
|
||||
if onMissing != nil {
|
||||
onMissing()
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
t.Fatalf("read sleeper pid file before timeout: %v", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sleeper pid %q: %v", data, err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
return
|
||||
@@ -253,8 +327,33 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
|
||||
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
|
||||
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
|
||||
waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }"
|
||||
lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"")
|
||||
// Wait for the child to record its PID before exiting, so the PID is
|
||||
// on disk while the tree is still alive: runProviderCommand kills the
|
||||
// tree one WaitDelay after the script exits, and a child that has not
|
||||
// written by then never will.
|
||||
//
|
||||
// This waits in cmd itself. Spawning a second PowerShell just to poll
|
||||
// cost another interpreter cold start, and that start is pure overhead
|
||||
// added AFTER the PID is already written. On a loaded runner it pushed
|
||||
// the script's exit past the 5s provider-command timeout, so the
|
||||
// nonzero-exit test saw a timeout instead of the exit status it
|
||||
// asserts. The wait is also bounded now: giving up and letting the PID
|
||||
// assertion fail is far more diagnosable than blocking until the
|
||||
// timeout fires and reporting an unrelated error. Each iteration is
|
||||
// about a second, since `ping -n 2` sleeps between its two echoes,
|
||||
// so the ceiling is roughly 15s: far longer than any cold PowerShell
|
||||
// start seen on a runner, and far shorter than
|
||||
// providerCommandTestBudget, so giving up surfaces a legible
|
||||
// PID-file failure rather than a timeout that hides the real cause.
|
||||
lines = append(lines,
|
||||
":zeroWaitReady",
|
||||
"if exist \""+readyFile+"\" goto zeroReady",
|
||||
"set /a ZERO_READY_TRIES+=1",
|
||||
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
)
|
||||
}
|
||||
lines = append(lines, "exit /b "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
|
||||
|
||||
@@ -87,7 +87,7 @@ func mergeMCPServer(base MCPServerConfig, next MCPServerConfig, canReenable bool
|
||||
// server the user explicitly enabled. The only layer allowed to
|
||||
// override an explicit higher-scope decision is the CLI override scope
|
||||
// (canReenable=true).
|
||||
if canReenable || !(baseDisabledSet && baseDisabled != next.Disabled) {
|
||||
if canReenable || !baseDisabledSet || baseDisabled == next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err)
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
|
||||
@@ -48,7 +48,7 @@ func buildMinimalPDF(text string) []byte {
|
||||
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
|
||||
buf.WriteString("0000000000 65535 f \n")
|
||||
for _, off := range offsets {
|
||||
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
|
||||
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
|
||||
@@ -277,7 +277,7 @@ func buildEmptyTextPDF() []byte {
|
||||
buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n")
|
||||
buf.WriteString("0000000000 65535 f \n")
|
||||
for _, off := range offsets {
|
||||
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n")
|
||||
buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
@@ -64,7 +65,7 @@ func TestDecodeSymbols(t *testing.T) {
|
||||
|
||||
func TestNavigateUnknownOp(t *testing.T) {
|
||||
m := NewManager(t.TempDir())
|
||||
_, _, ok, err := m.Navigate(nil, NavRequest{Op: "bogus", Path: "x.go"})
|
||||
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: "bogus", Path: "x.go"})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unknown nav op")
|
||||
}
|
||||
@@ -76,7 +77,7 @@ func TestNavigateUnknownOp(t *testing.T) {
|
||||
func TestNavigateUnsupportedExtensionDegrades(t *testing.T) {
|
||||
// A file type with no configured server degrades to ok=false, no error.
|
||||
m := NewManager(t.TempDir())
|
||||
_, _, ok, err := m.Navigate(nil, NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
|
||||
_, _, ok, err := m.Navigate(context.TODO(), NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("unsupported extension should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
|
||||
}
|
||||
if addr, err := netip.ParseAddr(normalized); err == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return nil
|
||||
@@ -407,7 +407,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
@@ -529,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
|
||||
return nil, err
|
||||
}
|
||||
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
@@ -542,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
|
||||
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) {
|
||||
if !detected[0].Reachable {
|
||||
t.Fatalf("runtime should be reachable: %#v", detected[0])
|
||||
}
|
||||
if detected[0].Models == nil || len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
|
||||
if len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
|
||||
t.Fatalf("expected discovered model list, got %#v", detected[0].Models)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func writePromptLine(builder *strings.Builder, format string, args ...any) {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(format, args...))
|
||||
fmt.Fprintf(builder, format, args...)
|
||||
}
|
||||
|
||||
func clampBudget(text string, budget int) string {
|
||||
|
||||
@@ -225,7 +225,7 @@ func (backend Backend) SandboxEnvMarkers(policy Policy) []string {
|
||||
if policy.Mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
if !(backend.CommandWrapping && backend.Available) && backend.Name != BackendWSL {
|
||||
if (!backend.CommandWrapping || !backend.Available) && backend.Name != BackendWSL {
|
||||
return nil
|
||||
}
|
||||
name := backend.Name
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -153,10 +153,7 @@ func unsafeCommandPrefix(prefix []string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if unsafeCommandPrefixLauncher(prefix[0]) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return unsafeCommandPrefixLauncher(prefix[0])
|
||||
}
|
||||
|
||||
func unsafeCommandPrefixPart(part string) bool {
|
||||
|
||||
@@ -249,7 +249,7 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool {
|
||||
return false
|
||||
}
|
||||
backend := engine.backend
|
||||
if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) {
|
||||
if !backend.Available || backend.Executable == "" || !backend.CommandWrapping || !backend.NativeIsolation {
|
||||
return false
|
||||
}
|
||||
// On Windows the command is only actually wrapped once `zero sandbox setup`
|
||||
|
||||
@@ -74,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()
|
||||
|
||||
+155
-7
@@ -16,7 +16,11 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
const grantSchemaVersion = 3
|
||||
const (
|
||||
grantSchemaVersion = 3
|
||||
grantLockTimeout = 5 * time.Second
|
||||
grantLockRetry = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
type Grant struct {
|
||||
ToolName string `json:"toolName"`
|
||||
@@ -139,7 +143,40 @@ func (store *GrantStore) ConsumeMigrationNotice() (string, error) {
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
// Look for a pending notice WITHOUT taking the interprocess lock. Acquiring
|
||||
// that lock creates the grants directory and opens <grants>.lockfile, so doing
|
||||
// it unconditionally would make this call require a WRITABLE grants directory
|
||||
// even when it has nothing to write. Every startup and every `zero exec` calls
|
||||
// this, so a user with no grants file whose configured grants path (or its
|
||||
// parent) sits on a read-only mount would fail outright with "failed to
|
||||
// migrate sandbox grants", where reading alone reports an empty state and
|
||||
// writes nothing.
|
||||
state, needsMigration, err := store.readStateFile(false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !needsMigration && (state.Migration == nil || !state.Migration.NoticePending) {
|
||||
return "", nil
|
||||
}
|
||||
// Past here a write is genuinely required — either a schema/policy migration,
|
||||
// which only a lock holder may perform, or clearing NoticePending — so the
|
||||
// lock is warranted and a read-only grants path legitimately fails (as it did
|
||||
// before this lock existed, via readState's own migration path).
|
||||
//
|
||||
// Clearing the pending flag is a read-modify-write like any other mutator, so
|
||||
// it takes the same interprocess lock: two frontends starting at once must not
|
||||
// both read NoticePending, both print the notice, and clobber each other's
|
||||
// write of the rest of the state.
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer unlock()
|
||||
// Re-read under the lock. The unlocked peek above is only a snapshot: another
|
||||
// process may have consumed the notice or migrated the file while this one
|
||||
// waited, and when needsMigration was set that snapshot's contents are
|
||||
// meaningless by readStateFile's contract.
|
||||
state, err = store.readStateLocked()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -161,7 +198,13 @@ func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
@@ -193,7 +236,13 @@ func (store *GrantStore) GrantCommandPrefix(input CommandPrefixInput) (CommandPr
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return CommandPrefixGrant{}, err
|
||||
}
|
||||
@@ -337,7 +386,13 @@ func (store *GrantStore) Revoke(toolName string) (int, error) {
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -372,7 +427,13 @@ func (store *GrantStore) RevokePath(toolName string, scopePath string) (int, err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -407,7 +468,13 @@ func (store *GrantStore) RevokePath(toolName string, scopePath string) (int, err
|
||||
func (store *GrantStore) Clear() (int, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
state, err := store.readState()
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readStateLocked()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -537,7 +604,52 @@ func reconcileScope(scope string, kind ScopeKind) (string, ScopeKind) {
|
||||
return scope, kind
|
||||
}
|
||||
|
||||
// readState reads the grant file for a caller that does NOT hold the
|
||||
// interprocess lock (the lookup/list paths). A schema or policy migration has to
|
||||
// write — a backup plus the migrated state — so it is performed under the lock,
|
||||
// and the read is retaken there because another process may have migrated the
|
||||
// file while this one waited.
|
||||
func (store *GrantStore) readState() (grantFile, error) {
|
||||
state, needsMigration, err := store.readStateFile(false)
|
||||
if err != nil || !needsMigration {
|
||||
return state, err
|
||||
}
|
||||
unlock, err := store.lockStateFile()
|
||||
if err != nil {
|
||||
return grantFile{}, err
|
||||
}
|
||||
defer unlock()
|
||||
return store.readStateLocked()
|
||||
}
|
||||
|
||||
// readStateLocked reads the grant file for a caller that already holds the
|
||||
// interprocess lock, so a migration writes through directly instead of
|
||||
// re-acquiring it (which would deadlock against the caller's own lock).
|
||||
func (store *GrantStore) readStateLocked() (grantFile, error) {
|
||||
state, _, err := store.readStateFile(true)
|
||||
return state, err
|
||||
}
|
||||
|
||||
// readStateFile decodes the grant file. needsMigration reports that the state on
|
||||
// disk must be rewritten (legacy schema or a changed policy version) and that the
|
||||
// caller has to retake this read while holding the interprocess lock; the
|
||||
// returned state is meaningless in that case.
|
||||
func (store *GrantStore) readStateFile(locked bool) (grantFile, bool, error) {
|
||||
state, err := store.decodeState(locked)
|
||||
if err != nil {
|
||||
if errors.Is(err, errGrantMigrationNeedsLock) {
|
||||
return grantFile{}, true, nil
|
||||
}
|
||||
return grantFile{}, false, err
|
||||
}
|
||||
return state, false, nil
|
||||
}
|
||||
|
||||
// errGrantMigrationNeedsLock is the internal signal from decodeState that the
|
||||
// file needs a migration write, which only a lock holder may perform.
|
||||
var errGrantMigrationNeedsLock = errors.New("sandbox grants file needs a migration write")
|
||||
|
||||
func (store *GrantStore) decodeState(locked bool) (grantFile, error) {
|
||||
data, err := os.ReadFile(store.filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -559,12 +671,18 @@ func (store *GrantStore) readState() (grantFile, error) {
|
||||
return grantFile{}, store.invalidGrantFile(err)
|
||||
}
|
||||
if head.SchemaVersion == 1 || head.SchemaVersion == 2 {
|
||||
if !locked {
|
||||
return grantFile{}, errGrantMigrationNeedsLock
|
||||
}
|
||||
return store.migrateLegacyState(data, head.SchemaVersion, head.Grants, head.CommandPrefixes)
|
||||
}
|
||||
if head.SchemaVersion != grantSchemaVersion {
|
||||
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
|
||||
}
|
||||
if head.PolicyVersion != execution.PolicyVersion {
|
||||
if !locked {
|
||||
return grantFile{}, errGrantMigrationNeedsLock
|
||||
}
|
||||
return store.migrateChangedPolicy(data, head.PolicyVersion, head.Grants, head.CommandPrefixes)
|
||||
}
|
||||
buckets := map[string][]Grant{}
|
||||
@@ -800,6 +918,36 @@ func (store *GrantStore) writeState(state grantFile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *GrantStore) lockStateFile() (func(), error) {
|
||||
lockPath := store.filePath + ".lockfile"
|
||||
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(grantLockTimeout)
|
||||
for {
|
||||
locked, err := tryLockGrantFile(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if locked {
|
||||
return func() {
|
||||
_ = unlockGrantFile(file)
|
||||
_ = file.Close()
|
||||
}, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("timed out waiting for sandbox grants lock at %s", lockPath)
|
||||
}
|
||||
time.Sleep(grantLockRetry)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStoredGrant(name string, grant Grant) (Grant, error) {
|
||||
if strings.TrimSpace(grant.ToolName) == "" {
|
||||
grant.ToolName = name
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func tryLockGrantFile(file *os.File) (bool, error) {
|
||||
err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func unlockGrantFile(file *os.File) error {
|
||||
return syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
grantLockfileFailImmediately = 0x00000001
|
||||
grantLockfileExclusiveLock = 0x00000002
|
||||
grantErrorLockViolation = syscall.Errno(33)
|
||||
grantErrorSharingViolation = syscall.Errno(32)
|
||||
)
|
||||
|
||||
var (
|
||||
grantKernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
grantProcLockFileEx = grantKernel32.NewProc("LockFileEx")
|
||||
grantProcUnlockFileEx = grantKernel32.NewProc("UnlockFileEx")
|
||||
)
|
||||
|
||||
func tryLockGrantFile(file *os.File) (bool, error) {
|
||||
var overlapped syscall.Overlapped
|
||||
result, _, err := grantProcLockFileEx.Call(
|
||||
file.Fd(),
|
||||
uintptr(grantLockfileExclusiveLock|grantLockfileFailImmediately),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&overlapped)),
|
||||
)
|
||||
if result != 0 {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, grantErrorLockViolation) || errors.Is(err, grantErrorSharingViolation) {
|
||||
return false, nil
|
||||
}
|
||||
if err == syscall.Errno(0) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func unlockGrantFile(file *os.File) error {
|
||||
var overlapped syscall.Overlapped
|
||||
result, _, err := grantProcUnlockFileEx.Call(
|
||||
file.Fd(),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&overlapped)),
|
||||
)
|
||||
if result != 0 || err == syscall.Errno(0) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
|
||||
@@ -70,6 +73,246 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStoreSerializesWritesAcrossStores(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore A returned error: %v", err)
|
||||
}
|
||||
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore B returned error: %v", err)
|
||||
}
|
||||
|
||||
unlock, err := storeA.lockStateFile()
|
||||
if err != nil {
|
||||
t.Fatalf("lockStateFile returned error: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := storeB.Grant(GrantInput{ToolName: "write_file", Decision: GrantAllow})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
unlock()
|
||||
t.Fatalf("Grant completed while another store held the file lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Expected: storeB is waiting for storeA to release the lock.
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Grant returned error after file lock release: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Grant did not complete after file lock release")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantStoreSerializesMigrationNoticeAcrossStores covers the other
|
||||
// read-modify-write on the state file: clearing the pending flag. Two frontends
|
||||
// starting at once must not both read it as pending and clobber each other.
|
||||
func TestGrantStoreSerializesMigrationNoticeAcrossStores(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
if err := writeText(path, `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}}}`); err != nil {
|
||||
t.Fatalf("write v1 grants: %v", err)
|
||||
}
|
||||
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore A returned error: %v", err)
|
||||
}
|
||||
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore B returned error: %v", err)
|
||||
}
|
||||
// Migrate first, so the notice is pending and the lock contention below is
|
||||
// about consuming it rather than about the migration write.
|
||||
if _, err := storeA.List(); err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
|
||||
unlock, err := storeA.lockStateFile()
|
||||
if err != nil {
|
||||
t.Fatalf("lockStateFile returned error: %v", err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
notice, err := storeB.ConsumeMigrationNotice()
|
||||
if err == nil && notice == "" {
|
||||
err = errors.New("expected the pending migration notice")
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
unlock()
|
||||
t.Fatalf("ConsumeMigrationNotice completed while another store held the file lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Expected: storeB is waiting for storeA to release the lock.
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeMigrationNotice after lock release: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ConsumeMigrationNotice did not complete after file lock release")
|
||||
}
|
||||
// The flag write landed, so the notice is not reported twice.
|
||||
if again, err := storeA.ConsumeMigrationNotice(); err != nil || again != "" {
|
||||
t.Fatalf("second ConsumeMigrationNotice = %q err=%v, want empty", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsumeMigrationNoticeWritesNothingWithoutAPendingNotice is the regression
|
||||
// test for jatmn's #755 finding: ConsumeMigrationNotice took the interprocess
|
||||
// lock unconditionally, and acquiring that lock MkdirAlls the grants directory
|
||||
// and creates <grants>.lockfile. Every startup and every `zero exec` calls this,
|
||||
// so it turned "read the grants state" into "require a writable grants
|
||||
// directory" — a user with no grants file whose grants path sat on a read-only
|
||||
// mount failed outright with "failed to migrate sandbox grants".
|
||||
//
|
||||
// Asserting that nothing is created is the portable form of that requirement: it
|
||||
// holds identically on every OS and as any user, unlike a read-only-directory
|
||||
// test (see the sibling test, which cannot run everywhere).
|
||||
func TestConsumeMigrationNoticeWritesNothingWithoutAPendingNotice(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "config")
|
||||
path := filepath.Join(dir, "sandbox-grants.json")
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
|
||||
notice, err := store.ConsumeMigrationNotice()
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeMigrationNotice with no grants file: %v", err)
|
||||
}
|
||||
if notice != "" {
|
||||
t.Fatalf("notice = %q, want empty when nothing was migrated", notice)
|
||||
}
|
||||
// Nothing at all may be created: not the lock file, not the grants file, not
|
||||
// even the directory that would hold them.
|
||||
if _, err := os.Lstat(dir); !os.IsNotExist(err) {
|
||||
entries, _ := os.ReadDir(dir)
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("the grants directory was created (contents: %v); reading a state with no pending notice must not require a writable directory", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsumeMigrationNoticeToleratesAReadOnlyGrantsDirectory exercises the
|
||||
// actual failure from the same finding, rather than only its portable proxy
|
||||
// above: with the grants directory read-only and no grants file, the pre-fix
|
||||
// code failed at MkdirAll/OpenFile for the lock file.
|
||||
//
|
||||
// It cannot run everywhere — Windows ignores the mode bits used here, and root
|
||||
// bypasses directory permissions entirely — so it skips instead of pretending to
|
||||
// cover those platforms.
|
||||
func TestConsumeMigrationNoticeToleratesAReadOnlyGrantsDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix directory mode bits do not deny writes on Windows")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root bypasses directory permissions, so a read-only directory cannot be simulated")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sandbox-grants.json")
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o500); err != nil {
|
||||
t.Skipf("cannot make the grants directory read-only: %v", err)
|
||||
}
|
||||
// Restore write permission so t.TempDir's cleanup can remove the directory.
|
||||
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
|
||||
// Confirm the directory really is read-only here; otherwise this test would
|
||||
// pass without exercising anything.
|
||||
if probe, err := os.OpenFile(filepath.Join(dir, ".probe"), os.O_CREATE|os.O_RDWR, 0o600); err == nil {
|
||||
_ = probe.Close()
|
||||
t.Skip("this filesystem still allows writes to a 0500 directory")
|
||||
}
|
||||
|
||||
notice, err := store.ConsumeMigrationNotice()
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeMigrationNotice on a read-only grants directory: %v", err)
|
||||
}
|
||||
if notice != "" {
|
||||
t.Fatalf("notice = %q, want empty when nothing was migrated", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantStoreMigrationTakesTheFileLock covers the migrations that write
|
||||
// through readState: a legacy file must not be rewritten (nor its backup written)
|
||||
// while another process holds the lock, or two processes could both migrate and
|
||||
// one would clobber the other's result.
|
||||
func TestGrantStoreMigrationTakesTheFileLock(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
original := `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}}}`
|
||||
if err := writeText(path, original); err != nil {
|
||||
t.Fatalf("write v1 grants: %v", err)
|
||||
}
|
||||
storeA, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore A returned error: %v", err)
|
||||
}
|
||||
storeB, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore B returned error: %v", err)
|
||||
}
|
||||
|
||||
unlock, err := storeA.lockStateFile()
|
||||
if err != nil {
|
||||
t.Fatalf("lockStateFile returned error: %v", err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := storeB.List()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
unlock()
|
||||
t.Fatalf("the migration ran while another store held the file lock: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
if raw, err := os.ReadFile(path); err != nil {
|
||||
unlock()
|
||||
t.Fatalf("ReadFile grants: %v", err)
|
||||
} else if string(raw) != original {
|
||||
unlock()
|
||||
t.Fatalf("the grant file was rewritten while the lock was held:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("List after lock release: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the migration did not complete after file lock release")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile migrated grants: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"schemaVersion": 3`) {
|
||||
t.Fatalf("grant file was not migrated after the lock was released:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "sandbox-grants.json")
|
||||
|
||||
@@ -38,7 +38,7 @@ func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error
|
||||
}
|
||||
writeRoots := landlockWritableRoots(profile.FileSystem, cwd)
|
||||
if len(writeRoots) == 0 {
|
||||
return errors.New("Landlock requires at least one writable root")
|
||||
return errors.New("Landlock requires at least one writable root") //nolint:staticcheck // Preserve the proper name and established user-facing text.
|
||||
}
|
||||
return installLandlockFilesystemRules(writeRoots)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ package sandbox
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux")
|
||||
var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux") //nolint:staticcheck // Preserve the exported sentinel's established text.
|
||||
|
||||
func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error {
|
||||
return ErrLandlockUnsupported
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -378,7 +379,31 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
awsDir := filepath.Join(home, ".aws")
|
||||
gcloudDir := filepath.Join(home, ".config", "gcloud")
|
||||
if err := mkdirAll(awsDir, gcloudDir); err != nil {
|
||||
npmrc := filepath.Join(home, ".npmrc")
|
||||
ghHosts := filepath.Join(home, ".config", "gh", "hosts.yml")
|
||||
netrc := filepath.Join(home, ".netrc")
|
||||
dockerConfig := filepath.Join(home, ".docker", "config.json")
|
||||
kubeConfig := filepath.Join(home, ".kube", "config")
|
||||
zeroConfig := filepath.Join(home, ".config", "zero")
|
||||
if err := mkdirAll(
|
||||
awsDir,
|
||||
gcloudDir,
|
||||
filepath.Dir(ghHosts),
|
||||
filepath.Dir(dockerConfig),
|
||||
filepath.Dir(kubeConfig),
|
||||
filepath.Dir(zeroConfig),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{npmrc, ghHosts, netrc, dockerConfig, kubeConfig, zeroConfig} {
|
||||
if path == zeroConfig {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(zeroConfig, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyFile := filepath.Join(home, "sa-key.json")
|
||||
@@ -387,7 +412,17 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
}
|
||||
|
||||
paths := credentialDenyReadPathsIn(home, keyFile, nil)
|
||||
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
|
||||
for _, want := range normalizeProfilePaths([]string{
|
||||
awsDir,
|
||||
gcloudDir,
|
||||
keyFile,
|
||||
npmrc,
|
||||
ghHosts,
|
||||
netrc,
|
||||
dockerConfig,
|
||||
kubeConfig,
|
||||
zeroConfig,
|
||||
}) {
|
||||
if !stringSliceContains(paths, want) {
|
||||
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
|
||||
}
|
||||
@@ -418,3 +453,58 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
|
||||
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configHome := filepath.Join(root, "xdg")
|
||||
npmrc := filepath.Join(root, "npm", "userconfig")
|
||||
ghConfigDir := filepath.Join(root, "gh")
|
||||
cloudSDKConfig := filepath.Join(root, "gcloud")
|
||||
netrc := filepath.Join(root, "netrc")
|
||||
dockerConfigDir := filepath.Join(root, "docker")
|
||||
kubeConfigA := filepath.Join(root, "kube", "a")
|
||||
kubeConfigB := filepath.Join(root, "kube", "b")
|
||||
zeroConfig := filepath.Join(configHome, "zero")
|
||||
filePaths := []string{
|
||||
npmrc,
|
||||
filepath.Join(ghConfigDir, "hosts.yml"),
|
||||
netrc,
|
||||
filepath.Join(dockerConfigDir, "config.json"),
|
||||
kubeConfigA,
|
||||
kubeConfigB,
|
||||
zeroConfig,
|
||||
}
|
||||
if err := os.MkdirAll(cloudSDKConfig, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range filePaths {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path == zeroConfig {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
ConfigHome: configHome,
|
||||
CloudSDKConfig: cloudSDKConfig,
|
||||
NPMUserConfig: npmrc,
|
||||
GHConfigDir: ghConfigDir,
|
||||
Netrc: netrc,
|
||||
DockerConfigDir: dockerConfigDir,
|
||||
KubeConfig: strings.Join([]string{kubeConfigA, kubeConfigB}, string(filepath.ListSeparator)),
|
||||
}, nil)
|
||||
wantPaths := append(filePaths, cloudSDKConfig)
|
||||
for _, want := range normalizeProfilePaths(wantPaths) {
|
||||
if !stringSliceContains(got, want) {
|
||||
t.Errorf("credential deny paths = %#v, want override %q included", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
-12
@@ -145,9 +145,10 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
|
||||
}
|
||||
|
||||
// credentialDenyReadPaths returns default deny-read entries for well-known
|
||||
// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file
|
||||
// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read
|
||||
// cloud secrets under the read-all workspace posture. Two deliberate limits:
|
||||
// credential stores so sandboxed commands cannot read secrets under the
|
||||
// read-all workspace posture. This includes tool configuration files that can
|
||||
// carry credentials and are now discoverable through the preserved caller
|
||||
// environment. Two deliberate limits:
|
||||
//
|
||||
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
|
||||
// runner onto the capability-SID/ACL deny path and away from the
|
||||
@@ -166,23 +167,100 @@ func credentialDenyReadPaths(policy Policy) []string {
|
||||
// A failed home lookup only drops the home-based candidates; the
|
||||
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
|
||||
home, _ := os.UserHomeDir()
|
||||
return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead)
|
||||
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
Home: home,
|
||||
ConfigHome: os.Getenv("XDG_CONFIG_HOME"),
|
||||
CloudSDKConfig: os.Getenv("CLOUDSDK_CONFIG"),
|
||||
GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
|
||||
NPMUserConfig: firstNonEmpty(os.Getenv("NPM_CONFIG_USERCONFIG"), os.Getenv("npm_config_userconfig")),
|
||||
GHConfigDir: os.Getenv("GH_CONFIG_DIR"),
|
||||
Netrc: os.Getenv("NETRC"),
|
||||
DockerConfigDir: os.Getenv("DOCKER_CONFIG"),
|
||||
KubeConfig: os.Getenv("KUBECONFIG"),
|
||||
}, policy.AllowRead)
|
||||
}
|
||||
|
||||
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
|
||||
// separated so tests can exercise it against a synthetic home directory.
|
||||
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
|
||||
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
|
||||
Home: home,
|
||||
GoogleCredentials: googleCredentials,
|
||||
}, allowRead)
|
||||
}
|
||||
|
||||
type credentialPathEnvironment struct {
|
||||
Home string
|
||||
ConfigHome string
|
||||
CloudSDKConfig string
|
||||
GoogleCredentials string
|
||||
NPMUserConfig string
|
||||
GHConfigDir string
|
||||
Netrc string
|
||||
DockerConfigDir string
|
||||
KubeConfig string
|
||||
}
|
||||
|
||||
func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowRead []string) []string {
|
||||
var candidates []string
|
||||
if home = strings.TrimSpace(home); home != "" {
|
||||
home := strings.TrimSpace(env.Home)
|
||||
configHome := strings.TrimSpace(env.ConfigHome)
|
||||
if configHome == "" && home != "" {
|
||||
configHome = filepath.Join(home, ".config")
|
||||
}
|
||||
if home != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".aws"),
|
||||
filepath.Join(home, ".config", "gcloud"),
|
||||
filepath.Join(home, ".azure"),
|
||||
)
|
||||
}
|
||||
if target := strings.TrimSpace(googleCredentials); target != "" {
|
||||
npmUserConfig := strings.TrimSpace(env.NPMUserConfig)
|
||||
if npmUserConfig == "" && home != "" {
|
||||
npmUserConfig = filepath.Join(home, ".npmrc")
|
||||
}
|
||||
netrc := strings.TrimSpace(env.Netrc)
|
||||
if netrc == "" && home != "" {
|
||||
netrc = filepath.Join(home, ".netrc")
|
||||
}
|
||||
dockerConfigDir := strings.TrimSpace(env.DockerConfigDir)
|
||||
if dockerConfigDir == "" && home != "" {
|
||||
dockerConfigDir = filepath.Join(home, ".docker")
|
||||
}
|
||||
candidates = append(candidates, npmUserConfig, netrc)
|
||||
if dockerConfigDir != "" {
|
||||
candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json"))
|
||||
}
|
||||
if configHome != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(configHome, "zero"),
|
||||
)
|
||||
}
|
||||
cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig)
|
||||
if cloudSDKConfig == "" && configHome != "" {
|
||||
cloudSDKConfig = filepath.Join(configHome, "gcloud")
|
||||
}
|
||||
if cloudSDKConfig != "" {
|
||||
candidates = append(candidates, cloudSDKConfig)
|
||||
}
|
||||
ghConfigDir := strings.TrimSpace(env.GHConfigDir)
|
||||
if ghConfigDir == "" && configHome != "" {
|
||||
ghConfigDir = filepath.Join(configHome, "gh")
|
||||
}
|
||||
if ghConfigDir != "" {
|
||||
candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml"))
|
||||
}
|
||||
if target := strings.TrimSpace(env.GoogleCredentials); target != "" {
|
||||
candidates = append(candidates, target)
|
||||
}
|
||||
kubeConfig := strings.TrimSpace(env.KubeConfig)
|
||||
if kubeConfig == "" && home != "" {
|
||||
kubeConfig = filepath.Join(home, ".kube", "config")
|
||||
}
|
||||
for _, path := range filepath.SplitList(kubeConfig) {
|
||||
if path = strings.TrimSpace(path); path != "" {
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
}
|
||||
allowRoots := normalizeProfilePaths(allowRead)
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, path := range normalizeProfilePaths(candidates) {
|
||||
@@ -206,11 +284,12 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead
|
||||
|
||||
// userGitConfigReadPaths returns the user's global git config FILES so a
|
||||
// sandboxed git can read identity and config (user.name/email, aliases) instead
|
||||
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
|
||||
// files only — not the ~/.config/git directory, which can hold an XDG credential
|
||||
// store — so credentials and the rest of HOME stay unreadable. Granted at the
|
||||
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
|
||||
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
|
||||
// of failing with "unable to access ~/.gitconfig". On macOS, where reads are
|
||||
// allow-listed, granting only these files avoids exposing the surrounding
|
||||
// configuration directory. Linux uses a read-all profile with explicit
|
||||
// credential deny rules. The paths are granted at the macOS seatbelt read rule
|
||||
// rather than the cross-platform PermissionProfile so HOME-dependent paths
|
||||
// don't leak into the platform-agnostic policy snapshot.
|
||||
func userGitConfigReadPaths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -27,13 +27,10 @@ var fallbackSandboxRuntimes = struct {
|
||||
}{roots: make(map[string]string)}
|
||||
|
||||
type SandboxRuntime struct {
|
||||
Root string `json:"root,omitempty"`
|
||||
Home string `json:"home,omitempty"`
|
||||
Cache string `json:"cache,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Temp string `json:"temp,omitempty"`
|
||||
Root string `json:"root,omitempty"`
|
||||
Cache string `json:"cache,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Temp string `json:"temp,omitempty"`
|
||||
}
|
||||
|
||||
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
|
||||
@@ -75,21 +72,15 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
|
||||
}
|
||||
}()
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Home: filepath.Join(root, "home"),
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Config: filepath.Join(root, "config"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
State: filepath.Join(root, "state"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
Root: root,
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
directories := []string{
|
||||
runtimeState.Root,
|
||||
runtimeState.Home,
|
||||
runtimeState.Cache,
|
||||
runtimeState.Config,
|
||||
runtimeState.Data,
|
||||
runtimeState.State,
|
||||
runtimeState.Temp,
|
||||
filepath.Join(runtimeState.Cache, "npm"),
|
||||
filepath.Join(runtimeState.Cache, "yarn"),
|
||||
@@ -207,16 +198,11 @@ func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []str
|
||||
return env
|
||||
}
|
||||
overrides := []string{
|
||||
"HOME=" + runtimeState.Home,
|
||||
"XDG_CACHE_HOME=" + runtimeState.Cache,
|
||||
"XDG_CONFIG_HOME=" + runtimeState.Config,
|
||||
"XDG_DATA_HOME=" + runtimeState.Data,
|
||||
"XDG_STATE_HOME=" + runtimeState.State,
|
||||
"TMPDIR=" + runtimeState.Temp,
|
||||
"TMP=" + runtimeState.Temp,
|
||||
"TEMP=" + runtimeState.Temp,
|
||||
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
|
||||
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
|
||||
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
|
||||
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
|
||||
|
||||
@@ -2,7 +2,9 @@ package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -22,12 +24,18 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
|
||||
if pathWithinRoot(workspace, runtimeState.Root) {
|
||||
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
|
||||
}
|
||||
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
|
||||
for _, path := range []string{runtimeState.Cache, runtimeState.Data, runtimeState.Temp, filepath.Join(runtimeState.Data, "cargo")} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"home", "config", "state"} {
|
||||
path := filepath.Join(runtimeState.Root, name)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("obsolete synthetic runtime directory %q exists: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
|
||||
@@ -116,34 +124,63 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
|
||||
func TestSandboxRuntimeEnvironmentPreservesUserConfiguration(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "runtime")
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Home: filepath.Join(root, "home"),
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Config: filepath.Join(root, "config"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
State: filepath.Join(root, "state"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
Root: root,
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
env := sandboxRuntimeEnvironment([]string{
|
||||
"HOME=/workspace",
|
||||
"HOME=/home/user",
|
||||
"XDG_CACHE_HOME=/host/cache",
|
||||
"XDG_CONFIG_HOME=/home/user/.config",
|
||||
"XDG_DATA_HOME=/home/user/.local/share",
|
||||
"XDG_STATE_HOME=/home/user/.local/state",
|
||||
"NPM_CONFIG_USERCONFIG=/home/user/.npmrc",
|
||||
"PATH=/usr/bin",
|
||||
}, &runtimeState)
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"HOME": runtimeState.Home,
|
||||
"XDG_CACHE_HOME": runtimeState.Cache,
|
||||
"XDG_CONFIG_HOME": runtimeState.Config,
|
||||
"XDG_DATA_HOME": runtimeState.Data,
|
||||
"XDG_STATE_HOME": runtimeState.State,
|
||||
"TMPDIR": runtimeState.Temp,
|
||||
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
|
||||
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
|
||||
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
|
||||
"HOME": "/home/user",
|
||||
"XDG_CONFIG_HOME": "/home/user/.config",
|
||||
"XDG_DATA_HOME": "/home/user/.local/share",
|
||||
"XDG_STATE_HOME": "/home/user/.local/state",
|
||||
"NPM_CONFIG_USERCONFIG": "/home/user/.npmrc",
|
||||
} {
|
||||
if got := envListValue(env, key, ""); got != want {
|
||||
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRuntimeEnvironmentUsesManagedWritableState(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "runtime")
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: root,
|
||||
Cache: filepath.Join(root, "cache"),
|
||||
Data: filepath.Join(root, "data"),
|
||||
Temp: filepath.Join(root, "tmp"),
|
||||
}
|
||||
env := sandboxRuntimeEnvironment([]string{
|
||||
"XDG_CACHE_HOME=/host/cache",
|
||||
"TMPDIR=/host/tmp",
|
||||
"PATH=/usr/bin",
|
||||
}, &runtimeState)
|
||||
|
||||
for key, want := range map[string]string{
|
||||
"XDG_CACHE_HOME": runtimeState.Cache,
|
||||
"TMPDIR": runtimeState.Temp,
|
||||
"TMP": runtimeState.Temp,
|
||||
"TEMP": runtimeState.Temp,
|
||||
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
|
||||
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
|
||||
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
|
||||
"PIP_CACHE_DIR": filepath.Join(runtimeState.Cache, "pip"),
|
||||
"GOCACHE": filepath.Join(runtimeState.Cache, "go-build"),
|
||||
"GOMODCACHE": filepath.Join(runtimeState.Data, "go-mod"),
|
||||
"CARGO_HOME": filepath.Join(runtimeState.Data, "cargo"),
|
||||
} {
|
||||
if got := envListValue(env, key, ""); got != want {
|
||||
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
|
||||
@@ -154,9 +191,93 @@ func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore(t *testing.T) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("git is unavailable")
|
||||
}
|
||||
root := t.TempDir()
|
||||
home := filepath.Join(root, "home")
|
||||
configHome := filepath.Join(home, ".config")
|
||||
repository := filepath.Join(root, "repository")
|
||||
runtimeRoot := filepath.Join(root, "runtime")
|
||||
for _, directory := range []string{
|
||||
filepath.Join(configHome, "git"),
|
||||
repository,
|
||||
filepath.Join(runtimeRoot, "cache"),
|
||||
filepath.Join(runtimeRoot, "data"),
|
||||
filepath.Join(runtimeRoot, "tmp"),
|
||||
} {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configHome, "git", "ignore"), []byte("ignored.txt\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repository, "ignored.txt"), []byte("ignored\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repository, "visible.txt"), []byte("visible\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runtimeState := SandboxRuntime{
|
||||
Root: runtimeRoot,
|
||||
Cache: filepath.Join(runtimeRoot, "cache"),
|
||||
Data: filepath.Join(runtimeRoot, "data"),
|
||||
Temp: filepath.Join(runtimeRoot, "tmp"),
|
||||
}
|
||||
inherited := append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL="+filepath.Join(root, "missing-global-config"),
|
||||
"GIT_DIR="+filepath.Join(root, "wrong-repository"),
|
||||
"GIT_CONFIG_COUNT=1",
|
||||
"GIT_CONFIG_KEY_0=core.excludesFile",
|
||||
"GIT_CONFIG_VALUE_0="+filepath.Join(root, "missing-ignore"),
|
||||
)
|
||||
env := sandboxRuntimeEnvironment(upsertEnvList(withoutGitEnvironmentOverrides(inherited),
|
||||
"HOME="+home,
|
||||
"XDG_CONFIG_HOME="+configHome,
|
||||
"GIT_CONFIG_NOSYSTEM=1",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
), &runtimeState)
|
||||
runGit := func(args ...string) string {
|
||||
t.Helper()
|
||||
command := exec.Command(git, args...)
|
||||
command.Dir = repository
|
||||
command.Env = env
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
runGit("init", "--quiet")
|
||||
status := runGit("status", "--short", "--untracked-files=all")
|
||||
if strings.Contains(status, "ignored.txt") {
|
||||
t.Fatalf("global ignore was not honored:\n%s", status)
|
||||
}
|
||||
if !strings.Contains(status, "visible.txt") {
|
||||
t.Fatalf("non-ignored file is missing from status:\n%s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func withoutGitEnvironmentOverrides(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, value := range env {
|
||||
key, _, ok := strings.Cut(value, "=")
|
||||
if ok && strings.HasPrefix(strings.ToUpper(key), "GIT_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cacheRoot := t.TempDir()
|
||||
t.Setenv("HOME", filepath.Join(t.TempDir(), "home"))
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
@@ -189,8 +310,8 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
|
||||
} else if !inUse {
|
||||
t.Fatal("command plan runtime must be marked in use")
|
||||
}
|
||||
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
|
||||
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
|
||||
if got := envListValue(plan.Env, "HOME", ""); got != os.Getenv("HOME") {
|
||||
t.Fatalf("HOME = %q, want caller home %q", got, os.Getenv("HOME"))
|
||||
}
|
||||
foundWriteRoot := false
|
||||
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrWindowsNetworkEnforcementUnavailable = errors.New("Windows sandbox network enforcement is not available")
|
||||
var ErrWindowsNetworkEnforcementUnavailable = errors.New("Windows sandbox network enforcement is not available") //nolint:staticcheck // Preserve the exported sentinel's established text.
|
||||
|
||||
const (
|
||||
windowsWFPProviderKey = "2e31d31c-3948-4753-9117-e5d1a6496f41"
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
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 ©
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ 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
|
||||
@@ -65,6 +68,29 @@ 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"`
|
||||
@@ -91,6 +117,7 @@ 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"`
|
||||
@@ -123,6 +150,7 @@ type CreateInput struct {
|
||||
SpecRejectReason string
|
||||
SpecSourceSessionID string
|
||||
SpecImplSessionID string
|
||||
Goal *Goal
|
||||
}
|
||||
|
||||
type ForkInput struct {
|
||||
@@ -279,6 +307,7 @@ 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,
|
||||
|
||||
@@ -17,6 +17,7 @@ func Info(dir string, name string) (SkillInfo, bool) {
|
||||
info.Hash = entry.Hash
|
||||
}
|
||||
}
|
||||
info.HashDrift = skillHashDrift(skill, info.Hash)
|
||||
return info, true
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,10 @@ type LockEntry struct {
|
||||
// SkillInfo bundles a discovered skill with its recorded source and hash, for
|
||||
// `skill info`.
|
||||
type SkillInfo struct {
|
||||
Skill Skill `json:"skill"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Skill Skill `json:"skill"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
HashDrift bool `json:"hashDrift"`
|
||||
}
|
||||
|
||||
// Install fetches the skill at options.Source and copies its SKILL.md into
|
||||
@@ -275,6 +276,7 @@ func InfoFromRoots(primaryDir string, roots []string, name string) (SkillInfo, b
|
||||
info.Hash = entry.Hash
|
||||
}
|
||||
}
|
||||
info.HashDrift = skillHashDrift(skill, info.Hash)
|
||||
return info, true
|
||||
}
|
||||
|
||||
@@ -481,3 +483,19 @@ func hashContent(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// skillHashDrift reports whether the on-disk SKILL.md no longer matches the
|
||||
// lockfile hash recorded at install time. Missing lock hashes never count as
|
||||
// drift (agents-only / unlocked skills). A locked skill whose SKILL.md cannot
|
||||
// be read is treated as drifted — not as a clean match.
|
||||
func skillHashDrift(skill Skill, lockHash string) bool {
|
||||
lockHash = strings.TrimSpace(lockHash)
|
||||
if lockHash == "" {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(skill.Path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return hashContent(data) != lockHash
|
||||
}
|
||||
@@ -407,4 +407,28 @@ func TestInfoReturnsFrontmatterSourceAndHash(t *testing.T) {
|
||||
if info.Hash != installed.Hash {
|
||||
t.Fatalf("Info hash = %q, want %q", info.Hash, installed.Hash)
|
||||
}
|
||||
if info.HashDrift {
|
||||
t.Fatal("expected no hash drift immediately after install")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(info.Skill.Path, []byte("---\nname: demo\ndescription: described\n---\nedited body\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, ok = Info(destDir, "demo")
|
||||
if !ok {
|
||||
t.Fatal("Info(demo) not found after edit")
|
||||
}
|
||||
if !info.HashDrift {
|
||||
t.Fatal("expected hash drift after SKILL.md edit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillHashDriftUnreadableLockedPath(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "missing", "SKILL.md")
|
||||
if !skillHashDrift(Skill{Path: missing}, "sha256:deadbeef") {
|
||||
t.Fatal("expected drift when locked SKILL.md cannot be read")
|
||||
}
|
||||
if skillHashDrift(Skill{Path: missing}, "") {
|
||||
t.Fatal("missing lock hash must not count as drift")
|
||||
}
|
||||
}
|
||||
@@ -827,7 +827,7 @@ func runChildProcess(ctx context.Context, binaryPath string, args []string, prog
|
||||
// ExitCode() is -1 when the child was terminated by a signal rather
|
||||
// than exiting. ProcessState.String() is the portable description
|
||||
// (e.g. "signal: killed") — capture it so the failure isn't opaque.
|
||||
signalDesc = exitErr.ProcessState.String()
|
||||
signalDesc = exitErr.String()
|
||||
}
|
||||
} else {
|
||||
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: -1, Started: started}, fmt.Errorf("run specialist child: %w", err)
|
||||
|
||||
+9
-27
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
@@ -614,7 +625,14 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
|
||||
|
||||
t.Run("approved require_escalated still blocks an unrelated syntax issue", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": `cd /d/tmp/zero-pr-158 && dir`,
|
||||
// No `&&` here on purpose. The POSIX-style path is what this case is
|
||||
// about, and on Windows PowerShell 5.1 a chained command trips the
|
||||
// separate windows_powershell_version preflight first, which shadows
|
||||
// the block being asserted. That only shows up on 5.1: the runners
|
||||
// have PowerShell 7, where && is valid and the version check does not
|
||||
// fire, so the substitution kept this green in CI while failing on the
|
||||
// shell a stock Windows box actually gets.
|
||||
"command": `cd /d/tmp/zero-pr-158`,
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
@@ -648,8 +666,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 +879,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 +903,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func TestIndependentExecCommandConstructorsShareDefaultManager(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
execTool := NewScopedExecCommandTool(root, nil, nil)
|
||||
writeTool := NewWriteStdinTool(nil)
|
||||
|
||||
@@ -76,17 +76,21 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager)
|
||||
writeTool := NewWriteStdinTool(manager)
|
||||
@@ -134,7 +138,7 @@ func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedExecCommandTool(root, nil, manager))
|
||||
@@ -183,7 +187,7 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only MSYS sandbox guard")
|
||||
}
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedExecCommandTool(root, nil, manager))
|
||||
@@ -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" {
|
||||
@@ -214,7 +224,7 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
|
||||
}
|
||||
|
||||
func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager)
|
||||
|
||||
@@ -253,7 +263,7 @@ func TestExecCommandReportsWorkspaceChanges(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell syntax")
|
||||
}
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
result := NewScopedExecCommandTool(root, nil, newExecSessionManager()).Run(context.Background(), map[string]any{
|
||||
"cmd": "mkdir -p src node_modules/pkg && printf 'export {}' > src/main.ts && printf generated > node_modules/pkg/index.js",
|
||||
"yield_time_ms": 30000,
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +327,26 @@ func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandDoesNotInferNativeSandboxDenialFromOutput(t *testing.T) {
|
||||
result := execToolResult(execToolResultInput{
|
||||
commandText: "touch \"$HOME/probe\"",
|
||||
output: "touch: cannot touch '/home/user/probe': Read-only file system",
|
||||
exited: true,
|
||||
exitCode: 1,
|
||||
enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)},
|
||||
})
|
||||
|
||||
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.Denial != nil {
|
||||
t.Fatalf("command output created a typed denial: %#v", result.ExecutionOutcome.Denial)
|
||||
}
|
||||
if result.Meta[SandboxLikelyDeniedMeta] == "true" {
|
||||
t.Fatalf("command output created sandbox-denial metadata: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func executionRequestHasCapability(request execution.Request, kind execution.CapabilityKind, scope string) bool {
|
||||
for _, capability := range request.Capabilities {
|
||||
if capability.Kind == kind && (scope == "" || capability.Scope == scope) {
|
||||
@@ -326,7 +357,7 @@ func executionRequestHasCapability(request execution.Request, kind execution.Cap
|
||||
}
|
||||
|
||||
func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager)
|
||||
writeTool := NewWriteStdinTool(manager)
|
||||
@@ -380,7 +411,7 @@ func parseListeningAddress(output string) string {
|
||||
}
|
||||
|
||||
func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := execution.NewProcessManager(execution.ProcessManagerOptions{CompletedRetention: 10 * time.Millisecond})
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager)
|
||||
|
||||
@@ -506,7 +537,7 @@ func TestWriteStdinInterruptTerminatesSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteStdinRejectsInputForNonTTYSession(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager)
|
||||
writeTool := NewWriteStdinTool(manager)
|
||||
@@ -537,9 +568,43 @@ func TestWriteStdinRejectsInputForNonTTYSession(t *testing.T) {
|
||||
manager.Stop(sessionID)
|
||||
}
|
||||
|
||||
// execTestRoot is t.TempDir with a cleanup that tolerates Windows still holding
|
||||
// the directory.
|
||||
//
|
||||
// A terminated process does not release its handles the instant the session
|
||||
// reports it exited, and Windows refuses to remove a directory with an open
|
||||
// handle where POSIX does not. t.TempDir's own cleanup removes once and fails
|
||||
// the test on a sharing violation, which turns ordinary teardown timing into a
|
||||
// test failure with nothing actually wrong underneath it.
|
||||
func execTestRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
root, err := os.MkdirTemp("", "zero-exec-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("create test root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if err := os.RemoveAll(root); err == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
// Deliberately not a failure. The directory is under the OS temp
|
||||
// root and the process holding it has already been terminated, so
|
||||
// the worst case is a stale directory, not a broken test.
|
||||
t.Logf("test root %s still held after the cleanup deadline; leaving it", root)
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
|
||||
for _, chars := range []string{`\u0003`, "exit\n"} {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager)
|
||||
writeTool := NewWriteStdinTool(manager)
|
||||
@@ -556,16 +621,46 @@ func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
|
||||
t.Fatalf("session_id is not numeric: %v", err)
|
||||
}
|
||||
|
||||
result := writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
"chars": chars,
|
||||
"yield_time_ms": 1000,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("stop input %q status = %s: %s", chars, result.Status, result.Output)
|
||||
}
|
||||
if result.Meta["session_id"] != "" {
|
||||
t.Fatalf("stop input %q should not leave session running, meta=%#v output=%q", chars, result.Meta, result.Output)
|
||||
// Re-send the stop until the session reports it has exited, rather than
|
||||
// waiting a fixed slice and asserting on whatever is true by then.
|
||||
//
|
||||
// A single 1s yield used to be enough because the session ran under
|
||||
// cmd.exe. Under PowerShell the tree is slower to stand up and tear down,
|
||||
// and the FIRST stop in a fresh process pays the interpreter's cold start,
|
||||
// so it routinely returns with termination already under way but not
|
||||
// finished. The old assertion read that as "the stop did not work" and
|
||||
// failed, intermittently, on whichever of the two inputs happened to run
|
||||
// first. CI never saw it because the runners have PowerShell 7 while a
|
||||
// stock Windows box only has 5.1.
|
||||
//
|
||||
// Repeating the same input rather than polling with empty chars is
|
||||
// deliberate: Terminate is a no-op against an already-dead tree, so it is
|
||||
// safe to repeat, and it keeps the interrupt flag that an empty-chars poll
|
||||
// would drop, which is what the interrupted assertion below needs.
|
||||
var result Result
|
||||
// Every poll's output is kept, not just the last one. Each Continue
|
||||
// returns only what the session produced since the previous collection,
|
||||
// so a marker printed just before the poll that observes the exit lands
|
||||
// in the earlier result and would be invisible to a check against the
|
||||
// final one alone.
|
||||
var collected strings.Builder
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
result = writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
"chars": chars,
|
||||
"yield_time_ms": 500,
|
||||
})
|
||||
collected.WriteString(result.Output)
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("stop input %q status = %s: %s", chars, result.Status, result.Output)
|
||||
}
|
||||
if result.Meta["session_id"] == "" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("stop input %q left the session running past the deadline, meta=%#v output=%q", chars, result.Meta, result.Output)
|
||||
}
|
||||
}
|
||||
if result.Meta["exit_code"] == "" {
|
||||
t.Fatalf("stop input %q should report exit_code, meta=%#v output=%q", chars, result.Meta, result.Output)
|
||||
@@ -573,6 +668,17 @@ func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
|
||||
if result.Meta["interrupted"] != "true" {
|
||||
t.Fatalf("stop input %q should report interrupted metadata, meta=%#v output=%q", chars, result.Meta, result.Output)
|
||||
}
|
||||
// The session has to have been killed rather than allowed to finish.
|
||||
// Without this the retry loop makes the test unfalsifiable: the helper
|
||||
// sleeps 5s, well inside the deadline, so a terminate that did nothing at
|
||||
// all would let the sleep end on its own and every assertion above would
|
||||
// still hold. The interrupted flag does not close the hole either, since
|
||||
// it echoes the request rather than the outcome. The helper prints this
|
||||
// line only on the way out of a completed sleep, so its absence is what
|
||||
// separates the two, and it says nothing about how long the loop took.
|
||||
if strings.Contains(collected.String(), "long sleep finished") {
|
||||
t.Fatalf("stop input %q let the session run to completion instead of terminating it, output=%q", chars, collected.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,7 +718,7 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("pty transport is currently implemented for linux")
|
||||
}
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager)
|
||||
writeTool := NewWriteStdinTool(manager)
|
||||
@@ -650,7 +756,7 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecSessionSnapshotsAndStopAll(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := execTestRoot(t)
|
||||
manager := newExecSessionManager()
|
||||
execTool := NewScopedExecCommandTool(root, nil, manager).(execCommandTool)
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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
@@ -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.",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -83,6 +83,7 @@ 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).
|
||||
|
||||
+6
-2
@@ -83,6 +83,7 @@ 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.
|
||||
@@ -175,6 +176,7 @@ 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
|
||||
@@ -196,13 +198,15 @@ 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 ·")
|
||||
return parent, batchCommands(sweepCmd, spinnerCmd)
|
||||
var goalCmd tea.Cmd
|
||||
parent, goalCmd = parent.launchGoalContinuationIfReady()
|
||||
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd)
|
||||
}
|
||||
|
||||
func btwCommandUnavailable(command parsedCommand) bool {
|
||||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||||
switch command.kind {
|
||||
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop,
|
||||
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal,
|
||||
commandRewind, commandCompact, commandSTTModel, commandMCP:
|
||||
return true
|
||||
case commandModel:
|
||||
|
||||
@@ -135,6 +135,51 @@ 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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -548,7 +548,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
// and a stored OAuth login (e.g. ChatGPT) is a credential too — the profile stays
|
||||
// keyless on purpose so newProvider attaches the bearer resolver + login key.
|
||||
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" &&
|
||||
!(hasDescriptor && descriptor.Local) && !oauthLoginAvailable(target) {
|
||||
(!hasDescriptor || !descriptor.Local) && !oauthLoginAvailable(target) {
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", false, nil
|
||||
}
|
||||
next, err := m.newProvider(target)
|
||||
|
||||
@@ -50,6 +50,7 @@ const (
|
||||
commandBTW
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandGoal
|
||||
commandVoice
|
||||
commandSTTModel
|
||||
commandUnknown
|
||||
@@ -338,6 +339,13 @@ 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",
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
@@ -87,10 +86,6 @@ func (m model) scrollableTranscriptLayoutView(header string, body transcriptBody
|
||||
return m.renderScrollableTranscriptWindow(frame, bodyWindow, window, width, overlay)
|
||||
}
|
||||
|
||||
func (m model) scrollableTranscriptView(header string, body string, footer string, width int, overlay string) string {
|
||||
return m.scrollableTranscriptLayoutView(header, transcriptBodyLayout{lines: viewLines(body)}, footer, width, overlay)
|
||||
}
|
||||
|
||||
func (m model) overlayMouseTop(overlayHeight int, width int) int {
|
||||
return m.overlayMouseRect(overlayHeight, width).y
|
||||
}
|
||||
@@ -230,17 +225,6 @@ func (d *streamingDecoder) tailLines() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ensureAgeTickReschedule is a small helper used after a fade-state change
|
||||
// to start the tick if it's not already running. The age-tick case
|
||||
// short-circuits when fadeActive is false, so calling this on a no-op
|
||||
// transition (e.g. a 0-byte delta) is safe.
|
||||
func (m model) ensureAgeTickReschedule() tea.Cmd {
|
||||
if !m.fadeActive {
|
||||
return nil
|
||||
}
|
||||
return streamingFadeTick()
|
||||
}
|
||||
|
||||
// newSTTDownloadPicker builds the model-download chooser, seeded with the
|
||||
// curated shortlist. The full model list from the release is fetched
|
||||
// asynchronously and merged in (see fetchSTTModelsCmd / handleSTTModelsFetched).
|
||||
@@ -288,11 +272,6 @@ func transcriptViewportStartForFrame(body string, frame transcriptFrameLayout, s
|
||||
return window.start, window.height, frame.bodyRect.y
|
||||
}
|
||||
|
||||
func transcriptViewportStartForLayout(layout transcriptBodyLayout, frame transcriptFrameLayout, scrollOffset int) (int, int, int) {
|
||||
window := transcriptViewportForLayout(layout, frame, scrollOffset).window()
|
||||
return window.start, window.height, frame.bodyRect.y
|
||||
}
|
||||
|
||||
func transcriptViewportForBody(body string, frame transcriptFrameLayout, offset int) transcriptViewport {
|
||||
return newTranscriptViewport(len(viewLines(body)), frame.bodyRect.height, offset)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -192,7 +193,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
|
||||
run("commit", "-q", "-m", "seed")
|
||||
write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens"
|
||||
|
||||
baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg)
|
||||
baseline := gitSweepCmd(context.TODO(), dir, true)().(gitSweepMsg)
|
||||
if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" {
|
||||
t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline)
|
||||
}
|
||||
@@ -201,7 +202,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
|
||||
write("scaffolded.txt", "hello\n")
|
||||
write("tracked.txt", "one\ntwo\nthree\nfour\n")
|
||||
|
||||
sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
|
||||
sweep := gitSweepCmd(context.TODO(), dir, false)().(gitSweepMsg)
|
||||
if !sweep.ok {
|
||||
t.Fatal("sweep failed")
|
||||
}
|
||||
@@ -224,7 +225,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
|
||||
run("commit", "-q", "-m", "second")
|
||||
run("mv", "tracked.txt", "renamed.txt")
|
||||
write("renamed.txt", "one\ntwo\nthree\nfour\nfive\n")
|
||||
renameSweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
|
||||
renameSweep := gitSweepCmd(context.TODO(), dir, false)().(gitSweepMsg)
|
||||
if !renameSweep.ok {
|
||||
t.Fatal("rename sweep failed")
|
||||
}
|
||||
@@ -237,7 +238,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
|
||||
}
|
||||
|
||||
// Not-a-repo → ok=false (the sweep latches off).
|
||||
if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok {
|
||||
if msg := gitSweepCmd(context.TODO(), t.TempDir(), false)().(gitSweepMsg); msg.ok {
|
||||
t.Fatal("a non-repo should report ok=false")
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool {
|
||||
if row.id != "" && rc.resolved[rcKey(row.runID, row.id)] {
|
||||
return true
|
||||
}
|
||||
return !(m.pending && row.runID != 0 && row.runID == m.activeRunID)
|
||||
return !m.pending || row.runID == 0 || row.runID != m.activeRunID
|
||||
case rowPermission:
|
||||
event := row.permission
|
||||
if event == nil || event.ToolCallID == "" || event.Action != agent.PermissionActionPrompt {
|
||||
@@ -57,7 +57,7 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool {
|
||||
}
|
||||
// An undecided prompt renders live (with the modal card) until its
|
||||
// decision lands or its run ends.
|
||||
return !(m.pending && row.runID != 0 && row.runID == m.activeRunID)
|
||||
return !m.pending || row.runID == 0 || row.runID != m.activeRunID
|
||||
default:
|
||||
// User/system/error/assistant/ask-user rows are appended in final form;
|
||||
// welcome rows render nothing and settle trivially.
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,7 @@ func writeTestPDF(t *testing.T, dir, name, text string) string {
|
||||
xrefStart := buf.Len()
|
||||
buf.WriteString("xref\n0 " + strconv.Itoa(len(offsets)+1) + "\n0000000000 65535 f \n")
|
||||
for _, off := range offsets {
|
||||
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\nstartxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
|
||||
|
||||
|
||||
@@ -594,8 +594,8 @@ func (m model) loopListText() string {
|
||||
when = "due"
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
|
||||
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
|
||||
fmt.Fprintf(&b, " %s · %s · iter %d · %s · %s\n",
|
||||
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt))
|
||||
}
|
||||
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
|
||||
return b.String()
|
||||
|
||||
+60
-11
@@ -245,10 +245,13 @@ type model struct {
|
||||
loopCounter int
|
||||
loopTicking bool
|
||||
loopLeavePrompt commandKind
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// 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
|
||||
// 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
|
||||
@@ -585,6 +588,7 @@ 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
|
||||
@@ -2415,6 +2419,9 @@ 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 {
|
||||
@@ -2469,8 +2476,13 @@ 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()
|
||||
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
|
||||
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)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapGeneratedMsg:
|
||||
@@ -4346,6 +4358,8 @@ 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.
|
||||
@@ -4750,6 +4764,14 @@ 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",
|
||||
@@ -4893,6 +4915,8 @@ 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()
|
||||
}
|
||||
@@ -4943,6 +4967,18 @@ 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
|
||||
@@ -4992,9 +5028,19 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
usageModelID := m.modelName
|
||||
var specReview *pendingSpecReviewPrompt
|
||||
options := m.agentOptions
|
||||
options.Registry = m.registry
|
||||
options.Registry = cloneToolRegistry(m.registry)
|
||||
goalAwareRun := !runOptions.specDraft && m.activeLoopID == "" &&
|
||||
sessions.IsResumableKind(m.activeSession.SessionKind)
|
||||
if goalAwareRun {
|
||||
options.Registry = m.goalRegistry()
|
||||
}
|
||||
if runOptions.registry != nil {
|
||||
options.Registry = runOptions.registry
|
||||
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.PermissionMode = m.permissionMode
|
||||
if runOptions.permissionMode != "" {
|
||||
@@ -5003,6 +5049,9 @@ 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
|
||||
@@ -5411,7 +5460,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, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
}
|
||||
if runOptions.specDraft {
|
||||
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
|
||||
@@ -5421,10 +5470,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, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
elapsed := m.now().Sub(started)
|
||||
@@ -5449,7 +5498,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, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1396,7 +1396,7 @@ func TestBeginRunPreparesRunCompletionWarningEachTurn(t *testing.T) {
|
||||
})
|
||||
|
||||
m = m.beginRun(nil)
|
||||
m = m.beginRun(nil)
|
||||
_ = m.beginRun(nil)
|
||||
|
||||
if prepares != 2 {
|
||||
t.Fatalf("PrepareRunCompletionWarning called %d times, want once per run", prepares)
|
||||
|
||||
+20
-14
@@ -218,20 +218,22 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyUp) || keyCtrl(msg, 'p'):
|
||||
if m.setup.stage == setupStageMethod {
|
||||
switch m.setup.stage {
|
||||
case setupStageMethod:
|
||||
m.moveSetupMethod(-1)
|
||||
} else if m.setup.stage == setupStageProvider {
|
||||
case setupStageProvider:
|
||||
m.moveSetupProvider(-1)
|
||||
} else if m.setup.stage == setupStageModel {
|
||||
case setupStageModel:
|
||||
m.moveSetupModel(-1)
|
||||
}
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyDown) || keyCtrl(msg, 'n'):
|
||||
if m.setup.stage == setupStageMethod {
|
||||
switch m.setup.stage {
|
||||
case setupStageMethod:
|
||||
m.moveSetupMethod(1)
|
||||
} else if m.setup.stage == setupStageProvider {
|
||||
case setupStageProvider:
|
||||
m.moveSetupProvider(1)
|
||||
} else if m.setup.stage == setupStageModel {
|
||||
case setupStageModel:
|
||||
m.moveSetupModel(1)
|
||||
}
|
||||
return m, nil
|
||||
@@ -279,15 +281,17 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
case "k":
|
||||
if m.setup.stage == setupStageProvider {
|
||||
switch m.setup.stage {
|
||||
case setupStageProvider:
|
||||
m.moveSetupProvider(-1)
|
||||
} else if m.setup.stage == setupStageModel {
|
||||
case setupStageModel:
|
||||
m.moveSetupModel(-1)
|
||||
}
|
||||
case "j":
|
||||
if m.setup.stage == setupStageProvider {
|
||||
switch m.setup.stage {
|
||||
case setupStageProvider:
|
||||
m.moveSetupProvider(1)
|
||||
} else if m.setup.stage == setupStageModel {
|
||||
case setupStageModel:
|
||||
m.moveSetupModel(1)
|
||||
}
|
||||
}
|
||||
@@ -360,16 +364,18 @@ func (m model) handleSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case mouseWheelUp(msg):
|
||||
m.clearMouseSelection()
|
||||
if m.setup.stage == setupStageProvider {
|
||||
switch m.setup.stage {
|
||||
case setupStageProvider:
|
||||
m.moveSetupProvider(-1)
|
||||
} else if m.setup.stage == setupStageModel {
|
||||
case setupStageModel:
|
||||
m.moveSetupModel(-1)
|
||||
}
|
||||
case mouseWheelDown(msg):
|
||||
m.clearMouseSelection()
|
||||
if m.setup.stage == setupStageProvider {
|
||||
switch m.setup.stage {
|
||||
case setupStageProvider:
|
||||
m.moveSetupProvider(1)
|
||||
} else if m.setup.stage == setupStageModel {
|
||||
case setupStageModel:
|
||||
m.moveSetupModel(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,7 +687,7 @@ func TestProviderWizardPersistsPastedKeyToUserConfig(t *testing.T) {
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
_ = updated.(model)
|
||||
|
||||
if captured.APIKey != secret {
|
||||
t.Fatalf("captured APIKey = %q, want pasted secret", captured.APIKey)
|
||||
|
||||
@@ -1473,7 +1473,7 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
|
||||
// scrollback clean. Skipped for: the uncapped detailed view (opts.bodyCap==0),
|
||||
// diff tools whose body must stay reviewable, and short output.
|
||||
collapsedFooter := ""
|
||||
if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && !(!failed && (isExploreTool(name) || isLocalControlTool(name))) {
|
||||
if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && (failed || (!isExploreTool(name) && !isLocalControlTool(name))) {
|
||||
collapsedFooter = collapsedToolFooter(row.detail)
|
||||
}
|
||||
if collapsedFooter != "" && !row.expanded {
|
||||
|
||||
+15
-7
@@ -317,18 +317,26 @@ 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: []string{
|
||||
"id: " + session.SessionID,
|
||||
"title: " + displayValue(session.Title, "untitled"),
|
||||
modelLine,
|
||||
providerLine,
|
||||
fmt.Sprintf("events: %d", eventCount),
|
||||
},
|
||||
Lines: lines,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1056,6 +1056,35 @@ 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")
|
||||
|
||||
@@ -393,10 +393,10 @@ func toolCallSummary(event streamjson.Event) string {
|
||||
case "write_stdin":
|
||||
sessionID := toolCallIntArg(args, "session_id")
|
||||
chars, _ := args["chars"].(string)
|
||||
switch {
|
||||
case chars == "":
|
||||
switch chars {
|
||||
case "":
|
||||
return fmt.Sprintf("poll session %d", sessionID)
|
||||
case chars == "\x03":
|
||||
case "\x03":
|
||||
return fmt.Sprintf("interrupt session %d", sessionID)
|
||||
default:
|
||||
return fmt.Sprintf("send input to session %d", sessionID)
|
||||
|
||||
@@ -220,8 +220,3 @@ func (t tuiTheme) onPanel(style lipgloss.Style) lipgloss.Style {
|
||||
func (t tuiTheme) onSel(style lipgloss.Style) lipgloss.Style {
|
||||
return style.Background(t.bgSel)
|
||||
}
|
||||
|
||||
// onPerm paints on the permission-card tint.
|
||||
func (t tuiTheme) onPerm(style lipgloss.Style) lipgloss.Style {
|
||||
return style.Background(t.bgPerm)
|
||||
}
|
||||
@@ -1360,7 +1360,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
if line.permOption && !(m.pendingPermission != nil && m.pendingPermission.typing) {
|
||||
if line.permOption && (m.pendingPermission == nil || !m.pendingPermission.typing) {
|
||||
// A left-click on a permission-popup option resolves it directly. The
|
||||
// typing guard is defence-in-depth: renderFocusedPermissionPrompt already
|
||||
// returns nil offsets in feedback mode, so no option row is registered as
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -211,6 +211,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -240,6 +243,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -162,10 +162,11 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
|
||||
|
||||
binaryName := "zero"
|
||||
optionalBinaries := linuxOptionalBinaries
|
||||
if runtime.GOOS == "windows" {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
binaryName = "zero.exe"
|
||||
optionalBinaries = windowsOptionalBinaries
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
case "darwin":
|
||||
optionalBinaries = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -151,10 +151,11 @@ func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
|
||||
func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
|
||||
binaryName := "zero"
|
||||
optionalName := "zero-seccomp"
|
||||
if runtime.GOOS == "windows" {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
binaryName = "zero.exe"
|
||||
optionalName = "zero-windows-command-runner.exe"
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
case "darwin":
|
||||
t.Skip("macOS ships no optional helper binaries to refresh")
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ func releasePlatform(goos string) (string, error) {
|
||||
// self-updating release asset. Point users at `npm update` rather
|
||||
// than a source rebuild, since that's the documented Termux
|
||||
// install/upgrade path and doesn't require a Go toolchain.
|
||||
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos)
|
||||
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos) //nolint:staticcheck // Preserve established user-facing error text.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ func validCommandName(name string) bool {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user