mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daabe11164 | ||
|
|
dcf229fecd | ||
|
|
3fae0de2ea | ||
|
|
4ba112a0eb | ||
|
|
b76c764b77 | ||
|
|
d73ec81e2d | ||
|
|
2479d946c1 | ||
|
|
d76f7f6931 | ||
|
|
37d69c96ce | ||
|
|
176f5a242b | ||
|
|
9e609c5242 | ||
|
|
2f531178f6 | ||
|
|
3b43c43eca | ||
|
|
faec8ee201 |
No files matched your search
@@ -24,6 +24,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
|
||||
@@ -34,12 +34,15 @@ jobs:
|
||||
- name: Checkout
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout requested ref
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
@@ -129,6 +132,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# No registry-url here on purpose: setup-node's registry-url writes an
|
||||
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
|
||||
|
||||
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
@@ -12,6 +14,8 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
permissions:
|
||||
@@ -27,6 +31,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run action-summary test suite
|
||||
run: node --test scripts/action-summary.test.mjs
|
||||
|
||||
- name: Parse action.yml and assert documented inputs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.8.0"}
|
||||
{".":"0.9.0"}
|
||||
@@ -6,6 +6,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
|
||||
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
|
||||
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
|
||||
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
|
||||
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
|
||||
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
|
||||
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
|
||||
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
|
||||
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
|
||||
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
|
||||
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
|
||||
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
|
||||
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
|
||||
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
|
||||
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
|
||||
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
|
||||
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
|
||||
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
|
||||
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
|
||||
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
|
||||
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
|
||||
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
|
||||
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
|
||||
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
|
||||
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
|
||||
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
|
||||
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
|
||||
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
|
||||
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
|
||||
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
|
||||
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
|
||||
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
|
||||
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
|
||||
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
|
||||
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
|
||||
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
|
||||
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
|
||||
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
|
||||
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
|
||||
|
||||
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package acp
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
@@ -246,22 +247,59 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
||||
}
|
||||
|
||||
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
||||
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
|
||||
text := strings.TrimRight(result.Output, "\n")
|
||||
if text == "" {
|
||||
text = result.Display.Summary
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
return []ToolCallContent{ToolContent(TextBlock(text))}
|
||||
content = append(content, ToolContent(TextBlock(text)))
|
||||
return appendToolResultDiffs(content, result.FileDiffs)
|
||||
}
|
||||
|
||||
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
|
||||
for _, diff := range diffs {
|
||||
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
|
||||
continue
|
||||
}
|
||||
var oldText *string
|
||||
if diff.OldExists {
|
||||
old := diff.OldText
|
||||
oldText = &old
|
||||
}
|
||||
var newText *string
|
||||
if diff.NewExists {
|
||||
updated := diff.NewText
|
||||
newText = &updated
|
||||
}
|
||||
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
||||
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
|
||||
for _, f := range result.ChangedFiles {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
|
||||
for _, diff := range result.FileDiffs {
|
||||
path := diff.Path
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
locs = append(locs, ToolCallLocation{Path: path})
|
||||
}
|
||||
// FileDiff.Path is canonical absolute path data while ChangedFiles is
|
||||
// normally workspace-relative. Without the trusted workspace root these
|
||||
// coordinate systems cannot be correlated safely: a suffix match would let
|
||||
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
|
||||
// shared seen set deduplicates only identities already exactly comparable.
|
||||
for _, f := range result.ChangedFiles {
|
||||
if f == "" || seen[f] {
|
||||
continue
|
||||
}
|
||||
seen[f] = true
|
||||
locs = append(locs, ToolCallLocation{Path: f})
|
||||
}
|
||||
return locs
|
||||
|
||||
@@ -2,6 +2,7 @@ package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
@@ -300,21 +301,26 @@ func TestToolCallStart(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToolCallResult(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
ok := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "tc1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "applied\n",
|
||||
ChangedFiles: []string{"a.go", ""},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
|
||||
})
|
||||
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
|
||||
t.Fatalf("unexpected ok result: %+v", ok)
|
||||
}
|
||||
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
t.Fatalf("unexpected content: %+v", ok.Content)
|
||||
}
|
||||
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
|
||||
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
|
||||
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
|
||||
t.Fatalf("unexpected diff content: %+v", diff)
|
||||
}
|
||||
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
|
||||
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
|
||||
}
|
||||
|
||||
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
||||
@@ -323,6 +329,190 @@ func TestToolCallResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty.txt")
|
||||
content := appendToolResultDiffs(nil, []tools.FileDiff{
|
||||
{Path: path, OldExists: false, NewExists: true, NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
|
||||
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
|
||||
})
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("diff content = %#v", content)
|
||||
}
|
||||
for index, diff := range content {
|
||||
encoded, err := json.Marshal(diff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wire["path"] != path {
|
||||
t.Fatalf("wire diff %d = %s", index, encoded)
|
||||
}
|
||||
switch index {
|
||||
case 0:
|
||||
oldText, present := wire["oldText"]
|
||||
if !present || oldText != nil || wire["newText"] != "" {
|
||||
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
|
||||
}
|
||||
case 1:
|
||||
if wire["oldText"] != "before" || wire["newText"] != "" {
|
||||
t.Fatalf("empty replacement diff = %s", encoded)
|
||||
}
|
||||
case 2:
|
||||
newText, present := wire["newText"]
|
||||
if wire["oldText"] != "before" || !present || newText != nil {
|
||||
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
rootPath := filepath.Join(root, "a.go")
|
||||
nestedPath := filepath.Join(root, "sub", "a.go")
|
||||
diff := func(path string) tools.FileDiff {
|
||||
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
diffs []tools.FileDiff
|
||||
want []string
|
||||
}{
|
||||
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
|
||||
FileDiffs: tc.diffs,
|
||||
})
|
||||
if len(locations) != len(tc.want) {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
for index := range tc.want {
|
||||
if locations[index].Path != tc.want[index] {
|
||||
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
|
||||
relativePath := " report.txt "
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
|
||||
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.go")
|
||||
locations := toolResultLocations(agent.ToolResult{
|
||||
ChangedFiles: []string{path, path},
|
||||
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
|
||||
})
|
||||
if len(locations) != 1 || locations[0].Path != path {
|
||||
t.Fatalf("exact duplicate locations = %#v", locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
|
||||
relativePath := "deleted.go"
|
||||
absolutePath := filepath.Join(t.TempDir(), relativePath)
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ChangedFiles: []string{relativePath},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
|
||||
}},
|
||||
})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
|
||||
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
|
||||
t.Fatalf("deleted file locations = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
path := filepath.Join(t.TempDir(), "secret.txt")
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
|
||||
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
|
||||
}}})
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
|
||||
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
|
||||
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{
|
||||
ChangedFiles: []string{"credentials.txt"},
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
|
||||
}},
|
||||
})
|
||||
update := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "call", Status: tools.StatusOK,
|
||||
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
|
||||
})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
|
||||
}
|
||||
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
|
||||
t.Fatalf("ACP path fallback = %#v", update.Locations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
|
||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
||||
for name, separator := range map[string]string{
|
||||
"combining grapheme joiner": "\u034f",
|
||||
"variation selector": "\ufe0f",
|
||||
} {
|
||||
for _, side := range []string{"old", "new"} {
|
||||
t.Run(name+" "+side, func(t *testing.T) {
|
||||
obfuscated := secret[:20] + separator + secret[20:]
|
||||
diff := tools.FileDiff{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
|
||||
OldText: "safe old", NewText: "safe new",
|
||||
}
|
||||
if side == "old" {
|
||||
diff.OldText = obfuscated
|
||||
} else {
|
||||
diff.NewText = obfuscated
|
||||
}
|
||||
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
|
||||
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
|
||||
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
|
||||
}
|
||||
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
|
||||
if len(update.Content) != 0 {
|
||||
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanUpdateAndStatus(t *testing.T) {
|
||||
upd := planUpdate([]tools.PlanItem{
|
||||
{Content: "step a", Status: "completed"},
|
||||
|
||||
+24
-3
@@ -284,9 +284,30 @@ type ToolCallContent struct {
|
||||
// type == "content"
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
// type == "diff"
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText string `json:"oldText,omitempty"`
|
||||
NewText string `json:"newText,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText *string `json:"oldText,omitempty"`
|
||||
NewText *string `json:"newText,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON preserves ACP's discriminated content union. A diff always has
|
||||
// path, while nullable oldText/newText distinguish creation, deletion, and an
|
||||
// existing file replaced with empty content. Other content variants omit all
|
||||
// diff fields rather than serializing irrelevant nulls.
|
||||
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
|
||||
if content.Type == "diff" {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
OldText *string `json:"oldText"`
|
||||
NewText *string `json:"newText"`
|
||||
}{
|
||||
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
|
||||
})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
}{Type: content.Type, Content: content.Content})
|
||||
}
|
||||
|
||||
func ToolContent(block ContentBlock) ToolCallContent {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -111,7 +112,11 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
testutil.WaitFor(t, "worker idle", func() bool {
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
return diagnostics.working == nil
|
||||
})
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
@@ -123,23 +128,6 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
@@ -1527,6 +1527,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
@@ -1833,6 +1834,10 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
|
||||
}
|
||||
|
||||
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
|
||||
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
|
||||
// explicitly cross the same transcript/redaction boundary before its result
|
||||
// can be forwarded through ACP.
|
||||
result = tools.ScrubResultSecrets(result)
|
||||
output, outputRedacted := scrubInterceptedOutput(result.Output)
|
||||
display := result.Display
|
||||
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
|
||||
@@ -1860,6 +1865,7 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
|
||||
Meta: meta,
|
||||
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: display,
|
||||
LoadedTools: loadedToolsFromResult(meta),
|
||||
@@ -2153,6 +2159,7 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
|
||||
@@ -27,6 +27,23 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
|
||||
Status: tools.StatusError,
|
||||
FileDiffs: []tools.FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "secret.txt"),
|
||||
OldExists: true,
|
||||
NewExists: true,
|
||||
OldText: "token=" + secret,
|
||||
NewText: "safe",
|
||||
}},
|
||||
})
|
||||
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
|
||||
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
|
||||
@@ -84,6 +84,7 @@ type ToolResult struct {
|
||||
Images []zeroruntime.ImageBlock
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
FileDiffs []tools.FileDiff
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
|
||||
@@ -49,6 +49,14 @@ const (
|
||||
exitInterrupted = 130
|
||||
)
|
||||
|
||||
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
|
||||
// An interactive run can be continued past the shared interactive default by the
|
||||
// user; an exec run has nobody to unstick it, so evals and automation would
|
||||
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
|
||||
// still finite against a genuinely runaway loop, which the loop's empty-turn
|
||||
// guard also cuts independently of this budget.
|
||||
const defaultExecMaxTurns = config.MaxTurnsCeiling
|
||||
|
||||
type execOutputFormat string
|
||||
type execInputFormat string
|
||||
|
||||
@@ -320,6 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
resolved.MaxTurns = execTurnBudget(resolved)
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
@@ -1222,6 +1231,20 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
|
||||
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
|
||||
}
|
||||
|
||||
// execTurnBudget applies the headless default to the resolved turn budget: an
|
||||
// interactive run can be continued past the shared default by the user, but an
|
||||
// exec run has nobody to unstick it, so an unconfigured budget starts at the
|
||||
// documented ceiling instead of truncating mid-task. Explicit sources
|
||||
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
|
||||
// MaxTurnsSet and pass through; an exec-profile budget still wins via
|
||||
// applyProfileTurnBudget afterward.
|
||||
func execTurnBudget(resolved config.ResolvedConfig) int {
|
||||
if resolved.MaxTurnsSet {
|
||||
return resolved.MaxTurns
|
||||
}
|
||||
return defaultExecMaxTurns
|
||||
}
|
||||
|
||||
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
|
||||
// The profile displaces the RESOLVED value, not the flag: a pinned budget
|
||||
// always wins and the profile backs off entirely, while an env/config budget
|
||||
|
||||
@@ -1975,3 +1975,23 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
|
||||
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resolved config.ResolvedConfig
|
||||
want int
|
||||
}{
|
||||
// A headless run has no user to continue past the shared interactive
|
||||
// default, so an unconfigured budget starts at the ceiling.
|
||||
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
|
||||
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
|
||||
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
|
||||
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := execTurnBudget(tc.resolved); got != tc.want {
|
||||
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MaxTurnsSet: cfg.maxTurnsSet,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
@@ -234,6 +235,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
}
|
||||
if src.MaxTurns > 0 {
|
||||
dst.MaxTurns = src.MaxTurns
|
||||
dst.maxTurnsSet = true
|
||||
}
|
||||
for _, provider := range src.Providers {
|
||||
mergeProvider(dst, provider)
|
||||
@@ -294,6 +296,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
}
|
||||
if src.MaxTurns > 0 {
|
||||
dst.MaxTurns = src.MaxTurns
|
||||
dst.maxTurnsSet = true
|
||||
}
|
||||
for _, provider := range src.Providers {
|
||||
candidate := providerMergeCandidate(*dst, provider)
|
||||
@@ -606,6 +609,7 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
|
||||
n = MaxTurnsCeiling
|
||||
}
|
||||
cfg.MaxTurns = n
|
||||
cfg.maxTurnsSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +730,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
}
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
cfg.maxTurnsSet = true
|
||||
}
|
||||
if overrides.Sandbox.Enabled != nil {
|
||||
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
|
||||
|
||||
@@ -2252,3 +2252,45 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
const providerJSON = `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
|
||||
}`
|
||||
providerWithTurns := strings.Replace(providerJSON, `"activeProvider": "p"`, `"activeProvider": "p", "maxTurns": 40`, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts ResolveOptions
|
||||
set bool
|
||||
}{
|
||||
{"built-in default only", ResolveOptions{
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, false},
|
||||
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
|
||||
{"project config", ResolveOptions{
|
||||
UserConfigPath: writeConfig(t, providerJSON),
|
||||
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
|
||||
}, true},
|
||||
{"env", ResolveOptions{
|
||||
Env: map[string]string{MaxTurnsEnv: "120"},
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, true},
|
||||
{"cli override", ResolveOptions{
|
||||
Overrides: Overrides{
|
||||
MaxTurns: 42,
|
||||
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
|
||||
},
|
||||
}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resolved, err := Resolve(tc.opts)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Resolve() error = %v", tc.name, err)
|
||||
}
|
||||
if resolved.MaxTurnsSet != tc.set {
|
||||
t.Fatalf("%s: MaxTurnsSet = %v, want %v (MaxTurns=%d)", tc.name, resolved.MaxTurnsSet, tc.set, resolved.MaxTurns)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,10 @@ type FileConfig struct {
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
// maxTurnsSet records that some merge source supplied a positive maxTurns —
|
||||
// i.e. the value is configured, not the built-in default. Unexported like
|
||||
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
|
||||
maxTurnsSet bool
|
||||
// Extra preserves top-level fields written by newer Zero versions or
|
||||
// extensions so a read-modify-write through this version is non-destructive.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
@@ -461,10 +465,15 @@ type Overrides struct {
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
// MaxTurnsSet reports whether MaxTurns came from an explicit source (a
|
||||
// config file, a provider command, ZERO_MAX_TURNS, or CLI overrides) rather
|
||||
// than the built-in default — callers that need a different default can
|
||||
// tell the two apart.
|
||||
MaxTurnsSet bool
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
// --- test doubles ---------------------------------------------------------
|
||||
@@ -191,7 +193,7 @@ func TestPoolQueuesWhenFull(t *testing.T) {
|
||||
}()
|
||||
<-started
|
||||
// Wait until the first run holds the only slot.
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
secondDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -224,7 +226,7 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
close(runDone)
|
||||
}()
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
pool.Drain() // KillTimeout elapses, straggler is force-killed
|
||||
if atomic.LoadInt32(&straggler.killed) != 1 {
|
||||
@@ -242,18 +244,6 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
|
||||
func TestPoolRunSurfacesStdoutReadError(t *testing.T) {
|
||||
// A worker stdout read error must surface as a failure (not be swallowed and
|
||||
// reported as clean success), and the worker is killed so it can't block on an
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
|
||||
@@ -34,7 +35,7 @@ func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
srv.Shutdown()
|
||||
if err := <-serveErr; err != nil {
|
||||
t.Fatalf("Serve with 0755 custom runtime directory: %v", err)
|
||||
@@ -212,18 +213,6 @@ func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Serve
|
||||
return srv
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("file %s did not appear within timeout", path)
|
||||
}
|
||||
|
||||
func TestServerEndToEnd(t *testing.T) {
|
||||
out := []string{`{"type":"event","seq":1}`, `{"type":"event","seq":2}`}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: out, exitCode: 0})
|
||||
@@ -231,7 +220,7 @@ func TestServerEndToEnd(t *testing.T) {
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
|
||||
// --- run a session and collect its stream-json output ---
|
||||
runClient, err := Dial(paths.Socket)
|
||||
@@ -384,7 +373,8 @@ func TestServeCleansStatusThroughBoundDirectoryAfterSwap(t *testing.T) {
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, filepath.Join(movedDir, "d.status"))
|
||||
movedStatus := filepath.Join(movedDir, "d.status")
|
||||
testutil.WaitFor(t, "file "+movedStatus, func() bool { _, err := os.Stat(movedStatus); return err == nil })
|
||||
srv.Shutdown()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
@@ -472,7 +462,7 @@ func TestServerSecondInstanceFails(t *testing.T) {
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv1.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
|
||||
// A second server on the same paths must refuse to start (single instance).
|
||||
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
@@ -493,7 +483,7 @@ func TestServerRejectsUnknownCommand(t *testing.T) {
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
go func() { _ = srv.Serve() }()
|
||||
defer srv.Shutdown()
|
||||
waitForFile(t, paths.Status)
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
|
||||
client, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
func drain(t *testing.T, buffered []string, live <-chan string) []string {
|
||||
@@ -111,7 +113,7 @@ func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
|
||||
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
|
||||
waitFor(t, func() bool { return sa.State() == SessionRunning })
|
||||
testutil.WaitFor(t, "session running", func() bool { return sa.State() == SessionRunning })
|
||||
|
||||
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
|
||||
// b must stay queued (its worker not yet launched) while a holds the slot.
|
||||
@@ -197,7 +199,7 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
|
||||
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
|
||||
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
|
||||
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
|
||||
testutil.WaitFor(t, "both sessions running", func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
|
||||
if _, ok := mgr.Get("r1"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
|
||||
@@ -224,6 +224,49 @@ func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// WriteInput writes bytes to a retained interactive process's stdin without
|
||||
// collecting output. Unlike Continue it never drains the pending output
|
||||
// buffer, so a concurrent write_stdin poll still sees everything the process
|
||||
// emitted; callers that only need the rolling tail use Snapshot instead.
|
||||
func (manager *ProcessManager) WriteInput(id int, data []byte) error {
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return ErrProcessNotFound
|
||||
}
|
||||
process.touch()
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !process.tty || process.stdin == nil {
|
||||
return ErrProcessStdinDisabled
|
||||
}
|
||||
if _, err := process.stdin.Write(data); err != nil && !process.doneClosed() {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResizeInput updates the PTY window size of a retained interactive process
|
||||
// so an attached terminal can fill its viewport. Non-positive dimensions are
|
||||
// a no-op; platforms without PTY support report the transport's error.
|
||||
func (manager *ProcessManager) ResizeInput(id int, cols, rows int) error {
|
||||
if cols <= 0 || rows <= 0 {
|
||||
return nil
|
||||
}
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return ErrProcessNotFound
|
||||
}
|
||||
process.touch()
|
||||
if !process.tty || process.stdin == nil {
|
||||
return ErrProcessStdinDisabled
|
||||
}
|
||||
if err := resizePTY(process.stdin, cols, rows); err != nil && !process.doneClosed() {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampInitialProcessWait(wait time.Duration) time.Duration {
|
||||
return min(wait, maxInteractiveYield)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -85,6 +86,126 @@ func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerWriteInputUnknownProcess(t *testing.T) {
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
if err := manager.WriteInput(4242, []byte("x")); !errors.Is(err, ErrProcessNotFound) {
|
||||
t.Fatalf("WriteInput unknown id = %v, want ErrProcessNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerWriteInputRejectsPipeProcess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer manager.Stop(started.ProcessID)
|
||||
if started.TTY {
|
||||
t.Fatal("pipe process reported a TTY")
|
||||
}
|
||||
if err := manager.WriteInput(started.ProcessID, []byte("x")); !errors.Is(err, ErrProcessStdinDisabled) {
|
||||
t.Fatalf("WriteInput pipe process = %v, want ErrProcessStdinDisabled", err)
|
||||
}
|
||||
if err := manager.WriteInput(started.ProcessID, nil); err != nil {
|
||||
t.Fatalf("WriteInput empty data = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerWriteInputDoesNotDrainPendingOutput(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("PTY sessions are only available on Linux")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.CommandContext(context.Background(), "cat")
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
CommandText: "cat", TTY: true,
|
||||
}, 50*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Skipf("PTY transport unavailable: %v", err)
|
||||
}
|
||||
if !started.TTY {
|
||||
t.Skip("PTY transport fell back to pipes")
|
||||
}
|
||||
defer manager.Stop(started.ProcessID)
|
||||
|
||||
if err := manager.WriteInput(started.ProcessID, []byte("hello\r")); err != nil {
|
||||
t.Fatalf("WriteInput: %v", err)
|
||||
}
|
||||
// The PTY echoes input back into the output stream, so the rolling recent
|
||||
// tail shows what was typed without anything being drained.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
snapshot, ok := manager.Snapshot(started.ProcessID)
|
||||
if ok && strings.Contains(snapshot.RecentOutput, "hello") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("recent output never echoed the input: %q", snapshot.RecentOutput)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
// WriteInput must not have consumed the pending buffer: a write_stdin-style
|
||||
// Continue still collects the echoed bytes.
|
||||
continued, err := manager.Continue(context.Background(), ProcessContinue{
|
||||
ProcessID: started.ProcessID, Wait: 200 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if !strings.Contains(continued.Output, "hello") {
|
||||
t.Fatalf("WriteInput drained the pending output: continued output = %q", continued.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerResizeInputUnknownProcess(t *testing.T) {
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
if err := manager.ResizeInput(4242, 100, 40); !errors.Is(err, ErrProcessNotFound) {
|
||||
t.Fatalf("ResizeInput unknown id = %v, want ErrProcessNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerResizeInputUpdatesWindowSize(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("PTY sessions are only available on Linux")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.CommandContext(context.Background(), "/bin/sh", "-c", "sleep 0.3; stty size")
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
CommandText: "stty size", TTY: true,
|
||||
}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Skipf("PTY transport unavailable: %v", err)
|
||||
}
|
||||
if !started.TTY {
|
||||
t.Skip("PTY transport fell back to pipes")
|
||||
}
|
||||
defer manager.Stop(started.ProcessID)
|
||||
|
||||
if err := manager.ResizeInput(started.ProcessID, 100, 40); err != nil {
|
||||
t.Fatalf("ResizeInput: %v", err)
|
||||
}
|
||||
continued, err := manager.Continue(context.Background(), ProcessContinue{
|
||||
ProcessID: started.ProcessID, Wait: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if combined := started.Output + continued.Output; !strings.Contains(combined, "40 100") {
|
||||
t.Fatalf("stty size output = %q, want %q", combined, "40 100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
|
||||
reaped := make(chan struct{})
|
||||
close(reaped)
|
||||
|
||||
@@ -11,3 +11,7 @@ import (
|
||||
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
|
||||
return nil, nil, errors.New("pty transport is unavailable on this platform")
|
||||
}
|
||||
|
||||
func resizePTY(_ io.Writer, _, _ int) error {
|
||||
return errors.New("pty transport is unavailable on this platform")
|
||||
}
|
||||
@@ -48,6 +48,9 @@ func openPTY() (*os.File, *os.File, error) {
|
||||
_ = master.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
// A fresh PTY defaults to a 0x0 window; seed a sane size so the process
|
||||
// never sees zero dimensions before an attached client reports its own.
|
||||
_ = unix.IoctlSetWinsize(masterFD, unix.TIOCSWINSZ, &unix.Winsize{Row: 24, Col: 80})
|
||||
pts, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN)
|
||||
if err != nil {
|
||||
_ = master.Close()
|
||||
@@ -80,3 +83,16 @@ func hardenPTYProcess(command *exec.Cmd) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// resizePTY reports a new window size to the PTY behind an interactive
|
||||
// process's stdin writer, so full-screen attached terminals drive the
|
||||
// session's own line wrapping.
|
||||
func resizePTY(w io.Writer, cols, rows int) error {
|
||||
file, ok := w.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return errors.New("pty transport is unavailable on this platform")
|
||||
}
|
||||
return unix.IoctlSetWinsize(int(file.Fd()), unix.TIOCSWINSZ, &unix.Winsize{
|
||||
Row: uint16(rows), Col: uint16(cols),
|
||||
})
|
||||
}
|
||||
@@ -216,6 +216,9 @@ func (client *networkClient) request(ctx context.Context, method string, params
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if message.isRequestOrNotification() {
|
||||
return fmt.Errorf("MCP %s expected a response from server %s, got method %q instead", method, client.server.Name, message.Method)
|
||||
}
|
||||
if !rpcIDMatches(message.ID, id) {
|
||||
return fmt.Errorf("MCP %s response id mismatch for server %s", method, client.server.Name)
|
||||
}
|
||||
|
||||
@@ -316,6 +316,50 @@ func TestNonOAuthServerIsUnaffected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkClientRejectsServerInitiatedRequestAsResponse(t *testing.T) {
|
||||
// A streamable-HTTP server that answers a tools/call POST with a
|
||||
// method-bearing frame (a server-initiated request) must surface a protocol
|
||||
// error rather than an empty, error-free result.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
message := readHTTPRPCMessage(t, r)
|
||||
switch message.Method {
|
||||
case "initialize":
|
||||
writeHTTPRPCResponse(t, w, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case "tools/call":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if _, err := w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(mustRaw(message.ID)) + `,"method":"roots/list","params":{}}`)); err != nil {
|
||||
t.Errorf("write server request: %v", err)
|
||||
}
|
||||
default:
|
||||
writeHTTPRPCResponse(t, w, message.ID, map[string]any{})
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
client, err := Connect(ctx, Server{Name: "plain", Type: ServerTypeHTTP, URL: upstream.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := client.CallTool(ctx, "lookup", map[string]any{"query": "zero"})
|
||||
if err == nil {
|
||||
t.Fatalf("CallTool() error = nil, result = %#v, want protocol error", result)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"roots/list"`) {
|
||||
t.Fatalf("CallTool() error = %q, want it to name the unexpected method", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSSERPCMessageSkipsNotifications(t *testing.T) {
|
||||
// A leading server notification (has a method) on the POST's event stream must
|
||||
// be skipped so the actual response (no method) is returned, instead of the
|
||||
|
||||
@@ -20,6 +20,7 @@ type LoopbackListener struct {
|
||||
state string
|
||||
result chan callbackResult
|
||||
server *http.Server
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
@@ -38,6 +39,14 @@ func NewLoopbackListener(state string) (*LoopbackListener, error) {
|
||||
// port (0 = OS-assigned). Used by ChatGPT OAuth which requires a fixed
|
||||
// redirect_uri of http://localhost:1455/auth/callback.
|
||||
func NewLoopbackListenerOnPort(state string, port int) (*LoopbackListener, error) {
|
||||
return newLoopbackListener(state, port, nil)
|
||||
}
|
||||
|
||||
// newLoopbackListener is NewLoopbackListenerOnPort with an optional ConnState
|
||||
// hook, set before the Serve goroutine starts so tests can synchronize on
|
||||
// connection lifecycle events (e.g. a stalled request header being accepted)
|
||||
// without racing the running server.
|
||||
func newLoopbackListener(state string, port int, connState func(net.Conn, http.ConnState)) (*LoopbackListener, error) {
|
||||
if strings.TrimSpace(state) == "" {
|
||||
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
|
||||
}
|
||||
@@ -49,9 +58,20 @@ func NewLoopbackListenerOnPort(state string, port int) (*LoopbackListener, error
|
||||
listener: ln,
|
||||
state: state,
|
||||
result: make(chan callbackResult, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
|
||||
go func() { _ = l.server.Serve(ln) }()
|
||||
l.server = &http.Server{
|
||||
Handler: http.HandlerFunc(l.handle),
|
||||
// This is a single-use, loopback-only callback server: a client that
|
||||
// never finishes sending its request header must not be able to hold
|
||||
// the accepted connection (and Close, below) open indefinitely.
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ConnState: connState,
|
||||
}
|
||||
go func() {
|
||||
_ = l.server.Serve(ln)
|
||||
close(l.done)
|
||||
}()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
@@ -99,11 +119,17 @@ func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts the listener down (bounded), idempotent.
|
||||
// Close shuts the listener down (bounded), idempotent. If a client leaves a
|
||||
// connection open past the shutdown deadline (e.g. a stalled request header),
|
||||
// Close forcibly closes it rather than leaking it past the end of the flow,
|
||||
// and waits for the Serve goroutine to finish before returning.
|
||||
func (l *LoopbackListener) Close() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = l.server.Shutdown(shutdownCtx)
|
||||
if err := l.server.Shutdown(shutdownCtx); err != nil {
|
||||
_ = l.server.Close()
|
||||
}
|
||||
<-l.done
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization code,
|
||||
|
||||
@@ -3,6 +3,9 @@ package oauth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -122,3 +125,99 @@ func TestLoopbackProviderError(t *testing.T) {
|
||||
t.Fatalf("Wait err = %v, want provider error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackCloseForceClosesSlowHeaderConnection(t *testing.T) {
|
||||
accepted := make(chan struct{}, 1)
|
||||
l, err := newLoopbackListener("audit-state", 0, func(_ net.Conn, state http.ConnState) {
|
||||
// StateNew fires as soon as Serve accepts the connection, before it
|
||||
// blocks reading the (never-completed) header. That's the earliest
|
||||
// point at which net/http's Shutdown will treat this connection as
|
||||
// non-idle, which is what this test needs to exercise.
|
||||
if state == http.StateNew {
|
||||
select {
|
||||
case accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
t.Cleanup(l.Close)
|
||||
addr := l.listener.Addr().String()
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// Wait for Serve to accept the connection before sending a request line
|
||||
// and a header key with no terminating CRLF, so Serve is guaranteed to
|
||||
// be blocked reading a partial header (rather than a fixed sleep guessing
|
||||
// that Accept has run).
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("server never accepted the connection")
|
||||
}
|
||||
fmt.Fprintf(conn, "GET /callback?code=audit-code&state=audit-state HTTP/1.1\r\nHost: %s\r\nX-Stall:", addr)
|
||||
|
||||
started := time.Now()
|
||||
l.Close()
|
||||
if elapsed := time.Since(started); elapsed < 900*time.Millisecond {
|
||||
t.Fatalf("Close returned after %s; expected it to wait out its one-second shutdown deadline first", elapsed)
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
var one [1]byte
|
||||
_, readErr := conn.Read(one[:])
|
||||
if readErr == nil {
|
||||
t.Fatal("read after Close succeeded; wanted the stalled connection to be closed")
|
||||
}
|
||||
if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() {
|
||||
t.Fatalf("read after Close timed out (%v); wanted the stalled connection closed rather than left open", readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackCloseIsIdempotent(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
l.Close()
|
||||
l.Close() // must not panic or hang
|
||||
}
|
||||
|
||||
func TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
t.Cleanup(l.Close)
|
||||
requestDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestDone)
|
||||
resp, err := http.Get(l.RedirectURI() + "?code=x&state=s")
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if _, err := l.Wait(ctx); err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
// Join the client goroutine so the response is fully read (and its
|
||||
// connection returned to idle) before timing Close, instead of racing it.
|
||||
select {
|
||||
case <-requestDone:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("http.Get goroutine did not complete")
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
l.Close()
|
||||
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("Close took %s after a completed, idle callback; want a prompt return", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ type Options struct {
|
||||
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
|
||||
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
|
||||
StreamIdleTimeout time.Duration
|
||||
SessionHeader string
|
||||
}
|
||||
|
||||
// Provider streams completions from Anthropic's Messages API.
|
||||
@@ -108,6 +109,7 @@ type Provider struct {
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
streamIdleTimeout time.Duration
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an Anthropic provider.
|
||||
@@ -143,6 +145,7 @@ func New(options Options) (*Provider, error) {
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
|
||||
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -163,12 +166,12 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
|
||||
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
|
||||
// the request, which unblocks the SSE reader goroutine.
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
@@ -185,6 +188,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
provider.sessionHeader.Apply(request, sessionID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("anthropic-version", provider.version)
|
||||
if provider.beta != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -53,6 +54,11 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
return newCodexProvider(profile, resolved, options)
|
||||
}
|
||||
|
||||
sessionHeader := sessionHeaderForProfile(profile, resolved)
|
||||
if sessionHeader != "" && strings.TrimSpace(options.UserAgent) == "" {
|
||||
options.UserAgent = "zero"
|
||||
}
|
||||
|
||||
switch resolved.providerKind {
|
||||
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
|
||||
// prompt_cache_key is an OpenAI-only chat-completions field. Strict
|
||||
@@ -74,6 +80,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
UserAgent: options.UserAgent,
|
||||
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
|
||||
DisablePromptCacheKey: resolved.providerKind == config.ProviderKindOpenAICompatible,
|
||||
SessionHeader: sessionHeader,
|
||||
})
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
return anthropic.New(anthropic.Options{
|
||||
@@ -88,6 +95,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
SessionHeader: sessionHeader,
|
||||
})
|
||||
case config.ProviderKindGoogle:
|
||||
return gemini.New(gemini.Options{
|
||||
@@ -345,6 +353,18 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
|
||||
return providercatalog.NormalizeID(profile.CatalogID) == "chatgpt"
|
||||
}
|
||||
|
||||
func sessionHeaderForProfile(profile config.ProviderProfile, resolved resolvedProfile) string {
|
||||
switch providercatalog.NormalizeID(profile.CatalogID) {
|
||||
case "opencode", "opencode-go", "opencode-go-anthropic-compatible":
|
||||
return "x-opencode-session"
|
||||
}
|
||||
endpoint, err := url.Parse(resolved.baseURL)
|
||||
if err == nil && strings.EqualFold(endpoint.Hostname(), "opencode.ai") && (endpoint.Path == "/zen" || strings.HasPrefix(endpoint.Path, "/zen/")) {
|
||||
return "x-opencode-session"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
|
||||
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
|
||||
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
|
||||
|
||||
@@ -3,13 +3,17 @@ package providers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -794,3 +798,566 @@ func TestResolveRuntimeMetadataRejectsModelOutsideProviderAllowlist(t *testing.T
|
||||
t.Fatalf("error = %q, want it to name the rejected model", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeader(t *testing.T) {
|
||||
const sessionHeader = "x-opencode-session"
|
||||
tests := []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
response string
|
||||
wantHeader bool
|
||||
wantAuth map[string]string
|
||||
}{
|
||||
{
|
||||
name: "catalog opencode-go default URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc-go",
|
||||
Model: "deepseek-v4-pro",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-oc-go"},
|
||||
},
|
||||
{
|
||||
name: "catalog opencode-go-anthropic-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go-anthropic",
|
||||
CatalogID: "opencode-go-anthropic-compatible",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
APIKey: "sk-oc-ant",
|
||||
Model: "minimax-m3",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-oc-ant"},
|
||||
},
|
||||
{
|
||||
name: "catalog opencode Zen",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-zen",
|
||||
CatalogID: "opencode",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-zen",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-zen"},
|
||||
},
|
||||
{
|
||||
name: "renamed catalog profile with custom proxy URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "my-opencode",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://proxy.example/v1",
|
||||
APIKey: "sk-proxy",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-proxy"},
|
||||
},
|
||||
{
|
||||
name: "catalog-less OpenAI-compatible OpenCode Go URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "manual-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai/zen/go/v1",
|
||||
APIKey: "sk-manual",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-manual"},
|
||||
},
|
||||
{
|
||||
name: "catalog-less Anthropic-compatible OpenCode Go URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "manual-go-anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
BaseURL: "https://opencode.ai/zen/go",
|
||||
APIKey: "sk-manual-ant",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-manual-ant"},
|
||||
},
|
||||
{
|
||||
name: "official OpenAI",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
APIKey: "sk-openai",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-openai"},
|
||||
},
|
||||
{
|
||||
name: "generic OpenAI-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "generic",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://provider.example/v1",
|
||||
APIKey: "sk-generic",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-generic"},
|
||||
},
|
||||
{
|
||||
name: "official Anthropic",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropic,
|
||||
APIKey: "sk-ant",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-ant"},
|
||||
},
|
||||
{
|
||||
name: "lookalike subdomain is not OpenCode",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "deceptive-host",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai.example/zen/go/v1",
|
||||
APIKey: "sk-fake",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
|
||||
},
|
||||
{
|
||||
name: "lookalike path is not OpenCode",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "deceptive-path",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://example.com/opencode.ai/zen/go",
|
||||
APIKey: "sk-fake",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
|
||||
},
|
||||
{
|
||||
name: "zenith path is not Zen",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "zenith",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai/zenith",
|
||||
APIKey: "sk-zenith",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-zenith"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &captureTransport{responseBody: test.response}
|
||||
provider, err := New(test.profile, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
UserAgent: "zero-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if transport.request == nil {
|
||||
t.Fatal("HTTP client was not used")
|
||||
}
|
||||
got := transport.request.Header.Get(sessionHeader)
|
||||
if test.wantHeader && got != "conversation-a" {
|
||||
t.Fatalf("%s = %q, want %q", sessionHeader, got, "conversation-a")
|
||||
}
|
||||
if !test.wantHeader && got != "" {
|
||||
t.Fatalf("%s = %q, want no session header", sessionHeader, got)
|
||||
}
|
||||
for header, want := range test.wantAuth {
|
||||
if got := transport.request.Header.Get(header); got != want {
|
||||
t.Fatalf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
if got := transport.request.Header.Get("User-Agent"); got != "zero-test" {
|
||||
t.Fatalf("User-Agent = %q, want zero-test", got)
|
||||
}
|
||||
if test.profile.ProviderKind == config.ProviderKindOpenAICompatible {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if _, ok := body["prompt_cache_key"]; ok {
|
||||
t.Fatalf("openai-compatible body carried prompt_cache_key: %#v", body["prompt_cache_key"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderFollowsConversationKey(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}
|
||||
options := Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"}
|
||||
provider, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
for _, key := range []string{"conversation-a", "conversation-a", "conversation-b"} {
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
}
|
||||
got := transport.sessionHeaders()
|
||||
want := []string{"conversation-a", "conversation-a", "conversation-b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("captured %d requests, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("request %d x-opencode-session = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
reconstructed, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := reconstructed.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
got = transport.sessionHeaders()
|
||||
if last := got[len(got)-1]; last != "conversation-a" {
|
||||
t.Fatalf("reconstructed provider sent %s = %q, want conversation-a", "x-opencode-session", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderCustomOverride(t *testing.T) {
|
||||
custom := map[string]string{"X-Opencode-Session": "explicit-session"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
CustomHeaders: custom,
|
||||
}
|
||||
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
||||
provider, err := New(profile, Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if got := transport.request.Header.Get("x-opencode-session"); got != "explicit-session" {
|
||||
t.Fatalf("x-opencode-session = %q, want explicit custom value", got)
|
||||
}
|
||||
if len(custom) != 1 || custom["X-Opencode-Session"] != "explicit-session" {
|
||||
t.Fatalf("profile CustomHeaders mutated: %#v", custom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderFallback(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}
|
||||
options := Options{HTTPClient: &http.Client{Transport: transport}}
|
||||
first, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
second, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
}
|
||||
for _, provider := range []zeroruntime.Provider{first, first, second} {
|
||||
stream, err := provider.StreamCompletion(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
}
|
||||
headers := transport.sessionHeaders()
|
||||
if len(headers) != 3 {
|
||||
t.Fatalf("captured %d requests, want 3", len(headers))
|
||||
}
|
||||
if headers[0] == "" || headers[1] != headers[0] {
|
||||
t.Fatalf("fallback headers = %q, %q — want one stable nonempty value per provider", headers[0], headers[1])
|
||||
}
|
||||
if headers[2] == headers[0] {
|
||||
t.Fatalf("distinct provider instances shared fallback %q", headers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderDefaultsUserAgent(t *testing.T) {
|
||||
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if got := transport.request.Header.Get("User-Agent"); got != "zero" {
|
||||
t.Fatalf("User-Agent = %q, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderConcurrent(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errsMu sync.Mutex
|
||||
var errs []string
|
||||
for _, session := range []string{"session-a", "session-b"} {
|
||||
wg.Add(1)
|
||||
go func(session string) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 5; i++ {
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("req-%s-%d", session, i)}},
|
||||
PromptCacheKey: session,
|
||||
})
|
||||
if err != nil {
|
||||
errsMu.Lock()
|
||||
errs = append(errs, err.Error())
|
||||
errsMu.Unlock()
|
||||
return
|
||||
}
|
||||
for event := range stream {
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
errsMu.Lock()
|
||||
errs = append(errs, event.Error)
|
||||
errsMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}(session)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, errText := range errs {
|
||||
t.Errorf("concurrent stream error: %s", errText)
|
||||
}
|
||||
|
||||
requests := transport.recorded()
|
||||
if len(requests) != 10 {
|
||||
t.Fatalf("captured %d requests, want 10", len(requests))
|
||||
}
|
||||
for _, request := range requests {
|
||||
var payload struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(request.body), &payload); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(payload.Messages) == 0 {
|
||||
t.Fatal("request carried no user message")
|
||||
}
|
||||
content := payload.Messages[0].Content
|
||||
want := "session-a"
|
||||
if strings.Contains(content, "session-b") {
|
||||
want = "session-b"
|
||||
}
|
||||
if got := request.header.Get("x-opencode-session"); got != want {
|
||||
t.Fatalf("request for %q carried x-opencode-session %q, want %q", content, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderAuthRetry(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
response string
|
||||
}{
|
||||
{
|
||||
name: "openai-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
},
|
||||
{
|
||||
name: "anthropic-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go-anthropic",
|
||||
CatalogID: "opencode-go-anthropic-compatible",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
APIKey: "sk-oc-ant",
|
||||
Model: "minimax-m3",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: test.response, unauthorized: 1}
|
||||
var calls atomic.Int32
|
||||
resolver := providerio.TokenResolver(func(context.Context, bool) (string, string, bool, error) {
|
||||
n := calls.Add(1)
|
||||
return "Authorization", fmt.Sprintf("Bearer tok-%d", n), true, nil
|
||||
})
|
||||
provider, err := New(test.profile, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
OAuthResolver: resolver,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
requests := transport.recorded()
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("captured %d requests, want 2 (401 + retry)", len(requests))
|
||||
}
|
||||
for i, request := range requests {
|
||||
if got := request.header.Get("x-opencode-session"); got != "conversation-a" {
|
||||
t.Fatalf("attempt %d x-opencode-session = %q, want conversation-a", i, got)
|
||||
}
|
||||
}
|
||||
if first, second := requests[0].header.Get("Authorization"), requests[1].header.Get("Authorization"); first == second {
|
||||
t.Fatalf("Authorization not refreshed across retry: %q", first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func drainStream(t *testing.T, stream <-chan zeroruntime.StreamEvent) {
|
||||
t.Helper()
|
||||
for event := range stream {
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
t.Fatalf("stream error: %s", event.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type recordedRequest struct {
|
||||
header http.Header
|
||||
body string
|
||||
}
|
||||
|
||||
type recordingTransport struct {
|
||||
mu sync.Mutex
|
||||
requests []recordedRequest
|
||||
status int
|
||||
body string
|
||||
unauthorized int
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
if request.Body != nil {
|
||||
data, _ := io.ReadAll(request.Body)
|
||||
body = string(data)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
transport.requests = append(transport.requests, recordedRequest{header: request.Header.Clone(), body: body})
|
||||
attempt := len(transport.requests)
|
||||
transport.mu.Unlock()
|
||||
|
||||
status := transport.status
|
||||
if attempt <= transport.unauthorized {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Status: fmt.Sprintf("%d", status),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(transport.body)),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) recorded() []recordedRequest {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
return append([]recordedRequest(nil), transport.requests...)
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) sessionHeaders() []string {
|
||||
requests := transport.recorded()
|
||||
headers := make([]string, 0, len(requests))
|
||||
for _, request := range requests {
|
||||
headers = append(headers, request.header.Get("x-opencode-session"))
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type Options struct {
|
||||
// enabled; the ZERO_DISABLE_PROMPT_CACHE_KEY env kill switch still applies
|
||||
// on top for any endpoint.
|
||||
DisablePromptCacheKey bool
|
||||
SessionHeader string
|
||||
}
|
||||
|
||||
// Provider streams completions from an OpenAI-compatible chat completions API.
|
||||
@@ -85,6 +86,7 @@ type Provider struct {
|
||||
parseThinkTags bool
|
||||
setRequestExtra func(*http.Request)
|
||||
disablePromptCacheKey bool
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an OpenAI-compatible provider.
|
||||
@@ -140,6 +142,7 @@ func New(options Options) (*Provider, error) {
|
||||
parseThinkTags: options.ParseThinkTags,
|
||||
setRequestExtra: options.SetRequestExtra,
|
||||
disablePromptCacheKey: options.DisablePromptCacheKey,
|
||||
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -156,13 +159,13 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
|
||||
endpoint := provider.endpoint
|
||||
|
||||
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
|
||||
@@ -186,6 +189,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
provider.sessionHeader.Apply(request, sessionID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -52,6 +53,28 @@ func ApplyAuthHeaders(request *http.Request, options AuthHeaders) {
|
||||
request.Header.Set(header, value)
|
||||
}
|
||||
|
||||
type SessionHeader struct {
|
||||
name string
|
||||
fallback string
|
||||
}
|
||||
|
||||
func NewSessionHeader(name string) SessionHeader {
|
||||
if name == "" {
|
||||
return SessionHeader{}
|
||||
}
|
||||
return SessionHeader{name: name, fallback: rand.Text()}
|
||||
}
|
||||
|
||||
func (header SessionHeader) Apply(request *http.Request, sessionID string) {
|
||||
if header.name == "" {
|
||||
return
|
||||
}
|
||||
if sessionID = strings.TrimSpace(sessionID); sessionID == "" {
|
||||
sessionID = header.fallback
|
||||
}
|
||||
request.Header.Set(header.name, sessionID)
|
||||
}
|
||||
|
||||
func CopyHeaders(headers map[string]string) map[string]string {
|
||||
if headers == nil {
|
||||
return nil
|
||||
|
||||
@@ -169,10 +169,53 @@ var sharedHTTPClient = func() *http.Client {
|
||||
// content-stall watchdogs.
|
||||
transport.ResponseHeaderTimeout = 120 * time.Second
|
||||
transport.IdleConnTimeout = 30 * time.Second
|
||||
// Periodically close idle connections to prevent stale HTTP/2
|
||||
// connections from causing PROTOCOL_ERROR on the next request.
|
||||
// OpenRouter (and other HTTP/2 backends) can silently RST or
|
||||
// GOAWAY a connection that has been idle in the pool; the next
|
||||
// request over that connection gets a PROTOCOL_ERROR. Closing
|
||||
// idle connections every 30s forces a fresh dial so the error
|
||||
// window is bounded to ~30s while reuse still benefits healthy
|
||||
// connections. No-op on darwin where DisableKeepAlives already
|
||||
// prevents pooling.
|
||||
if runtime.GOOS != "darwin" {
|
||||
startIdleConnCloser(transport, defaultIdleConnCloseInterval)
|
||||
}
|
||||
transport.DisableKeepAlives = runtime.GOOS == "darwin"
|
||||
return &http.Client{Transport: transport}
|
||||
}()
|
||||
|
||||
const defaultIdleConnCloseInterval = 30 * time.Second
|
||||
|
||||
// startIdleConnCloser launches a background goroutine that periodically closes
|
||||
// idle keep-alive connections on transport until the returned stop function is
|
||||
// invoked.
|
||||
func startIdleConnCloser(transport *http.Transport, interval time.Duration) func() {
|
||||
if interval <= 0 || transport == nil {
|
||||
return func() {}
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPClient returns the configured client or the shared, stall-hardened default.
|
||||
func HTTPClient(client *http.Client) *http.Client {
|
||||
if client != nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httptrace"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -371,3 +373,97 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
|
||||
t.Fatal("an explicit client must be returned unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// startIdleConnCloser periodically closes idle pooled connections so stale HTTP/2
|
||||
// connections are not reused across long idle periods.
|
||||
func TestStartIdleConnCloserClosesIdleConnections(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DisableKeepAlives = false
|
||||
defer tr.CloseIdleConnections()
|
||||
|
||||
client := &http.Client{Transport: tr}
|
||||
|
||||
// First request creates a connection in the pool.
|
||||
resp1, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("first request failed: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp1.Body)
|
||||
_ = resp1.Body.Close()
|
||||
|
||||
// Immediate second request reuses the pooled connection.
|
||||
var reusedFirst bool
|
||||
trace1 := &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
reusedFirst = info.Reused
|
||||
},
|
||||
}
|
||||
req2, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace1), http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request failed: %v", err)
|
||||
}
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("second request failed: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp2.Body)
|
||||
_ = resp2.Body.Close()
|
||||
|
||||
if !reusedFirst {
|
||||
t.Fatal("expected immediate second request to reuse keep-alive connection")
|
||||
}
|
||||
|
||||
// Start closer with a short interval and wait for it to fire.
|
||||
stop := startIdleConnCloser(tr, 20*time.Millisecond)
|
||||
defer stop()
|
||||
|
||||
// Poll until the idle closer evicts the connection and a fresh dial occurs.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var closed bool
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
var reused bool
|
||||
trace := &httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
reused = info.Reused
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request failed: %v", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if !reused {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
t.Fatal("expected connection to be closed by idle closer and not reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartIdleConnCloserEdgeCases(t *testing.T) {
|
||||
// Zero or negative interval returns a no-op stop func without starting goroutine.
|
||||
stopZero := startIdleConnCloser(nil, 0)
|
||||
stopZero()
|
||||
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
stopNil := startIdleConnCloser(tr, -1*time.Second)
|
||||
stopNil()
|
||||
|
||||
// Calling stop multiple times is idempotent.
|
||||
stop := startIdleConnCloser(tr, 100*time.Millisecond)
|
||||
stop()
|
||||
stop()
|
||||
}
|
||||
@@ -33,8 +33,14 @@ type WindowsACLEntry struct {
|
||||
// down onto the target's EXISTING descendants (not just new ones it
|
||||
// creates going forward), which is why direct-only denies must set this
|
||||
// flag rather than rely on inheritance.
|
||||
NoInherit bool `json:"noInherit,omitempty"`
|
||||
Materialize bool `json:"materialize,omitempty"`
|
||||
NoInherit bool `json:"noInherit,omitempty"`
|
||||
// Anchor is the write root Path was DERIVED from, for the paths this
|
||||
// package constructs rather than the operator naming. The apply requires
|
||||
// the object it finally opens to still live under it, so a reparse point
|
||||
// planted on the derived tail cannot walk an elevated ACL write out of the
|
||||
// sandbox. Empty for an operator-named path, which has no owned tail.
|
||||
Anchor string `json:"anchor,omitempty"`
|
||||
Materialize bool `json:"materialize,omitempty"`
|
||||
}
|
||||
|
||||
type WindowsACLPlan struct {
|
||||
@@ -57,10 +63,22 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
|
||||
Capability: capability.SID,
|
||||
})
|
||||
for _, path := range capability.ProtectedWriteDenyPaths {
|
||||
// ANCHORED ONLY WHERE THE ANCHOR IS TRUE. Most of these are derived
|
||||
// from the root and sit under it, and holding those inside it is the
|
||||
// point. But ReadOnlySubpaths is a profile field an operator can set to
|
||||
// any path, and one deliberately placed outside the root is a
|
||||
// configuration that works today; anchoring it would turn that into a
|
||||
// containment refusal. A path that is not under the root gets no
|
||||
// anchor and keeps the final-component guard it always had.
|
||||
anchor := ""
|
||||
if pathWithinRoot(capability.Root, path) {
|
||||
anchor = capability.Root
|
||||
}
|
||||
entries = append(entries, WindowsACLEntry{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: path,
|
||||
Capability: capability.SID,
|
||||
Anchor: anchor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ const (
|
||||
)
|
||||
|
||||
type windowsACLPathGroup struct {
|
||||
Path string
|
||||
Path string
|
||||
// Anchor is the write root Path was derived from, empty when the operator
|
||||
// named the path. See verifyWindowsACLHandleUnderAnchor.
|
||||
Anchor string
|
||||
Entries []WindowsACLEntry
|
||||
Materialize bool
|
||||
}
|
||||
@@ -69,6 +72,12 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup {
|
||||
}
|
||||
group.Entries = append(group.Entries, entry)
|
||||
group.Materialize = group.Materialize || entry.Materialize
|
||||
// One path is derived from at most one root, so the first anchor seen is
|
||||
// the anchor. Taking it rather than overwriting keeps a later
|
||||
// operator-named duplicate of the same path from clearing it.
|
||||
if group.Anchor == "" {
|
||||
group.Anchor = entry.Anchor
|
||||
}
|
||||
}
|
||||
out := make([]windowsACLPathGroup, 0, len(byPath))
|
||||
for _, group := range byPath {
|
||||
@@ -104,6 +113,13 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
}
|
||||
return windowsACLSnapshot{}, false, nil
|
||||
}
|
||||
// Before creating anything: os.MkdirAll walks a pathname and follows every
|
||||
// reparse point on it, so a junction on the derived tail would have this
|
||||
// elevated setup create the directory outside the write root and only the
|
||||
// containment check below would notice, after the fact.
|
||||
if err := verifyWindowsACLPathUnderAnchor(group.Anchor, path); err != nil {
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err)
|
||||
}
|
||||
@@ -123,6 +139,15 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
|
||||
}
|
||||
return windowsACLSnapshot{}, false, err
|
||||
}
|
||||
// THE OBJECT HAS TO BE WHERE THE PLAN SAID IT WOULD BE. The open above
|
||||
// refuses a reparse point at the final component and resolves every one
|
||||
// above it, which is right for a path the operator named and not enough for
|
||||
// one this package derived from a write root: a junction on the derived tail
|
||||
// redirects the handle out of the sandbox with nothing about the final
|
||||
// object looking wrong.
|
||||
if err := verifyWindowsACLHandleUnderAnchor(handle, group.Anchor, path); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("read windows ACL for %s: %w", path, err))
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Keeping an elevated ACL write inside the tree it was planned for.
|
||||
//
|
||||
// FILE_FLAG_OPEN_REPARSE_POINT GUARDS ONE COMPONENT, AND A PATH HAS MANY. The
|
||||
// apply opens its target with that flag, which stops the FINAL component from
|
||||
// being followed and refuses the handle when it is a reparse point. Every
|
||||
// component above it is resolved normally, as it must be for any absolute path
|
||||
// to work at all.
|
||||
//
|
||||
// That is enough for a target the operator named, and not enough for one the
|
||||
// sandbox derived. The write-root carveouts are derived: <root>/.git/hooks and
|
||||
// <root>/.git/config are constructed from a root, and <root>/.git is a name an
|
||||
// unprivileged workspace writer can create. `mklink /J` needs no privilege, so
|
||||
// a junction there makes the apply open <junction-target>/hooks instead, and
|
||||
// `zero sandbox setup` writes a deny-write ACE, as Administrator, on an object
|
||||
// outside the workspace. The final-component flag never fires: the object at
|
||||
// the end of the walk is an ordinary directory.
|
||||
//
|
||||
// So the derived targets carry the root they were derived FROM, and the object
|
||||
// finally opened has to still be inside it. The check is on the handle, not on
|
||||
// the name: GetFinalPathNameByHandle answers where the object this process is
|
||||
// holding actually lives, and both sides of the comparison go through it so the
|
||||
// spelling is normalized the same way (\\?\ prefix, long names, drive letter).
|
||||
// A junction anywhere along the derived tail moves the answer out of the root
|
||||
// and the apply refuses.
|
||||
//
|
||||
// The rule is deliberately not "no reparse point anywhere in the path". Above
|
||||
// the write root the path belongs to the operator, who may legitimately keep a
|
||||
// workspace under a junction or a mapped directory, and refusing that would
|
||||
// break setups this has nothing to say about. Only the tail the sandbox itself
|
||||
// appended is held strict.
|
||||
|
||||
// windowsFinalPathByHandle reports where an open handle's object actually lives.
|
||||
func windowsFinalPathByHandle(handle windows.Handle) (string, error) {
|
||||
buffer := make([]uint16, windows.MAX_LONG_PATH)
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int(n) > len(buffer) {
|
||||
buffer = make([]uint16, n)
|
||||
n, err = windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return windows.UTF16ToString(buffer[:n]), nil
|
||||
}
|
||||
|
||||
// windowsFinalPathOf opens path without following a final-component reparse
|
||||
// point and reports where the object lives. A missing path is surfaced as
|
||||
// os.ErrNotExist so callers can walk up to a parent that exists.
|
||||
func windowsFinalPathOf(path string) (string, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode windows path %s: %w", path, err)
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.READ_CONTROL,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open windows path %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
final, err := windowsFinalPathByHandle(handle)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve windows path %s: %w", path, err)
|
||||
}
|
||||
return final, nil
|
||||
}
|
||||
|
||||
// windowsACLTailUnderAnchor returns the components target adds to anchor, and
|
||||
// whether target is under anchor lexically at all.
|
||||
func windowsACLTailUnderAnchor(anchor, target string) (string, bool) {
|
||||
relative, err := filepath.Rel(filepath.Clean(anchor), filepath.Clean(target))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if relative == "." {
|
||||
return "", true
|
||||
}
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return relative, true
|
||||
}
|
||||
|
||||
// windowsACLContainmentError reports a derived target that resolved outside the
|
||||
// root it was derived from.
|
||||
type windowsACLContainmentError struct {
|
||||
Anchor string
|
||||
Target string
|
||||
Actual string
|
||||
}
|
||||
|
||||
func (err windowsACLContainmentError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"refusing to apply ACL to %s: it resolves to %s, outside the write root %s; "+
|
||||
"a reparse point on the way down redirects an elevated ACL write out of the sandbox",
|
||||
err.Target, err.Actual, err.Anchor)
|
||||
}
|
||||
|
||||
// verifyWindowsACLHandleUnderAnchor refuses a handle whose object does not live
|
||||
// where the plan said it would.
|
||||
//
|
||||
// anchor empty means the target was named rather than derived, and there is no
|
||||
// owned tail to hold strict; the final-component check the caller already did is
|
||||
// the whole guard for those.
|
||||
func verifyWindowsACLHandleUnderAnchor(handle windows.Handle, anchor, target string) error {
|
||||
if strings.TrimSpace(anchor) == "" {
|
||||
return nil
|
||||
}
|
||||
tail, ok := windowsACLTailUnderAnchor(anchor, target)
|
||||
if !ok {
|
||||
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: target}
|
||||
}
|
||||
anchorFinal, err := windowsFinalPathOf(anchor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve write root %s: %w", anchor, err)
|
||||
}
|
||||
targetFinal, err := windowsFinalPathByHandle(handle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve windows ACL target %s: %w", target, err)
|
||||
}
|
||||
expected := anchorFinal
|
||||
if tail != "" {
|
||||
expected = filepath.Join(anchorFinal, tail)
|
||||
}
|
||||
if !strings.EqualFold(filepath.Clean(targetFinal), filepath.Clean(expected)) {
|
||||
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: targetFinal}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyWindowsACLPathUnderAnchor makes the same check against the deepest
|
||||
// component of target that exists, before anything is created.
|
||||
//
|
||||
// Materialization creates a missing target with os.MkdirAll, which walks a
|
||||
// pathname and follows every reparse point on it, so without this an elevated
|
||||
// setup creates the directory on the far side of a junction and only the
|
||||
// containment check afterwards notices. The window between this check and that
|
||||
// create is not closed here; closing it needs the components created relative to
|
||||
// retained handles, which is the rooted descent tracked in #808.
|
||||
func verifyWindowsACLPathUnderAnchor(anchor, target string) error {
|
||||
if strings.TrimSpace(anchor) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := windowsACLTailUnderAnchor(anchor, target); !ok {
|
||||
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: target}
|
||||
}
|
||||
existing := filepath.Clean(target)
|
||||
for {
|
||||
if _, err := os.Lstat(existing); err == nil {
|
||||
break
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect windows ACL target %s: %w", existing, err)
|
||||
}
|
||||
parent := filepath.Dir(existing)
|
||||
if parent == existing {
|
||||
return nil
|
||||
}
|
||||
existing = parent
|
||||
}
|
||||
tail, ok := windowsACLTailUnderAnchor(anchor, existing)
|
||||
if !ok || tail == "" {
|
||||
// Above or at the write root, which is the operator's to arrange.
|
||||
return nil
|
||||
}
|
||||
handle, err := windowsACLOpenForContainment(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
// THE ANCESTOR ITSELF IS THE REDIRECTION, WHICH IS WHY ITS NAME LOOKS RIGHT.
|
||||
//
|
||||
// Asking only where this object lives answers "exactly where you asked": the
|
||||
// open above does not follow a final-component reparse point, so a junction
|
||||
// at <root>/.git comes back as <root>/.git. os.MkdirAll does follow it, and
|
||||
// creates the missing components on the far side. So what disqualifies this
|
||||
// ancestor is that it IS a reparse point, not where it reports living.
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect windows ACL target %s: %w", existing, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: existing + " (a reparse point)"}
|
||||
}
|
||||
return verifyWindowsACLHandleUnderAnchor(handle, anchor, existing)
|
||||
}
|
||||
|
||||
func windowsACLOpenForContainment(path string) (windows.Handle, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode windows ACL target %s: %w", path, err)
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.READ_CONTROL,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open windows ACL target %s: %w", path, err)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// windowsACLTestDenySID is a well-known group this process is not a member of.
|
||||
//
|
||||
// A deny ACE is what the carveouts actually are, and denying a group the test
|
||||
// runs as revokes its own WRITE_DAC: the apply succeeds and then nothing can
|
||||
// put the DACL back, so the temporary tree cannot be removed. Guests keeps the
|
||||
// deny semantics without locking the test out of its own fixture.
|
||||
const windowsACLTestDenySID = "S-1-5-32-546"
|
||||
|
||||
// makeACLJunction plants a directory junction at link pointing at target.
|
||||
//
|
||||
// A junction rather than a symlink on purpose: mklink /J needs no privilege, so
|
||||
// it is reachable by exactly the unprivileged workspace writer this guards
|
||||
// against, and the test runs on an ordinary developer or CI account.
|
||||
func makeACLJunction(t *testing.T, link, target string) {
|
||||
t.Helper()
|
||||
output, err := exec.Command("cmd.exe", "/c", "mklink", "/J", link, target).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("mklink /J unavailable here: %v (%s)", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
// AN ELEVATED ACL WRITE MUST NOT LEAVE THE WRITE ROOT THROUGH A JUNCTION.
|
||||
//
|
||||
// The apply opens its target with FILE_FLAG_OPEN_REPARSE_POINT, which refuses a
|
||||
// reparse point at the FINAL component and resolves every component above it.
|
||||
// The write-root carveouts are derived rather than named: <root>/.git/hooks and
|
||||
// <root>/.git/config are constructed from the root, and <root>/.git is a name an
|
||||
// unprivileged workspace writer can create before setup runs. A junction there
|
||||
// makes the apply open <junction-target>/hooks, an ordinary directory with
|
||||
// nothing wrong about its final component, and `zero sandbox setup` writes a
|
||||
// deny-write ACE on it as Administrator, outside the workspace.
|
||||
func TestApplyWindowsACLRefusesACarveoutRedirectedByAJunction(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
makeACLJunction(t, filepath.Join(root, ".git"), outside)
|
||||
|
||||
carveout := filepath.Join(root, ".git", "hooks")
|
||||
group := windowsACLPathGroup{
|
||||
Path: carveout,
|
||||
Anchor: root,
|
||||
Entries: []WindowsACLEntry{{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: carveout,
|
||||
Capability: windowsACLTestDenySID,
|
||||
Anchor: root,
|
||||
}},
|
||||
}
|
||||
|
||||
_, applied, err := applyWindowsACLPathGroup(group)
|
||||
if applied {
|
||||
t.Error("applied a deny-write ACE through a junction, so an elevated setup rewrote the DACL of an object outside the workspace")
|
||||
}
|
||||
var containment windowsACLContainmentError
|
||||
if !errors.As(err, &containment) {
|
||||
t.Fatalf("err = %v, want a containment refusal", err)
|
||||
}
|
||||
if !strings.Contains(containment.Actual, "elsewhere") {
|
||||
t.Errorf("the refusal does not name where the target actually resolved: %v", containment)
|
||||
}
|
||||
}
|
||||
|
||||
// The same carveout inside an ordinary workspace still gets its ACE, or the
|
||||
// refusal above would prove only that the apply fails.
|
||||
func TestApplyWindowsACLStillAppliesAnOrdinaryCarveout(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
carveout := filepath.Join(root, ".git", "hooks")
|
||||
if err := os.MkdirAll(carveout, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := windowsACLPathGroup{
|
||||
Path: carveout,
|
||||
Anchor: root,
|
||||
Entries: []WindowsACLEntry{{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: carveout,
|
||||
Capability: windowsACLTestDenySID,
|
||||
Anchor: root,
|
||||
}},
|
||||
}
|
||||
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
// PUT IT BACK, OR THE TEMP TREE CANNOT BE REMOVED. The ACE just written
|
||||
// denies write to the group this test runs as, and t.TempDir removes the
|
||||
// tree when the test ends.
|
||||
if applied {
|
||||
t.Cleanup(func() {
|
||||
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
|
||||
t.Errorf("rollback: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !applied {
|
||||
t.Fatal("an ordinary carveout inside the write root was not applied")
|
||||
}
|
||||
if snapshot.Path != carveout {
|
||||
t.Fatalf("snapshot path = %q, want %q", snapshot.Path, carveout)
|
||||
}
|
||||
}
|
||||
|
||||
// MATERIALIZATION MUST NOT CREATE THROUGH THE JUNCTION EITHER. os.MkdirAll
|
||||
// walks a pathname and follows every reparse point on it, so without the
|
||||
// pre-create check an elevated setup makes the directory on the far side and
|
||||
// only notices afterwards.
|
||||
func TestApplyWindowsACLRefusesToMaterializeThroughAJunction(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere")
|
||||
if err := os.MkdirAll(outside, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
makeACLJunction(t, filepath.Join(root, ".git"), outside)
|
||||
|
||||
carveout := filepath.Join(root, ".git", "hooks")
|
||||
if _, err := os.Lstat(filepath.Join(outside, "hooks")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("SETUP INVALID: the target already holds hooks: %v", err)
|
||||
}
|
||||
group := windowsACLPathGroup{
|
||||
Path: carveout,
|
||||
Anchor: root,
|
||||
Materialize: true,
|
||||
Entries: []WindowsACLEntry{{
|
||||
Action: WindowsACLDenyRead,
|
||||
Path: carveout,
|
||||
Capability: windowsACLTestDenySID,
|
||||
Anchor: root,
|
||||
Materialize: true,
|
||||
}},
|
||||
}
|
||||
|
||||
if _, applied, err := applyWindowsACLPathGroup(group); applied || err == nil {
|
||||
t.Errorf("materialized through a junction: applied=%v err=%v", applied, err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(outside, "hooks")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("an elevated create landed outside the write root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator-named path carries no anchor and keeps the behaviour it had: the
|
||||
// final component is still refused when it is a reparse point, and nothing above
|
||||
// it is second-guessed, because above the write root the path belongs to the
|
||||
// operator, who may keep a workspace under a junction.
|
||||
func TestApplyWindowsACLLeavesAnUnanchoredPathAlone(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "leaf"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
makeACLJunction(t, filepath.Join(base, "link"), outside)
|
||||
|
||||
named := filepath.Join(base, "link", "leaf")
|
||||
group := windowsACLPathGroup{
|
||||
Path: named,
|
||||
Entries: []WindowsACLEntry{{
|
||||
Action: WindowsACLDenyWrite,
|
||||
Path: named,
|
||||
Capability: windowsACLTestDenySID,
|
||||
}},
|
||||
}
|
||||
|
||||
snapshot, applied, err := applyWindowsACLPathGroup(group)
|
||||
if err != nil {
|
||||
t.Fatalf("an operator-named path below a junction was refused: %v", err)
|
||||
}
|
||||
if applied {
|
||||
t.Cleanup(func() {
|
||||
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
|
||||
t.Errorf("rollback: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !applied {
|
||||
t.Fatal("an operator-named path below a junction was not applied")
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE GUARD ON THE CREATE HAS TO BE PINNED WHERE IT CAN BE SEEN.
|
||||
//
|
||||
// Through the whole apply, dropping the pre-create check is invisible: the
|
||||
// directory is made on the far side of the junction, the containment check on
|
||||
// the handle then refuses, and the failure path removes what it made, so the
|
||||
// filesystem afterwards looks the same either way. The transient elevated create
|
||||
// outside the write root is the thing being prevented, so it is checked here,
|
||||
// against the function that prevents it.
|
||||
func TestVerifyWindowsACLPathUnderAnchorRefusesAMissingTargetBehindAJunction(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere")
|
||||
if err := os.MkdirAll(outside, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
makeACLJunction(t, filepath.Join(root, ".git"), outside)
|
||||
|
||||
carveout := filepath.Join(root, ".git", "hooks")
|
||||
if _, err := os.Lstat(carveout); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("SETUP INVALID: the carveout already exists, so nothing would be created: %v", err)
|
||||
}
|
||||
|
||||
err := verifyWindowsACLPathUnderAnchor(root, carveout)
|
||||
var containment windowsACLContainmentError
|
||||
if !errors.As(err, &containment) {
|
||||
t.Fatalf("err = %v, want a containment refusal before anything is created", err)
|
||||
}
|
||||
|
||||
// The ordinary case still says yes, or the refusal proves only that the
|
||||
// function rejects things.
|
||||
ordinary := t.TempDir()
|
||||
if err := verifyWindowsACLPathUnderAnchor(ordinary, filepath.Join(ordinary, ".git", "hooks")); err != nil {
|
||||
t.Errorf("an ordinary missing carveout was refused: %v", err)
|
||||
}
|
||||
// And an unanchored path is not this function's business.
|
||||
if err := verifyWindowsACLPathUnderAnchor("", carveout); err != nil {
|
||||
t.Errorf("an unanchored path was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -240,3 +240,117 @@ func TestDedupeWindowsACLEntriesKeepsInheritanceVariants(t *testing.T) {
|
||||
t.Fatalf("dedupe order/shape = %#v, want first NoInherit then inheritable", out)
|
||||
}
|
||||
}
|
||||
|
||||
// THE PLAN HAS TO CARRY THE ANCHOR, OR THE APPLY HAS NOTHING TO ENFORCE.
|
||||
//
|
||||
// The apply-side tests hand applyWindowsACLPathGroup an anchor directly, so they
|
||||
// pass whether or not anything ever sets one. This drives the real builder and
|
||||
// checks which entries come out anchored: the carveouts derived from a write
|
||||
// root, and not the paths the operator named, which have no owned tail and whose
|
||||
// intermediates are the operator's own business.
|
||||
//
|
||||
// NATIVE PATHS, NOT WINDOWS LITERALS. The builder is cross-platform and so is
|
||||
// this test, and the anchoring decision runs through pathWithinRoot, which is
|
||||
// filepath.Rel underneath. On Linux a backslash is an ordinary character, so
|
||||
// `C:\workspace\.git\hooks` is one component and is not under `C:\workspace`;
|
||||
// the neighbouring tests get away with such literals because they only compare
|
||||
// strings they built the same way and never ask whether one contains another.
|
||||
func TestBuildWindowsACLPlanAnchorsDerivedCarveouts(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
carveout := filepath.Join(workspace, ".git", "hooks")
|
||||
metadata := filepath.Join(workspace, ".zero")
|
||||
named := filepath.Join(t.TempDir(), "named-by-the-operator")
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ReadOnlySubpaths: []string{carveout},
|
||||
ProtectedMetadataNames: []string{".zero"},
|
||||
}},
|
||||
DenyWrite: []string{named},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
|
||||
anchors := map[string]string{}
|
||||
for _, entry := range plan.Entries {
|
||||
anchors[strings.ToLower(entry.Path)] = entry.Anchor
|
||||
}
|
||||
for _, derived := range []string{carveout, metadata} {
|
||||
anchor, present := anchors[strings.ToLower(derived)]
|
||||
if !present {
|
||||
t.Fatalf("SETUP INVALID: the plan has no entry for the derived carveout %s", derived)
|
||||
}
|
||||
if anchor != workspace {
|
||||
t.Errorf("derived carveout %s carries anchor %q, want the write root it came from", derived, anchor)
|
||||
}
|
||||
}
|
||||
operatorAnchor, present := anchors[strings.ToLower(named)]
|
||||
if !present {
|
||||
t.Fatal("SETUP INVALID: the plan has no entry for the operator-named deny path")
|
||||
}
|
||||
if operatorAnchor != "" {
|
||||
t.Errorf("operator-named path carries anchor %q, want none: its intermediates are not the sandbox's to police", operatorAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// AND AN OUT-OF-ROOT READ-ONLY SUBPATH IS NOT ANCHORED AT ALL.
|
||||
//
|
||||
// ReadOnlySubpaths is a profile field an operator can set to any path. One
|
||||
// placed outside the write root is a configuration that works today, and
|
||||
// anchoring it to that root would turn it into a containment refusal: the
|
||||
// object is not under the root and never was. Only the paths that are actually
|
||||
// under it are held to it.
|
||||
func TestBuildWindowsACLPlanLeavesAnOutOfRootSubpathUnanchored(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
inside := filepath.Join(workspace, "vendor")
|
||||
outside := filepath.Join(t.TempDir(), "shared")
|
||||
plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{
|
||||
Root: workspace,
|
||||
ReadOnlySubpaths: []string{inside, outside},
|
||||
}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsACLPlan: %v", err)
|
||||
}
|
||||
|
||||
seen := map[string]string{}
|
||||
for _, entry := range plan.Entries {
|
||||
seen[strings.ToLower(entry.Path)] = entry.Anchor
|
||||
}
|
||||
insideAnchor, present := seen[strings.ToLower(inside)]
|
||||
if !present {
|
||||
t.Fatal("SETUP INVALID: the plan has no entry for the in-root subpath")
|
||||
}
|
||||
if insideAnchor != workspace {
|
||||
t.Errorf("in-root subpath carries anchor %q, want the write root", insideAnchor)
|
||||
}
|
||||
outsideAnchor, present := seen[strings.ToLower(outside)]
|
||||
if !present {
|
||||
t.Fatal("SETUP INVALID: the plan has no entry for the out-of-root subpath")
|
||||
}
|
||||
if outsideAnchor != "" {
|
||||
t.Errorf("out-of-root subpath carries anchor %q, want none: the apply would refuse a config that works today", outsideAnchor)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
// controllableLauncher records every launched spec and lets a test control each
|
||||
@@ -85,18 +87,6 @@ func newSwarmForWithSize(t *testing.T, l MemberLauncher, maxTeamSize int) *Swarm
|
||||
return sw
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
func okFor(spec MemberSpec, _ int) (MemberResult, error) {
|
||||
return MemberResult{Result: "ok:" + spec.Task, SessionID: "sess-" + spec.ID}, nil
|
||||
}
|
||||
@@ -108,7 +98,7 @@ func TestSpawnCompletes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
waitFor(t, "task done", func() bool {
|
||||
testutil.WaitFor(t, "task done", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -125,7 +115,7 @@ func TestSpawnInheritsPolicy(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
|
||||
testutil.WaitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
|
||||
spec := l.recorded()[0]
|
||||
if spec.Model != "orch-model" {
|
||||
t.Fatalf("member model = %q, want inherited orch-model", spec.Model)
|
||||
@@ -158,7 +148,7 @@ func TestConcurrencyCapAndQueueDrains(t *testing.T) {
|
||||
}
|
||||
// Release everyone; the queue should drain one-per-slot until all are done.
|
||||
close(gate)
|
||||
waitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
|
||||
testutil.WaitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
|
||||
if team.Running() != 0 || team.QueueDepth() != 0 {
|
||||
t.Fatalf("after drain running=%d queue=%d, want 0/0", team.Running(), team.QueueDepth())
|
||||
}
|
||||
@@ -176,7 +166,7 @@ func TestRetryOnTemporaryError(t *testing.T) {
|
||||
})
|
||||
sw := newSwarmFor(t, l)
|
||||
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
|
||||
waitFor(t, "task recovered", func() bool {
|
||||
testutil.WaitFor(t, "task recovered", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -195,7 +185,7 @@ func TestRetryExhaustionFails(t *testing.T) {
|
||||
})
|
||||
sw := newSwarmFor(t, l)
|
||||
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
|
||||
waitFor(t, "task failed", func() bool {
|
||||
testutil.WaitFor(t, "task failed", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusFailed
|
||||
})
|
||||
@@ -210,7 +200,7 @@ func TestPermanentErrorNoRetry(t *testing.T) {
|
||||
})
|
||||
sw := newSwarmFor(t, l)
|
||||
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
|
||||
waitFor(t, "task failed", func() bool {
|
||||
testutil.WaitFor(t, "task failed", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusFailed
|
||||
})
|
||||
@@ -296,7 +286,7 @@ func TestClosePreventsMemberRetry(t *testing.T) {
|
||||
sw.Close()
|
||||
close(closed)
|
||||
}()
|
||||
waitFor(t, "swarm closed state", func() bool {
|
||||
testutil.WaitFor(t, "swarm closed state", func() bool {
|
||||
sw.lifecycleMu.RLock()
|
||||
defer sw.lifecycleMu.RUnlock()
|
||||
return sw.closed
|
||||
@@ -325,7 +315,7 @@ func TestCloseWaitsForMemberWatchers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
waitFor(t, "task running", func() bool {
|
||||
testutil.WaitFor(t, "task running", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
@@ -365,7 +355,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
|
||||
sw := newSwarmFor(t, l)
|
||||
pol := Policy{Model: "m"}
|
||||
origID, _ := sw.Spawn(pol, "team", "teammate", "original task", "/w")
|
||||
waitFor(t, "original running", func() bool {
|
||||
testutil.WaitFor(t, "original running", func() bool {
|
||||
task, ok := sw.Coordinator().Get(origID)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
@@ -389,7 +379,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
|
||||
}
|
||||
// The new member carries the handoff note in its task and preserves cwd.
|
||||
close(gate)
|
||||
waitFor(t, "spec for new member", func() bool {
|
||||
testutil.WaitFor(t, "spec for new member", func() bool {
|
||||
for _, s := range l.recorded() {
|
||||
if s.ID == newID {
|
||||
return true
|
||||
@@ -405,7 +395,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// A handoff of an already-terminal task is rejected.
|
||||
waitFor(t, "new task done", func() bool {
|
||||
testutil.WaitFor(t, "new task done", func() bool {
|
||||
task, ok := sw.Coordinator().Get(newID)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -470,7 +460,7 @@ func TestHandoffMailboxFailureRestoresSourceCompletion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
waitFor(t, "original running", func() bool {
|
||||
testutil.WaitFor(t, "original running", func() bool {
|
||||
task, ok := sw.Coordinator().Get(origID)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
@@ -485,7 +475,7 @@ func TestHandoffMailboxFailureRestoresSourceCompletion(t *testing.T) {
|
||||
}
|
||||
|
||||
close(gate)
|
||||
waitFor(t, "source completion after aborted handoff", func() bool {
|
||||
testutil.WaitFor(t, "source completion after aborted handoff", func() bool {
|
||||
task, ok := sw.Coordinator().Get(origID)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -532,7 +522,7 @@ func TestHandoffSuccessorCollisionDoesNotStopSource(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
waitFor(t, "original running", func() bool {
|
||||
testutil.WaitFor(t, "original running", func() bool {
|
||||
task, ok := coord.Get(origID)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
@@ -557,7 +547,7 @@ func TestHandoffSuccessorCollisionDoesNotStopSource(t *testing.T) {
|
||||
t.Fatalf("collision misdelivered %d handoff notes to the existing task", len(msgs))
|
||||
}
|
||||
close(gate)
|
||||
waitFor(t, "source completion after rejected collision", func() bool {
|
||||
testutil.WaitFor(t, "source completion after rejected collision", func() bool {
|
||||
task, ok := coord.Get(origID)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -643,7 +633,7 @@ func TestCloseReleasesBlockedHandoffBeforeMemberExit(t *testing.T) {
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Close did not return after the source member exited")
|
||||
}
|
||||
waitFor(t, "source completion after handoff cancellation", func() bool {
|
||||
testutil.WaitFor(t, "source completion after handoff cancellation", func() bool {
|
||||
task, ok := sw.Coordinator().Get(origID)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -704,7 +694,7 @@ func TestHandoffTimesOutWithoutStartingSuccessor(t *testing.T) {
|
||||
}
|
||||
|
||||
close(releaseOriginal)
|
||||
waitFor(t, "source completion after handoff timeout", func() bool {
|
||||
testutil.WaitFor(t, "source completion after handoff timeout", func() bool {
|
||||
task, ok := sw.Coordinator().Get(origID)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -876,7 +866,7 @@ func TestHandoffRemovesQueuedOriginalBeforeDispatchingSuccessor(t *testing.T) {
|
||||
if _, err := sw.Spawn(pol, "team", "teammate", "slot blocker", "/w"); err != nil {
|
||||
t.Fatalf("Spawn blocker: %v", err)
|
||||
}
|
||||
waitFor(t, "blocker running", func() bool { return len(l.recorded()) == 1 })
|
||||
testutil.WaitFor(t, "blocker running", func() bool { return len(l.recorded()) == 1 })
|
||||
origID, err := sw.Spawn(pol, "team", "teammate", "queued original", "/w")
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn queued original: %v", err)
|
||||
@@ -890,7 +880,7 @@ func TestHandoffRemovesQueuedOriginalBeforeDispatchingSuccessor(t *testing.T) {
|
||||
t.Fatalf("Handoff: %v", err)
|
||||
}
|
||||
close(gate)
|
||||
waitFor(t, "successor launch", func() bool {
|
||||
testutil.WaitFor(t, "successor launch", func() bool {
|
||||
for _, spec := range l.recorded() {
|
||||
if spec.ID == newID {
|
||||
return true
|
||||
@@ -923,7 +913,7 @@ func TestAdoptOrphans(t *testing.T) {
|
||||
t.Fatalf("adopted = %v, want [orphan-1]", adopted)
|
||||
}
|
||||
// The orphan is relaunched under a fresh agent and completes.
|
||||
waitFor(t, "orphan completed", func() bool {
|
||||
testutil.WaitFor(t, "orphan completed", func() bool {
|
||||
task, ok := sw.Coordinator().Get("orphan-1")
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -964,7 +954,7 @@ func TestCollectScopesToTeam(t *testing.T) {
|
||||
sw := newSwarmFor(t, l)
|
||||
a, _ := sw.Spawn(Policy{}, "alpha", "teammate", "ta", "")
|
||||
_, _ = sw.Spawn(Policy{}, "beta", "teammate", "tb", "")
|
||||
waitFor(t, "alpha done", func() bool {
|
||||
testutil.WaitFor(t, "alpha done", func() bool {
|
||||
task, ok := sw.Coordinator().Get(a)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -1110,7 +1100,7 @@ func TestAdmittedDispatchDoesNotLaunchAfterClose(t *testing.T) {
|
||||
sw.Close()
|
||||
close(closed)
|
||||
}()
|
||||
waitFor(t, "swarm closed state", func() bool {
|
||||
testutil.WaitFor(t, "swarm closed state", func() bool {
|
||||
sw.lifecycleMu.RLock()
|
||||
defer sw.lifecycleMu.RUnlock()
|
||||
return sw.closed
|
||||
@@ -1170,7 +1160,7 @@ func TestCloseBetweenLaunchPrecheckAndReturn(t *testing.T) {
|
||||
sw.Close()
|
||||
close(closed)
|
||||
}()
|
||||
waitFor(t, "swarm closed state", func() bool {
|
||||
testutil.WaitFor(t, "swarm closed state", func() bool {
|
||||
sw.lifecycleMu.RLock()
|
||||
defer sw.lifecycleMu.RUnlock()
|
||||
return sw.closed
|
||||
@@ -1268,7 +1258,7 @@ func TestCloseBetweenRetryLaunchPrecheckAndReturn(t *testing.T) {
|
||||
sw.Close()
|
||||
close(closed)
|
||||
}()
|
||||
waitFor(t, "swarm closed state", func() bool {
|
||||
testutil.WaitFor(t, "swarm closed state", func() bool {
|
||||
sw.lifecycleMu.RLock()
|
||||
defer sw.lifecycleMu.RUnlock()
|
||||
return sw.closed
|
||||
@@ -1460,7 +1450,7 @@ func TestCloseFailsQueuedSpecOnLateCreatedTeam(t *testing.T) {
|
||||
sw.Close()
|
||||
close(closed)
|
||||
}()
|
||||
waitFor(t, "swarm closed state", func() bool {
|
||||
testutil.WaitFor(t, "swarm closed state", func() bool {
|
||||
sw.lifecycleMu.RLock()
|
||||
defer sw.lifecycleMu.RUnlock()
|
||||
return sw.closed
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
// testTicker returns a ticker factory backed by a single unbounded-handshake
|
||||
@@ -39,11 +41,11 @@ func TestSchedulerFiresAndCountsRuns(t *testing.T) {
|
||||
for i := 0; i < 3; i++ {
|
||||
ticks <- time.Time{}
|
||||
want := i + 1
|
||||
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == want })
|
||||
testutil.WaitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == want })
|
||||
}
|
||||
|
||||
// After MaxRuns the job retires itself.
|
||||
waitFor(t, "job retired", func() bool { _, ok := findJob(sched.List(), id); return !ok })
|
||||
testutil.WaitFor(t, "job retired", func() bool { _, ok := findJob(sched.List(), id); return !ok })
|
||||
if got := len(l.recorded()); got != 3 {
|
||||
t.Fatalf("spawned %d members, want 3", got)
|
||||
}
|
||||
@@ -68,11 +70,11 @@ func TestSchedulerSkipsWhilePreviousRuns(t *testing.T) {
|
||||
|
||||
// Fire 1: spawns and the member stays running (gated).
|
||||
ticks <- time.Time{}
|
||||
waitFor(t, "first spawn", func() bool { return len(l.recorded()) == 1 })
|
||||
testutil.WaitFor(t, "first spawn", func() bool { return len(l.recorded()) == 1 })
|
||||
|
||||
// Fire 2: previous still running => skipped, no new spawn.
|
||||
ticks <- time.Time{}
|
||||
waitFor(t, "skip recorded", func() bool {
|
||||
testutil.WaitFor(t, "skip recorded", func() bool {
|
||||
j, ok := findJob(sched.List(), id)
|
||||
return ok && j.Skipped == 1
|
||||
})
|
||||
@@ -82,14 +84,14 @@ func TestSchedulerSkipsWhilePreviousRuns(t *testing.T) {
|
||||
|
||||
// Release the first member, then fire 3: previous terminal => spawns again.
|
||||
close(gate)
|
||||
waitFor(t, "first done", func() bool { return sw.Coordinator().Summarize().Done == 1 })
|
||||
testutil.WaitFor(t, "first done", func() bool { return sw.Coordinator().Summarize().Done == 1 })
|
||||
ticks <- time.Time{}
|
||||
waitFor(t, "second spawn", func() bool { return len(l.recorded()) == 2 })
|
||||
testutil.WaitFor(t, "second spawn", func() bool { return len(l.recorded()) == 2 })
|
||||
// fireIfIdle's spawn (what "second spawn" observes via the launcher) and
|
||||
// run's subsequent job.incRuns() are sequential but distinct steps in the
|
||||
// scheduler's goroutine; wait for Runs itself rather than assuming the
|
||||
// launcher recording it means the job's counter is updated too.
|
||||
waitFor(t, "second run recorded", func() bool {
|
||||
testutil.WaitFor(t, "second run recorded", func() bool {
|
||||
j, ok := findJob(sched.List(), id)
|
||||
return ok && j.Runs == 2
|
||||
})
|
||||
@@ -215,7 +217,7 @@ func TestSchedulerDailyRecomputesNextDelay(t *testing.T) {
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
ticks <- time.Time{}
|
||||
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == i+1 })
|
||||
testutil.WaitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == i+1 })
|
||||
}
|
||||
if len(delays) < 2 {
|
||||
t.Fatalf("expected at least two requested delays, got %v", delays)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -94,7 +95,7 @@ func TestSpawnToolThroughRegistry(t *testing.T) {
|
||||
if id == "" {
|
||||
t.Fatal("spawn must return a task_id in Meta")
|
||||
}
|
||||
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
|
||||
testutil.WaitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
|
||||
spec := l.recorded()[0]
|
||||
if spec.Model != "m1" || spec.Cwd != "/work" {
|
||||
t.Fatalf("policy/cwd not threaded into member: model=%q cwd=%q", spec.Model, spec.Cwd)
|
||||
@@ -165,7 +166,7 @@ func TestStatusAndCollectTools(t *testing.T) {
|
||||
"agent_type": "teammate", "task": "compute", "team": "alpha",
|
||||
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
|
||||
id := spawn.Meta["task_id"]
|
||||
waitFor(t, "task done", func() bool {
|
||||
testutil.WaitFor(t, "task done", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusDone
|
||||
})
|
||||
@@ -213,7 +214,7 @@ func TestCollectBlocksUntilMembersFinish(t *testing.T) {
|
||||
"agent_type": "teammate", "task": "compute", "team": "alpha",
|
||||
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
|
||||
id := spawn.Meta["task_id"]
|
||||
waitFor(t, "member running", func() bool {
|
||||
testutil.WaitFor(t, "member running", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
@@ -258,7 +259,7 @@ func TestCollectReturnsPartialOnTimeout(t *testing.T) {
|
||||
"agent_type": "teammate", "task": "compute", "team": "alpha",
|
||||
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
|
||||
id := spawn.Meta["task_id"]
|
||||
waitFor(t, "member running", func() bool {
|
||||
testutil.WaitFor(t, "member running", func() bool {
|
||||
task, ok := sw.Coordinator().Get(id)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
@@ -324,7 +325,7 @@ func TestNewWithUserDefinitions(t *testing.T) {
|
||||
if _, err := sw.Spawn(Policy{Model: "m"}, "team", "researcher", "find things", ""); err != nil {
|
||||
t.Fatalf("Spawn user-defined agent: %v", err)
|
||||
}
|
||||
waitFor(t, "researcher used", func() bool { return used })
|
||||
testutil.WaitFor(t, "researcher used", func() bool { return used })
|
||||
|
||||
// A bad definition makes New fail closed.
|
||||
if _, err := New(Options{BaseDir: t.TempDir(), Launcher: newLauncher(okFor), Definitions: []Definition{{AgentType: " "}}}); err == nil {
|
||||
@@ -341,7 +342,7 @@ func TestHandoffToolThroughRegistry(t *testing.T) {
|
||||
"agent_type": "teammate", "task": "long", "team": "alpha",
|
||||
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
|
||||
origID := spawn.Meta["task_id"]
|
||||
waitFor(t, "running", func() bool {
|
||||
testutil.WaitFor(t, "running", func() bool {
|
||||
task, ok := sw.Coordinator().Get(origID)
|
||||
return ok && task.Status == StatusRunning
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package testutil provides small, shared helpers for tests across the
|
||||
// codebase.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WaitFor polls cond until it returns true, or fails the test if it does not
|
||||
// become true within a generous deadline. what names the condition being
|
||||
// waited for so a timeout failure message is actionable.
|
||||
func WaitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -363,7 +365,7 @@ func TestUnifiedPatchCopyOperation(t *testing.T) {
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "dst.txt")); string(content) != "hello\nnew\n" {
|
||||
t.Fatalf("copy destination = %q", string(content))
|
||||
}
|
||||
if len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "src.txt" || result.ChangedFiles[1] != "dst.txt" {
|
||||
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "dst.txt" {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
destination, err := filepath.EvalSymlinks(filepath.Join(root, "dst.txt"))
|
||||
@@ -571,9 +573,9 @@ func TestApplyStructuredPatchChangeRefusesSourceChangedAfterPlanning(t *testing.
|
||||
"copy": {kind: structuredPatchCopy, from: target, to: destination, before: "planned\n", after: "planned\n", mode: 0o644},
|
||||
} {
|
||||
writeTestFile(t, path, "changed after planning\n")
|
||||
committed, err := applyStructuredPatchChange(workspace, change)
|
||||
if err == nil || committed || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
|
||||
t.Fatalf("%s: expected a refusal, got committed=%v err=%v", name, committed, err)
|
||||
outcome, err := applyStructuredPatchChange(workspace, change)
|
||||
if err == nil || len(outcome.completed) != 0 || len(outcome.incompletePaths) != 0 || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
|
||||
t.Fatalf("%s: expected a refusal, got outcome=%#v err=%v", name, outcome, err)
|
||||
}
|
||||
if content, _ := os.ReadFile(path); string(content) != "changed after planning\n" {
|
||||
t.Fatalf("%s: file must be untouched, got %q", name, string(content))
|
||||
@@ -709,4 +711,158 @@ func TestApplyPatchOperationsReportsCommittedPrefixOnFailure(t *testing.T) {
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "third.txt")); string(content) != "three\n" {
|
||||
t.Fatalf("third.txt must be untouched, got %q", string(content))
|
||||
}
|
||||
if got := result.ChangedFiles; len(got) != 1 || got[0] != "first.txt" {
|
||||
t.Fatalf("partial patch ChangedFiles = %#v, want committed prefix only", got)
|
||||
}
|
||||
resolvedFirst, err := filepath.EvalSymlinks(filepath.Join(root, "first.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := result.FileDiffs; len(got) != 1 || got[0] != (FileDiff{Path: resolvedFirst, OldExists: true, NewExists: true, OldText: "one\n", NewText: "ONE\n"}) {
|
||||
t.Fatalf("partial patch FileDiffs = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchOperationsReportsWorkspaceRelativeCommittedPrefixUnderCwd(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
applyRoot := filepath.Join(root, "sub", "dir")
|
||||
if err := os.MkdirAll(applyRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(applyRoot, "first.txt"), "one\n")
|
||||
writeTestFile(t, filepath.Join(applyRoot, "second.txt"), "two\n")
|
||||
structuredPatchBeforeCommit = func(change structuredPatchChange) {
|
||||
if change.to.relative == "second.txt" {
|
||||
writeTestFile(t, filepath.Join(applyRoot, "second.txt"), "changed\n")
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { structuredPatchBeforeCommit = nil })
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.txt", "@@", "-one", "+ONE",
|
||||
"*** Update File: second.txt", "@@", "-two", "+TWO",
|
||||
"*** End Patch", "",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"cwd": "sub/dir", "patch": patch,
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "already committed: sub/dir/first.txt") {
|
||||
t.Fatalf("nested partial failure = status=%s output=%q", result.Status, result.Output)
|
||||
}
|
||||
if got := result.ChangedFiles; len(got) != 1 || got[0] != "sub/dir/first.txt" {
|
||||
t.Fatalf("nested partial ChangedFiles = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchSeparatesVerifiedAndUnverifiedPartialPublications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
priorPublish := structuredPatchPublishNoReplace
|
||||
structuredPatchPublishNoReplace = func(workspace *os.Root, source, target string, mode os.FileMode) (bool, error) {
|
||||
if target != "uncertain.txt" {
|
||||
return priorPublish(workspace, source, target, mode)
|
||||
}
|
||||
file, err := workspace.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := file.WriteString("partial"); err != nil {
|
||||
_ = file.Close()
|
||||
return true, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, errors.New("injected failure after partial publication")
|
||||
}
|
||||
t.Cleanup(func() { structuredPatchPublishNoReplace = priorPublish })
|
||||
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Add File: verified.txt", "+verified",
|
||||
"*** Add File: uncertain.txt", "+intended complete content",
|
||||
"*** Add File: untouched.txt", "+untouched",
|
||||
"*** End Patch", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("partial publication status = %s, want error", result.Status)
|
||||
}
|
||||
for _, want := range []string{"already committed: verified.txt", "published but unverified: uncertain.txt"} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("partial publication error must contain %q: %s", want, result.Output)
|
||||
}
|
||||
}
|
||||
committedFragment := strings.SplitN(result.Output, "; published but unverified:", 2)[0]
|
||||
if strings.Contains(committedFragment, "uncertain.txt") {
|
||||
t.Fatalf("unverified path appears in committed fragment: %s", result.Output)
|
||||
}
|
||||
if got, want := result.ChangedFiles, []string{"verified.txt"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("ChangedFiles = %#v, want verified publications only %#v", got, want)
|
||||
}
|
||||
resolvedVerified, err := filepath.EvalSymlinks(filepath.Join(root, "verified.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := result.FileDiffs; len(got) != 1 || got[0].Path != resolvedVerified {
|
||||
t.Fatalf("FileDiffs = %#v, want verified.txt only", got)
|
||||
}
|
||||
if got := mustReadTestFile(t, filepath.Join(root, "verified.txt")); got != "verified\n" {
|
||||
t.Fatalf("verified publication = %q", got)
|
||||
}
|
||||
if got := mustReadTestFile(t, filepath.Join(root, "uncertain.txt")); got != "partial" {
|
||||
t.Fatalf("unverified publication = %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "untouched.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("later publication must remain untouched: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchMoveReportsPublishedDestinationWhenSourceRemovalFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "source.txt"), "before\n")
|
||||
priorRemove := structuredPatchRemove
|
||||
structuredPatchRemove = func(workspace *os.Root, name string) error {
|
||||
if name == "source.txt" {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return priorRemove(workspace, name)
|
||||
}
|
||||
t.Cleanup(func() { structuredPatchRemove = priorRemove })
|
||||
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: source.txt",
|
||||
"*** Move to: destination.txt",
|
||||
"@@",
|
||||
"-before",
|
||||
"+after",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("move status = %s, want error", result.Status)
|
||||
}
|
||||
if got := mustReadTestFile(t, filepath.Join(root, "source.txt")); got != "before\n" {
|
||||
t.Fatalf("source content = %q", got)
|
||||
}
|
||||
if got := mustReadTestFile(t, filepath.Join(root, "destination.txt")); got != "after\n" {
|
||||
t.Fatalf("destination content = %q", got)
|
||||
}
|
||||
if got, want := result.ChangedFiles, []string{"destination.txt"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("ChangedFiles = %#v, want %#v", got, want)
|
||||
}
|
||||
resolvedDestination, err := filepath.EvalSymlinks(filepath.Join(root, "destination.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDiff := FileDiff{Path: resolvedDestination, OldExists: false, NewExists: true, NewText: "after\n"}
|
||||
if got := result.FileDiffs; len(got) != 1 || got[0] != wantDiff {
|
||||
t.Fatalf("FileDiffs = %#v, want %#v", got, []FileDiff{wantDiff})
|
||||
}
|
||||
if !strings.Contains(result.Display.Preview, "destination.txt") || strings.Contains(result.Display.Preview, "source.txt") {
|
||||
t.Fatalf("preview must show only destination creation: %q", result.Display.Preview)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,122 @@
|
||||
package tools
|
||||
|
||||
import udiff "github.com/aymanbagabas/go-udiff"
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
)
|
||||
|
||||
// maxToolPreviewBytes caps the inline diff a write tool appends to its result, so
|
||||
// a large generated file can't flood the transcript or balloon the persisted
|
||||
// session events. Past this the tool falls back to its summary line alone.
|
||||
const maxToolPreviewBytes = 48 * 1024
|
||||
|
||||
// A structured replacement contains two complete file sides, so its transport
|
||||
// budget is intentionally separate from the single rendered-preview budget.
|
||||
// Each side may be as large as a normal preview; aggregate producers apply the
|
||||
// two-sided result cap in file order.
|
||||
const maxToolResultFileDiffBytes = 2 * maxToolPreviewBytes
|
||||
|
||||
// FileDiff is a human-facing before/after file change. Registry-boundary
|
||||
// redaction applies to both sides before any caller receives it.
|
||||
type FileDiff struct {
|
||||
// Path is the canonical absolute path required by ACP diff content. The
|
||||
// separate ChangedFiles result remains workspace-relative for local UI use.
|
||||
Path string
|
||||
// OldExists and NewExists distinguish an empty file from a missing side of a
|
||||
// create/delete/move. Empty strings alone cannot encode that difference.
|
||||
OldExists bool
|
||||
NewExists bool
|
||||
OldText string
|
||||
NewText string
|
||||
}
|
||||
|
||||
// boundedFileDiff declines rather than truncating: a truncated side would look
|
||||
// like an exact file replacement. Callers keep ChangedFiles as the safe
|
||||
// fallback for large, unsafe, or unchanged content. Each complete side gets the
|
||||
// same bound as a rendered preview; aggregate result producers apply their own
|
||||
// cap. Control bytes are rejected. Ordinary Unicode format/space characters are
|
||||
// retained unless removing them reveals a credential shape that the normal
|
||||
// redactor could not see in the original text.
|
||||
func boundedFileDiff(path, oldText, newText string, oldExists, newExists bool) (FileDiff, bool) {
|
||||
if !filepath.IsAbs(path) || (!oldExists && !newExists) ||
|
||||
(oldExists == newExists && oldText == newText) ||
|
||||
!utf8.ValidString(oldText) || !utf8.ValidString(newText) ||
|
||||
unsafeDiffText(oldText) || unsafeDiffText(newText) ||
|
||||
len(oldText) > maxToolPreviewBytes || len(newText) > maxToolPreviewBytes {
|
||||
return FileDiff{}, false
|
||||
}
|
||||
return FileDiff{Path: path, OldExists: oldExists, NewExists: newExists, OldText: oldText, NewText: newText}, true
|
||||
}
|
||||
|
||||
func unsafeDiffText(text string) bool {
|
||||
if !utf8.ValidString(text) {
|
||||
return true
|
||||
}
|
||||
hasCanonicalizableSeparator := false
|
||||
for _, r := range text {
|
||||
switch r {
|
||||
case '\n', '\r', '\t', ' ':
|
||||
continue
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return true
|
||||
}
|
||||
if isDiffCredentialIgnorable(r) {
|
||||
hasCanonicalizableSeparator = true
|
||||
}
|
||||
}
|
||||
if !hasCanonicalizableSeparator {
|
||||
return false
|
||||
}
|
||||
return diffTextRevealsObfuscatedSecret(text)
|
||||
}
|
||||
|
||||
func diffTextRevealsObfuscatedSecret(text string) bool {
|
||||
if !utf8.ValidString(text) {
|
||||
return false
|
||||
}
|
||||
canonical := strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '\n', '\r', '\t', ' ':
|
||||
return r
|
||||
}
|
||||
if isDiffCredentialIgnorable(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, text)
|
||||
return canonical != text && redaction.RedactString(canonical, redaction.Options{}) != canonical
|
||||
}
|
||||
|
||||
// isDiffCredentialIgnorable mirrors Unicode's Default_Ignorable_Code_Point
|
||||
// derived property using the tables exposed by the Go standard library. Cf
|
||||
// catches format controls, while the other two tables cover non-Cf default
|
||||
// ignorables such as U+034F COMBINING GRAPHEME JOINER and variation selectors.
|
||||
// Unicode whitespace is also removable because it can split a credential that
|
||||
// the boundary redactor would otherwise recognize. The caller preserves normal
|
||||
// ASCII layout whitespace before consulting this helper.
|
||||
func isDiffCredentialIgnorable(r rune) bool {
|
||||
return unicode.Is(unicode.Cf, r) ||
|
||||
unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r) ||
|
||||
unicode.Is(unicode.Variation_Selector, r) ||
|
||||
unicode.IsSpace(r)
|
||||
}
|
||||
|
||||
// boundedUnifiedDiff returns a unified diff of oldContent -> newContent labelled
|
||||
// with path, suitable for the TUI's diff card renderer. A create (oldContent "")
|
||||
// yields an all-additions (green) preview; an overwrite/edit yields red/green.
|
||||
// Returns "" when there is no change or the diff exceeds maxToolPreviewBytes.
|
||||
// Returns "" when there is no change, the rendered diff is unsafe text, or the
|
||||
// diff exceeds maxToolPreviewBytes. This applies the same unsafe-text gate as
|
||||
// FileDiff to what reaches Display.Preview, without discarding a safe hunk only
|
||||
// because an unrelated part of the source file contains an unsafe byte.
|
||||
func boundedUnifiedDiff(path, oldContent, newContent string) string {
|
||||
diff := udiff.Unified(path, path, oldContent, newContent)
|
||||
if diff == "" || len(diff) > maxToolPreviewBytes {
|
||||
if diff == "" || !utf8.ValidString(diff) || unsafeDiffText(diff) || len(diff) > maxToolPreviewBytes {
|
||||
return ""
|
||||
}
|
||||
return diff
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBoundedFileDiffRefusesPartialOrBinaryContent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.txt")
|
||||
if diff, ok := boundedFileDiff(path, "old", "new", true, true); !ok || diff.Path != path || !diff.OldExists || !diff.NewExists || diff.OldText != "old" || diff.NewText != "new" {
|
||||
t.Fatalf("small text diff = %#v, %t", diff, ok)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"unchanged", "same", "same"},
|
||||
{"binary old", string([]byte{0xff}), "text"},
|
||||
{"binary new", "text", string([]byte{0xff})},
|
||||
{"nul old", "token=sk-proj-abc\x00def", "text"},
|
||||
{"escape new", "text", "token=sk-proj-abc\x1bdef"},
|
||||
{"c1 old", "token=sk-proj-abc\u0085def", "text"},
|
||||
{"zero width space", "token=sk-proj-abc\u200bdef", "text"},
|
||||
{"zero width joiner", "token=sk-proj-abc\u200ddef", "text"},
|
||||
{"byte order mark", "token=sk-proj-abc\ufeffdef", "text"},
|
||||
{"soft hyphen", "token=sk-proj-abc\u00addef", "text"},
|
||||
{"non breaking space", "token=sk-proj-abc\u00a0def", "text"},
|
||||
{"too large", strings.Repeat("a", maxToolPreviewBytes+1), "b"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if diff, ok := boundedFileDiff(path, tc.old, tc.new, true, true); ok || diff != (FileDiff{}) {
|
||||
t.Fatalf("unexpected diff = %#v, %t", diff, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedFileDiffPreservesEmptyFileOperations(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty.txt")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
oldExists, newExists bool
|
||||
}{
|
||||
{name: "create", newExists: true},
|
||||
{name: "delete", oldExists: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
diff, ok := boundedFileDiff(path, "", "", tc.oldExists, tc.newExists)
|
||||
if !ok || diff.OldExists != tc.oldExists || diff.NewExists != tc.newExists {
|
||||
t.Fatalf("empty %s = %#v, %t", tc.name, diff, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedUnifiedDiffRejectsUnsafeRichText(t *testing.T) {
|
||||
if got := boundedUnifiedDiff("safe.txt", "before\n", "after\n"); got == "" {
|
||||
t.Fatal("safe unified diff was unexpectedly omitted")
|
||||
}
|
||||
for _, content := range []string{
|
||||
"token=sk-proj-abc\x00def",
|
||||
"token=sk-proj-abc\x1bdef",
|
||||
"token=sk-proj-abc\u200bdef",
|
||||
string([]byte{0xff}),
|
||||
} {
|
||||
if got := boundedUnifiedDiff("secret.txt", content, "safe\n"); got != "" {
|
||||
t.Fatalf("unsafe rich diff = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
old := strings.Repeat("unchanged\n", 12) + "form\ffeed\n" + strings.Repeat("unchanged\n", 12)
|
||||
updated := strings.Replace(old, "unchanged\n", "changed\n", 1)
|
||||
if got := boundedUnifiedDiff("safe-hunk.txt", old, updated); got == "" || strings.Contains(got, "\f") {
|
||||
t.Fatalf("safe hunk near unrelated unsafe text = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchFileDiffsPreserveEmptyOperationsAndResultBudget(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changes := []structuredPatchChange{
|
||||
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "created"), relative: "created"}},
|
||||
{kind: structuredPatchDelete, from: structuredPatchTarget{absolute: filepath.Join(root, "deleted"), relative: "deleted"}},
|
||||
{kind: structuredPatchUpdate, from: structuredPatchTarget{absolute: filepath.Join(root, "from"), relative: "from"}, to: structuredPatchTarget{absolute: filepath.Join(root, "to"), relative: "to"}},
|
||||
}
|
||||
diffs := fileDiffsFromStructuredPatch(".", changes)
|
||||
if len(diffs) != 4 {
|
||||
t.Fatalf("empty create/delete/move diffs = %#v", diffs)
|
||||
}
|
||||
for _, diff := range diffs {
|
||||
if diff.Path == "" || (!diff.OldExists && !diff.NewExists) {
|
||||
t.Fatalf("invalid diff = %#v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
large := strings.Repeat("x", maxToolResultFileDiffBytes/3+1)
|
||||
budgeted := fileDiffsFromStructuredPatch(".", []structuredPatchChange{
|
||||
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "one")}, after: large},
|
||||
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "two")}, after: "tiny"},
|
||||
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "three")}, after: large},
|
||||
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "four")}, after: large},
|
||||
})
|
||||
if len(budgeted) != 3 || filepath.Base(budgeted[0].Path) != "one" || filepath.Base(budgeted[1].Path) != "two" || filepath.Base(budgeted[2].Path) != "three" {
|
||||
t.Fatalf("ordered aggregate file-diff budget = %#v", budgeted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchFileDiffsKeepEligibleSameBasenameSibling(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diffs := fileDiffsFromStructuredPatch(".", []structuredPatchChange{
|
||||
{
|
||||
kind: structuredPatchAdd,
|
||||
to: structuredPatchTarget{absolute: filepath.Join(root, "a.go"), relative: "a.go"},
|
||||
after: strings.Repeat("x", maxToolPreviewBytes+1),
|
||||
},
|
||||
{
|
||||
kind: structuredPatchAdd,
|
||||
to: structuredPatchTarget{absolute: filepath.Join(root, "sub", "a.go"), relative: filepath.Join("sub", "a.go")},
|
||||
after: "package sub\n",
|
||||
},
|
||||
})
|
||||
if len(diffs) != 1 || diffs[0].Path != filepath.Join(root, "sub", "a.go") {
|
||||
t.Fatalf("same-basename rich diffs = %#v", diffs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedDiffPreservesOrdinaryUnicodeButRejectsObfuscatedSecrets(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "unicode.txt")
|
||||
for name, content := range map[string]string{
|
||||
"family emoji": "family: 👨👩👧👦\n",
|
||||
"emoji variation selector": "heart: ❤️\n",
|
||||
"combining grapheme joiner": "ordinary\u034fprose\n",
|
||||
"nonbreaking space": "ordinary\u00a0prose\n",
|
||||
"byte order mark": "\ufeffdocument\n",
|
||||
"soft hyphen": "co\u00adoperate\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, ok := boundedFileDiff(path, "before\n", content, true, true); !ok {
|
||||
t.Fatalf("ordinary Unicode content was rejected: %q", content)
|
||||
}
|
||||
if preview := boundedUnifiedDiff("unicode.txt", "before\n", content); preview == "" {
|
||||
t.Fatal("ordinary Unicode preview was omitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
||||
for name, separator := range map[string]string{
|
||||
"zero width space": "\u200b",
|
||||
"zero width joiner": "\u200d",
|
||||
"byte order mark": "\ufeff",
|
||||
"soft hyphen": "\u00ad",
|
||||
"nonbreaking space": "\u00a0",
|
||||
"combining grapheme joiner": "\u034f",
|
||||
"variation selector": "\ufe0f",
|
||||
} {
|
||||
t.Run("split "+name, func(t *testing.T) {
|
||||
obfuscated := secret[:20] + separator + secret[20:]
|
||||
if _, ok := boundedFileDiff(path, "before\n", obfuscated, true, true); ok {
|
||||
t.Fatalf("obfuscated credential produced a rich diff: %q", obfuscated)
|
||||
}
|
||||
if preview := boundedUnifiedDiff("secret.txt", "before\n", obfuscated); preview != "" {
|
||||
t.Fatalf("obfuscated credential produced preview: %q", preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileMarksSuppressedObfuscatedSecretAsRedacted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
||||
obfuscated := secret[:20] + "\u200b" + secret[20:]
|
||||
result := NewScopedWriteFileTool(root, nil).Run(t.Context(), map[string]any{
|
||||
"path": "secret.txt", "content": obfuscated,
|
||||
})
|
||||
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || !result.Redacted {
|
||||
t.Fatalf("obfuscated-secret result = status=%s changed=%#v diffs=%#v redacted=%t", result.Status, result.ChangedFiles, result.FileDiffs, result.Redacted)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,10 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
}
|
||||
}
|
||||
content := string(contentBytes)
|
||||
priorInfo, err := os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading " + relativePath + ": " + err.Error())
|
||||
}
|
||||
occurrences := strings.Count(content, oldString)
|
||||
|
||||
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
|
||||
@@ -153,19 +157,25 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
|
||||
if err := commitFileContents(absolutePath, priorInfo, &content, updated); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
modelKnownContent := updated
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker re-baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
formatting := maybeFormatWrittenFile(ctx, absolutePath, updated)
|
||||
formatting := maybeFormatWrittenFileScoped(ctx, tool.workspaceRoot, tool.scope, absolutePath, updated, priorInfo.Mode().Perm())
|
||||
updated = formatting.Content
|
||||
finalContentKnown := formatting.ContentKnown
|
||||
// Re-baseline to the content we just wrote so subsequent edits in this session
|
||||
// compare against the current on-disk state, not the pre-edit version.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
if updated == modelKnownContent {
|
||||
newInfo := formatting.Info
|
||||
if newInfo == nil {
|
||||
newInfo, _ = os.Stat(absolutePath)
|
||||
}
|
||||
if !finalContentKnown {
|
||||
options.FileTracker.Forget(absolutePath)
|
||||
} else if updated == modelKnownContent {
|
||||
// OUR edit, so we know precisely which lines moved: RecordEdit carries
|
||||
// across the reads this edit did not disturb instead of dropping them.
|
||||
//
|
||||
@@ -195,12 +205,25 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
}
|
||||
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
|
||||
summary += formatting.notice(relativePath)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
if finalContentKnown {
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
}
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
if finalContentKnown {
|
||||
if diff, ok := boundedFileDiff(absolutePath, content, updated, true, true); ok {
|
||||
result.FileDiffs = []FileDiff{diff}
|
||||
} else if diffTextRevealsObfuscatedSecret(content) || diffTextRevealsObfuscatedSecret(updated) {
|
||||
result.Redacted = true
|
||||
}
|
||||
}
|
||||
// Card-only preview (Display.Preview): the model's Output stays the one-line
|
||||
// summary, so the red/green diff costs zero model tokens.
|
||||
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: boundedUnifiedDiff(relativePath, content, updated)}
|
||||
preview := ""
|
||||
if finalContentKnown {
|
||||
preview = boundedUnifiedDiff(relativePath, content, updated)
|
||||
}
|
||||
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: preview}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,11 @@ type ExecSessionSnapshot = execution.ProcessSnapshot
|
||||
|
||||
type ExecSessionController interface {
|
||||
ExecSessions() []ExecSessionSnapshot
|
||||
ExecSession(id int) (ExecSessionSnapshot, bool)
|
||||
StopExecSession(id int) bool
|
||||
StopAllExecSessions() []int
|
||||
WriteExecSessionInput(id int, data []byte) error
|
||||
ResizeExecSession(id int, cols, rows int) error
|
||||
}
|
||||
|
||||
type execCommandTool struct {
|
||||
@@ -95,7 +98,7 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
|
||||
},
|
||||
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
|
||||
"prefix_rule": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Reusable approval prefix for this command, only with `sandbox_permissions: \"require_escalated\"`; keep it narrow, for example [\"git\", \"pull\"]."},
|
||||
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes.", Default: false},
|
||||
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes. Use true for commands that may prompt for input (sudo, ssh, interactive installers): the user can then type into the session directly. Setuid tools such as sudo also need sandbox_permissions \"require_escalated\", since the sandbox blocks privilege escalation.", Default: false},
|
||||
},
|
||||
Required: []string{"cmd"},
|
||||
AdditionalProperties: false,
|
||||
@@ -131,6 +134,10 @@ func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
|
||||
return tool.manager.List()
|
||||
}
|
||||
|
||||
func (tool execCommandTool) ExecSession(id int) (ExecSessionSnapshot, bool) {
|
||||
return tool.manager.Snapshot(id)
|
||||
}
|
||||
|
||||
func (tool execCommandTool) StopExecSession(id int) bool {
|
||||
return tool.manager.Stop(id)
|
||||
}
|
||||
@@ -139,6 +146,14 @@ func (tool execCommandTool) StopAllExecSessions() []int {
|
||||
return tool.manager.StopAll()
|
||||
}
|
||||
|
||||
func (tool execCommandTool) WriteExecSessionInput(id int, data []byte) error {
|
||||
return tool.manager.WriteInput(id, data)
|
||||
}
|
||||
|
||||
func (tool execCommandTool) ResizeExecSession(id int, cols, rows int) error {
|
||||
return tool.manager.ResizeInput(id, cols, rows)
|
||||
}
|
||||
|
||||
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
|
||||
commandText, err := execCommandArg(args)
|
||||
if err != nil {
|
||||
@@ -172,8 +187,14 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
||||
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
|
||||
return interactiveBlockResult(interactive)
|
||||
// tty:true exists exactly to run prompting commands, so the
|
||||
// non-interactive guard steps aside and the PTY path gets the session.
|
||||
if !ttyRequested {
|
||||
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
|
||||
result := interactiveBlockResult(interactive)
|
||||
result.Output += "\nRerun with tty:true if the user should interact with it."
|
||||
return result
|
||||
}
|
||||
}
|
||||
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
|
||||
if err != nil {
|
||||
@@ -453,7 +474,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
|
||||
if input.exited && input.exitCode != 0 && !input.interrupted {
|
||||
status = StatusError
|
||||
}
|
||||
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
|
||||
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted, input.tty)
|
||||
if status == StatusError && input.exited && !input.interrupted {
|
||||
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
|
||||
meta["shell_issue"] = issue.Kind
|
||||
@@ -575,7 +596,7 @@ func executionChangeSummaries(changes []execution.Change) []execution.Change {
|
||||
return summaries
|
||||
}
|
||||
|
||||
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
|
||||
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool, tty bool) string {
|
||||
output = strings.TrimRight(output, "\r\n")
|
||||
parts := []string{}
|
||||
if output != "" {
|
||||
@@ -593,12 +614,18 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
|
||||
parts = append(parts, "interrupted: true")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
|
||||
if exitCode != 0 && strings.Contains(output, "no new privileges") {
|
||||
parts = append(parts, `Hint: this command needs sandbox_permissions "require_escalated" (the sandbox blocks setuid); retry with it and tty:true if it prompts.`)
|
||||
}
|
||||
} else {
|
||||
if output == "" {
|
||||
parts = append(parts, "Command is still running.")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("session_id: %d", sessionID))
|
||||
parts = append(parts, fmt.Sprintf("Use write_stdin with session_id %d and empty chars to poll; send chars \"\\u0003\" to interrupt/stop it.", sessionID))
|
||||
if tty {
|
||||
parts = append(parts, fmt.Sprintf("This session has a terminal and the user can already type into it (it opened in their TUI; /attach %d reopens it). If it is waiting on a password or confirmation, say so in one line, then keep polling with write_stdin (empty chars, yield_time_ms 60000) until it exits. Do not end your turn while it is running, and never ask for the password in chat.", sessionID))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
@@ -894,3 +894,56 @@ func TestTruncateExecOutputPreservesUTF8(t *testing.T) {
|
||||
t.Fatalf("truncated output is not valid UTF-8: %q", truncated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecCommandOutputTTYAttachHint(t *testing.T) {
|
||||
running := formatExecCommandOutput("", 1007, false, 0, false, true)
|
||||
if !strings.Contains(running, "/attach 1007 reopens it") || !strings.Contains(running, "Do not end your turn while it is running") {
|
||||
t.Fatalf("tty running session should point at /attach and keep-poll guidance: %q", running)
|
||||
}
|
||||
exited := formatExecCommandOutput("", 1007, true, 0, false, true)
|
||||
if strings.Contains(exited, "/attach") {
|
||||
t.Fatalf("exited session should not mention /attach: %q", exited)
|
||||
}
|
||||
pipes := formatExecCommandOutput("", 1007, false, 0, false, false)
|
||||
if strings.Contains(pipes, "/attach") {
|
||||
t.Fatalf("non-tty session should not mention /attach: %q", pipes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandInteractiveBlockSuggestsTTY(t *testing.T) {
|
||||
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
|
||||
result := tool.Run(context.Background(), map[string]any{"cmd": "ssh host.example.com"})
|
||||
if result.Status != StatusError || result.Meta["safety_block"] != "interactive_command" {
|
||||
t.Fatalf("expected interactive safety block, got meta=%#v output=%q", result.Meta, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "tty:true") {
|
||||
t.Fatalf("block output should suggest tty:true: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandTTYSkipsInteractiveBlock(t *testing.T) {
|
||||
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
|
||||
// ssh to a bogus host fails fast on its own; the point is that the
|
||||
// interactive guard must not fire ahead of the tty path.
|
||||
result := tool.Run(context.Background(), map[string]any{
|
||||
"cmd": "ssh host.example.com", "tty": true, "yield_time_ms": 250,
|
||||
})
|
||||
if result.Meta["safety_block"] == "interactive_command" {
|
||||
t.Fatalf("tty:true should not hit the interactive block: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecCommandOutputNoNewPrivilegesHint(t *testing.T) {
|
||||
blocked := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 1, false, true)
|
||||
if !strings.Contains(blocked, `sandbox_permissions "require_escalated"`) {
|
||||
t.Fatalf("no_new_privs failure should hint at require_escalated: %q", blocked)
|
||||
}
|
||||
normal := formatExecCommandOutput("some other error", 1007, true, 1, false, true)
|
||||
if strings.Contains(normal, "require_escalated") {
|
||||
t.Fatalf("unrelated failure should not hint at require_escalated: %q", normal)
|
||||
}
|
||||
blockedOK := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 0, false, true)
|
||||
if strings.Contains(blockedOK, "require_escalated") {
|
||||
t.Fatalf("zero-exit output should not hint at require_escalated: %q", blockedOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
var errFileChangedDuringWrite = errors.New("file changed on disk before the write committed")
|
||||
|
||||
// fileWriteBeforeCommit is a deterministic test hook. Production leaves it
|
||||
// nil; tests use it to replace a path after observation but before opening the
|
||||
// object that will actually be mutated.
|
||||
var fileWriteBeforeCommit func(path string)
|
||||
|
||||
// fileWriteStat is a deterministic test seam for proving that the opened-file
|
||||
// identity is captured before the final preimage comparison. Production uses
|
||||
// the file descriptor directly.
|
||||
var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat() }
|
||||
|
||||
// commitFileContents binds an overwrite to the file identity and bytes that
|
||||
// the caller observed. A create uses exclusive creation. An overwrite opens the
|
||||
// observed object without truncation, verifies identity/content through that
|
||||
// handle, then truncates and writes the same handle. A path replacement before
|
||||
// or during commit therefore fails instead of publishing stale rich evidence.
|
||||
//
|
||||
// expectedInfo nil means the caller observed a missing path. expectedContent
|
||||
// may be nil for an existing but unreadable file; that path may still be
|
||||
// overwritten, but callers must omit rich before/after evidence.
|
||||
func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent *string, content string) error {
|
||||
if fileWriteBeforeCommit != nil {
|
||||
fileWriteBeforeCommit(path)
|
||||
}
|
||||
|
||||
if expectedInfo == nil {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
openedInfo, err := fileWriteStat(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return writeAndVerifyFileIdentity(path, file, openedInfo, content, false)
|
||||
}
|
||||
|
||||
flags := os.O_WRONLY
|
||||
if expectedContent != nil {
|
||||
flags = os.O_RDWR
|
||||
}
|
||||
file, err := os.OpenFile(path, flags, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
openedInfo, err := fileWriteStat(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if !os.SameFile(expectedInfo, openedInfo) {
|
||||
_ = file.Close()
|
||||
return errFileChangedDuringWrite
|
||||
}
|
||||
pathInfo, err := os.Stat(path)
|
||||
if err != nil || !os.SameFile(openedInfo, pathInfo) {
|
||||
_ = file.Close()
|
||||
return errFileChangedDuringWrite
|
||||
}
|
||||
if expectedContent != nil {
|
||||
current, readErr := io.ReadAll(file)
|
||||
if readErr != nil {
|
||||
_ = file.Close()
|
||||
return readErr
|
||||
}
|
||||
if string(current) != *expectedContent {
|
||||
_ = file.Close()
|
||||
return errFileChangedDuringWrite
|
||||
}
|
||||
}
|
||||
return writeAndVerifyFileIdentity(path, file, openedInfo, content, true)
|
||||
}
|
||||
|
||||
func writeAndVerifyFileIdentity(path string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error {
|
||||
if truncate {
|
||||
if err := file.Truncate(0); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := io.WriteString(file, content); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
pathInfo, err := os.Stat(path)
|
||||
if err != nil || !os.SameFile(openedInfo, pathInfo) {
|
||||
return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func installFileWriteRace(t *testing.T, mutate func(string)) {
|
||||
t.Helper()
|
||||
prior := fileWriteBeforeCommit
|
||||
fileWriteBeforeCommit = mutate
|
||||
t.Cleanup(func() { fileWriteBeforeCommit = prior })
|
||||
}
|
||||
|
||||
func installFileWriteStat(t *testing.T, stat func(*os.File) (os.FileInfo, error)) {
|
||||
t.Helper()
|
||||
prior := fileWriteStat
|
||||
fileWriteStat = stat
|
||||
t.Cleanup(func() { fileWriteStat = prior })
|
||||
}
|
||||
|
||||
func TestWriteFileRefusesCreateAndOverwriteRaces(t *testing.T) {
|
||||
t.Run("create", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "created.txt")
|
||||
installFileWriteRace(t, func(path string) {
|
||||
if err := os.WriteFile(path, []byte("other writer\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "created.txt", "content": "zero\n",
|
||||
})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("raced create status = %s, want error", result.Status)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "other writer\n" {
|
||||
t.Fatalf("raced create content = %q, err=%v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overwrite", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installFileWriteRace(t, func(path string) {
|
||||
if err := os.WriteFile(path, []byte("other writer\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, errFileChangedDuringWrite.Error()) {
|
||||
t.Fatalf("raced overwrite = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "other writer\n" {
|
||||
t.Fatalf("raced overwrite content = %q, err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEditFileRefusesPreimageRace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installFileWriteRace(t, func(path string) {
|
||||
if err := os.WriteFile(path, []byte("other writer\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "old_string": "observed", "new_string": "zero",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, errFileChangedDuringWrite.Error()) {
|
||||
t.Fatalf("raced edit = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "other writer\n" {
|
||||
t.Fatalf("raced edit content = %q, err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverwriteDoesNotStatOpenedFileAfterFinalPreimageComparison(t *testing.T) {
|
||||
for name, run := range map[string]func(string) Result{
|
||||
"write overwrite": func(root string) Result {
|
||||
return NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
},
|
||||
"edit": func(root string) Result {
|
||||
return NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "old_string": "observed", "new_string": "zero",
|
||||
})
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
statCalls := 0
|
||||
installFileWriteStat(t, func(file *os.File) (os.FileInfo, error) {
|
||||
statCalls++
|
||||
if statCalls == 2 {
|
||||
// This preserves the inode, so identity-only checks cannot detect it.
|
||||
if err := os.WriteFile(target, []byte("other writer\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return file.Stat()
|
||||
})
|
||||
|
||||
result := run(root)
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("overwrite status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if statCalls != 1 {
|
||||
t.Fatalf("opened file was statted %d times; the final byte comparison must be followed directly by mutation", statCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// Format-on-write for the mutating file tools. When enabled, a successful
|
||||
@@ -44,7 +48,9 @@ var formatOnWriteTimeout = 10 * time.Second
|
||||
// and finds out from a CI format check it cannot see, which is the thing this
|
||||
// feature exists to prevent.
|
||||
type formatOnWriteResult struct {
|
||||
Content string
|
||||
Content string
|
||||
ContentKnown bool
|
||||
Info os.FileInfo
|
||||
// Formatter is the binary that was run, named in the notice so the user can
|
||||
// tell a slow gofmt from a slow prettier.
|
||||
Formatter string
|
||||
@@ -117,14 +123,32 @@ func formatOnWriteEnabled() bool {
|
||||
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||
}
|
||||
|
||||
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
|
||||
// enabled and on PATH) and returns the file's content afterwards. Best-effort
|
||||
// throughout: any failure — no formatter, formatter error, timeout, unreadable
|
||||
// result — returns writtenContent so the caller's state matches the last write
|
||||
// it performed itself. Only the timeout is reported back, for the reason on
|
||||
// formatOnWriteResult.
|
||||
var runFormatOnWriteCommand = func(ctx context.Context, binaryPath string, arguments []string, directory string) error {
|
||||
formatter := exec.CommandContext(ctx, binaryPath, arguments...)
|
||||
formatter.Dir = directory
|
||||
formatter.Stdin = strings.NewReader("")
|
||||
return formatter.Run()
|
||||
}
|
||||
|
||||
var readFormattedFile = readRootedFile
|
||||
|
||||
// maybeFormatWrittenFile is the unscoped test-facing wrapper. Production
|
||||
// callers use maybeFormatWrittenFileScoped so a formatter cannot redirect the
|
||||
// final read or recovery write outside the configured write roots.
|
||||
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) formatOnWriteResult {
|
||||
unformatted := formatOnWriteResult{Content: writtenContent}
|
||||
restoreMode := os.FileMode(0o644)
|
||||
if info, err := os.Stat(absolutePath); err == nil {
|
||||
restoreMode = info.Mode().Perm()
|
||||
}
|
||||
return maybeFormatWrittenFileScoped(ctx, filepath.Dir(absolutePath), nil, absolutePath, writtenContent, restoreMode)
|
||||
}
|
||||
|
||||
// maybeFormatWrittenFileScoped runs the configured formatter and returns only
|
||||
// content verified through a descriptor-bound root. Formatter failures restore
|
||||
// writtenContent through that same root; if restoration or the final read
|
||||
// fails, ContentKnown is false and callers omit exact diff evidence.
|
||||
func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, scope PathScope, absolutePath string, writtenContent string, restoreMode os.FileMode) formatOnWriteResult {
|
||||
unformatted := formatOnWriteResult{Content: writtenContent, ContentKnown: true}
|
||||
if !formatOnWriteEnabled() {
|
||||
return unformatted
|
||||
}
|
||||
@@ -136,42 +160,90 @@ func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenCon
|
||||
if err != nil {
|
||||
return unformatted
|
||||
}
|
||||
root, relativePath, err := openFormattedFileRoot(workspaceRoot, scope, absolutePath)
|
||||
if err != nil {
|
||||
unformatted.ContentKnown = false
|
||||
return unformatted
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
|
||||
defer cancel()
|
||||
arguments := append(append([]string(nil), command[1:]...), absolutePath)
|
||||
formatter := exec.CommandContext(formatCtx, binaryPath, arguments...)
|
||||
formatter.Dir = filepath.Dir(absolutePath)
|
||||
formatter.Stdin = strings.NewReader("")
|
||||
if err := formatter.Run(); err != nil {
|
||||
if err := runFormatOnWriteCommand(formatCtx, binaryPath, arguments, filepath.Dir(absolutePath)); err != nil {
|
||||
unformatted.Formatter = command[0]
|
||||
// THE FORMATTER EDITS IN PLACE, SO A FAILED RUN CAN LEAVE THE FILE
|
||||
// NEITHER FORMATTED NOR AS WRITTEN. Killed by the deadline or by the
|
||||
// caller, or exiting partway through its own rewrite, the target can hold
|
||||
// a truncation. Returning the written bytes on top of that would leave the
|
||||
// tracker baseline and the diff preview describing a file that is not on
|
||||
// disk, which is a worse failure than the missing formatting: the next
|
||||
// edit compares against content the file does not have.
|
||||
//
|
||||
// Written back unconditionally on this path rather than only when the
|
||||
// bytes differ. Comparing first means reading the file to find out, and a
|
||||
// read that fails leaves the same ambiguity this exists to remove.
|
||||
if restoreErr := os.WriteFile(absolutePath, []byte(writtenContent), 0o644); restoreErr != nil {
|
||||
if restoreErr := restoreFormattedFile(root, relativePath, writtenContent, restoreMode); restoreErr != nil {
|
||||
unformatted.RestoreFailed = true
|
||||
unformatted.ContentKnown = false
|
||||
} else if restored, info, readErr := readFormattedFile(root, relativePath); readErr != nil {
|
||||
unformatted.ContentKnown = false
|
||||
} else {
|
||||
unformatted.Content = string(restored)
|
||||
unformatted.Info = info
|
||||
}
|
||||
// OUR deadline, not the caller's cancellation and not the formatter's own
|
||||
// exit status. A cancelled tool call is already being reported as
|
||||
// cancelled, and a formatter that ran and refused the file usually means
|
||||
// content it could not parse, which the write itself does not promise to
|
||||
// fix. Neither is this notice's business; the restore above is, for all
|
||||
// three.
|
||||
if errors.Is(formatCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
|
||||
unformatted.TimedOut = true
|
||||
}
|
||||
return unformatted
|
||||
}
|
||||
formatted, err := os.ReadFile(absolutePath)
|
||||
|
||||
formatted, info, err := readFormattedFile(root, relativePath)
|
||||
if err != nil {
|
||||
unformatted.ContentKnown = false
|
||||
return unformatted
|
||||
}
|
||||
return formatOnWriteResult{Content: string(formatted), Formatter: command[0]}
|
||||
return formatOnWriteResult{Content: string(formatted), ContentKnown: true, Info: info, Formatter: command[0]}
|
||||
}
|
||||
|
||||
func restoreFormattedFile(root *os.Root, relativePath string, content string, mode os.FileMode) error {
|
||||
file, err := root.OpenFile(relativePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(file, content); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
// openFormattedFileRoot opens the write root before the formatter runs and
|
||||
// computes the target relative to that descriptor-bound root. Atomic in-root
|
||||
// replacement remains valid; a formatter that swaps the target to an escaping
|
||||
// symlink is rejected when readFormattedFile opens it through the root.
|
||||
func openFormattedFileRoot(workspaceRoot string, scope PathScope, absolutePath string) (*os.Root, string, error) {
|
||||
roots, err := scopedRoots(workspaceRoot, scope)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var firstErr error
|
||||
for _, configuredRoot := range roots {
|
||||
resolvedRoot, err := filepath.Abs(configuredRoot)
|
||||
if err == nil {
|
||||
resolvedRoot, err = filepath.EvalSymlinks(resolvedRoot)
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidate := sandbox.NormalizePrefixForRoot(absolutePath, resolvedRoot)
|
||||
relativePath, err := filepath.Rel(resolvedRoot, candidate)
|
||||
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
|
||||
continue
|
||||
}
|
||||
root, err := os.OpenRoot(resolvedRoot)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return root, relativePath, nil
|
||||
}
|
||||
if firstErr != nil {
|
||||
return nil, "", firstErr
|
||||
}
|
||||
return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -98,7 +99,7 @@ func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
|
||||
func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
formatting := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text")
|
||||
if formatting.Content != "raw text" {
|
||||
if formatting.Content != "raw text" || !formatting.ContentKnown {
|
||||
t.Fatalf("unknown extension must pass through: %q", formatting.Content)
|
||||
}
|
||||
if notice := formatting.notice("notes.xyz"); notice != "" {
|
||||
@@ -115,10 +116,418 @@ func TestFormatOnWriteFormatterLookupFailure(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
formatting := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent)
|
||||
if formatting.Content != uglyContent {
|
||||
if formatting.Content != uglyContent || !formatting.ContentKnown {
|
||||
t.Fatalf("missing formatter must return written content, got %q", formatting.Content)
|
||||
}
|
||||
if notice := formatting.notice("a.go"); notice != "" {
|
||||
t.Fatalf("an uninstalled formatter is a standing fact, not a miss worth reporting, got %q", notice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteReadsMutatedFileAfterFormatterFailure(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
targetPath := filepath.Join(t.TempDir(), "a.go")
|
||||
if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
|
||||
if err := os.WriteFile(targetPath, []byte("formatter-mutated"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return exec.ErrNotFound
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
|
||||
formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested")
|
||||
if !formatting.ContentKnown || formatting.Content != "requested" {
|
||||
t.Fatalf("formatter failure content = %q, known=%t", formatting.Content, formatting.ContentKnown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteReportsWhenFormatterFailureCannotBeRestored(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
targetPath := filepath.Join(t.TempDir(), "a.go")
|
||||
if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(targetPath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return exec.ErrNotFound
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
|
||||
formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested")
|
||||
if !formatting.RestoreFailed {
|
||||
t.Fatal("failed recovery was not reported")
|
||||
}
|
||||
if formatting.ContentKnown {
|
||||
t.Fatalf("failed recovery reported known content %q", formatting.Content)
|
||||
}
|
||||
if notice := formatting.notice("a.go"); !strings.Contains(notice, "WARNING: a.go may not hold what was written") {
|
||||
t.Fatalf("failed recovery notice = %q", notice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileRecreatesFormatterDeletedTargetWithOriginalMode(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
existing bool
|
||||
wantMode os.FileMode
|
||||
overwrite bool
|
||||
}{
|
||||
{name: "create", wantMode: 0o644},
|
||||
{name: "overwrite", existing: true, wantMode: 0o751, overwrite: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
if tc.existing {
|
||||
if err := os.WriteFile(targetPath, []byte("before\n"), tc.wantMode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return exec.ErrNotFound
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "a.go", "content": "requested\n", "overwrite": tc.overwrite,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("write status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(targetPath)
|
||||
if err != nil || string(content) != "requested\n" {
|
||||
t.Fatalf("recreated content = %q, err=%v", content, err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != tc.wantMode {
|
||||
t.Fatalf("recreated mode = %o, want %o", got, tc.wantMode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteMarksUnreadableFinalStateUnknown(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
targetPath := filepath.Join(t.TempDir(), "a.go")
|
||||
if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
priorReader := readFormattedFile
|
||||
runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil }
|
||||
readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission }
|
||||
t.Cleanup(func() {
|
||||
runFormatOnWriteCommand = priorRunner
|
||||
readFormattedFile = priorReader
|
||||
})
|
||||
|
||||
formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested")
|
||||
if formatting.ContentKnown || formatting.Content != "requested" {
|
||||
t.Fatalf("unreadable formatter result = %q, known=%t", formatting.Content, formatting.ContentKnown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileUsesVerifiedBytesAfterFormatterFailure(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
|
||||
if err := os.WriteFile(targetPath, []byte("formatter-mutated\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return exec.ErrNotFound
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "a.go", "content": "requested\n",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("write status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got := result.FileDiffs; len(got) != 1 || got[0].NewText != "requested\n" {
|
||||
t.Fatalf("formatter-failure FileDiff = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileUsesVerifiedBytesAfterFormatterFailure(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
if err := os.WriteFile(targetPath, []byte("before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
|
||||
if err := os.WriteFile(targetPath, []byte("formatter-mutated\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return exec.ErrNotFound
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
|
||||
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "a.go", "old_string": "before", "new_string": "requested",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("edit status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got := result.FileDiffs; len(got) != 1 || got[0].OldText != "before\n" || got[0].NewText != "requested\n" {
|
||||
t.Fatalf("formatter-failure edit FileDiff = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileOmitsRichDiffWhenFormatterFinalReadFails(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
priorReader := readFormattedFile
|
||||
runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil }
|
||||
readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission }
|
||||
t.Cleanup(func() {
|
||||
runFormatOnWriteCommand = priorRunner
|
||||
readFormattedFile = priorReader
|
||||
})
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "a.go", "content": "requested\n",
|
||||
})
|
||||
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 {
|
||||
t.Fatalf("unverified formatter result = status=%s changed=%#v diffs=%#v", result.Status, result.ChangedFiles, result.FileDiffs)
|
||||
}
|
||||
if result.Display.Preview != "" {
|
||||
t.Fatalf("unverified formatter result exposed stale preview: %q", result.Display.Preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileOmitsPreviewWhenFormatterFinalReadFails(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
if err := os.WriteFile(targetPath, []byte("before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
priorReader := readFormattedFile
|
||||
runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil }
|
||||
readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission }
|
||||
t.Cleanup(func() {
|
||||
runFormatOnWriteCommand = priorRunner
|
||||
readFormattedFile = priorReader
|
||||
})
|
||||
|
||||
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "a.go", "old_string": "before", "new_string": "requested",
|
||||
})
|
||||
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 {
|
||||
t.Fatalf("unverified formatter result = status=%s changed=%#v diffs=%#v", result.Status, result.ChangedFiles, result.FileDiffs)
|
||||
}
|
||||
if result.Display.Preview != "" {
|
||||
t.Fatalf("unverified formatter result exposed stale preview: %q", result.Display.Preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileOmitsRichEvidenceWhenFormatterReplacesTargetWithOutOfRootSymlink(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trackedPath := filepath.Join(resolvedRoot, "a.go")
|
||||
outsidePath := filepath.Join(t.TempDir(), "outside.go")
|
||||
outsideContent := "package external\n\nconst Secret = \"outside\"\n"
|
||||
if err := os.WriteFile(outsidePath, []byte(outsideContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsidePath, targetPath); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
tracker := NewFileTracker()
|
||||
|
||||
diagnosticsCalled := false
|
||||
result := NewScopedWriteFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go", "content": "package requested\n",
|
||||
}, RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string {
|
||||
diagnosticsCalled = true
|
||||
return "must not run"
|
||||
}})
|
||||
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || result.Display.Preview != "" {
|
||||
t.Fatalf("out-of-root formatter result = status=%s changed=%#v diffs=%#v preview=%q output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Display.Preview, result.Output)
|
||||
}
|
||||
if diagnosticsCalled {
|
||||
t.Fatal("diagnostics must not inspect an unverified formatter target")
|
||||
}
|
||||
if _, tracked := tracker.Version(trackedPath); tracked {
|
||||
t.Fatal("out-of-root formatter target must not be recorded in the tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileOmitsRichEvidenceWhenFormatterReplacesTargetWithOutOfRootSymlink(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trackedPath := filepath.Join(resolvedRoot, "a.go")
|
||||
if err := os.WriteFile(targetPath, []byte("package before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsidePath := filepath.Join(t.TempDir(), "outside.go")
|
||||
outsideContent := "package external\n\nconst Secret = \"outside\"\n"
|
||||
if err := os.WriteFile(outsidePath, []byte(outsideContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsidePath, targetPath); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
tracker := NewFileTracker()
|
||||
read := NewScopedReadFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if read.Status != StatusOK {
|
||||
t.Fatalf("read before edit failed: %s", read.Output)
|
||||
}
|
||||
|
||||
diagnosticsCalled := false
|
||||
result := NewScopedEditFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go", "old_string": "before", "new_string": "requested",
|
||||
}, RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string {
|
||||
diagnosticsCalled = true
|
||||
return "must not run"
|
||||
}})
|
||||
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || result.Display.Preview != "" {
|
||||
t.Fatalf("out-of-root formatter result = status=%s changed=%#v diffs=%#v preview=%q output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Display.Preview, result.Output)
|
||||
}
|
||||
if diagnosticsCalled {
|
||||
t.Fatal("diagnostics must not inspect an unverified formatter target")
|
||||
}
|
||||
if _, tracked := tracker.Version(trackedPath); tracked {
|
||||
t.Fatal("out-of-root formatter target must not be recorded in the tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
formatted := "package formatted\n"
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
|
||||
tempPath := filepath.Join(root, "formatter.tmp")
|
||||
if err := os.WriteFile(tempPath, []byte(formatted), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return os.Rename(tempPath, targetPath)
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
tracker := NewFileTracker()
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go", "content": "package requested\n",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if result.Status != StatusOK || len(result.FileDiffs) != 1 || result.FileDiffs[0].NewText != formatted {
|
||||
t.Fatalf("atomic formatter write = status=%s diffs=%#v output=%q", result.Status, result.FileDiffs, result.Output)
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version, tracked := tracker.Version(filepath.Join(resolvedRoot, "a.go"))
|
||||
if !tracked || version.Hash != HashContent([]byte(formatted)) {
|
||||
t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "a.go")
|
||||
if err := os.WriteFile(targetPath, []byte("package before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
formatted := "package formatted\n"
|
||||
priorRunner := runFormatOnWriteCommand
|
||||
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
|
||||
tempPath := filepath.Join(root, "formatter.tmp")
|
||||
if err := os.WriteFile(tempPath, []byte(formatted), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return os.Rename(tempPath, targetPath)
|
||||
}
|
||||
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
|
||||
tracker := NewFileTracker()
|
||||
read := NewScopedReadFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if read.Status != StatusOK {
|
||||
t.Fatalf("read before edit failed: %s", read.Output)
|
||||
}
|
||||
|
||||
result := NewScopedEditFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go", "old_string": "before", "new_string": "requested",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if result.Status != StatusOK || len(result.FileDiffs) != 1 || result.FileDiffs[0].NewText != formatted {
|
||||
t.Fatalf("atomic formatter edit = status=%s diffs=%#v output=%q", result.Status, result.FileDiffs, result.Output)
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version, tracked := tracker.Version(filepath.Join(resolvedRoot, "a.go"))
|
||||
if !tracked || version.Hash != HashContent([]byte(formatted)) {
|
||||
t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked)
|
||||
}
|
||||
}
|
||||
@@ -343,6 +343,33 @@ func scrubResultSecrets(res Result) Result {
|
||||
res.Display.Preview = scrubbed
|
||||
res.Redacted = true
|
||||
}
|
||||
fileDiffs := make([]FileDiff, 0, len(res.FileDiffs))
|
||||
for _, diff := range res.FileDiffs {
|
||||
// Never normalize control bytes in a diff: normalizing after redaction can
|
||||
// reassemble a split credential. Decline unsafe rich content entirely and
|
||||
// leave ChangedFiles as the safe fallback.
|
||||
if unsafeDiffText(diff.OldText) || unsafeDiffText(diff.NewText) {
|
||||
res.Redacted = true
|
||||
continue
|
||||
}
|
||||
if scrubbed := redaction.RedactString(diff.OldText, redaction.Options{}); scrubbed != diff.OldText {
|
||||
diff.OldText = scrubbed
|
||||
res.Redacted = true
|
||||
}
|
||||
if scrubbed := redaction.RedactString(diff.NewText, redaction.Options{}); scrubbed != diff.NewText {
|
||||
diff.NewText = scrubbed
|
||||
res.Redacted = true
|
||||
}
|
||||
// Redaction can collapse two distinct credentials to the same token. At
|
||||
// this final outbound boundary, retain rich evidence only when the
|
||||
// transformed sides still describe a real transition; ChangedFiles
|
||||
// remains the safe path-only fallback.
|
||||
if diff.OldExists == diff.NewExists && diff.OldText == diff.NewText {
|
||||
continue
|
||||
}
|
||||
fileDiffs = append(fileDiffs, diff)
|
||||
}
|
||||
res.FileDiffs = fileDiffs
|
||||
// Meta values carry model-controlled strings (e.g. glob pattern, bash cwd) and
|
||||
// are forwarded into the transcript, so they are part of the boundary too.
|
||||
for key, value := range res.Meta {
|
||||
@@ -354,6 +381,13 @@ func scrubResultSecrets(res Result) Result {
|
||||
return res
|
||||
}
|
||||
|
||||
// ScrubResultSecrets applies the registry's transcript boundary to a result
|
||||
// that was produced before Registry.RunWithOptions could own it, such as a
|
||||
// local pre-permission rejection in the agent loop.
|
||||
func ScrubResultSecrets(res Result) Result {
|
||||
return scrubResultSecrets(res)
|
||||
}
|
||||
|
||||
func CoreReadOnlyToolsScoped(workspaceRoot string, scope PathScope) []Tool {
|
||||
return []Tool{
|
||||
NewScopedReadMinifiedFileTool(workspaceRoot, scope),
|
||||
|
||||
@@ -445,13 +445,120 @@ func (t denyTool) Run(context.Context, map[string]any) Result { return Result{St
|
||||
// must be scrubbed too, not just the tool-execution paths.
|
||||
func TestScrubResultSecretsRedactsPreview(t *testing.T) {
|
||||
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
res := scrubResultSecrets(Result{Display: Display{Preview: "+++ b/x\n+token := \"" + secret + "\""}})
|
||||
res := scrubResultSecrets(Result{
|
||||
Display: Display{Preview: "+++ b/x\n+token := \"" + secret + "\""},
|
||||
FileDiffs: []FileDiff{{Path: filepath.Join(t.TempDir(), "x"), OldExists: true, NewExists: true, OldText: "before " + secret, NewText: "after " + secret}},
|
||||
})
|
||||
if strings.Contains(res.Display.Preview, secret) {
|
||||
t.Errorf("Display.Preview (the card-only code preview) must be redacted, leaked: %q", res.Display.Preview)
|
||||
}
|
||||
if !res.Redacted {
|
||||
t.Error("scrubbing a secret from the preview should set Redacted")
|
||||
}
|
||||
if len(res.FileDiffs) != 1 || strings.Contains(res.FileDiffs[0].OldText, secret) || strings.Contains(res.FileDiffs[0].NewText, secret) {
|
||||
t.Errorf("FileDiff must be redacted: %#v", res.FileDiffs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubResultSecretsDropsControlSplitFileDiff(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
res := scrubResultSecrets(Result{FileDiffs: []FileDiff{{
|
||||
Path: filepath.Join(t.TempDir(), "x"),
|
||||
OldExists: true,
|
||||
NewExists: true,
|
||||
OldText: "token=" + secret[:12] + "\x00" + secret[12:],
|
||||
NewText: "safe",
|
||||
}}})
|
||||
if len(res.FileDiffs) != 0 || !res.Redacted {
|
||||
t.Fatalf("unsafe FileDiff = %#v, redacted = %t", res.FileDiffs, res.Redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubResultSecretsDropsDefaultIgnorableSplitFileDiffOnEitherSide(t *testing.T) {
|
||||
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
|
||||
for name, separator := range map[string]string{
|
||||
"combining grapheme joiner": "\u034f",
|
||||
"variation selector": "\ufe0f",
|
||||
} {
|
||||
for _, side := range []string{"old", "new"} {
|
||||
t.Run(name+" "+side, func(t *testing.T) {
|
||||
obfuscated := secret[:20] + separator + secret[20:]
|
||||
diff := FileDiff{
|
||||
Path: filepath.Join(t.TempDir(), "x"), OldExists: true, NewExists: true,
|
||||
OldText: "safe old", NewText: "safe new",
|
||||
}
|
||||
if side == "old" {
|
||||
diff.OldText = obfuscated
|
||||
} else {
|
||||
diff.NewText = obfuscated
|
||||
}
|
||||
res := scrubResultSecrets(Result{FileDiffs: []FileDiff{diff}})
|
||||
if len(res.FileDiffs) != 0 || !res.Redacted {
|
||||
t.Fatalf("unsafe FileDiff = %#v, redacted = %t", res.FileDiffs, res.Redacted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubResultSecretsDoesNotMutateCallerFileDiffSlice(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "x")
|
||||
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
retainedOld := "before token=" + secret
|
||||
retainedNew := "after token=" + secret
|
||||
original := []FileDiff{
|
||||
{Path: path, OldExists: true, NewExists: true, OldText: "token=sk-proj-abc\x00def", NewText: "unsafe"},
|
||||
{Path: path, OldExists: true, NewExists: true, OldText: retainedOld, NewText: retainedNew},
|
||||
}
|
||||
result := scrubResultSecrets(Result{FileDiffs: original})
|
||||
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || strings.Contains(result.FileDiffs[0].NewText, secret) {
|
||||
t.Fatalf("filtered FileDiffs = %#v", result.FileDiffs)
|
||||
}
|
||||
if original[0].NewText != "unsafe" || original[1].OldText != retainedOld || original[1].NewText != retainedNew {
|
||||
t.Fatalf("caller slice was mutated: %#v", original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubResultSecretsDropsSemanticallyUnchangedRedactedDiff(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.txt")
|
||||
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
|
||||
result := scrubResultSecrets(Result{
|
||||
ChangedFiles: []string{"credentials.txt"},
|
||||
FileDiffs: []FileDiff{{
|
||||
Path: path, OldExists: true, NewExists: true,
|
||||
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
|
||||
}},
|
||||
})
|
||||
if len(result.FileDiffs) != 0 {
|
||||
t.Fatalf("semantically unchanged redacted diff = %#v", result.FileDiffs)
|
||||
}
|
||||
if got, want := result.ChangedFiles, []string{"credentials.txt"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("ChangedFiles = %#v, want %#v", got, want)
|
||||
}
|
||||
if !result.Redacted {
|
||||
t.Fatal("credential rotation must set Redacted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubResultSecretsKeepsRealTransitionAroundRedactedSecrets(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.txt")
|
||||
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
|
||||
result := scrubResultSecrets(Result{FileDiffs: []FileDiff{{
|
||||
Path: path, OldExists: true, NewExists: true,
|
||||
OldText: "environment=staging token=" + oldSecret,
|
||||
NewText: "environment=production token=" + newSecret,
|
||||
}}})
|
||||
if len(result.FileDiffs) != 1 {
|
||||
t.Fatalf("redacted real transition = %#v", result.FileDiffs)
|
||||
}
|
||||
if result.FileDiffs[0].OldText == result.FileDiffs[0].NewText {
|
||||
t.Fatalf("redacted transition became unchanged: %#v", result.FileDiffs[0])
|
||||
}
|
||||
if strings.Contains(result.FileDiffs[0].OldText, oldSecret) || strings.Contains(result.FileDiffs[0].NewText, newSecret) {
|
||||
t.Fatalf("redacted transition leaked a secret: %#v", result.FileDiffs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithOptionsScrubsSecretsOnDenialPaths(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// readRootedFile returns bytes and identity from the same opened object, and
|
||||
// verifies that the rooted path still names that object after the read. Root.Open
|
||||
// binds symlink containment to use rather than relying on a pathname pre-check.
|
||||
func readRootedFile(root *os.Root, relativePath string) ([]byte, os.FileInfo, error) {
|
||||
file, err := root.Open(relativePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = file.Close()
|
||||
}
|
||||
}()
|
||||
openedInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !openedInfo.Mode().IsRegular() {
|
||||
return nil, nil, fmt.Errorf("%s is not a regular file", relativePath)
|
||||
}
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pathInfo, err := root.Stat(relativePath)
|
||||
if err != nil || !os.SameFile(openedInfo, pathInfo) {
|
||||
return nil, nil, fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
closed = true
|
||||
return content, openedInfo, nil
|
||||
}
|
||||
@@ -86,12 +86,13 @@ type structuredPatchTarget struct {
|
||||
}
|
||||
|
||||
type structuredPatchChange struct {
|
||||
kind structuredPatchKind
|
||||
from structuredPatchTarget
|
||||
to structuredPatchTarget
|
||||
before string
|
||||
after string
|
||||
mode os.FileMode
|
||||
kind structuredPatchKind
|
||||
from structuredPatchTarget
|
||||
to structuredPatchTarget
|
||||
before string
|
||||
after string
|
||||
mode os.FileMode
|
||||
beforeInfo os.FileInfo
|
||||
}
|
||||
|
||||
// unifiedHunkRangePattern recognises a unified-diff range header ("-12,4 +12,6",
|
||||
@@ -156,8 +157,14 @@ func applyPatchOperations(applyRoot, relativeRoot string, operations []structure
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
applyOutcome, err := applyStructuredPatchChanges(workspace, relativeRoot, changes, options.FileTracker)
|
||||
if err != nil {
|
||||
result := errorResult("Error applying patch: " + err.Error())
|
||||
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, applyOutcome.committed)
|
||||
result.FileDiffs = fileDiffsFromStructuredPatch(relativeRoot, applyOutcome.committed)
|
||||
result.Redacted = structuredPatchContainsObfuscatedSecret(applyOutcome.committed)
|
||||
result.Display = Display{Summary: result.Output, Kind: "diff", Preview: structuredPatchPreview(applyOutcome.committed)}
|
||||
return result
|
||||
}
|
||||
|
||||
for _, change := range changes {
|
||||
@@ -183,10 +190,78 @@ func applyPatchOperations(applyRoot, relativeRoot string, operations []structure
|
||||
}
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
|
||||
result.FileDiffs = fileDiffsFromStructuredPatch(relativeRoot, changes)
|
||||
result.Redacted = structuredPatchContainsObfuscatedSecret(changes)
|
||||
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
|
||||
return result
|
||||
}
|
||||
|
||||
func structuredPatchContainsObfuscatedSecret(changes []structuredPatchChange) bool {
|
||||
for _, change := range changes {
|
||||
if diffTextRevealsObfuscatedSecret(change.before) || diffTextRevealsObfuscatedSecret(change.after) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fileDiffsFromStructuredPatch(_ string, changes []structuredPatchChange) []FileDiff {
|
||||
const maxToolResultFileDiffs = 64
|
||||
diffs := make([]FileDiff, 0, len(changes)*2)
|
||||
usedBytes := 0
|
||||
appendGroup := func(group ...FileDiff) bool {
|
||||
if len(group) == 0 || len(diffs)+len(group) > maxToolResultFileDiffs {
|
||||
return false
|
||||
}
|
||||
groupBytes := 0
|
||||
for _, diff := range group {
|
||||
groupBytes += len(diff.OldText) + len(diff.NewText)
|
||||
}
|
||||
if usedBytes+groupBytes > maxToolResultFileDiffBytes {
|
||||
return false
|
||||
}
|
||||
diffs = append(diffs, group...)
|
||||
usedBytes += groupBytes
|
||||
return true
|
||||
}
|
||||
makeDiff := func(path, before, after string, oldExists, newExists bool) (FileDiff, bool) {
|
||||
return boundedFileDiff(path, before, after, oldExists, newExists)
|
||||
}
|
||||
for _, change := range changes {
|
||||
var group []FileDiff
|
||||
switch {
|
||||
case change.kind == structuredPatchDelete:
|
||||
if diff, ok := makeDiff(change.from.absolute, change.before, "", true, false); ok {
|
||||
group = append(group, diff)
|
||||
}
|
||||
case change.kind == structuredPatchAdd:
|
||||
if diff, ok := makeDiff(change.to.absolute, "", change.after, false, true); ok {
|
||||
group = append(group, diff)
|
||||
}
|
||||
case change.kind == structuredPatchCopy && change.from.absolute != change.to.absolute:
|
||||
// A copy leaves its source unchanged; the destination is a create.
|
||||
if diff, ok := makeDiff(change.to.absolute, "", change.after, false, true); ok {
|
||||
group = append(group, diff)
|
||||
}
|
||||
case change.kind == structuredPatchUpdate && change.from.absolute != change.to.absolute:
|
||||
// A move is two filesystem changes, not a destination overwrite.
|
||||
from, fromOK := makeDiff(change.from.absolute, change.before, "", true, false)
|
||||
to, toOK := makeDiff(change.to.absolute, "", change.after, false, true)
|
||||
if fromOK && toOK {
|
||||
group = append(group, from, to)
|
||||
}
|
||||
default:
|
||||
if diff, ok := makeDiff(change.to.absolute, change.before, change.after, true, true); ok {
|
||||
group = append(group, diff)
|
||||
}
|
||||
}
|
||||
if len(group) > 0 && !appendGroup(group...) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
|
||||
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
|
||||
if normalized == "" {
|
||||
@@ -394,15 +469,12 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
|
||||
}
|
||||
change.after = operation.contents
|
||||
case structuredPatchDelete, structuredPatchUpdate, structuredPatchCopy:
|
||||
info, err := root.Stat(from.relative)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
|
||||
}
|
||||
change.mode = info.Mode()
|
||||
content, err := root.ReadFile(from.relative)
|
||||
content, info, err := readRootedFile(root, from.relative)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
|
||||
}
|
||||
change.mode = info.Mode()
|
||||
change.beforeInfo = info
|
||||
if err := tracker.CheckConflict(from.absolute, content); err != nil {
|
||||
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
|
||||
}
|
||||
@@ -672,38 +744,112 @@ func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bo
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
|
||||
type structuredPatchApplyOutcome struct {
|
||||
committed []structuredPatchChange
|
||||
incompletePaths []string
|
||||
}
|
||||
|
||||
type structuredPatchChangeOutcome struct {
|
||||
completed []structuredPatchChange
|
||||
incompletePaths []string
|
||||
}
|
||||
|
||||
func applyStructuredPatchChanges(root *os.Root, relativeRoot string, changes []structuredPatchChange, tracker *FileTracker) (structuredPatchApplyOutcome, error) {
|
||||
// committed lists, in order, the paths whose change reached disk before a
|
||||
// later change failed, so the caller (and the model) knows exactly which
|
||||
// files now hold the patched content and which were never touched.
|
||||
var committed []string
|
||||
var outcome structuredPatchApplyOutcome
|
||||
for _, change := range changes {
|
||||
done, err := applyStructuredPatchChange(root, change)
|
||||
if done {
|
||||
committed = append(committed, structuredPatchChangePaths(change)...)
|
||||
}
|
||||
changeOutcome, err := applyStructuredPatchChange(root, change)
|
||||
outcome.committed = append(outcome.committed, changeOutcome.completed...)
|
||||
outcome.incompletePaths = append(outcome.incompletePaths, changeOutcome.incompletePaths...)
|
||||
if err != nil {
|
||||
forgetStructuredPatchFiles(tracker, changes)
|
||||
if len(committed) > 0 {
|
||||
return fmt.Errorf("%w; patch was partially applied — already committed: %s; the remaining files are unchanged; re-read the committed files before retrying", err, strings.Join(committed, ", "))
|
||||
committedPaths := changedFilesFromStructuredPatch(relativeRoot, outcome.committed)
|
||||
unverifiedPaths := appendUniqueStructuredPatchPaths(nil, relativeRoot, outcome.incompletePaths)
|
||||
if len(committedPaths) > 0 || len(unverifiedPaths) > 0 {
|
||||
parts := []string{"patch was partially applied"}
|
||||
if len(committedPaths) > 0 {
|
||||
parts = append(parts, "already committed: "+strings.Join(committedPaths, ", "))
|
||||
}
|
||||
if len(unverifiedPaths) > 0 {
|
||||
parts = append(parts, "published but unverified: "+strings.Join(unverifiedPaths, ", "))
|
||||
}
|
||||
parts = append(parts, "the remaining files are unchanged")
|
||||
if len(unverifiedPaths) > 0 {
|
||||
parts = append(parts, "re-read the listed files before retrying")
|
||||
} else {
|
||||
parts = append(parts, "re-read the committed files before retrying")
|
||||
}
|
||||
return outcome, fmt.Errorf("%w; %s", err, strings.Join(parts, "; "))
|
||||
}
|
||||
return err
|
||||
return outcome, err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// structuredPatchChangePaths names the workspace-relative paths a committed
|
||||
// change touched: the destination, plus the source of a move or copy.
|
||||
func structuredPatchChangePaths(change structuredPatchChange) []string {
|
||||
if change.kind == structuredPatchDelete {
|
||||
return []string{change.from.relative}
|
||||
// completedStructuredPatchEffect turns a partially completed compound change
|
||||
// into the exact filesystem sub-effect that is still verifiable after the
|
||||
// error. In particular, a failed move may have published its destination while
|
||||
// leaving the source in place; that is a destination creation, not a move.
|
||||
func completedStructuredPatchEffect(root *os.Root, change structuredPatchChange) (structuredPatchChange, bool) {
|
||||
if change.to.relative == "" {
|
||||
return structuredPatchChange{}, false
|
||||
}
|
||||
paths := []string{change.to.relative}
|
||||
if change.from.absolute != change.to.absolute && change.from.relative != "" {
|
||||
paths = append([]string{change.from.relative}, paths...)
|
||||
content, _, err := readRootedFile(root, change.to.relative)
|
||||
if err != nil || string(content) != change.after {
|
||||
return structuredPatchChange{}, false
|
||||
}
|
||||
return paths
|
||||
switch change.kind {
|
||||
case structuredPatchAdd, structuredPatchCopy:
|
||||
return structuredPatchChange{
|
||||
kind: structuredPatchAdd,
|
||||
to: change.to,
|
||||
after: change.after,
|
||||
mode: change.mode,
|
||||
}, true
|
||||
case structuredPatchUpdate:
|
||||
if change.from.absolute != change.to.absolute {
|
||||
return structuredPatchChange{
|
||||
kind: structuredPatchAdd,
|
||||
to: change.to,
|
||||
after: change.after,
|
||||
mode: change.mode,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
return structuredPatchChange{}, false
|
||||
}
|
||||
|
||||
func incompleteStructuredPatchWrite(root *os.Root, change structuredPatchChange, published bool) structuredPatchChangeOutcome {
|
||||
if !published {
|
||||
return structuredPatchChangeOutcome{}
|
||||
}
|
||||
if completed, ok := completedStructuredPatchEffect(root, change); ok {
|
||||
return structuredPatchChangeOutcome{completed: []structuredPatchChange{completed}}
|
||||
}
|
||||
if change.to.relative != "" {
|
||||
return structuredPatchChangeOutcome{incompletePaths: []string{change.to.relative}}
|
||||
}
|
||||
return structuredPatchChangeOutcome{}
|
||||
}
|
||||
|
||||
func appendUniqueStructuredPatchPaths(existing []string, relativeRoot string, paths []string) []string {
|
||||
seen := make(map[string]bool, len(existing)+len(paths))
|
||||
for _, path := range existing {
|
||||
seen[path] = true
|
||||
}
|
||||
for _, path := range paths {
|
||||
if relativeRoot != "" && relativeRoot != "." {
|
||||
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
|
||||
}
|
||||
if path != "" && !seen[path] {
|
||||
seen[path] = true
|
||||
existing = append(existing, path)
|
||||
}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
|
||||
@@ -723,7 +869,17 @@ func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchC
|
||||
// deterministically; it is nil in production.
|
||||
var structuredPatchBeforeCommit func(change structuredPatchChange)
|
||||
|
||||
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
|
||||
// structuredPatchBeforeRename runs after a same-path update has staged and
|
||||
// closed its replacement but before the final preimage recheck and rename.
|
||||
// Tests use it to reproduce a competing writer deterministically.
|
||||
var structuredPatchBeforeRename func(change structuredPatchChange)
|
||||
|
||||
// structuredPatchRemove is a deterministic test seam for failures after a
|
||||
// move destination has been published. Production removes through the opened
|
||||
// workspace root.
|
||||
var structuredPatchRemove = func(root *os.Root, name string) error { return root.Remove(name) }
|
||||
|
||||
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (structuredPatchChangeOutcome, error) {
|
||||
if structuredPatchBeforeCommit != nil {
|
||||
structuredPatchBeforeCommit(change)
|
||||
}
|
||||
@@ -732,39 +888,63 @@ func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bo
|
||||
// by another process in between is refused rather than overwritten or
|
||||
// removed.
|
||||
if change.kind != structuredPatchAdd {
|
||||
current, err := root.ReadFile(change.from.relative)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
|
||||
}
|
||||
if string(current) != change.before {
|
||||
return false, fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
|
||||
if err := recheckStructuredPatchPreimage(root, change); err != nil {
|
||||
return structuredPatchChangeOutcome{}, err
|
||||
}
|
||||
}
|
||||
switch change.kind {
|
||||
case structuredPatchDelete:
|
||||
if err := root.Remove(change.from.relative); err != nil {
|
||||
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
|
||||
if err := structuredPatchRemove(root, change.from.relative); err != nil {
|
||||
return structuredPatchChangeOutcome{}, fmt.Errorf("deleting %s: %w", change.from.relative, err)
|
||||
}
|
||||
return true, nil
|
||||
return structuredPatchChangeOutcome{completed: []structuredPatchChange{change}}, nil
|
||||
case structuredPatchAdd, structuredPatchCopy:
|
||||
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
|
||||
var beforePublish func() error
|
||||
if change.kind == structuredPatchCopy {
|
||||
beforePublish = structuredPatchPrePublishGuard(root, change)
|
||||
}
|
||||
published, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, true, beforePublish)
|
||||
if err != nil {
|
||||
return incompleteStructuredPatchWrite(root, change, published), err
|
||||
}
|
||||
return structuredPatchChangeOutcome{completed: []structuredPatchChange{change}}, nil
|
||||
case structuredPatchUpdate:
|
||||
moving := change.from.absolute != change.to.absolute
|
||||
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
|
||||
published, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving, structuredPatchPrePublishGuard(root, change))
|
||||
if err != nil {
|
||||
return committed, err
|
||||
return incompleteStructuredPatchWrite(root, change, published), err
|
||||
}
|
||||
if moving {
|
||||
if err := root.Remove(change.from.relative); err != nil {
|
||||
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
|
||||
if err := structuredPatchRemove(root, change.from.relative); err != nil {
|
||||
return incompleteStructuredPatchWrite(root, change, true), fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
return structuredPatchChangeOutcome{completed: []structuredPatchChange{change}}, nil
|
||||
}
|
||||
return false, fmt.Errorf("unsupported structured patch operation")
|
||||
return structuredPatchChangeOutcome{}, fmt.Errorf("unsupported structured patch operation")
|
||||
}
|
||||
|
||||
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
|
||||
func recheckStructuredPatchPreimage(root *os.Root, change structuredPatchChange) error {
|
||||
current, info, err := readRootedFile(root, change.from.relative)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
|
||||
}
|
||||
if (change.beforeInfo != nil && !os.SameFile(change.beforeInfo, info)) || string(current) != change.before {
|
||||
return fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func structuredPatchPrePublishGuard(root *os.Root, change structuredPatchChange) func() error {
|
||||
return func() error {
|
||||
if change.kind == structuredPatchUpdate && change.from.absolute == change.to.absolute && structuredPatchBeforeRename != nil {
|
||||
structuredPatchBeforeRename(change)
|
||||
}
|
||||
return recheckStructuredPatchPreimage(root, change)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool, beforePublish func() error) (bool, error) {
|
||||
parent := filepath.Dir(target.relative)
|
||||
if err := root.MkdirAll(parent, 0o755); err != nil {
|
||||
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
|
||||
@@ -785,8 +965,13 @@ func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, conte
|
||||
if err := temp.Close(); err != nil {
|
||||
return false, fmt.Errorf("writing %s: %w", target.relative, err)
|
||||
}
|
||||
if beforePublish != nil {
|
||||
if err := beforePublish(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if createOnly {
|
||||
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
|
||||
return structuredPatchPublishNoReplace(root, tempName, target.relative, mode)
|
||||
}
|
||||
if err := root.Rename(tempName, target.relative); err != nil {
|
||||
return false, fmt.Errorf("writing %s: %w", target.relative, err)
|
||||
@@ -798,6 +983,11 @@ func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode
|
||||
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
|
||||
}
|
||||
|
||||
// structuredPatchPublishNoReplace is a deterministic test seam for failures
|
||||
// after a create-only target has become visible. Production uses the atomic
|
||||
// no-replace publisher above.
|
||||
var structuredPatchPublishNoReplace = publishStructuredPatchNoReplace
|
||||
|
||||
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
|
||||
if linkErr := link(source, target); linkErr == nil {
|
||||
cleanupErr := removeStructuredPatchTemp(root, source)
|
||||
@@ -914,9 +1104,19 @@ func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPa
|
||||
seen := make(map[string]bool)
|
||||
var paths []string
|
||||
for _, change := range changes {
|
||||
targets := []structuredPatchTarget{change.to}
|
||||
if change.from.absolute != change.to.absolute {
|
||||
targets = append([]structuredPatchTarget{change.from}, targets...)
|
||||
var targets []structuredPatchTarget
|
||||
switch change.kind {
|
||||
case structuredPatchDelete:
|
||||
targets = []structuredPatchTarget{change.from}
|
||||
case structuredPatchUpdate:
|
||||
targets = []structuredPatchTarget{change.to}
|
||||
if change.from.absolute != change.to.absolute {
|
||||
targets = append([]structuredPatchTarget{change.from}, targets...)
|
||||
}
|
||||
default:
|
||||
// Adds and copies mutate only their destination; the copy source is
|
||||
// evidence for the operation, not a changed file.
|
||||
targets = []structuredPatchTarget{change.to}
|
||||
}
|
||||
for _, target := range targets {
|
||||
path := target.relative
|
||||
|
||||
@@ -120,6 +120,10 @@ type Result struct {
|
||||
// entries under a granted extra write root are absolute, since
|
||||
// workspace-relative would be ambiguous there.
|
||||
ChangedFiles []string
|
||||
// FileDiffs carries exact, bounded before/after text for built-in file
|
||||
// mutations. A missing entry means the client must fall back to ChangedFiles
|
||||
// rather than inventing a partial diff.
|
||||
FileDiffs []FileDiff
|
||||
// ChangeSummaries contains bounded generated-tree changes. These are shown
|
||||
// in session evidence and the Files panel but are never treated as files to
|
||||
// open or diagnose individually.
|
||||
|
||||
@@ -12,6 +12,7 @@ type writeFileTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
scope PathScope
|
||||
readFile func(string) ([]byte, error)
|
||||
}
|
||||
|
||||
func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
@@ -34,6 +35,7 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
readFile: os.ReadFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +63,10 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
}
|
||||
|
||||
existed := false
|
||||
if _, err := os.Stat(absolutePath); err == nil {
|
||||
var priorInfo os.FileInfo
|
||||
if info, err := os.Stat(absolutePath); err == nil {
|
||||
existed = true
|
||||
priorInfo = info
|
||||
if !overwrite {
|
||||
return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.")
|
||||
}
|
||||
@@ -82,7 +86,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
|
||||
// the overwrite rather than clobbering a file whose current state is
|
||||
// unknown (it may have been replaced or removed out from under us).
|
||||
current, rerr := os.ReadFile(absolutePath)
|
||||
current, rerr := tool.readFile(absolutePath)
|
||||
if rerr != nil {
|
||||
return errorResult(fileConflictMessage(relativePath))
|
||||
}
|
||||
@@ -95,9 +99,11 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
// Capture the prior content (before we replace it) so an overwrite can show a
|
||||
// real diff; a fresh create stays "" and previews as all-additions.
|
||||
priorContent := ""
|
||||
priorContentKnown := !existed
|
||||
if existed {
|
||||
if prev, rerr := os.ReadFile(absolutePath); rerr == nil {
|
||||
if prev, rerr := tool.readFile(absolutePath); rerr == nil {
|
||||
priorContent = string(prev)
|
||||
priorContentKnown = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,20 +113,36 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
||||
var expectedContent *string
|
||||
if priorContentKnown {
|
||||
expectedContent = &priorContent
|
||||
}
|
||||
if err := commitFileContents(absolutePath, priorInfo, expectedContent, content); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
modelKnownContent := content
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
formatting := maybeFormatWrittenFile(ctx, absolutePath, content)
|
||||
restoreMode := os.FileMode(0o644)
|
||||
if priorInfo != nil {
|
||||
restoreMode = priorInfo.Mode().Perm()
|
||||
}
|
||||
formatting := maybeFormatWrittenFileScoped(ctx, tool.workspaceRoot, tool.scope, absolutePath, content, restoreMode)
|
||||
content = formatting.Content
|
||||
finalContentKnown := formatting.ContentKnown
|
||||
// Baseline the freshly written content so a later edit/overwrite in this
|
||||
// session compares against what is now on disk.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
|
||||
if content == modelKnownContent {
|
||||
newInfo := formatting.Info
|
||||
if newInfo == nil {
|
||||
newInfo, _ = os.Stat(absolutePath)
|
||||
}
|
||||
if finalContentKnown {
|
||||
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
|
||||
} else {
|
||||
options.FileTracker.Forget(absolutePath)
|
||||
}
|
||||
if finalContentKnown && content == modelKnownContent {
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content))
|
||||
}
|
||||
if !existed {
|
||||
@@ -139,13 +161,28 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
}
|
||||
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
|
||||
summary += formatting.notice(relativePath)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
if finalContentKnown {
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
}
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Do not pretend an unreadable overwrite was a creation. The write may be
|
||||
// valid, but ACP only receives an exact before/after pair we actually saw.
|
||||
if priorContentKnown && finalContentKnown {
|
||||
if diff, ok := boundedFileDiff(absolutePath, priorContent, content, existed, true); ok {
|
||||
result.FileDiffs = []FileDiff{diff}
|
||||
} else if diffTextRevealsObfuscatedSecret(priorContent) || diffTextRevealsObfuscatedSecret(content) {
|
||||
result.Redacted = true
|
||||
}
|
||||
}
|
||||
// Card-only preview: a real unified diff (all-green for a create, red/green for
|
||||
// an overwrite) on Display.Preview. Output stays the summary, so the model never
|
||||
// re-reads the file — the rich preview costs zero model tokens.
|
||||
result.Display = Display{Summary: summary, Kind: "file", Preview: boundedUnifiedDiff(relativePath, priorContent, content)}
|
||||
preview := ""
|
||||
if priorContentKnown && finalContentKnown {
|
||||
preview = boundedUnifiedDiff(relativePath, priorContent, content)
|
||||
}
|
||||
result.Display = Display{Summary: summary, Kind: "file", Preview: preview}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -323,6 +325,10 @@ func TestEditFileToolReplacesExactStrings(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "code.go")
|
||||
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
|
||||
path, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "code.go",
|
||||
@@ -367,7 +373,12 @@ func TestEditFileToolReplacesCRLF(t *testing.T) {
|
||||
|
||||
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
|
||||
path := filepath.Join(root, "code.go")
|
||||
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
|
||||
path, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "code.go", "old_string": "const a = 1", "new_string": "const a = 42",
|
||||
})
|
||||
@@ -387,6 +398,9 @@ func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
|
||||
t.Fatalf("edit preview missing diff marker %q: %q", want, res.Display.Preview)
|
||||
}
|
||||
}
|
||||
if got := res.FileDiffs; len(got) != 1 || got[0].Path != path || !got[0].OldExists || !got[0].NewExists || got[0].OldText != "const a = 1\nconst b = 2\n" || got[0].NewText != "const a = 42\nconst b = 2\n" {
|
||||
t.Fatalf("file diffs = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolEmitsAdditionsDiff(t *testing.T) {
|
||||
@@ -408,6 +422,13 @@ func TestWriteFileToolEmitsAdditionsDiff(t *testing.T) {
|
||||
if strings.Contains(res.Display.Preview, "\n-line") {
|
||||
t.Fatalf("a fresh-create diff must have no removed lines: %q", res.Display.Preview)
|
||||
}
|
||||
path, err := filepath.EvalSymlinks(filepath.Join(root, "new.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := res.FileDiffs; len(got) != 1 || got[0].Path != path || got[0].OldExists || !got[0].NewExists || got[0].OldText != "" || got[0].NewText != "line one\nline two\n" {
|
||||
t.Fatalf("file diffs = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolOverwriteEmitsRedGreenDiff(t *testing.T) {
|
||||
@@ -429,6 +450,28 @@ func TestWriteFileToolOverwriteEmitsRedGreenDiff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileToolOmitsDiffWhenOverwritePreimageCannotBeRead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "private.txt")
|
||||
writeTestFile(t, path, "before\n")
|
||||
tool := NewScopedWriteFileTool(root, nil).(writeFileTool)
|
||||
tool.readFile = func(string) ([]byte, error) { return nil, os.ErrPermission }
|
||||
registry := NewRegistry()
|
||||
registry.Register(tool)
|
||||
result := registry.RunWithOptions(context.Background(), tool.Name(), map[string]any{
|
||||
"path": "private.txt", "content": "after\n", "overwrite": true,
|
||||
}, RunOptions{PermissionGranted: true})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("write = %s", result.Output)
|
||||
}
|
||||
if len(result.FileDiffs) != 0 {
|
||||
t.Fatalf("unreadable preimage must not produce a create-like diff: %#v", result.FileDiffs)
|
||||
}
|
||||
if got, err := os.ReadFile(path); err != nil || string(got) != "after\n" {
|
||||
t.Fatalf("written content = %q, err = %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolAllowsDeletingRegions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "notes.txt")
|
||||
@@ -584,6 +627,17 @@ func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
|
||||
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
|
||||
t.Fatalf("ChangedFiles = %v", got)
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := result.FileDiffs, []FileDiff{
|
||||
{Path: filepath.Join(resolvedRoot, "nested", "new.txt"), OldExists: false, NewExists: true, OldText: "", NewText: "created\n"},
|
||||
{Path: filepath.Join(resolvedRoot, "old.txt"), OldExists: true, NewExists: false, OldText: "old\n", NewText: ""},
|
||||
{Path: filepath.Join(resolvedRoot, "moved.txt"), OldExists: false, NewExists: true, OldText: "", NewText: "moved\n"},
|
||||
}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("FileDiffs = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
|
||||
@@ -831,7 +885,7 @@ func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
|
||||
after: "patch content\n", mode: 0o644,
|
||||
}
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
|
||||
if err == nil || !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
|
||||
}
|
||||
@@ -857,7 +911,7 @@ func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
|
||||
before: "removed by another writer\n", mode: 0o644,
|
||||
}
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("delete of an already removed file should fail")
|
||||
}
|
||||
@@ -894,7 +948,7 @@ func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testin
|
||||
}
|
||||
defer func() { structuredPatchBeforeCommit = nil }()
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
|
||||
if !removed {
|
||||
t.Fatal("pre-commit hook did not run")
|
||||
}
|
||||
@@ -909,6 +963,59 @@ func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchUpdatePreservesCompetingWriteBeforeRename(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
competing string
|
||||
replaceIdentity bool
|
||||
}{
|
||||
{name: "content changed", competing: "competing writer\n"},
|
||||
{name: "identity changed with same content", competing: "planned\n", replaceIdentity: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "file.txt")
|
||||
writeTestFile(t, targetPath, "planned\n")
|
||||
patch := "*** Begin Patch\n*** Update File: file.txt\n@@\n-planned\n+patched\n*** End Patch\n"
|
||||
hookRan := false
|
||||
var competingInfo os.FileInfo
|
||||
structuredPatchBeforeRename = func(change structuredPatchChange) {
|
||||
if tc.replaceIdentity {
|
||||
tempPath := filepath.Join(root, "competing.tmp")
|
||||
writeTestFile(t, tempPath, tc.competing)
|
||||
if err := os.Rename(tempPath, targetPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err := os.WriteFile(targetPath, []byte(tc.competing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
competingInfo, err = os.Stat(targetPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hookRan = true
|
||||
}
|
||||
t.Cleanup(func() { structuredPatchBeforeRename = nil })
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if !hookRan {
|
||||
t.Fatal("pre-rename hook did not run")
|
||||
}
|
||||
if result.Status != StatusError || len(result.ChangedFiles) != 0 || len(result.FileDiffs) != 0 {
|
||||
t.Fatalf("raced update = status=%s changed=%#v diffs=%#v output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Output)
|
||||
}
|
||||
if got := mustReadTestFile(t, targetPath); got != tc.competing {
|
||||
t.Fatalf("raced update overwrote competing content: %q", got)
|
||||
}
|
||||
finalInfo, err := os.Stat(targetPath)
|
||||
if err != nil || !os.SameFile(competingInfo, finalInfo) {
|
||||
t.Fatalf("raced update replaced the competing file identity: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace, err := os.OpenRoot(root)
|
||||
@@ -978,6 +1085,45 @@ func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncompleteStructuredPatchNoReplaceWriteReportsOnlyVerifiedTarget(t *testing.T) {
|
||||
for name, content := range map[string]string{
|
||||
"complete publication": "complete content\n",
|
||||
"partial publication": "partial",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
writeTestFile(t, targetPath, content)
|
||||
change := structuredPatchChange{
|
||||
kind: structuredPatchAdd,
|
||||
to: structuredPatchTarget{
|
||||
requested: "target.txt",
|
||||
relative: "target.txt",
|
||||
absolute: targetPath,
|
||||
},
|
||||
after: "complete content\n",
|
||||
mode: 0o644,
|
||||
}
|
||||
|
||||
outcome := incompleteStructuredPatchWrite(workspace, change, true)
|
||||
if content == change.after {
|
||||
if len(outcome.completed) != 1 || outcome.completed[0].kind != structuredPatchAdd || len(outcome.incompletePaths) != 0 {
|
||||
t.Fatalf("verified publication outcome = %#v", outcome)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(outcome.completed) != 0 || !slices.Equal(outcome.incompletePaths, []string{"target.txt"}) {
|
||||
t.Fatalf("partial publication outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
trackedPath := filepath.Join(root, "tracked.txt")
|
||||
@@ -1017,7 +1163,7 @@ func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState
|
||||
},
|
||||
}
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, changes, tracker)
|
||||
_, err = applyStructuredPatchChanges(workspace, ".", changes, tracker)
|
||||
if err == nil || !strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("second change = %v, want partial-application error", err)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (m model) attachmentThumbnailVisible(width int) bool {
|
||||
}
|
||||
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
|
||||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
|
||||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
|
||||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -30,15 +32,21 @@ func (m model) backgroundTerminalSessions() []tools.ExecSessionSnapshot {
|
||||
}
|
||||
|
||||
func (m model) backgroundTerminalSummary() string {
|
||||
count := len(m.backgroundTerminalSessions())
|
||||
if count == 0 {
|
||||
sessions := m.backgroundTerminalSessions()
|
||||
if len(sessions) == 0 {
|
||||
return ""
|
||||
}
|
||||
plural := ""
|
||||
if count != 1 {
|
||||
if len(sessions) != 1 {
|
||||
plural = "s"
|
||||
}
|
||||
return fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", count, plural)
|
||||
summary := fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", len(sessions), plural)
|
||||
for _, session := range sessions {
|
||||
if session.TTY {
|
||||
return summary + " · /attach to type into it"
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (m model) stopAllBackgroundTerminalSessions() []int {
|
||||
@@ -71,6 +79,12 @@ func (m model) backgroundTerminalsText() string {
|
||||
Rows: rows,
|
||||
}}
|
||||
card.Actions = []string{"/stop <session_id>", "/stop"}
|
||||
for _, session := range sessions {
|
||||
if session.TTY {
|
||||
card.Actions = append([]string{"/attach <session_id>"}, card.Actions...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return renderCommandCardTranscript(card)
|
||||
}
|
||||
|
||||
@@ -112,6 +126,82 @@ func (m model) stopBackgroundTerminalsText(input string) string {
|
||||
return renderStopBackgroundTerminalsCard([]int{id}, "")
|
||||
}
|
||||
|
||||
// attachTerminalCommand implements /attach: with an explicit session id it
|
||||
// validates and opens the attach overlay; bare, it picks the one interactive
|
||||
// session or lists the choices.
|
||||
func (m model) attachTerminalCommand(input string) (model, tea.Cmd) {
|
||||
controller, ok := m.execSessionController()
|
||||
if !ok {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
|
||||
Title: "Background Terminals",
|
||||
Summary: []string{"unavailable"},
|
||||
Sections: []commandCardSection{{
|
||||
Rows: []commandRow{{Text: "exec_command is not registered."}},
|
||||
}},
|
||||
})})
|
||||
return m, nil
|
||||
}
|
||||
// The overlay composites into the main transcript viewport; in a view that
|
||||
// replaces it (subchat, detailed transcript, another overlay) it would
|
||||
// capture keystrokes while invisible, so refuse to open there.
|
||||
if !m.terminalAttachCanOpen() {
|
||||
return m.showTransientNoticeInline("Leave the current view first", transientNoticeInfo), nil
|
||||
}
|
||||
input = strings.TrimSpace(input)
|
||||
if input != "" {
|
||||
id, err := strconv.Atoi(input)
|
||||
if err != nil || id <= 0 {
|
||||
return m.appendAttachCard("invalid session id", "Usage: /attach [session_id]"), nil
|
||||
}
|
||||
snapshot, found := controller.ExecSession(id)
|
||||
if !found {
|
||||
return m.appendAttachCard("not found", fmt.Sprintf("No running terminal session %d.", id)), nil
|
||||
}
|
||||
if !snapshot.TTY {
|
||||
return m.appendAttachCard("not interactive", fmt.Sprintf("Session %d has no terminal — it was started without tty:true (or this platform has no PTY), so it can't take input. Use /stop %d to end it.", id, id)), nil
|
||||
}
|
||||
return m.openTerminalAttach(id)
|
||||
}
|
||||
var interactive []tools.ExecSessionSnapshot
|
||||
for _, session := range controller.ExecSessions() {
|
||||
if session.TTY {
|
||||
interactive = append(interactive, session)
|
||||
}
|
||||
}
|
||||
switch len(interactive) {
|
||||
case 0:
|
||||
return m.showTransientNoticeInline("No interactive terminal sessions running.", transientNoticeInfo), nil
|
||||
case 1:
|
||||
return m.openTerminalAttach(interactive[0].ID)
|
||||
}
|
||||
rows := make([]commandRow, 0, len(interactive))
|
||||
now := m.now()
|
||||
for _, session := range interactive {
|
||||
rows = append(rows, commandRow{Text: formatBackgroundTerminalRow(session, now)})
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
|
||||
Title: "Background Terminals",
|
||||
Summary: []string{fmt.Sprintf("%d interactive sessions", len(interactive))},
|
||||
Sections: []commandCardSection{{
|
||||
Title: "running",
|
||||
Rows: rows,
|
||||
}},
|
||||
Actions: []string{"/attach <session_id>"},
|
||||
})})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) appendAttachCard(summary string, text string) model {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
|
||||
Title: "Background Terminals",
|
||||
Summary: []string{summary},
|
||||
Sections: []commandCardSection{{
|
||||
Rows: []commandRow{{Text: text}},
|
||||
}},
|
||||
})})
|
||||
return m
|
||||
}
|
||||
|
||||
func renderStopBackgroundTerminalsCard(stopped []int, note string) string {
|
||||
card := commandCard{Title: "Background Terminals"}
|
||||
if len(stopped) == 0 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -13,6 +14,11 @@ type fakeExecSessionTool struct {
|
||||
sessions []tools.ExecSessionSnapshot
|
||||
stopped []int
|
||||
stopAll bool
|
||||
writes [][]byte
|
||||
|
||||
resizeCalls int
|
||||
resizeCols int
|
||||
resizeRows int
|
||||
}
|
||||
|
||||
func (tool *fakeExecSessionTool) Name() string { return tools.ExecCommandToolName }
|
||||
@@ -35,6 +41,15 @@ func (tool *fakeExecSessionTool) ExecSessions() []tools.ExecSessionSnapshot {
|
||||
return append([]tools.ExecSessionSnapshot(nil), tool.sessions...)
|
||||
}
|
||||
|
||||
func (tool *fakeExecSessionTool) ExecSession(id int) (tools.ExecSessionSnapshot, bool) {
|
||||
for _, session := range tool.sessions {
|
||||
if session.ID == id {
|
||||
return session, true
|
||||
}
|
||||
}
|
||||
return tools.ExecSessionSnapshot{}, false
|
||||
}
|
||||
|
||||
func (tool *fakeExecSessionTool) StopExecSession(id int) bool {
|
||||
for _, session := range tool.sessions {
|
||||
if session.ID == id {
|
||||
@@ -54,6 +69,35 @@ func (tool *fakeExecSessionTool) StopAllExecSessions() []int {
|
||||
return ids
|
||||
}
|
||||
|
||||
func (tool *fakeExecSessionTool) WriteExecSessionInput(id int, data []byte) error {
|
||||
for _, session := range tool.sessions {
|
||||
if session.ID != id {
|
||||
continue
|
||||
}
|
||||
if !session.TTY {
|
||||
return execution.ErrProcessStdinDisabled
|
||||
}
|
||||
tool.writes = append(tool.writes, append([]byte(nil), data...))
|
||||
return nil
|
||||
}
|
||||
return execution.ErrProcessNotFound
|
||||
}
|
||||
|
||||
func (tool *fakeExecSessionTool) ResizeExecSession(id int, cols, rows int) error {
|
||||
for _, session := range tool.sessions {
|
||||
if session.ID != id {
|
||||
continue
|
||||
}
|
||||
if !session.TTY {
|
||||
return execution.ErrProcessStdinDisabled
|
||||
}
|
||||
tool.resizeCalls++
|
||||
tool.resizeCols, tool.resizeRows = cols, rows
|
||||
return nil
|
||||
}
|
||||
return execution.ErrProcessNotFound
|
||||
}
|
||||
|
||||
func modelWithFakeExecSessions(tool *fakeExecSessionTool, now time.Time) model {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tool)
|
||||
@@ -127,6 +171,103 @@ func TestStopBackgroundTerminalsTextRejectsInvalidSessionID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundTerminalsTextAdvertisesAttachForTTY(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
m := modelWithFakeExecSessions(&fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{{
|
||||
ID: 1000, Command: "sudo apt install x", StartedAt: now, Status: "running", TTY: true,
|
||||
}},
|
||||
}, now)
|
||||
|
||||
text := m.backgroundTerminalsText()
|
||||
if !strings.Contains(text, "/attach <session_id>") {
|
||||
t.Fatalf("/ps card should offer /attach for a tty session:\n%s", text)
|
||||
}
|
||||
if summary := m.backgroundTerminalSummary(); !strings.HasSuffix(summary, " · /attach to type into it") {
|
||||
t.Fatalf("summary = %q, want /attach hint", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandNeedsController(t *testing.T) {
|
||||
m := model{now: func() time.Time { return time.Unix(100, 0) }}
|
||||
next, _ := m.attachTerminalCommand("7")
|
||||
if !transcriptContains(next.transcript, "exec_command is not registered.") {
|
||||
t.Fatalf("transcript = %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandExplicitID(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
|
||||
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, now)
|
||||
|
||||
next, cmd := m.attachTerminalCommand("7")
|
||||
if cmd == nil || next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
|
||||
t.Fatalf("expected attach to session 7, state=%#v cmd=%v", next.terminalAttach, cmd)
|
||||
}
|
||||
|
||||
next, _ = m.attachTerminalCommand("8")
|
||||
if !transcriptContains(next.transcript, "Session 8 has no terminal") {
|
||||
t.Fatalf("non-tty session should be refused: %#v", next.transcript)
|
||||
}
|
||||
next, _ = m.attachTerminalCommand("9")
|
||||
if !transcriptContains(next.transcript, "No running terminal session 9.") {
|
||||
t.Fatalf("missing session should be reported: %#v", next.transcript)
|
||||
}
|
||||
next, _ = m.attachTerminalCommand("abc")
|
||||
if !transcriptContains(next.transcript, "Usage: /attach [session_id]") {
|
||||
t.Fatalf("invalid id should show usage: %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandBarePicksSingleTTY(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
|
||||
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, now)
|
||||
|
||||
next, _ := m.attachTerminalCommand("")
|
||||
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
|
||||
t.Fatalf("bare /attach should pick the only tty session, state=%#v", next.terminalAttach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandBareNoneOrSeveral(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
m := modelWithFakeExecSessions(&fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
|
||||
},
|
||||
}, now)
|
||||
next, _ := m.attachTerminalCommand("")
|
||||
if next.terminalAttach != nil || !strings.Contains(next.transientNotice.text, "No interactive terminal sessions running.") {
|
||||
t.Fatalf("bare /attach with no tty sessions: notice=%q state=%#v", next.transientNotice.text, next.terminalAttach)
|
||||
}
|
||||
|
||||
m = modelWithFakeExecSessions(&fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
|
||||
{ID: 9, TTY: true, Status: "running", Command: "top", StartedAt: now},
|
||||
},
|
||||
}, now)
|
||||
next, _ = m.attachTerminalCommand("")
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("bare /attach with several tty sessions should list, not attach")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "/attach <session_id>") || !transcriptContains(next.transcript, "cat") || !transcriptContains(next.transcript, "top") {
|
||||
t.Fatalf("expected a choice card listing both sessions: %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitStopsBackgroundTerminals(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{{ID: 1000, StartedAt: time.Unix(100, 0), Status: "running"}},
|
||||
|
||||
@@ -306,6 +306,10 @@ func btwMessageRunID(msg tea.Msg) (int, bool) {
|
||||
return typed.runID, true
|
||||
case toolCallStreamStartMsg:
|
||||
return typed.runID, true
|
||||
case interactiveExecStartMsg:
|
||||
return typed.runID, true
|
||||
case terminalAutoAttachTickMsg:
|
||||
return typed.runID, true
|
||||
case toolCallStreamDeltaMsg:
|
||||
return typed.runID, true
|
||||
case planUpdateMsg:
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
@@ -365,17 +366,30 @@ func (defaultModelSwitchCompactionPolicy) BeforeModelSwitch(request modelSwitchC
|
||||
|
||||
var modelSwitchCompactionGuard modelSwitchCompactionPolicy = defaultModelSwitchCompactionPolicy{}
|
||||
|
||||
// sanitizeCardField strips the card protocol's separator bytes from
|
||||
// user-controlled values (titles can legally contain anything --session-title
|
||||
// was given), so a hostile or accidental \x1f / newline cannot shift fields
|
||||
// or leak control characters into the transcript.
|
||||
// sanitizeCardField strips the card protocol's separator byte and every
|
||||
// control rune from user-controlled values (titles can legally contain
|
||||
// anything --session-title was given, and a directory name can legally
|
||||
// contain an ESC byte on Unix). Line separators and tabs become spaces so
|
||||
// words stay apart; the remaining controls — ESC/CSI/OSC initiators, BEL, BS,
|
||||
// VT, NEL, NUL — are dropped outright, since any of them can repaint the
|
||||
// terminal or shift cells when the value lands in a rendered row.
|
||||
func sanitizeCardField(value string) string {
|
||||
value = strings.ReplaceAll(value, sessionsCardFieldSep, " ")
|
||||
value = strings.ReplaceAll(value, "\n", " ")
|
||||
return strings.ReplaceAll(value, "\x00", "")
|
||||
var out strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == '\x1f' || r == '\n' || r == '\r' || r == '\t':
|
||||
out.WriteRune(' ')
|
||||
case unicode.IsControl(r):
|
||||
default:
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"); ""
|
||||
// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"), falling
|
||||
// back to the month/day this year and the bare date for sessions older than a
|
||||
// month, so deep history reads as a calendar date rather than "400d ago"; ""
|
||||
// when the timestamp does not parse, so the card simply omits it.
|
||||
func relativeAge(timestamp string, now time.Time) string {
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
|
||||
@@ -385,14 +399,19 @@ func relativeAge(timestamp string, now time.Time) string {
|
||||
age := now.Sub(parsed)
|
||||
switch {
|
||||
case age < time.Minute:
|
||||
return "just now"
|
||||
return "now"
|
||||
case age < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(age.Minutes()))
|
||||
case age < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(age.Hours()))
|
||||
default:
|
||||
case age <= 30*24*time.Hour:
|
||||
return fmt.Sprintf("%dd ago", int(age.Hours()/24))
|
||||
}
|
||||
parsed, now = parsed.Local(), now.Local()
|
||||
if parsed.Year() == now.Year() {
|
||||
return parsed.Format("Jan _2")
|
||||
}
|
||||
return parsed.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// handleModelCommand applies a model switch against the ACTIVE provider (the
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
commandPermissions
|
||||
commandPS
|
||||
commandStop
|
||||
commandAttach
|
||||
commandSandboxSetup
|
||||
commandProvider
|
||||
commandModel
|
||||
@@ -140,6 +141,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Stop running background terminal sessions.",
|
||||
kind: commandStop,
|
||||
},
|
||||
{
|
||||
name: "/attach",
|
||||
usage: "/attach [session_id]",
|
||||
group: commandGroupRuntime,
|
||||
description: "Type directly into a running interactive terminal session.",
|
||||
kind: commandAttach,
|
||||
},
|
||||
{
|
||||
name: "/sandbox-setup",
|
||||
usage: "/sandbox-setup",
|
||||
|
||||
@@ -977,6 +977,8 @@ func TestParseBackgroundTerminalCommands(t *testing.T) {
|
||||
{input: "/ps", kind: commandPS, text: ""},
|
||||
{input: "/stop", kind: commandStop, text: ""},
|
||||
{input: "/stop 1000", kind: commandStop, text: "1000"},
|
||||
{input: "/attach", kind: commandAttach, text: ""},
|
||||
{input: "/attach 7", kind: commandAttach, text: "7"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := parseCommand(tc.input)
|
||||
|
||||
+63
-1
@@ -253,6 +253,9 @@ type model struct {
|
||||
composerSelection composerSelectionState
|
||||
dictation dictationController
|
||||
sttKeyPrompt *sttKeyPromptState
|
||||
terminalAttach *terminalAttachState
|
||||
terminalAutoAttach *terminalAutoAttachState
|
||||
terminalAttachSeen map[int]bool
|
||||
// plan holds the sticky plan panel state (steps, expansion, timings)
|
||||
// synced from the update_plan tool. See plan_panel.go.
|
||||
plan planPanelState
|
||||
@@ -1245,7 +1248,7 @@ func (m *model) stopPRWatcher() {
|
||||
func (m model) noBlockingModal() bool {
|
||||
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
|
||||
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
|
||||
m.sttKeyPrompt == nil && m.renamePrompt == nil
|
||||
m.sttKeyPrompt == nil && m.renamePrompt == nil && m.terminalAttach == nil
|
||||
}
|
||||
|
||||
func (m model) quit() (tea.Model, tea.Cmd) {
|
||||
@@ -1447,6 +1450,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.MouseMsg:
|
||||
// Attached to a live terminal: the overlay owns the viewport, so mouse
|
||||
// events go nowhere rather than hitting transcript selection below.
|
||||
if m.terminalAttach != nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.setup.visible {
|
||||
return m.handleSetupMouse(msg)
|
||||
}
|
||||
@@ -1533,6 +1541,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m.attachClipboardImage(msg.data, msg.mediaType), nil
|
||||
case tea.PasteMsg:
|
||||
// While attached to a live terminal, a paste forwards verbatim to the
|
||||
// PTY stdin — never into the composer or transcript.
|
||||
if m.terminalAttach != nil {
|
||||
return m.handleTerminalAttachPaste(msg.Content)
|
||||
}
|
||||
// A paste into the cloud-STT key prompt fills the key (the common way to
|
||||
// enter an API key), not the composer.
|
||||
if m.sttKeyPrompt != nil {
|
||||
@@ -1565,6 +1578,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.handleVoiceCaptureRelease()
|
||||
}
|
||||
return m, nil
|
||||
case terminalAttachTickMsg:
|
||||
return m.refreshTerminalAttach()
|
||||
case interactiveExecStartMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
return m, nil
|
||||
}
|
||||
m.terminalAutoAttach = &terminalAutoAttachState{
|
||||
runID: msg.runID,
|
||||
known: msg.known,
|
||||
deadline: m.now().Add(terminalAutoAttachTimeout),
|
||||
}
|
||||
return m, terminalAutoAttachTickCmd(msg.runID)
|
||||
case terminalAutoAttachTickMsg:
|
||||
return m.pollTerminalAutoAttach(msg)
|
||||
case tea.KeyPressMsg:
|
||||
if m.petDragActive {
|
||||
pixelDrag := m.petPixelDrag
|
||||
@@ -1604,6 +1631,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.sttKeyPrompt != nil {
|
||||
return m.handleSTTKeyPromptKey(msg)
|
||||
}
|
||||
// Attached to a live terminal session: keystrokes (including Ctrl+C,
|
||||
// which maps to 0x03 for the process) go to the PTY until Esc detaches.
|
||||
if m.terminalAttach != nil {
|
||||
return m.handleTerminalAttachKey(msg)
|
||||
}
|
||||
if m.renamePrompt != nil {
|
||||
return m.handleSessionRenameKey(msg)
|
||||
}
|
||||
@@ -2457,6 +2489,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Size the composer so long input scrolls horizontally with the cursor
|
||||
// visible instead of being clipped invisibly past the right edge.
|
||||
m.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
|
||||
// An attached terminal fills the viewport, so the PTY tracks the
|
||||
// live size instead of a fixed default.
|
||||
m = m.resizeAttachedTerminalPTY()
|
||||
// The title bar prints once into native scrollback when the inline
|
||||
// renderer is active. In alt-screen mode it stays pinned inside View.
|
||||
if !m.altScreen && !m.headerPrinted && msg.Width > 0 {
|
||||
@@ -2632,6 +2667,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.runCancel = nil
|
||||
m.activeRunID = 0
|
||||
m.terminalAutoAttach = nil
|
||||
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
|
||||
// A fully successful turn means the task is done. Weaker models often
|
||||
// forget the final update_plan, leaving the panel stuck mid-progress;
|
||||
@@ -3135,8 +3171,11 @@ func (m model) transcriptView() string {
|
||||
mcpOverlay := m.mcpManagerOverlay(width)
|
||||
pickerOverlay := m.pickerOverlay(width)
|
||||
sttKeyOverlay := m.sttKeyPromptOverlay(width)
|
||||
terminalAttachOverlay := m.terminalAttachOverlay(width)
|
||||
viewportOverlay := ""
|
||||
switch {
|
||||
case terminalAttachOverlay != "":
|
||||
viewportOverlay = terminalAttachOverlay
|
||||
case sttKeyOverlay != "":
|
||||
viewportOverlay = sttKeyOverlay
|
||||
case helpOverlayContent != "":
|
||||
@@ -3204,6 +3243,13 @@ func (m model) pinnedTitleBar(width int) string {
|
||||
|
||||
func (m model) footerView(width int) string {
|
||||
var footer strings.Builder
|
||||
// An attached terminal owns the keyboard and the viewport; the composer is
|
||||
// inert, so like the ask_user and permission modals only the status line
|
||||
// renders. Frame math routes through footerView, so this also shrinks the
|
||||
// footer rect the overlay's row budget is derived from.
|
||||
if m.terminalAttach != nil {
|
||||
return m.footerStatusLine(width)
|
||||
}
|
||||
if m.renamePrompt != nil {
|
||||
footer.WriteString(m.sessionRenamePromptView(width))
|
||||
footer.WriteString("\n")
|
||||
@@ -4739,6 +4785,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
case commandStop:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.stopBackgroundTerminalsText(command.text)})
|
||||
return m, nil
|
||||
case commandAttach:
|
||||
return m.attachTerminalCommand(command.text)
|
||||
case commandSandboxSetup:
|
||||
return m.startSandboxSetupCommand(command.text)
|
||||
case commandProvider:
|
||||
@@ -5240,6 +5288,7 @@ func (m model) beginRun(cancel context.CancelFunc) model {
|
||||
}
|
||||
m.runID++
|
||||
m.activeRunID = m.runID
|
||||
m.terminalAutoAttach = nil
|
||||
m.runCancel = cancel
|
||||
m.pending = true
|
||||
// Clear per-run tracking state so stale specialists and plans from the
|
||||
@@ -5397,6 +5446,7 @@ func (m *model) cancelRun() {
|
||||
m.pending = false
|
||||
m.runCancel = nil
|
||||
m.activeRunID = 0
|
||||
m.terminalAutoAttach = nil
|
||||
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
|
||||
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
|
||||
m.pendingPermission = nil
|
||||
@@ -5760,6 +5810,18 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
// live status. The child session ID is not known yet (it's created
|
||||
// inside the executor), so we use the tool call ID as a temporary
|
||||
// key and reconcile on the result.
|
||||
// A tty exec_command wants the attach overlay: the session registers
|
||||
// with the process manager inside the tool's Run, so the update loop
|
||||
// polls for it on a tick.
|
||||
if call.Name == tools.ExecCommandToolName && execCallWantsTTY(call.Arguments) && m.runtimeMessageSink != nil {
|
||||
known := map[int]bool{}
|
||||
if controller, ok := m.execSessionController(); ok {
|
||||
for _, session := range controller.ExecSessions() {
|
||||
known[session.ID] = true
|
||||
}
|
||||
}
|
||||
m.runtimeMessageSink(interactiveExecStartMsg{runID: runID, known: known})
|
||||
}
|
||||
if call.Name == "Task" {
|
||||
name, desc := parseTaskCallArgs(call.Arguments)
|
||||
if m.runtimeMessageSink != nil {
|
||||
|
||||
@@ -1087,16 +1087,16 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionPickerLabelAlignsTitles(t *testing.T) {
|
||||
today := sessionPickerLabel("20:47:50", "Today title")
|
||||
older := sessionPickerLabel("Jul 24 10:47", "Older title")
|
||||
recent := sessionPickerLabel("2h ago", "Recent title")
|
||||
older := sessionPickerLabel("2006-01-02", "Older title")
|
||||
|
||||
todayColumn := strings.Index(today, "Today title")
|
||||
recentColumn := strings.Index(recent, "Recent title")
|
||||
olderColumn := strings.Index(older, "Older title")
|
||||
if todayColumn < 0 || olderColumn < 0 {
|
||||
t.Fatalf("sessionPickerLabel omitted a title: today=%q older=%q", today, older)
|
||||
if recentColumn < 0 || olderColumn < 0 {
|
||||
t.Fatalf("sessionPickerLabel omitted a title: recent=%q older=%q", recent, older)
|
||||
}
|
||||
if todayColumn != olderColumn {
|
||||
t.Fatalf("title columns differ: today=%d (%q), older=%d (%q)", todayColumn, today, olderColumn, older)
|
||||
if recentColumn != olderColumn {
|
||||
t.Fatalf("title columns differ: recent=%d (%q), older=%d (%q)", recentColumn, recent, olderColumn, older)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -299,7 +299,7 @@ func (m model) wantsMouseCapture() bool {
|
||||
if m.mouseReleased {
|
||||
return false // user released the mouse for native text selection/copy
|
||||
}
|
||||
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive())
|
||||
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.terminalAttach != nil || m.suggestionsActive())
|
||||
}
|
||||
|
||||
func (m model) setupWantsMouseCapture() bool {
|
||||
@@ -513,10 +513,11 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
|
||||
return mouseSelectionTarget{}, false
|
||||
}
|
||||
}
|
||||
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
|
||||
maxVisible := pickerMaxVisible(m.picker, width)
|
||||
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
|
||||
start := selectableListStart(len(m.picker.items), maxVisible, selected)
|
||||
visible := m.picker.items[start : start+maxVisible]
|
||||
details := pickerShowsDetails(m.picker, width)
|
||||
// y=0 titled top border, y=1 search line, y=2 separator; rows begin at y=3.
|
||||
line := 3
|
||||
lastGroup := ""
|
||||
@@ -528,12 +529,18 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
|
||||
line++
|
||||
lastGroup = item.Group
|
||||
}
|
||||
if hit.y == line {
|
||||
// A rendered detail line belongs to its item: a click anywhere on the
|
||||
// two-line block selects the same row.
|
||||
rowLines := 1
|
||||
if details && item.Detail != "" {
|
||||
rowLines = 2
|
||||
}
|
||||
if hit.y >= line && hit.y < line+rowLines {
|
||||
index := start + offset
|
||||
m.picker.selected = index
|
||||
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
|
||||
}
|
||||
line++
|
||||
line += rowLines
|
||||
}
|
||||
return mouseSelectionTarget{}, false
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ func (m model) petLayoutActive() bool {
|
||||
func (m model) petObscuringModalActive() bool {
|
||||
return m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil ||
|
||||
m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
|
||||
m.sttKeyPrompt != nil || m.renamePrompt != nil
|
||||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil
|
||||
}
|
||||
|
||||
func (m model) petComposerReservedColumns(width int) int {
|
||||
|
||||
@@ -34,12 +34,15 @@ const (
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
// underlying command handler when chosen. Meta is the optional right-aligned
|
||||
// readout (ctx window · capabilities); the dot flags mark provider locality
|
||||
// for model rows (accent = remote, blue = local).
|
||||
// for model rows (accent = remote, blue = local). Detail is an optional faint
|
||||
// second line under the row (project · model · size for sessions), rendered
|
||||
// only on medium-and-wider tiers where the extra line reads cleanly.
|
||||
type pickerItem struct {
|
||||
Group string
|
||||
Label string
|
||||
Value string
|
||||
Meta string
|
||||
Detail string
|
||||
Provider string // display tag (catalog id / locality)
|
||||
// OwnerProvider is the saved provider profile name a model belongs to, so the
|
||||
// /model picker can switch providers when a model from a non-active provider is
|
||||
@@ -167,7 +170,7 @@ func (p *commandPicker) applyQuery() {
|
||||
// the joined haystack, and a fuzzy subsequence is the last-resort match.
|
||||
func scorePickerItem(item pickerItem, query string) (int, bool) {
|
||||
label := strings.ToLower(item.Label)
|
||||
hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta}, " "))
|
||||
hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta, item.Detail}, " "))
|
||||
switch {
|
||||
case label == query:
|
||||
return 0, true
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
@@ -1973,8 +1974,27 @@ func TestPermissionCollapseIsRunScoped(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionsCardFieldsAreSanitized(t *testing.T) {
|
||||
if got := sanitizeCardField("evil\x1ftitle\nwith\x00bytes"); strings.ContainsAny(got, "\x1f\n\x00") {
|
||||
t.Fatalf("sanitizeCardField left separator bytes: %q", got)
|
||||
inputs := []string{
|
||||
"evil\x1ftitle",
|
||||
"line\nbreak",
|
||||
"line\rbreak",
|
||||
"nul\x00byte",
|
||||
"esc\x1b[31mred",
|
||||
"csi\x1b[2J\x1b[H",
|
||||
"osc\x1b]0;pwned\a",
|
||||
"bell\aring",
|
||||
"back\bspace",
|
||||
"tab\there",
|
||||
"vert\vtab",
|
||||
"nel\u0085here",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
got := sanitizeCardField(input)
|
||||
for _, r := range got {
|
||||
if unicode.IsControl(r) {
|
||||
t.Fatalf("sanitizeCardField(%q) left control rune %q in %q", input, r, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-27
@@ -8,7 +8,6 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -360,28 +359,10 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st
|
||||
})
|
||||
}
|
||||
|
||||
// sessionWhen formats a session's RFC3339 timestamp for the picker: a precise
|
||||
// clock time (with seconds) for today so same-minute sessions stay distinct, the
|
||||
// month/day and time earlier this year, else the date. Empty on a parse error.
|
||||
func sessionWhen(timestamp string, now time.Time) string {
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, now = parsed.Local(), now.Local()
|
||||
switch {
|
||||
case parsed.Year() == now.Year() && parsed.YearDay() == now.YearDay():
|
||||
return parsed.Format("15:04:05")
|
||||
case parsed.Year() == now.Year():
|
||||
return parsed.Format("Jan _2 15:04")
|
||||
default:
|
||||
return parsed.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
// newSessionPicker builds the interactive /resume picker (mirrors /model & /provider):
|
||||
// one row per resumable session — title (Label) + id and relative age (Meta). Returns
|
||||
// nil when there are no resumable sessions so the caller falls back to the text path.
|
||||
// one row per resumable session — age + title (Label), session id (Value), and a
|
||||
// project/model/size line (Detail). Returns nil when there are no resumable
|
||||
// sessions so the caller falls back to the text path.
|
||||
func (m model) newSessionPicker() *commandPicker {
|
||||
if m.sessionStore == nil {
|
||||
return nil
|
||||
@@ -414,13 +395,17 @@ func (m model) newSessionPicker() *commandPicker {
|
||||
// Lead with a fixed-width timestamp so titles form one scannable column.
|
||||
// The raw id remains the selection/search value but stays out of the row:
|
||||
// rendering it consumed half the picker and truncated the useful title.
|
||||
label := displayValue(meta.Title, "untitled")
|
||||
if when := sessionWhen(meta.UpdatedAt, now); when != "" {
|
||||
// Session metadata is persisted, user-controlled text — a title from
|
||||
// --session-title or /rename can legally contain newlines, which would
|
||||
// break the picker's fixed row geometry.
|
||||
label := displayValue(sanitizeCardField(meta.Title), "untitled")
|
||||
if when := relativeAge(meta.UpdatedAt, now); when != "" {
|
||||
label = sessionPickerLabel(when, label)
|
||||
}
|
||||
items = append(items, pickerItem{
|
||||
Label: label,
|
||||
Value: meta.SessionID,
|
||||
Label: label,
|
||||
Value: meta.SessionID,
|
||||
Detail: m.sessionPickerDetail(meta),
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
@@ -435,12 +420,45 @@ func (m model) newSessionPicker() *commandPicker {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionPickerTimeWidth = len("Jan 02 15:04")
|
||||
const sessionPickerTimeWidth = len("2006-01-02")
|
||||
|
||||
func sessionPickerLabel(when, title string) string {
|
||||
return fmt.Sprintf("%-*s %s", sessionPickerTimeWidth, when, title)
|
||||
}
|
||||
|
||||
// sessionPickerDetail composes the faint second line under a /resume row:
|
||||
// the session's project directory (~/-contracted), the model it ran on, and a
|
||||
// short status chip when the session is not a plain mainline run (forks,
|
||||
// tagged sessions such as btw side-chats). Missing fields are simply omitted —
|
||||
// the picker is workspace-scoped, so the project column is a confirmation,
|
||||
// not a disambiguator.
|
||||
func (m model) sessionPickerDetail(meta sessions.Metadata) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if project := displayPath(m.cwd, meta.Cwd); project != "" {
|
||||
parts = append(parts, project)
|
||||
}
|
||||
if modelID := strings.TrimSpace(meta.ModelID); modelID != "" {
|
||||
parts = append(parts, modelID)
|
||||
}
|
||||
if status := sessionPickerStatus(meta); status != "" {
|
||||
parts = append(parts, status)
|
||||
}
|
||||
return sanitizeCardField(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
// sessionPickerStatus is the concise status chip for a /resume row: an
|
||||
// explicit tag when the session carries one, otherwise a marker for forked
|
||||
// sessions. Plain mainline sessions get none — their shape is the default.
|
||||
func sessionPickerStatus(meta sessions.Metadata) string {
|
||||
if tag := strings.TrimSpace(meta.Tag); tag != "" {
|
||||
return tag
|
||||
}
|
||||
if meta.SessionKind == sessions.SessionKindFork {
|
||||
return "fork"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sessionHasResumableContent reports whether a session has anything worth
|
||||
// resuming: a tool call/result, or a non-user message with real content (not the
|
||||
// no-output guardrail stop). Empty/failed runs return false and are hidden from
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
@@ -300,7 +301,7 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) {
|
||||
// not one of the 4 semantic steps this test drives — process them so the
|
||||
// model state advances, but don't count them toward the budget.
|
||||
switch runtimeMsg.(type) {
|
||||
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
|
||||
case toolCallStreamStartMsg, toolCallStreamDeltaMsg, interactiveExecStartMsg:
|
||||
continue
|
||||
}
|
||||
received++
|
||||
@@ -407,7 +408,7 @@ func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
|
||||
updated, _ = next.Update(runtimeMsg)
|
||||
next = updated.(model)
|
||||
switch runtimeMsg.(type) {
|
||||
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
|
||||
case toolCallStreamStartMsg, toolCallStreamDeltaMsg, interactiveExecStartMsg:
|
||||
continue
|
||||
case permissionRequestMsg:
|
||||
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
|
||||
@@ -1249,3 +1250,188 @@ func assertPayloadFieldContains(t *testing.T, event sessions.Event, key string,
|
||||
t.Fatalf("expected payload %s to contain %q, got %#v in %#v", key, want, payload[key], payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeAgeFormatsLastActivity(t *testing.T) {
|
||||
now := time.Date(2026, 9, 12, 15, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
ts time.Time
|
||||
want string
|
||||
}{
|
||||
{"just now", now.Add(-10 * time.Second), "now"},
|
||||
{"slight clock skew", now.Add(30 * time.Second), "now"},
|
||||
{"minutes", now.Add(-42 * time.Minute), "42m ago"},
|
||||
{"hours", now.Add(-3 * time.Hour), "3h ago"},
|
||||
{"days", now.Add(-12 * 24 * time.Hour), "12d ago"},
|
||||
{"exactly one month", now.Add(-30 * 24 * time.Hour), "30d ago"},
|
||||
{"this year", time.Date(2026, 1, 5, 9, 0, 0, 0, time.UTC), "Jan 5"},
|
||||
{"last year", time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), "2025-06-01"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := relativeAge(tc.ts.Format(time.RFC3339), now); got != tc.want {
|
||||
t.Errorf("%s: sessionAge = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := relativeAge("not-a-time", now); got != "" {
|
||||
t.Errorf("unparseable timestamp = %q, want empty", got)
|
||||
}
|
||||
if got := relativeAge("", now); got != "" {
|
||||
t.Errorf("empty timestamp = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPickerDetailComposesProjectAndModel(t *testing.T) {
|
||||
m := model{cwd: "/repo"}
|
||||
detail := m.sessionPickerDetail(sessions.Metadata{
|
||||
Title: "work",
|
||||
Cwd: "/repo",
|
||||
ModelID: "gpt-5",
|
||||
})
|
||||
for _, want := range []string{"/repo", "gpt-5"} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Fatalf("detail %q missing %q", detail, want)
|
||||
}
|
||||
}
|
||||
// Missing fields are omitted rather than rendered as empty segments.
|
||||
if sparse := m.sessionPickerDetail(sessions.Metadata{Title: "bare"}); sparse != "" {
|
||||
t.Fatalf("empty metadata should produce no detail line, got %q", sparse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPickerDetailStatusChips(t *testing.T) {
|
||||
m := model{}
|
||||
if got := m.sessionPickerDetail(sessions.Metadata{SessionKind: sessions.SessionKindFork}); !strings.Contains(got, "fork") {
|
||||
t.Fatalf("fork session detail %q should carry the fork chip", got)
|
||||
}
|
||||
if got := m.sessionPickerDetail(sessions.Metadata{Tag: "btw"}); !strings.Contains(got, "btw") {
|
||||
t.Fatalf("tagged session detail %q should carry the tag", got)
|
||||
}
|
||||
if got := m.sessionPickerDetail(sessions.Metadata{Title: "plain"}); got != "" {
|
||||
t.Fatalf("plain session detail %q should have no status chip", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumePickerDetailRendersOnlyAtMediumWidth(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
sess, err := store.Create(sessions.CreateInput{Title: "Detail row", ModelID: "gpt-5-pickertest", Provider: "openai"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": "hi"},
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{SessionStore: store})
|
||||
m.input.SetValue("/resume")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if next.picker == nil || next.picker.kind != pickerSession {
|
||||
t.Fatalf("expected /resume to open the session picker, got %#v", next.picker)
|
||||
}
|
||||
|
||||
next.width = 120
|
||||
if wide := viewString(next.View()); !strings.Contains(wide, "gpt-5-pickertest") {
|
||||
t.Fatalf("wide picker should render the detail line with the model:\n%s", wide)
|
||||
}
|
||||
next.width = 60
|
||||
if narrow := viewString(next.View()); strings.Contains(narrow, "gpt-5-pickertest") {
|
||||
t.Fatalf("narrow picker should keep the single-line fallback:\n%s", narrow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumePickerFilterMatchesDetailFields(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
mk := func(title, modelID string) {
|
||||
sess, err := store.Create(sessions.CreateInput{Title: title, ModelID: modelID, Provider: "openai"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create %q returned error: %v", title, err)
|
||||
}
|
||||
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": "hi"},
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendEvent %q returned error: %v", title, err)
|
||||
}
|
||||
}
|
||||
mk("alpha", "gpt-5")
|
||||
mk("beta", "claude-sonnet-4.5")
|
||||
|
||||
m := newModel(context.Background(), Options{SessionStore: store})
|
||||
m.input.SetValue("/resume")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if next.picker == nil {
|
||||
t.Fatal("expected /resume to open the session picker")
|
||||
}
|
||||
// The model id lives only on the detail line; it must still filter rows.
|
||||
next.picker.query = "claude-sonnet"
|
||||
next.picker.applyQuery()
|
||||
if len(next.picker.items) != 1 || !strings.Contains(next.picker.items[0].Label, "beta") {
|
||||
t.Fatalf("filter by model should keep only the matching session, got %#v", next.picker.items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumePickerSanitizesMultilineMetadata(t *testing.T) {
|
||||
// Persisted session metadata is user-controlled: a title or model id can
|
||||
// legally contain newlines, which must not break the picker's fixed
|
||||
// one-or-two-line row geometry (a stray line shifts every row below it and
|
||||
// misaligns mouse hit-testing).
|
||||
store := testSessionStore(t)
|
||||
sess, err := store.Create(sessions.CreateInput{
|
||||
Title: "alpha\nbravo",
|
||||
ModelID: "gpt\x1b[31m5",
|
||||
Cwd: "/repo",
|
||||
Tag: "side\x1b]0;x\achat",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": "hi"},
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{SessionStore: store, Cwd: "/repo"})
|
||||
m.input.SetValue("/resume")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if next.picker == nil || len(next.picker.items) != 1 {
|
||||
t.Fatalf("expected one picker item, got %#v", next.picker)
|
||||
}
|
||||
item := next.picker.items[0]
|
||||
for _, r := range item.Label + item.Detail {
|
||||
if unicode.IsControl(r) {
|
||||
t.Fatalf("picker row leaked control rune %q: label=%q detail=%q", r, item.Label, item.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericPickerMouseSelectsItemFromDetailLine(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.width = 120
|
||||
m.picker = &commandPicker{
|
||||
kind: pickerSession,
|
||||
items: []pickerItem{
|
||||
{Label: "one", Value: "s1", Detail: "meta one"},
|
||||
{Label: "two", Value: "s2", Detail: "meta two"},
|
||||
},
|
||||
}
|
||||
m.picker.allItems = append([]pickerItem{}, m.picker.items...)
|
||||
|
||||
width := chatWidth(m.width)
|
||||
overlay := m.pickerOverlay(width)
|
||||
rect := m.overlayMouseRect(len(viewLines(overlay)), width)
|
||||
// Rows begin at y=3 inside the overlay; item 1's detail line is y=6.
|
||||
// Click near the horizontal middle so the point lands inside the
|
||||
// centered overlay block.
|
||||
target, ok := m.selectGenericPickerAtMouse(testMouseClick(tea.MouseLeft, width/2, rect.y+6))
|
||||
if !ok {
|
||||
t.Fatal("click on a detail line should select its item")
|
||||
}
|
||||
if target.Value != "s2" {
|
||||
t.Fatalf("detail-line click selected %q, want s2", target.Value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// terminalAttachState is the modal "attach to a live terminal" overlay: while
|
||||
// it is set, keystrokes are forwarded verbatim to the session's PTY stdin and
|
||||
// the overlay shows a rolling tail of its output. The bytes the user types are
|
||||
// never inspected, stored, or logged — the PTY's own line discipline (e.g.
|
||||
// sudo's no-echo prompt) handles masking.
|
||||
type terminalAttachState struct {
|
||||
sessionID int
|
||||
command string
|
||||
output string
|
||||
ptyCols int
|
||||
ptyRows int
|
||||
bracketedPaste bool
|
||||
}
|
||||
|
||||
const terminalAttachTickInterval = 100 * time.Millisecond
|
||||
|
||||
type terminalAttachTickMsg struct{}
|
||||
|
||||
func terminalAttachTickCmd() tea.Cmd {
|
||||
return tea.Tick(terminalAttachTickInterval, func(time.Time) tea.Msg {
|
||||
return terminalAttachTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// terminalAutoAttachState watches a run for a brand-new tty exec session so the
|
||||
// attach overlay can open on it without the user typing /attach.
|
||||
type terminalAutoAttachState struct {
|
||||
runID int
|
||||
known map[int]bool
|
||||
deadline time.Time
|
||||
}
|
||||
|
||||
const terminalAutoAttachTimeout = 60 * time.Second
|
||||
|
||||
// interactiveExecStartMsg signals that the active run just invoked exec_command
|
||||
// with tty:true; the session registers with the process manager moments later,
|
||||
// so the tick polls for it. The baseline session set is captured in OnToolCall,
|
||||
// before the tool runs: Program.Send only queues the message, so snapshotting
|
||||
// in Update could already see the new session and exclude it as pre-existing.
|
||||
type interactiveExecStartMsg struct {
|
||||
runID int
|
||||
known map[int]bool
|
||||
}
|
||||
|
||||
// The tick carries the run it belongs to so the /btw router can deliver it to
|
||||
// the hidden parent model instead of dropping it on the visible side model.
|
||||
type terminalAutoAttachTickMsg struct {
|
||||
runID int
|
||||
}
|
||||
|
||||
func terminalAutoAttachTickCmd(runID int) tea.Cmd {
|
||||
return tea.Tick(terminalAttachTickInterval, func(time.Time) tea.Msg {
|
||||
return terminalAutoAttachTickMsg{runID: runID}
|
||||
})
|
||||
}
|
||||
|
||||
// execCallWantsTTY reports whether an exec_command tool call's arguments JSON
|
||||
// requests a PTY. Malformed JSON means false.
|
||||
func execCallWantsTTY(arguments string) bool {
|
||||
var args struct {
|
||||
TTY bool `json:"tty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
|
||||
return false
|
||||
}
|
||||
return args.TTY
|
||||
}
|
||||
|
||||
// openTerminalAttach attaches to an interactive exec session. Callers check
|
||||
// TTY eligibility first; a session that is already gone or exited surfaces as a
|
||||
// transient notice instead of an empty overlay.
|
||||
func (m model) openTerminalAttach(id int) (model, tea.Cmd) {
|
||||
state := &terminalAttachState{sessionID: id}
|
||||
if controller, ok := m.execSessionController(); ok {
|
||||
snapshot, found := controller.ExecSession(id)
|
||||
switch {
|
||||
case !found:
|
||||
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d ended.", id), transientNoticeInfo), nil
|
||||
case snapshot.Status != "running":
|
||||
exitCode := 0
|
||||
if snapshot.ExitCode != nil {
|
||||
exitCode = *snapshot.ExitCode
|
||||
}
|
||||
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d finished (exit %d).", id, exitCode), transientNoticeInfo), nil
|
||||
default:
|
||||
state.command = snapshot.Command
|
||||
state.output = snapshot.RecentOutput
|
||||
state.bracketedPaste = bracketedPasteMode(snapshot.RecentOutput, false)
|
||||
}
|
||||
}
|
||||
m.terminalAttach = state
|
||||
if m.terminalAttachSeen == nil {
|
||||
m.terminalAttachSeen = map[int]bool{}
|
||||
}
|
||||
m.terminalAttachSeen[id] = true
|
||||
m.clearSuggestions()
|
||||
m = m.resizeAttachedTerminalPTY()
|
||||
return m, terminalAttachTickCmd()
|
||||
}
|
||||
|
||||
// resizeAttachedTerminalPTY reports the overlay's output area to the PTY so
|
||||
// the session lays out at the size it is actually displayed at. Errors are
|
||||
// ignored: platforms without PTY support have nothing to resize.
|
||||
func (m model) resizeAttachedTerminalPTY() model {
|
||||
state := m.terminalAttach
|
||||
if state == nil {
|
||||
return m
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
// The box border and inset take 4 cells; one more is the cursor
|
||||
// reservation, matching the width renderTerminalTail draws at.
|
||||
cols := maxInt(1, width-5)
|
||||
rows := m.terminalAttachViewportRows(width)
|
||||
if cols == state.ptyCols && rows == state.ptyRows {
|
||||
return m
|
||||
}
|
||||
controller, ok := m.execSessionController()
|
||||
if !ok {
|
||||
return m
|
||||
}
|
||||
state.ptyCols, state.ptyRows = cols, rows
|
||||
_ = controller.ResizeExecSession(state.sessionID, cols, rows)
|
||||
return m
|
||||
}
|
||||
|
||||
// refreshTerminalAttach polls the session snapshot for the overlay tail. When
|
||||
// the session exits or disappears the overlay closes itself with a notice;
|
||||
// detaching also stops the tick because the handler drops it while
|
||||
// terminalAttach is nil.
|
||||
func (m model) refreshTerminalAttach() (model, tea.Cmd) {
|
||||
state := m.terminalAttach
|
||||
if state == nil {
|
||||
return m, nil
|
||||
}
|
||||
controller, ok := m.execSessionController()
|
||||
if !ok {
|
||||
m.terminalAttach = nil
|
||||
return m, nil
|
||||
}
|
||||
snapshot, found := controller.ExecSession(state.sessionID)
|
||||
if !found {
|
||||
m.terminalAttach = nil
|
||||
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d ended.", state.sessionID), transientNoticeInfo), nil
|
||||
}
|
||||
state.output = snapshot.RecentOutput
|
||||
state.bracketedPaste = bracketedPasteMode(snapshot.RecentOutput, state.bracketedPaste)
|
||||
if snapshot.Status != "running" {
|
||||
m.terminalAttach = nil
|
||||
exitCode := 0
|
||||
if snapshot.ExitCode != nil {
|
||||
exitCode = *snapshot.ExitCode
|
||||
}
|
||||
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d finished (exit %d).", state.sessionID, exitCode), transientNoticeInfo), nil
|
||||
}
|
||||
return m, terminalAttachTickCmd()
|
||||
}
|
||||
|
||||
// pollTerminalAutoAttach runs the auto-attach watcher: while the run is live it
|
||||
// looks for a new tty session and opens the overlay on it, giving up at the
|
||||
// deadline (a permission prompt can hold the session start for a while).
|
||||
func (m model) pollTerminalAutoAttach(msg terminalAutoAttachTickMsg) (model, tea.Cmd) {
|
||||
state := m.terminalAutoAttach
|
||||
if state == nil || msg.runID != state.runID {
|
||||
return m, nil
|
||||
}
|
||||
if m.activeRunID != state.runID || !m.pending || m.now().After(state.deadline) {
|
||||
m.terminalAutoAttach = nil
|
||||
return m, nil
|
||||
}
|
||||
controller, ok := m.execSessionController()
|
||||
if !ok {
|
||||
return m, terminalAutoAttachTickCmd(state.runID)
|
||||
}
|
||||
for _, session := range controller.ExecSessions() {
|
||||
if !session.TTY || session.Status != "running" || state.known[session.ID] || m.terminalAttachSeen[session.ID] {
|
||||
continue
|
||||
}
|
||||
if !m.terminalAttachCanOpen() || m.terminalAttach != nil {
|
||||
// A modal (permission prompt, picker, …) or an existing attach owns
|
||||
// the viewport; keep watching so the overlay opens once it clears.
|
||||
return m, terminalAutoAttachTickCmd(state.runID)
|
||||
}
|
||||
m.terminalAutoAttach = nil
|
||||
return m.openTerminalAttach(session.ID)
|
||||
}
|
||||
return m, terminalAutoAttachTickCmd(state.runID)
|
||||
}
|
||||
|
||||
// terminalAttachCanOpen reports whether the attach overlay would actually be
|
||||
// rendered right now: beyond the modal prompts, the subchat drill-in, file
|
||||
// view, setup wizard, help/run-details overlays, the detailed transcript, and
|
||||
// active suggestions each replace or cover the viewport it draws into, so
|
||||
// opening then would capture keys into an invisible overlay.
|
||||
func (m model) terminalAttachCanOpen() bool {
|
||||
return m.noBlockingModalExceptAttach() &&
|
||||
!m.helpOverlay && !m.leaderHelpOverlay && !m.runDetailsOpen &&
|
||||
!m.subchat.active && !m.fileView.active && !m.setup.visible &&
|
||||
!m.transcriptDetailed && !m.suggestionsActive()
|
||||
}
|
||||
|
||||
// noBlockingModalExceptAttach is noBlockingModal without the attach overlay's
|
||||
// own term, for the auto-attach watcher deciding whether another modal owns
|
||||
// the viewport.
|
||||
func (m model) noBlockingModalExceptAttach() bool {
|
||||
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
|
||||
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
|
||||
m.sttKeyPrompt == nil && m.renamePrompt == nil
|
||||
}
|
||||
|
||||
// writeTerminalAttachInput forwards bytes to the session stdin. The bytes are
|
||||
// written through untouched and never echoed into the transcript, a notice, or
|
||||
// the overlay. Errors only update overlay state or a generic notice.
|
||||
func (m model) writeTerminalAttachInput(state *terminalAttachState, data []byte) model {
|
||||
controller, ok := m.execSessionController()
|
||||
if !ok {
|
||||
m.terminalAttach = nil
|
||||
return m
|
||||
}
|
||||
err := controller.WriteExecSessionInput(state.sessionID, data)
|
||||
switch {
|
||||
case errors.Is(err, execution.ErrProcessNotFound):
|
||||
m.terminalAttach = nil
|
||||
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d ended.", state.sessionID), transientNoticeInfo)
|
||||
case errors.Is(err, execution.ErrProcessStdinDisabled):
|
||||
m.terminalAttach = nil
|
||||
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d no longer accepts input.", state.sessionID), transientNoticeWarning)
|
||||
case err != nil:
|
||||
return m.showTransientNoticeInline("Could not send input to session "+strconv.Itoa(state.sessionID)+".", transientNoticeWarning)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// handleTerminalAttachKey owns every keystroke while attached: Esc detaches,
|
||||
// anything else maps to PTY bytes and goes to the process (so Ctrl+C reaches
|
||||
// it as 0x03 instead of triggering the TUI's own exit confirmation).
|
||||
func (m model) handleTerminalAttachKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
state := m.terminalAttach
|
||||
if keyIs(msg, tea.KeyEsc) {
|
||||
m.terminalAttach = nil
|
||||
return m.showTransientNoticeInline(
|
||||
fmt.Sprintf("Detached from session %d — it keeps running; /attach %d to return.", state.sessionID, state.sessionID),
|
||||
transientNoticeInfo), nil
|
||||
}
|
||||
data, ok := ptyInputBytes(msg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
return m.writeTerminalAttachInput(state, data), nil
|
||||
}
|
||||
|
||||
// handleTerminalAttachPaste forwards a paste to the PTY. Bubble Tea strips
|
||||
// the outer terminal's bracketed-paste markers, so when the child enabled
|
||||
// bracketed paste itself the framing is re-added around the content.
|
||||
func (m model) handleTerminalAttachPaste(content string) (tea.Model, tea.Cmd) {
|
||||
state := m.terminalAttach
|
||||
if content == "" {
|
||||
return m, nil
|
||||
}
|
||||
data := []byte(content)
|
||||
if state.bracketedPaste {
|
||||
data = append(append([]byte("\x1b[200~"), data...), "\x1b[201~"...)
|
||||
}
|
||||
return m.writeTerminalAttachInput(state, data), nil
|
||||
}
|
||||
|
||||
// bracketedPasteMode tracks the child's bracketed-paste mode from its output:
|
||||
// the last enable/disable sequence wins, and output without either keeps the
|
||||
// previous state since RecentOutput is a rolling window that can lose it.
|
||||
func bracketedPasteMode(output string, current bool) bool {
|
||||
on := strings.LastIndex(output, "\x1b[?2004h")
|
||||
off := strings.LastIndex(output, "\x1b[?2004l")
|
||||
switch {
|
||||
case on < 0 && off < 0:
|
||||
return current
|
||||
case on > off:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ptyInputBytes maps a keystroke to the byte sequence a terminal would send.
|
||||
// Esc is deliberately unmapped: it detaches instead of reaching the process.
|
||||
func ptyInputBytes(msg tea.KeyMsg) ([]byte, bool) {
|
||||
key := msg.Key()
|
||||
if keyHasMod(msg, tea.ModCtrl) {
|
||||
if key.Code >= 'a' && key.Code <= 'z' {
|
||||
return []byte{byte(key.Code - 'a' + 1)}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if keyAlt(msg) {
|
||||
if text := keyText(msg); text != "" {
|
||||
return append([]byte{0x1b}, text...), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
switch key.Code {
|
||||
case tea.KeyEnter:
|
||||
return []byte("\r"), true
|
||||
case tea.KeyTab:
|
||||
return []byte("\t"), true
|
||||
case tea.KeyBackspace:
|
||||
return []byte{0x7f}, true
|
||||
case tea.KeyDelete:
|
||||
return []byte("\x1b[3~"), true
|
||||
case tea.KeyUp:
|
||||
return []byte("\x1b[A"), true
|
||||
case tea.KeyDown:
|
||||
return []byte("\x1b[B"), true
|
||||
case tea.KeyRight:
|
||||
return []byte("\x1b[C"), true
|
||||
case tea.KeyLeft:
|
||||
return []byte("\x1b[D"), true
|
||||
case tea.KeyHome:
|
||||
return []byte("\x1b[H"), true
|
||||
case tea.KeyEnd:
|
||||
return []byte("\x1b[F"), true
|
||||
case tea.KeyPgUp:
|
||||
return []byte("\x1b[5~"), true
|
||||
case tea.KeyPgDown:
|
||||
return []byte("\x1b[6~"), true
|
||||
}
|
||||
if text := keyText(msg); text != "" {
|
||||
return []byte(text), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// renderTerminalTail turns raw PTY output into display lines: carriage returns
|
||||
// overwrite their line from column 0 (progress-bar style), ANSI sequences are
|
||||
// stripped, remaining control runes are dropped (tab expands to spaces), and
|
||||
// the result is the last rows lines top-aligned and padded to exactly rows.
|
||||
func renderTerminalTail(raw string, width, rows int) []string {
|
||||
if rows < 0 {
|
||||
rows = 0
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
source := strings.Split(raw, "\n")
|
||||
lines := make([]string, 0, len(source))
|
||||
for _, line := range source {
|
||||
lines = append(lines, sanitizeTerminalLine(line, width))
|
||||
}
|
||||
if len(lines) > rows {
|
||||
lines = lines[len(lines)-rows:]
|
||||
}
|
||||
for len(lines) < rows {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func sanitizeTerminalLine(line string, width int) string {
|
||||
var resolved []rune
|
||||
for _, segment := range strings.Split(line, "\r") {
|
||||
runes := []rune(segment)
|
||||
if len(runes) < len(resolved) {
|
||||
runes = append(runes, resolved[len(runes):]...)
|
||||
}
|
||||
resolved = runes
|
||||
}
|
||||
stripped := ansi.Strip(string(resolved))
|
||||
var builder strings.Builder
|
||||
for _, r := range stripped {
|
||||
switch {
|
||||
case r == '\t':
|
||||
builder.WriteString(" ")
|
||||
case unicode.IsControl(r):
|
||||
default:
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if width <= 0 {
|
||||
return builder.String()
|
||||
}
|
||||
return fitStyledLine(builder.String(), width)
|
||||
}
|
||||
|
||||
// terminalAttachViewportRows is the number of output rows the attached
|
||||
// terminal gets: the transcript viewport's body height minus the box's two
|
||||
// border lines and its footer hint line, so the bordered overlay fills the
|
||||
// viewport exactly. The same frame math the compositor uses keeps the two in
|
||||
// sync — and footerView already collapses to the status line while attached.
|
||||
func (m model) terminalAttachViewportRows(width int) int {
|
||||
if m.height <= 0 {
|
||||
return 10
|
||||
}
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
return maxInt(6, frame.bodyRect.height-3)
|
||||
}
|
||||
|
||||
func (m model) terminalAttachOverlay(width int) string {
|
||||
state := m.terminalAttach
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
command := compactCommandOutputText(state.command)
|
||||
if command == "" {
|
||||
command = "command"
|
||||
}
|
||||
// styledBlockFillTitle drops the title when it can't fit the top rule, so
|
||||
// truncate the command to whatever the border can carry.
|
||||
titlePrefix := zeroTheme.accent.Render("●") + " Terminal "
|
||||
titleSuffix := " [running]"
|
||||
titleBudget := width - 5 - lipgloss.Width(titleSuffix)
|
||||
title := titlePrefix + truncateRunes(command, maxInt(0, titleBudget-lipgloss.Width(titlePrefix))) + titleSuffix
|
||||
innerWidth := maxInt(1, width-4)
|
||||
// Reserve one cell so the cursor never pushes a full line past the edge.
|
||||
tail := renderTerminalTail(state.output, innerWidth-1, m.terminalAttachViewportRows(width))
|
||||
cursor := -1
|
||||
for index := len(tail) - 1; index >= 0; index-- {
|
||||
if strings.TrimSpace(ansi.Strip(tail[index])) != "" {
|
||||
cursor = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if cursor < 0 && len(tail) > 0 {
|
||||
cursor = 0
|
||||
}
|
||||
if cursor >= 0 {
|
||||
tail[cursor] += zeroTheme.accent.Render("▌")
|
||||
}
|
||||
footer := fmt.Sprintf("Esc detach · Ctrl+C goes to the process · /stop %d to kill", state.sessionID)
|
||||
lines := make([]string, 0, len(tail)+1)
|
||||
lines = append(lines, tail...)
|
||||
lines = append(lines, zeroTheme.faint.Render(footer))
|
||||
return styledBlockFillTitle(width, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func attachedModel(t *testing.T) (model, *fakeExecSessionTool) {
|
||||
t.Helper()
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{{
|
||||
ID: 7,
|
||||
TTY: true,
|
||||
Status: "running",
|
||||
Command: "sudo apt install x",
|
||||
RecentOutput: "[sudo] password for me: ",
|
||||
StartedAt: time.Unix(100, 0),
|
||||
}},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.width = 100
|
||||
m.height = 30
|
||||
return m, tool
|
||||
}
|
||||
|
||||
func TestOpenTerminalAttachSetsState(t *testing.T) {
|
||||
m, _ := attachedModel(t)
|
||||
next, cmd := m.openTerminalAttach(7)
|
||||
if cmd == nil {
|
||||
t.Fatal("openTerminalAttach should schedule a refresh tick")
|
||||
}
|
||||
state := next.terminalAttach
|
||||
if state == nil || state.sessionID != 7 {
|
||||
t.Fatalf("state = %#v", state)
|
||||
}
|
||||
if state.command != "sudo apt install x" || state.output != "[sudo] password for me: " {
|
||||
t.Fatalf("state did not pick up the snapshot: %#v", state)
|
||||
}
|
||||
if !next.terminalAttachSeen[7] {
|
||||
t.Fatal("openTerminalAttach should mark the session as seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachForwardsKeys(t *testing.T) {
|
||||
m, tool := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
for _, msg := range []tea.Msg{testKeyText("s"), testKey(tea.KeyEnter)} {
|
||||
updated, _ := next.Update(msg)
|
||||
next = updated.(model)
|
||||
}
|
||||
if next.terminalAttach == nil {
|
||||
t.Fatal("typing should stay attached")
|
||||
}
|
||||
if len(tool.writes) != 2 || string(tool.writes[0]) != "s" || string(tool.writes[1]) != "\r" {
|
||||
t.Fatalf("writes = %#v, want [s \\r]", tool.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachForwardsCtrlC(t *testing.T) {
|
||||
m, tool := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
updated, _ := next.Update(testKeyCtrl('c'))
|
||||
next = updated.(model)
|
||||
if len(tool.writes) != 1 || len(tool.writes[0]) != 1 || tool.writes[0][0] != 0x03 {
|
||||
t.Fatalf("writes = %#v, want [0x03]", tool.writes)
|
||||
}
|
||||
if next.terminalAttach == nil {
|
||||
t.Fatal("Ctrl+C detached the session — it should go to the process")
|
||||
}
|
||||
if next.exitConfirmActive {
|
||||
t.Fatal("Ctrl+C armed the exit confirmation while attached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachForwardsPaste(t *testing.T) {
|
||||
m, tool := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
updated, _ := next.Update(testPaste("pw"))
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil {
|
||||
t.Fatal("paste should stay attached")
|
||||
}
|
||||
if len(tool.writes) != 1 || string(tool.writes[0]) != "pw" {
|
||||
t.Fatalf("writes = %#v, want [pw]", tool.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachPasteKeepsBracketedFraming(t *testing.T) {
|
||||
m, tool := attachedModel(t)
|
||||
tool.sessions[0].RecentOutput = "\x1b[?2004h[sudo] password for me: "
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
updated, _ := next.Update(testPaste("pw"))
|
||||
next = updated.(model)
|
||||
want := "\x1b[200~pw\x1b[201~"
|
||||
if len(tool.writes) != 1 || string(tool.writes[0]) != want {
|
||||
t.Fatalf("writes = %q, want [%q]", tool.writes, want)
|
||||
}
|
||||
|
||||
// A disable sequence in later output turns framing back off once the
|
||||
// refresh tick observes it.
|
||||
tool.sessions[0].RecentOutput += "\x1b[?2004l"
|
||||
updated, _ = next.Update(terminalAttachTickMsg{})
|
||||
next = updated.(model)
|
||||
_, _ = next.Update(testPaste("pw3"))
|
||||
if last := string(tool.writes[len(tool.writes)-1]); last != "pw3" {
|
||||
t.Fatalf("write after disable = %q, want raw pw3", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBracketedPasteMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
output string
|
||||
current bool
|
||||
want bool
|
||||
}{
|
||||
{name: "enable last", output: "\x1b[?2004h", want: true},
|
||||
{name: "disable last", output: "\x1b[?2004l", current: true, want: false},
|
||||
{name: "neither keeps current", output: "plain output", current: true, want: true},
|
||||
{name: "neither stays off", output: "plain output", want: false},
|
||||
{name: "enable after disable", output: "\x1b[?2004ltext\x1b[?2004h", want: true},
|
||||
{name: "disable after enable", output: "\x1b[?2004htext\x1b[?2004l", current: true, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := bracketedPasteMode(tc.output, tc.current); got != tc.want {
|
||||
t.Fatalf("bracketedPasteMode(%q, %v) = %v, want %v", tc.output, tc.current, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachEscDetaches(t *testing.T) {
|
||||
m, tool := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
updated, _ := next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("Esc should detach")
|
||||
}
|
||||
if len(tool.writes) != 0 {
|
||||
t.Fatalf("Esc forwarded bytes to the process: %#v", tool.writes)
|
||||
}
|
||||
if !strings.Contains(next.transientNotice.text, "Detached from session 7") {
|
||||
t.Fatalf("notice = %q", next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachTickAutoClosesOnExit(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{{
|
||||
ID: 7, TTY: true, Status: "running", Command: "sudo apt install x",
|
||||
StartedAt: time.Unix(100, 0),
|
||||
}},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
exitCode := 1
|
||||
tool.sessions[0].Status = "exited"
|
||||
tool.sessions[0].ExitCode = &exitCode
|
||||
updated, _ := next.Update(terminalAttachTickMsg{})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("overlay should close itself when the session exits")
|
||||
}
|
||||
if !strings.Contains(next.transientNotice.text, "Terminal session 7 finished (exit 1).") {
|
||||
t.Fatalf("notice = %q", next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachTickAutoClosesOnMissing(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{{
|
||||
ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0),
|
||||
}},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
tool.sessions = nil
|
||||
updated, _ := next.Update(terminalAttachTickMsg{})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("overlay should close itself when the session disappears")
|
||||
}
|
||||
if !strings.Contains(next.transientNotice.text, "Terminal session 7 ended.") {
|
||||
t.Fatalf("notice = %q", next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachOverlay(t *testing.T) {
|
||||
m, _ := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
overlay := next.terminalAttachOverlay(80)
|
||||
for _, want := range []string{"Terminal", "sudo apt install x", "[running]", "[sudo] password for me:", "Esc detach", "/stop 7"} {
|
||||
if !strings.Contains(overlay, want) {
|
||||
t.Fatalf("overlay missing %q:\n%s", want, overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachOverlayFillsViewport(t *testing.T) {
|
||||
m, _ := attachedModel(t)
|
||||
m.width, m.height = 120, 40
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
overlay := next.terminalAttachOverlay(120)
|
||||
lines := strings.Split(overlay, "\n")
|
||||
frame := next.scrollableTranscriptFrame(next.pinnedTitleBar(120), next.footerView(120))
|
||||
if len(lines) != frame.bodyRect.height {
|
||||
t.Fatalf("overlay lines = %d, want viewport body height %d", len(lines), frame.bodyRect.height)
|
||||
}
|
||||
for index, line := range lines {
|
||||
if width := lipgloss.Width(line); width != 120 {
|
||||
t.Fatalf("overlay line %d width = %d, want 120: %q", index, width, line)
|
||||
}
|
||||
}
|
||||
plain := ansi.Strip(overlay)
|
||||
if !strings.Contains(plain, "Terminal") || !strings.Contains(plain, "[running]") {
|
||||
t.Fatalf("overlay missing border title: %q", lines[0])
|
||||
}
|
||||
if !strings.HasPrefix(plain, "╭") || !strings.Contains(lines[len(lines)-1], "╰") {
|
||||
t.Fatal("overlay should be a bordered box")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterViewWhileAttachedDropsComposer(t *testing.T) {
|
||||
m, _ := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
footer := ansi.Strip(next.footerView(100))
|
||||
if strings.Contains(footer, "describe a task") {
|
||||
t.Fatalf("attached footer still shows the composer: %q", footer)
|
||||
}
|
||||
if strings.TrimSpace(footer) == "" {
|
||||
t.Fatal("attached footer should still render the status line")
|
||||
}
|
||||
m.input.SetValue("typed")
|
||||
if plain := ansi.Strip(m.footerView(100)); !strings.Contains(plain, "typed") {
|
||||
t.Fatalf("detached footer lost the composer: %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachResizesPTYOnOpenAndResize(t *testing.T) {
|
||||
m, tool := attachedModel(t)
|
||||
|
||||
// A resize while nothing is attached must not reach the controller.
|
||||
if updated, _ := m.Update(tea.WindowSizeMsg{Width: 110, Height: 35}); updated != nil {
|
||||
m = updated.(model)
|
||||
}
|
||||
if tool.resizeCalls != 0 {
|
||||
t.Fatalf("WindowSizeMsg while detached resized: %d calls", tool.resizeCalls)
|
||||
}
|
||||
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
wantCols := chatWidth(m.width) - 5
|
||||
wantRows := next.terminalAttachViewportRows(chatWidth(m.width))
|
||||
if tool.resizeCalls != 1 || tool.resizeCols != wantCols || tool.resizeRows != wantRows {
|
||||
t.Fatalf("open resize = %d calls %dx%d, want 1 call %dx%d", tool.resizeCalls, tool.resizeCols, tool.resizeRows, wantCols, wantRows)
|
||||
}
|
||||
|
||||
updated, _ := next.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||
next = updated.(model)
|
||||
wantCols, wantRows = 115, next.terminalAttachViewportRows(120)
|
||||
if tool.resizeCalls != 2 || tool.resizeCols != wantCols || tool.resizeRows != wantRows {
|
||||
t.Fatalf("resize = %d calls %dx%d, want 2 calls %dx%d", tool.resizeCalls, tool.resizeCols, tool.resizeRows, wantCols, wantRows)
|
||||
}
|
||||
|
||||
// A no-change resize is not forwarded again.
|
||||
_, _ = next.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||
if tool.resizeCalls != 2 {
|
||||
t.Fatalf("unchanged size resized again: %d calls", tool.resizeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCallWantsTTY(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args string
|
||||
want bool
|
||||
}{
|
||||
{"tty true", `{"cmd":"x","tty":true}`, true},
|
||||
{"tty false", `{"cmd":"x","tty":false}`, false},
|
||||
{"tty absent", `{"cmd":"x"}`, false},
|
||||
{"garbage", `not json`, false},
|
||||
{"empty", ``, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := execCallWantsTTY(tc.args); got != tc.want {
|
||||
t.Fatalf("execCallWantsTTY(%q) = %v, want %v", tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveExecStartAutoAttaches(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
|
||||
updated, cmd := m.Update(interactiveExecStartMsg{runID: 3})
|
||||
next := updated.(model)
|
||||
if next.terminalAutoAttach == nil || cmd == nil {
|
||||
t.Fatal("interactiveExecStartMsg should arm the watcher and a tick")
|
||||
}
|
||||
|
||||
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
|
||||
ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0),
|
||||
})
|
||||
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
|
||||
t.Fatalf("auto-attach did not open on the new session: %#v", next.terminalAttach)
|
||||
}
|
||||
if !next.terminalAttachSeen[7] {
|
||||
t.Fatal("session 7 should be marked seen")
|
||||
}
|
||||
if next.terminalAutoAttach != nil {
|
||||
t.Fatal("watcher should stop once it attached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAutoAttachSkipsKnownSession(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 5, TTY: true, Status: "running", Command: "top", StartedAt: time.Unix(100, 0)},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
|
||||
// Session 5 predates the tool call — it is not this call's session.
|
||||
updated, _ := m.Update(interactiveExecStartMsg{runID: 3, known: map[int]bool{5: true}})
|
||||
next := updated.(model)
|
||||
updated, cmd := next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("watcher must not attach to a session that was already running")
|
||||
}
|
||||
if cmd == nil || next.terminalAutoAttach == nil {
|
||||
t.Fatal("watcher should keep ticking while nothing new appears")
|
||||
}
|
||||
|
||||
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
|
||||
ID: 6, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(200, 0),
|
||||
})
|
||||
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil || next.terminalAttach.sessionID != 6 {
|
||||
t.Fatalf("watcher should attach to the new session 6: %#v", next.terminalAttach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveExecStartBaselinePredatesSessionRegistration(t *testing.T) {
|
||||
// The baseline is captured when OnToolCall fires, before the tool's Run
|
||||
// registers the session — a snapshot taken at Update time would already
|
||||
// contain it and wrongly exclude it as pre-existing.
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 5, TTY: true, Status: "running", Command: "top", StartedAt: time.Unix(100, 0)},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
|
||||
msg := interactiveExecStartMsg{runID: 3, known: map[int]bool{5: true}}
|
||||
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
|
||||
ID: 6, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(200, 0),
|
||||
})
|
||||
|
||||
updated, _ := m.Update(msg)
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil || next.terminalAttach.sessionID != 6 {
|
||||
t.Fatalf("watcher should attach to the session registered after the baseline: %#v", next.terminalAttach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAutoAttachDoesNotReattachSeen(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
m.terminalAttachSeen = map[int]bool{7: true} // user already attached and detached
|
||||
|
||||
updated, _ := m.Update(interactiveExecStartMsg{runID: 3})
|
||||
next := updated.(model)
|
||||
updated, cmd := next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("watcher must not re-open a session the user detached")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("watcher should keep ticking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAutoAttachTickIgnoresStaleRun(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
m.terminalAutoAttach = &terminalAutoAttachState{
|
||||
runID: 3, known: map[int]bool{}, deadline: m.now().Add(terminalAutoAttachTimeout),
|
||||
}
|
||||
|
||||
updated, cmd := m.Update(terminalAutoAttachTickMsg{runID: 9})
|
||||
next := updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("a stale tick must not attach")
|
||||
}
|
||||
if next.terminalAutoAttach == nil || cmd != nil {
|
||||
t.Fatal("a stale tick should be dropped without touching the watcher")
|
||||
}
|
||||
|
||||
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
|
||||
t.Fatalf("the live tick should still attach: %#v", next.terminalAttach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRoutesAutoAttachTickToHiddenParent(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{}
|
||||
m := newBTWTestModel(t)
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tool)
|
||||
m.registry = registry
|
||||
m.pending = true
|
||||
m.runID = 7
|
||||
m.activeRunID = 7
|
||||
|
||||
side, _ := m.handleBTWCommand("")
|
||||
routed, _, ok := side.routeBTWParentMessage(interactiveExecStartMsg{runID: 7})
|
||||
if !ok {
|
||||
t.Fatal("interactiveExecStartMsg was not routed to the parent")
|
||||
}
|
||||
side = routed
|
||||
if side.btw.parent == nil || side.btw.parent.terminalAutoAttach == nil {
|
||||
t.Fatal("watcher was not armed on the hidden parent")
|
||||
}
|
||||
|
||||
// A tick tagged for the side run must not reach the parent watcher.
|
||||
if routed, _, ok := side.routeBTWParentMessage(terminalAutoAttachTickMsg{runID: side.btw.sideRunIDBase}); ok || routed.btw.parent.terminalAttach != nil {
|
||||
t.Fatal("a side-run tick must not reach the parent watcher")
|
||||
}
|
||||
|
||||
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
|
||||
ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0),
|
||||
})
|
||||
routed, _, ok = side.routeBTWParentMessage(terminalAutoAttachTickMsg{runID: 7})
|
||||
if !ok {
|
||||
t.Fatal("parent-run tick was not routed to the parent")
|
||||
}
|
||||
side = routed
|
||||
if side.btw.parent == nil || side.btw.parent.terminalAttach == nil || side.btw.parent.terminalAttach.sessionID != 7 {
|
||||
t.Fatalf("parent overlay did not open: %#v", side.btw.parent)
|
||||
}
|
||||
if side.terminalAttach != nil {
|
||||
t.Fatal("attach state leaked onto the side model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAutoAttachDefersToOtherViews(t *testing.T) {
|
||||
newWatchingModel := func(t *testing.T) (model, *fakeExecSessionTool) {
|
||||
t.Helper()
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
m.terminalAutoAttach = &terminalAutoAttachState{
|
||||
runID: 3, known: map[int]bool{}, deadline: m.now().Add(terminalAutoAttachTimeout),
|
||||
}
|
||||
return m, tool
|
||||
}
|
||||
|
||||
m, _ := newWatchingModel(t)
|
||||
m.helpOverlay = true
|
||||
updated, cmd := m.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next := updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("overlay must not open over the help overlay")
|
||||
}
|
||||
if cmd == nil || next.terminalAutoAttach == nil {
|
||||
t.Fatal("watcher should keep ticking while help owns the viewport")
|
||||
}
|
||||
next.helpOverlay = false
|
||||
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil {
|
||||
t.Fatal("overlay should open once help closes")
|
||||
}
|
||||
|
||||
m, _ = newWatchingModel(t)
|
||||
m.subchat.active = true
|
||||
updated, cmd = m.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("overlay must not open inside the subchat view")
|
||||
}
|
||||
if cmd == nil || next.terminalAutoAttach == nil {
|
||||
t.Fatal("watcher should keep ticking while subchat owns the viewport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandRefusesWhileSubchatActive(t *testing.T) {
|
||||
m, _ := attachedModel(t)
|
||||
m.subchat.active = true
|
||||
|
||||
next, _ := m.attachTerminalCommand("7")
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("/attach must not open the overlay inside the subchat view")
|
||||
}
|
||||
if !strings.Contains(next.transientNotice.text, "Leave the current view first") {
|
||||
t.Fatalf("expected the leave-view notice, got %q", next.transientNotice.text)
|
||||
}
|
||||
next.subchat.active = false
|
||||
next, _ = next.attachTerminalCommand("7")
|
||||
if next.terminalAttach == nil {
|
||||
t.Fatal("/attach should open once the subchat view is left")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAutoAttachIgnoresWrongRun(t *testing.T) {
|
||||
m, _ := attachedModel(t)
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
|
||||
updated, cmd := m.Update(interactiveExecStartMsg{runID: 9})
|
||||
next := updated.(model)
|
||||
if next.terminalAutoAttach != nil || cmd != nil {
|
||||
t.Fatal("a msg for another run must not arm the watcher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAutoAttachWaitsForPermissionPrompt(t *testing.T) {
|
||||
tool := &fakeExecSessionTool{
|
||||
sessions: []tools.ExecSessionSnapshot{
|
||||
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
|
||||
},
|
||||
}
|
||||
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
|
||||
m.activeRunID = 3
|
||||
m.pending = true
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: agent.PermissionRequest{ToolName: "exec_command"},
|
||||
decide: func(agent.PermissionDecision) {},
|
||||
}
|
||||
|
||||
// The watcher is armed before the session registers? Session already here —
|
||||
// simplest equivalent: arm it directly and confirm the modal blocks opening.
|
||||
m.terminalAutoAttach = &terminalAutoAttachState{
|
||||
runID: 3, known: map[int]bool{}, deadline: m.now().Add(terminalAutoAttachTimeout),
|
||||
}
|
||||
updated, cmd := m.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next := updated.(model)
|
||||
if next.terminalAttach != nil {
|
||||
t.Fatal("overlay must not open over a permission prompt")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("watcher should keep ticking while the modal is up")
|
||||
}
|
||||
|
||||
next.pendingPermission = nil
|
||||
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
|
||||
t.Fatalf("overlay should open once the modal clears: %#v", next.terminalAttach)
|
||||
}
|
||||
}
|
||||
|
||||
// The user's keystrokes go to the PTY and nowhere else: not the transcript,
|
||||
// not the composer, not a notice.
|
||||
func TestTerminalAttachNeverEchoesInput(t *testing.T) {
|
||||
m, tool := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
|
||||
for _, msg := range []tea.Msg{testKeyText("s"), testKeyText("3"), testKeyText("c"), testKeyText("r"), testKeyText("e"), testKeyText("t"), testPaste("pw")} {
|
||||
updated, _ := next.Update(msg)
|
||||
next = updated.(model)
|
||||
}
|
||||
if len(tool.writes) == 0 {
|
||||
t.Fatal("nothing was forwarded")
|
||||
}
|
||||
for _, row := range next.transcript {
|
||||
if strings.Contains(row.text, "s3cret") || strings.Contains(row.text, "pw") {
|
||||
t.Fatalf("typed bytes leaked into transcript row: %q", row.text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(next.transientNotice.text, "s3cret") || strings.Contains(next.transientNotice.text, "pw") {
|
||||
t.Fatalf("typed bytes leaked into the notice: %q", next.transientNotice.text)
|
||||
}
|
||||
if strings.Contains(next.composerValue(), "s3cret") {
|
||||
t.Fatal("typed bytes leaked into the composer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAttachSwallowsMouse(t *testing.T) {
|
||||
m, _ := attachedModel(t)
|
||||
next, _ := m.openTerminalAttach(7)
|
||||
updated, _ := next.Update(testMouseClick(tea.MouseLeft, 5, 5))
|
||||
next = updated.(model)
|
||||
if next.terminalAttach == nil {
|
||||
t.Fatal("mouse click detached the session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPtyInputBytes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"printable", testKeyText("a"), "a", true},
|
||||
{"space", testKey(tea.KeySpace), " ", true},
|
||||
{"enter", testKey(tea.KeyEnter), "\r", true},
|
||||
{"tab", testKey(tea.KeyTab), "\t", true},
|
||||
{"backspace", testKey(tea.KeyBackspace), "\x7f", true},
|
||||
{"delete", testKey(tea.KeyDelete), "\x1b[3~", true},
|
||||
{"up", testKey(tea.KeyUp), "\x1b[A", true},
|
||||
{"down", testKey(tea.KeyDown), "\x1b[B", true},
|
||||
{"right", testKey(tea.KeyRight), "\x1b[C", true},
|
||||
{"left", testKey(tea.KeyLeft), "\x1b[D", true},
|
||||
{"home", testKey(tea.KeyHome), "\x1b[H", true},
|
||||
{"end", testKey(tea.KeyEnd), "\x1b[F", true},
|
||||
{"pgup", testKey(tea.KeyPgUp), "\x1b[5~", true},
|
||||
{"pgdn", testKey(tea.KeyPgDown), "\x1b[6~", true},
|
||||
{"ctrl+c", testKeyCtrl('c'), "\x03", true},
|
||||
{"ctrl+d", testKeyCtrl('d'), "\x04", true},
|
||||
{"ctrl+a", testKeyCtrl('a'), "\x01", true},
|
||||
{"alt+x", testKeyAltText("x"), "\x1bx", true},
|
||||
{"esc unmapped", testKey(tea.KeyEsc), "", false},
|
||||
{"ctrl+1 unmapped", testKeyCtrl('1'), "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := ptyInputBytes(tc.msg)
|
||||
if ok != tc.ok || string(got) != tc.want {
|
||||
t.Fatalf("ptyInputBytes = %q, %v; want %q, %v", got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTerminalTail(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
width int
|
||||
rows int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "tail and padding",
|
||||
raw: "one\ntwo\nthree",
|
||||
width: 10, rows: 4,
|
||||
want: []string{"one", "two", "three", ""},
|
||||
},
|
||||
{
|
||||
name: "keeps last rows",
|
||||
raw: "a\nb\nc\nd",
|
||||
width: 10, rows: 2,
|
||||
want: []string{"c", "d"},
|
||||
},
|
||||
{
|
||||
name: "carriage return overwrites",
|
||||
raw: "progress 10%\rprogress 90%",
|
||||
width: 20, rows: 1,
|
||||
want: []string{"progress 90%"},
|
||||
},
|
||||
{
|
||||
name: "shorter overwrite keeps remainder",
|
||||
raw: "downloading...\r100%",
|
||||
width: 20, rows: 1,
|
||||
want: []string{"100%loading..."},
|
||||
},
|
||||
{
|
||||
name: "crlf normalized",
|
||||
raw: "line1\r\nline2",
|
||||
width: 10, rows: 2,
|
||||
want: []string{"line1", "line2"},
|
||||
},
|
||||
{
|
||||
name: "ansi stripped",
|
||||
raw: "\x1b[31mred\x1b[0m plain",
|
||||
width: 20, rows: 1,
|
||||
want: []string{"red plain"},
|
||||
},
|
||||
{
|
||||
name: "control runes dropped, tab expanded",
|
||||
raw: "a\x07b\tc",
|
||||
width: 20, rows: 1,
|
||||
want: []string{"ab c"},
|
||||
},
|
||||
{
|
||||
name: "width truncation",
|
||||
raw: "abcdefghijklmnop",
|
||||
width: 5, rows: 1,
|
||||
want: []string{"abcd…"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := renderTerminalTail(tc.raw, tc.width, tc.rows)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("renderTerminalTail = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("renderTerminalTail[%d] = %q, want %q (all: %#v)", i, got[i], tc.want[i], got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1160,7 +1160,7 @@ func (m model) transcriptHitTestSource() (header string, items []transcriptBodyI
|
||||
// outright — a modal/overlay is up, or there's no alt-screen viewport at all.
|
||||
func (m model) transcriptHitTestBlocked() bool {
|
||||
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
|
||||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.suggestionsActive()
|
||||
}
|
||||
|
||||
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,
|
||||
|
||||
+42
-1
@@ -773,7 +773,7 @@ func (m model) pickerOverlay(width int) string {
|
||||
overlayWidth = width
|
||||
}
|
||||
innerWidth := maxInt(1, overlayWidth-4)
|
||||
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
|
||||
maxVisible := pickerMaxVisible(m.picker, width)
|
||||
start := 0
|
||||
visible := []pickerItem{}
|
||||
if len(m.picker.items) > 0 {
|
||||
@@ -781,6 +781,7 @@ func (m model) pickerOverlay(width int) string {
|
||||
start = selectableListStart(len(m.picker.items), maxVisible, m.picker.selected)
|
||||
visible = m.picker.items[start : start+maxVisible]
|
||||
}
|
||||
showDetails := pickerShowsDetails(m.picker, width)
|
||||
|
||||
lines := make([]string, 0, len(visible)+7)
|
||||
title := strings.TrimSpace(m.picker.title)
|
||||
@@ -821,6 +822,16 @@ func (m model) pickerOverlay(width int) string {
|
||||
gap := innerWidth - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right
|
||||
lines = append(lines, fitStyledLine(line, innerWidth))
|
||||
if showDetails && item.Detail != "" {
|
||||
// The detail line sits under the row on the same selection band,
|
||||
// indented to the label column so it reads as part of the item.
|
||||
// Pad the remainder like the row above so the band is full-width.
|
||||
detailText := " " + item.Detail
|
||||
detailGap := innerWidth - lipgloss.Width(detailText)
|
||||
detail := surface(zeroTheme.faint).Render(detailText) +
|
||||
surface(zeroTheme.faint).Render(strings.Repeat(" ", maxInt(0, detailGap)))
|
||||
lines = append(lines, fitStyledLine(detail, innerWidth))
|
||||
}
|
||||
}
|
||||
if len(visible) == 0 {
|
||||
if m.picker.loading {
|
||||
@@ -845,6 +856,36 @@ func (m model) pickerOverlay(width int) string {
|
||||
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
|
||||
}
|
||||
|
||||
// pickerShowsDetails reports whether the overlay renders each row's optional
|
||||
// Detail line: only when a row actually carries one and the terminal is at
|
||||
// least medium width, where the two-line treatment reads cleanly. Narrower
|
||||
// tiers keep the single-line list fallback.
|
||||
func pickerShowsDetails(p *commandPicker, width int) bool {
|
||||
if p == nil || widthTier(width) < tierMedium {
|
||||
return false
|
||||
}
|
||||
for _, item := range p.items {
|
||||
if item.Detail != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pickerMaxVisible caps the visible window in items. When detail lines render,
|
||||
// each item costs two rows, so the budget halves to keep the overlay the same
|
||||
// height as the single-line list.
|
||||
func pickerMaxVisible(p *commandPicker, width int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
budget := pickerOverlayMaxVisible
|
||||
if pickerShowsDetails(p, width) {
|
||||
budget = pickerOverlayMaxVisible / 2
|
||||
}
|
||||
return minInt(budget, len(p.items))
|
||||
}
|
||||
|
||||
// themePickerOverlay keeps candidate rendering inside the picker. Moving through
|
||||
// themes therefore shows their hierarchy and code treatment without repainting the
|
||||
// transcript, terminal canvas, or any other active UI.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Zero — a fast, multi-provider terminal coding agent.",
|
||||
"type": "module",
|
||||
"module": "bin/zero.js",
|
||||
|
||||
Reference in new issue
Block a user