mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2510c59269 | ||
|
|
b9fc003d09 | ||
|
|
0c9125d5a6 | ||
|
|
902f171e31 | ||
|
|
516653bf84 | ||
|
|
aadb4a27e9 | ||
|
|
a6ec0435e3 |
No files matched your search
@@ -142,13 +142,15 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
|
||||
// session/request_permission request from a ZERO permission request.
|
||||
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
||||
args := marshalArgs(req.Args)
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
ToolCallID: req.ToolCallID,
|
||||
Title: toolTitle(req.ToolName, string(args)),
|
||||
Kind: toolKindFor(req.ToolName),
|
||||
Status: ToolStatusPending,
|
||||
RawInput: rawInputBytes(args),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, req.ToolName)
|
||||
return upd
|
||||
}
|
||||
|
||||
func marshalArgs(args map[string]any) []byte {
|
||||
|
||||
@@ -82,3 +82,17 @@ func TestPermissionToolCall(t *testing.T) {
|
||||
t.Error("expected rawInput from args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
|
||||
call := permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "browser-1",
|
||||
ToolName: "browser_connect",
|
||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
||||
})
|
||||
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
|
||||
t.Fatalf("browser descriptor = %#v", got)
|
||||
}
|
||||
if call.Title != "browser connect" {
|
||||
t.Fatalf("title = %q", call.Title)
|
||||
}
|
||||
}
|
||||
+119
-1
@@ -2,7 +2,9 @@ package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -44,12 +46,125 @@ func toolKindFor(name string) string {
|
||||
|
||||
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
||||
func toolTitle(name, rawArgs string) string {
|
||||
if browser, ok := browserToolDetails(name); ok {
|
||||
return browserToolTitle(browser.Command, rawArgs)
|
||||
}
|
||||
if hint := primaryArgHint(rawArgs); hint != "" {
|
||||
return name + " " + hint
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// browserToolDetails identifies ZERO's local browser helpers without treating
|
||||
// similarly named MCP tools as browser automation. The descriptor intentionally
|
||||
// contains no request data: ACP tool input is already protocol-visible, but a
|
||||
// durable UI must not need to retain text, local CDP targets, or full URLs just
|
||||
// to recognise the browser operation.
|
||||
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
|
||||
const prefix = "browser_"
|
||||
command, ok := strings.CutPrefix(name, prefix)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch command {
|
||||
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
|
||||
return &BrowserToolDetails{Version: 1, Command: command}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
|
||||
|
||||
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
|
||||
// extension channel. Keeping this in one helper prevents start, result, and
|
||||
// permission payloads from drifting onto different wire shapes.
|
||||
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
|
||||
browser, ok := browserToolDetails(name)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(browser)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
|
||||
}
|
||||
|
||||
// browserToolTitle avoids putting browser_type text, an attached DevTools
|
||||
// endpoint, or a URL query/fragment in a tool-card title. Those values can
|
||||
// carry credentials or session data; the UI only needs the operation and, for
|
||||
// navigation, a human-recognisable origin.
|
||||
func browserToolTitle(command, rawArgs string) string {
|
||||
switch command {
|
||||
case "action":
|
||||
action, ok := exactJSONStringArg(rawArgs, "command")
|
||||
if !ok {
|
||||
return "browser action"
|
||||
}
|
||||
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
|
||||
return "browser action " + action
|
||||
}
|
||||
return "browser action"
|
||||
case "open":
|
||||
rawURL, ok := exactJSONStringArg(rawArgs, "url")
|
||||
if !ok {
|
||||
return "browser open"
|
||||
}
|
||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
||||
if err != nil {
|
||||
return "browser open"
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "browser open"
|
||||
}
|
||||
origin := u.Scheme + "://" + u.Host
|
||||
if !browserTitleTextSafe(origin) {
|
||||
return "browser open"
|
||||
}
|
||||
return "browser open " + truncateHint(origin)
|
||||
default:
|
||||
return "browser " + command
|
||||
}
|
||||
}
|
||||
|
||||
// browserTitleTextSafe validates text after URL parsing has decoded escaped
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
|
||||
// format/bidi, and line/paragraph separator runes can reorder or split the
|
||||
// permission label shown to a user. The execution URL remains unchanged.
|
||||
func browserTitleTextSafe(text string) bool {
|
||||
if !utf8.ValidString(text) {
|
||||
return false
|
||||
}
|
||||
for _, r := range text {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
|
||||
// exact JSON key is considered, and a non-string value is invalid. In
|
||||
// particular, an incidental "URL" key must not change a permission title when
|
||||
// browser_open will only read "url".
|
||||
func exactJSONStringArg(rawArgs, key string) (string, bool) {
|
||||
var args map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(rawArgs), &args) != nil {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := args[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
|
||||
// raw JSON arguments. Best-effort; returns "" when it can't parse.
|
||||
func primaryArgHint(rawArgs string) string {
|
||||
@@ -89,7 +204,7 @@ func rawInput(args string) json.RawMessage {
|
||||
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
|
||||
// update (status in_progress — ZERO executes immediately after advertising).
|
||||
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
return ToolCallUpdate{
|
||||
upd := ToolCallUpdate{
|
||||
SessionUpdate: UpdateToolCall,
|
||||
ToolCallID: call.ID,
|
||||
Title: toolTitle(call.Name, call.Arguments),
|
||||
@@ -97,6 +212,8 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
Status: ToolStatusInProgress,
|
||||
RawInput: rawInput(call.Arguments),
|
||||
}
|
||||
attachBrowserToolDetails(&upd, call.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
||||
@@ -116,6 +233,7 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
||||
if locs := toolResultLocations(result); len(locs) > 0 {
|
||||
upd.Locations = locs
|
||||
}
|
||||
attachBrowserToolDetails(&upd, result.Name)
|
||||
return upd
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
|
||||
t.Helper()
|
||||
raw, ok := update.Meta[zeroBrowserMetaKey]
|
||||
if !ok {
|
||||
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
|
||||
}
|
||||
var details BrowserToolDetails
|
||||
if err := json.Unmarshal(raw, &details); err != nil {
|
||||
t.Fatalf("decode browser metadata: %v", err)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
||||
m := agentMessageChunk("hello")
|
||||
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
||||
@@ -56,6 +71,217 @@ func TestToolTitleAndHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
|
||||
start := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-1",
|
||||
Name: "browser_open",
|
||||
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
|
||||
})
|
||||
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
|
||||
t.Fatalf("browser descriptor = %#v, want open", got)
|
||||
}
|
||||
if start.Title != "browser open https://example.com" {
|
||||
t.Fatalf("browser title = %q", start.Title)
|
||||
}
|
||||
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
|
||||
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
|
||||
}
|
||||
encoded, err := json.Marshal(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire struct {
|
||||
Meta map[string]json.RawMessage `json:"_meta"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
|
||||
t.Fatalf("browser wire metadata = %#v", wire.Meta)
|
||||
}
|
||||
|
||||
typed := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-2",
|
||||
Name: "browser_type",
|
||||
Arguments: `{"ref":"email","text":"[email protected]"}`,
|
||||
})
|
||||
if got := browserDescriptor(t, typed); got.Command != "type" {
|
||||
t.Fatalf("browser type descriptor = %#v", got)
|
||||
}
|
||||
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
|
||||
t.Fatalf("browser type title = %q", typed.Title)
|
||||
}
|
||||
|
||||
action := toolCallStart(agent.ToolCall{
|
||||
ID: "browser-3",
|
||||
Name: "browser_action",
|
||||
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
|
||||
})
|
||||
if action.Title != "browser action keyboard_insert_text" {
|
||||
t.Fatalf("browser action title = %q", action.Title)
|
||||
}
|
||||
|
||||
result := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "browser-2",
|
||||
Name: "browser_type",
|
||||
Status: tools.StatusOK,
|
||||
})
|
||||
if got := browserDescriptor(t, result); got.Command != "type" {
|
||||
t.Fatalf("browser result descriptor = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{
|
||||
ID: "start",
|
||||
Name: "browser_open",
|
||||
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
|
||||
}),
|
||||
toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "result",
|
||||
Name: "browser_type",
|
||||
Status: tools.StatusOK,
|
||||
}),
|
||||
permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "permission",
|
||||
ToolName: "browser_connect",
|
||||
Args: map[string]any{"target": "127.0.0.1:9222"},
|
||||
}),
|
||||
}
|
||||
|
||||
type protocolToolCallUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
encoded, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(encoded, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := root["browser"]; ok {
|
||||
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
|
||||
}
|
||||
|
||||
var protocol protocolToolCallUpdate
|
||||
if err := json.Unmarshal(encoded, &protocol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarded, err := json.Marshal(protocol)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roundTripped ToolCallUpdate
|
||||
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
details := browserDescriptor(t, roundTripped)
|
||||
if details.Version != 1 || details.Command == "" {
|
||||
t.Fatalf("round-tripped browser descriptor = %#v", details)
|
||||
}
|
||||
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
|
||||
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
|
||||
if strings.Contains(descriptorJSON, secret) {
|
||||
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
|
||||
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
|
||||
t.Fatalf("bare-host title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
|
||||
t.Fatalf("case-variant URL title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("open", `{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`); got != "browser open https://actual.example.test" {
|
||||
t.Fatalf("exact URL key title = %q", got)
|
||||
}
|
||||
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
|
||||
t.Fatalf("unknown browser action title = %q", got)
|
||||
}
|
||||
|
||||
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
|
||||
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
|
||||
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
|
||||
t.Fatalf("bounded browser origin title = %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
|
||||
for _, rawURL := range []string{
|
||||
"https://safe.example%E2%80%AEevil.test/path",
|
||||
"https://safe.example%E2%81%A6evil.test/path",
|
||||
"https://safe.example%C2%85evil.test/path",
|
||||
"https://safe.example%E2%80%A8evil.test/path",
|
||||
"https://safe.example%E2%80%A9evil.test/path",
|
||||
} {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("execution URL rejected: %v", err)
|
||||
}
|
||||
if normalized != rawURL {
|
||||
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
|
||||
}
|
||||
|
||||
args, err := json.Marshal(map[string]any{"url": rawURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
|
||||
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
|
||||
}
|
||||
for _, update := range updates {
|
||||
encoded, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded ToolCallUpdate
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Title != "browser open" {
|
||||
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
|
||||
}
|
||||
for _, r := range decoded.Title {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
|
||||
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
|
||||
if len(start.Meta) != 0 {
|
||||
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
|
||||
}
|
||||
encoded, err := json.Marshal(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), `"browser"`) {
|
||||
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStart(t *testing.T) {
|
||||
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
||||
if upd.SessionUpdate != UpdateToolCall {
|
||||
|
||||
@@ -210,6 +210,21 @@ type ToolCallUpdate struct {
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
|
||||
// namespaced key so protocol-shaped clients can preserve them while decoding
|
||||
// and re-encoding a tool call.
|
||||
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserToolDetails identifies the browser helper operation behind a tool
|
||||
// call. Version is the schema version for this optional ZERO extension;
|
||||
// Command is one of install, launch, connect, open, snapshot, click, type,
|
||||
// press, or action. Future fields must remain display-safe and must not
|
||||
// include browser profile data, cookies, typed text, URL paths/queries, or
|
||||
// DevTools endpoints.
|
||||
type BrowserToolDetails struct {
|
||||
Version int `json:"version"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
||||
|
||||
+31
-12
@@ -76,12 +76,17 @@ type BuildArgsInput struct {
|
||||
}
|
||||
|
||||
type BuildResumeArgsInput struct {
|
||||
SessionID string
|
||||
Prompt string
|
||||
CurrentDepth int
|
||||
Manifest Manifest
|
||||
Cwd string
|
||||
PermissionMode string
|
||||
SessionID string
|
||||
Prompt string
|
||||
CurrentDepth int
|
||||
Manifest Manifest
|
||||
Cwd string
|
||||
// ParentModel and ParentReasoningEffort are the same fallbacks the fresh
|
||||
// path takes, and they are here for the same reason: appendModelArgs uses
|
||||
// them only when the manifest pins nothing of its own.
|
||||
ParentModel string
|
||||
ParentReasoningEffort string
|
||||
PermissionMode string
|
||||
}
|
||||
|
||||
type BuildArgsResult struct {
|
||||
@@ -344,6 +349,18 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR
|
||||
}
|
||||
args := []string{"exec", "--resume", sessionID}
|
||||
args = append(args, promptArgs...)
|
||||
// A RESUMED SPECIALIST KEEPS THE MODEL ITS MANIFEST PINNED.
|
||||
//
|
||||
// The fresh path appends this and the resume path did not, so a specialist
|
||||
// pinned to a cheap model ran on that model once and then silently reverted
|
||||
// to the parent's configured model on every resume. Nothing reports it: the
|
||||
// child starts normally and the only symptom is the bill.
|
||||
//
|
||||
// Resuming does not restore the recorded model on its own. sessions.PrepareExec
|
||||
// records the model it ran under but never feeds it back into provider
|
||||
// construction, so without this the child takes whatever the config default
|
||||
// resolves to now.
|
||||
args = appendModelArgs(args, input.Manifest, input.ParentModel, input.ParentReasoningEffort)
|
||||
args = append(args, "--auto", specialistAutonomy(input.PermissionMode), "--output-format", "stream-json")
|
||||
// See BuildArgs: only plan/spec-draft propagate --permission-mode so --auto
|
||||
// remains the authority for member/auto/ask/unsafe child resolution.
|
||||
@@ -424,12 +441,14 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o
|
||||
return ExecResult{}, err
|
||||
}
|
||||
built, err := executor.BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: params.Resume,
|
||||
Prompt: params.Prompt,
|
||||
CurrentDepth: options.CurrentDepth,
|
||||
Manifest: manifest,
|
||||
Cwd: options.Cwd,
|
||||
PermissionMode: options.PermissionMode,
|
||||
SessionID: params.Resume,
|
||||
Prompt: params.Prompt,
|
||||
CurrentDepth: options.CurrentDepth,
|
||||
Manifest: manifest,
|
||||
Cwd: options.Cwd,
|
||||
ParentModel: options.ParentModel,
|
||||
ParentReasoningEffort: options.ParentReasoningEffort,
|
||||
PermissionMode: options.PermissionMode,
|
||||
})
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package specialist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
)
|
||||
|
||||
// argValue returns the value following flag in argv, and whether it was present.
|
||||
func argValue(args []string, flag string) (string, bool) {
|
||||
for index, arg := range args {
|
||||
if arg == flag && index+1 < len(args) {
|
||||
return args[index+1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func pinnedManifest(model string, effort string) Manifest {
|
||||
return Manifest{
|
||||
Metadata: Metadata{
|
||||
Name: "skim",
|
||||
Description: "Bounded read-only lookups.",
|
||||
Model: model,
|
||||
ReasoningEffort: effort,
|
||||
Tools: []string{"read_file"},
|
||||
},
|
||||
SystemPrompt: "Find the thing and stop.",
|
||||
ResolvedTools: []string{"read_file"},
|
||||
}
|
||||
}
|
||||
|
||||
// A RESUMED SPECIALIST KEEPS THE MODEL ITS MANIFEST PINNED.
|
||||
//
|
||||
// Metadata.Model exists so a bounded, delegated task can run on a cheaper model
|
||||
// than its parent. BuildArgs appended it; BuildResumeArgs did not. So the
|
||||
// specialist ran on the cheap model once, and the moment the orchestrator
|
||||
// resumed it the child fell back to whatever the parent's configured model
|
||||
// resolved to. Nothing surfaced it: the resumed child starts normally, does the
|
||||
// work, and the only symptom is the bill.
|
||||
//
|
||||
// Resuming does not restore the recorded model on its own, which is why the flag
|
||||
// has to be passed again rather than relied upon.
|
||||
func TestAResumedSpecialistKeepsItsPinnedModel(t *testing.T) {
|
||||
manifest := pinnedManifest("claude-haiku-4.5", "")
|
||||
|
||||
fresh, err := (Executor{}).BuildArgs(BuildArgsInput{
|
||||
Manifest: manifest,
|
||||
Prompt: "find the thing",
|
||||
CurrentDepth: 0,
|
||||
ParentModel: "claude-opus-4.1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildArgs: %v", err)
|
||||
}
|
||||
// SETUP: the fresh path really does pin it, or the comparison below is
|
||||
// asserting against a path that never worked either.
|
||||
freshModel, ok := argValue(fresh.Args, "--model")
|
||||
if !ok || freshModel != "claude-haiku-4.5" {
|
||||
t.Fatalf("SETUP INVALID: the fresh path passed --model %q (present=%t), want the manifest's model", freshModel, ok)
|
||||
}
|
||||
|
||||
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
Prompt: "keep going",
|
||||
CurrentDepth: 0,
|
||||
Manifest: manifest,
|
||||
ParentModel: "claude-opus-4.1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildResumeArgs: %v", err)
|
||||
}
|
||||
resumedModel, ok := argValue(resumed.Args, "--model")
|
||||
if !ok {
|
||||
t.Fatalf("a resumed specialist carries no --model at all, so it reverts to the parent's configured model: %v", resumed.Args)
|
||||
}
|
||||
if resumedModel != "claude-haiku-4.5" {
|
||||
t.Fatalf("a resumed specialist runs on %q, want the manifest's pinned %q", resumedModel, "claude-haiku-4.5")
|
||||
}
|
||||
}
|
||||
|
||||
// And a manifest that pins nothing still inherits the parent's model, which is
|
||||
// what makes the fallback in appendModelArgs meaningful rather than the pinned
|
||||
// case being special-cased.
|
||||
func TestAResumedSpecialistWithNoPinInheritsTheParentModel(t *testing.T) {
|
||||
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
Prompt: "keep going",
|
||||
CurrentDepth: 0,
|
||||
Manifest: pinnedManifest("", ""),
|
||||
ParentModel: "claude-opus-4.1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildResumeArgs: %v", err)
|
||||
}
|
||||
model, ok := argValue(resumed.Args, "--model")
|
||||
if !ok || model != "claude-opus-4.1" {
|
||||
t.Fatalf("a resumed specialist with no pinned model passed --model %q (present=%t), want the parent's", model, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// The reasoning-effort rule travels with the model, or the two paths disagree
|
||||
// about what a pinned model implies. appendModelArgs inherits the parent's
|
||||
// effort ONLY when the manifest pins no model of its own: a manifest that chose
|
||||
// a different model has not agreed to the parent's effort for it.
|
||||
func TestAResumedSpecialistFollowsTheSameReasoningEffortRule(t *testing.T) {
|
||||
t.Run("pinned model does not inherit parent effort", func(t *testing.T) {
|
||||
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
Prompt: "keep going",
|
||||
CurrentDepth: 0,
|
||||
Manifest: pinnedManifest("claude-haiku-4.5", ""),
|
||||
ParentModel: "claude-opus-4.1",
|
||||
ParentReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildResumeArgs: %v", err)
|
||||
}
|
||||
if effort, ok := argValue(resumed.Args, "--reasoning-effort"); ok {
|
||||
t.Fatalf("a manifest that pinned its own model inherited the parent's effort %q", effort)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no pinned model inherits parent effort", func(t *testing.T) {
|
||||
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
Prompt: "keep going",
|
||||
CurrentDepth: 0,
|
||||
Manifest: pinnedManifest("", ""),
|
||||
ParentModel: "claude-opus-4.1",
|
||||
ParentReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildResumeArgs: %v", err)
|
||||
}
|
||||
effort, ok := argValue(resumed.Args, "--reasoning-effort")
|
||||
if !ok || effort != "high" {
|
||||
t.Fatalf("a manifest pinning nothing passed --reasoning-effort %q (present=%t), want the parent's", effort, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The two builders agree on where the flag sits relative to the rest of the
|
||||
// argv, so a future change to one does not silently reorder only the other.
|
||||
func TestBothBuildersPlaceTheModelBeforeTheAutonomyFlag(t *testing.T) {
|
||||
manifest := pinnedManifest("claude-haiku-4.5", "")
|
||||
fresh, err := (Executor{}).BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "go", CurrentDepth: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildArgs: %v", err)
|
||||
}
|
||||
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
|
||||
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ", Prompt: "go", CurrentDepth: 0, Manifest: manifest,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildResumeArgs: %v", err)
|
||||
}
|
||||
for _, argv := range [][]string{fresh.Args, resumed.Args} {
|
||||
model := indexOf(argv, "--model")
|
||||
auto := indexOf(argv, "--auto")
|
||||
if model < 0 || auto < 0 || model > auto {
|
||||
t.Fatalf("--model at %d, --auto at %d in %s", model, auto, strings.Join(argv, " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(args []string, want string) int {
|
||||
for index, arg := range args {
|
||||
if arg == want {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// AND THE CALL SITE PASSES IT, WHICH THE BUILDER TESTS ABOVE CANNOT SEE.
|
||||
//
|
||||
// Every test above calls BuildResumeArgs directly. Dropping the ParentModel
|
||||
// field from the runResume call site still compiles and still passes all of
|
||||
// them, because the builder is doing its job with whatever it is handed. The
|
||||
// defect this fix repairs lived at the call site, not in the builder, so it
|
||||
// needs a test that goes through Run.
|
||||
//
|
||||
// Driven through the real Run dispatch with the RunChild seam capturing argv.
|
||||
func TestRunResumePassesTheParentModelThroughToTheChild(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
if _, err := store.Create(sessions.CreateInput{
|
||||
SessionID: "child_task",
|
||||
SessionKind: sessions.SessionKindChild,
|
||||
Tag: SessionTagSpecialist,
|
||||
Depth: 1,
|
||||
ParentSessionID: parent.SessionID,
|
||||
AgentName: "skim",
|
||||
TaskID: "child_task",
|
||||
}); err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
|
||||
zero := 0
|
||||
var captured []string
|
||||
executor := Executor{
|
||||
BinaryPath: "/usr/local/bin/zero",
|
||||
SessionStore: store,
|
||||
NewSessionID: func() (string, error) { return "child_task", nil },
|
||||
Load: func(LoadOptions) (LoadResult, error) {
|
||||
// No pinned model, so the parent's is the only thing that can supply one.
|
||||
return LoadResult{Specialists: []Manifest{pinnedManifest("", "")}}, nil
|
||||
},
|
||||
RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) {
|
||||
captured = append([]string(nil), args...)
|
||||
return ChildRunResult{
|
||||
Events: []streamjson.Event{
|
||||
{Type: streamjson.EventRunStart, RunID: "run_1", SessionID: "child_task"},
|
||||
{Type: streamjson.EventFinal, RunID: "run_1", Text: "done"},
|
||||
{Type: streamjson.EventRunEnd, RunID: "run_1", Status: "success", ExitCode: &zero},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := executor.Run(context.Background(), TaskParameters{
|
||||
Name: "skim",
|
||||
Prompt: "keep going",
|
||||
Resume: "child_task",
|
||||
}, TaskRunOptions{
|
||||
ParentSessionID: parent.SessionID,
|
||||
ParentModel: "claude-opus-4.1",
|
||||
}); err != nil {
|
||||
t.Fatalf("Run(resume): %v", err)
|
||||
}
|
||||
|
||||
// SETUP: this really was the resume path, not a fresh spawn.
|
||||
if index := indexOf(captured, "--resume"); index < 0 {
|
||||
t.Fatalf("SETUP INVALID: the child was not resumed: %v", captured)
|
||||
}
|
||||
model, ok := argValue(captured, "--model")
|
||||
if !ok {
|
||||
t.Fatalf("the resumed child was launched with no --model, so it runs on whatever the config default resolves to: %v", captured)
|
||||
}
|
||||
if model != "claude-opus-4.1" {
|
||||
t.Fatalf("the resumed child was launched with --model %q, want the parent's %q", model, "claude-opus-4.1")
|
||||
}
|
||||
}
|
||||
|
||||
// THE FRESH CALL SITE NEEDS THE SAME GUARD, FOR THE SAME REASON.
|
||||
//
|
||||
// runFresh and runResume both construct their builder input by hand, and both
|
||||
// have a byte-identical ParentModel line. Deleting either one compiles. The
|
||||
// resume half is what this change repairs; this covers the other half so the
|
||||
// pair cannot drift again in the direction nobody was looking.
|
||||
func TestRunFreshPassesTheParentModelThroughToTheChild(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
|
||||
zero := 0
|
||||
var captured []string
|
||||
executor := Executor{
|
||||
BinaryPath: "/usr/local/bin/zero",
|
||||
SessionStore: store,
|
||||
NewSessionID: func() (string, error) { return "child_task", nil },
|
||||
Load: func(LoadOptions) (LoadResult, error) {
|
||||
return LoadResult{Specialists: []Manifest{pinnedManifest("", "")}}, nil
|
||||
},
|
||||
RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) {
|
||||
captured = append([]string(nil), args...)
|
||||
return ChildRunResult{
|
||||
Events: []streamjson.Event{
|
||||
{Type: streamjson.EventRunStart, RunID: "run_1", SessionID: "child_task"},
|
||||
{Type: streamjson.EventFinal, RunID: "run_1", Text: "done"},
|
||||
{Type: streamjson.EventRunEnd, RunID: "run_1", Status: "success", ExitCode: &zero},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := executor.Run(context.Background(), TaskParameters{
|
||||
Name: "skim",
|
||||
Prompt: "find the thing",
|
||||
}, TaskRunOptions{
|
||||
ParentSessionID: parent.SessionID,
|
||||
ParentModel: "claude-opus-4.1",
|
||||
}); err != nil {
|
||||
t.Fatalf("Run(fresh): %v", err)
|
||||
}
|
||||
|
||||
// SETUP: a fresh spawn, not a resume, or this covers the wrong site.
|
||||
if indexOf(captured, "--resume") >= 0 {
|
||||
t.Fatalf("SETUP INVALID: the child was resumed rather than freshly spawned: %v", captured)
|
||||
}
|
||||
model, ok := argValue(captured, "--model")
|
||||
if !ok || model != "claude-opus-4.1" {
|
||||
t.Fatalf("the fresh child was launched with --model %q (present=%t), want the parent's", model, ok)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ func runBashToolHelper(command string) {
|
||||
fmt.Println("listening", listener.Addr().String())
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
// Printed per request, so a test can prove the session delivers output
|
||||
// mid-flight: this line cannot exist before the first read has happened.
|
||||
fmt.Println("served", request.URL.Path)
|
||||
_, _ = response.Write([]byte("zero-server-ok"))
|
||||
}),
|
||||
}
|
||||
@@ -522,7 +525,14 @@ func TestBashToolTimeoutKillsBackgroundChildren(t *testing.T) {
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected timeout error status, got %s: %q", result.Status, result.Output)
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
// The regression this guards is Run blocking until the background child's own
|
||||
// sleep ends, so the bound only has to sit under that. One second sat almost
|
||||
// exactly on it: the call legitimately costs a sandbox plan, a fork and exec of
|
||||
// a shell plus a subshell, the 300ms timeout itself and the post-kill drain,
|
||||
// and spawn alone was measured here at 720ms to 1.44s with every core busy. A
|
||||
// merely slow runner therefore failed a correct system. Three seconds is still
|
||||
// far below the child's lifetime, so nothing is given up.
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("Run blocked %s past the 300ms timeout; background child held the pipes", elapsed)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -373,10 +374,6 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("foreground server should return session_id, meta=%#v output=%q", start.Meta, start.Output)
|
||||
}
|
||||
addr := parseListeningAddress(start.Output)
|
||||
if addr == "" {
|
||||
t.Fatalf("server output did not include listening address: %q", start.Output)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
@@ -384,7 +381,12 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
response, err := http.Get("http://" + addr)
|
||||
addr := waitForListeningAddress(t, writeTool, sessionID, start.Output)
|
||||
|
||||
// Bounded, so a server that accepts a connection and never answers fails here
|
||||
// with its address instead of hanging until the package timeout kills the run.
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
response, err := client.Get("http://" + addr)
|
||||
if err != nil {
|
||||
t.Fatalf("foreground exec server was not reachable at %s: %v; output=%q", addr, err, start.Output)
|
||||
}
|
||||
@@ -396,6 +398,153 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
|
||||
if string(bytes) != "zero-server-ok" {
|
||||
t.Fatalf("server response = %q", string(bytes))
|
||||
}
|
||||
|
||||
// AND THE SESSION DELIVERS OUTPUT MID-FLIGHT, PINNED DETERMINISTICALLY.
|
||||
//
|
||||
// The helper prints this line only when it serves a request, so it cannot have
|
||||
// been in the first read: the request had not happened yet. It can only arrive
|
||||
// through a poll of a process that is still running, which is the delivery the
|
||||
// address parse used to cover by accident and only on a machine slow enough to
|
||||
// miss the first read. A regression where a live session buffers everything
|
||||
// until it exits leaves the rest of this test green and fails here.
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
var served strings.Builder
|
||||
for !strings.Contains(served.String(), "served") && time.Now().Before(deadline) {
|
||||
poll := writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
"chars": "",
|
||||
"yield_time_ms": 250,
|
||||
})
|
||||
served.WriteString(poll.Output)
|
||||
if poll.Status != StatusOK {
|
||||
t.Fatalf("the exec session was gone before it reported serving the request:\n%s", served.String())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(served.String(), "served") {
|
||||
t.Fatalf("a still-running session never delivered the line the server printed while serving:\n%s", served.String())
|
||||
}
|
||||
|
||||
// AND A READ DRAINS WHAT IT RETURNS.
|
||||
//
|
||||
// One more poll, because "the line showed up" is satisfied just as well by
|
||||
// output that is re-delivered forever. drain copies and then nils the buffer,
|
||||
// so this read carries nothing; if it stopped clearing, every session in
|
||||
// production would repeat its whole output on every poll while every other
|
||||
// assertion here stayed green. The manager-level test that asserts the same
|
||||
// count is skipped on Windows, so this is the only place guarding it there.
|
||||
again := writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
"chars": "",
|
||||
"yield_time_ms": 250,
|
||||
})
|
||||
if strings.Contains(again.Output, "served") {
|
||||
t.Errorf("a second read returned output the first had already delivered, so reads are no longer draining:\n%s", again.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForListeningAddress polls the exec session until the helper says where it
|
||||
// is listening.
|
||||
//
|
||||
// ONE YIELD IS AN ALLOWANCE, NOT A GUARANTEE, AND THIS TEST TREATED IT AS ONE.
|
||||
//
|
||||
// The helper is this test binary re-executed. The first read used to be its only
|
||||
// chance: 500ms to be spawned through the process manager, reach net.Listen and
|
||||
// have its line drained back. A Windows CI runner under load does not get there
|
||||
// in time, so the read returned "Command is still running." with no address and
|
||||
// the test failed as though the server were broken. It failed that way on
|
||||
// unrelated contributor PRs, which is worse than a slow test: it reports somebody
|
||||
// else's change as the fault.
|
||||
//
|
||||
// Polling is what the banner in that very output tells the caller to do, so it is
|
||||
// what this does, up to a bound far above any plausible start.
|
||||
//
|
||||
// AND IT STILL FAILS FAST WHEN THE FAILURE IS REAL. A session that has gone away
|
||||
// answers with StatusError, and a process that exited before listening answers
|
||||
// with an exit_code line. Either is a genuine defect, so neither is worth waiting
|
||||
// out: they end the loop immediately with what the session actually said.
|
||||
//
|
||||
// Each read DRAINS: managedProcess.collect returns only the bytes produced since
|
||||
// the previous call, so these chunks are incremental and none of them is the whole
|
||||
// output. Each is parsed on its own, which is sound because the helper writes its
|
||||
// address with a single Println and a write that small does not tear. The whole
|
||||
// transcript is kept anyway, so a failure shows every chunk rather than the last.
|
||||
func waitForListeningAddress(t *testing.T, writeTool Tool, sessionID int, first string) string {
|
||||
t.Helper()
|
||||
if addr := validListeningAddress(first); addr != "" {
|
||||
return addr
|
||||
}
|
||||
// AND WHEN IT DOES NOT, THIS TEST STOPS COVERING THE FIRST READ.
|
||||
//
|
||||
// That parse used to be the only assertion in the repo that a still-running
|
||||
// session's first read carries the child's stdout at all: every other test
|
||||
// either uses a tiny yield against a helper that prints later, or uses a large
|
||||
// one against a helper that exits, which is the collect-on-exit branch. The
|
||||
// manager-level equivalent is skipped on Windows because it shells out to
|
||||
// /bin/sh. The coverage cannot be pinned deterministically without
|
||||
// reintroducing the race, since collect() blocks the whole window either way,
|
||||
// so it is given up deliberately here and paid back after the request below,
|
||||
// where a line that cannot exist before the first read proves the poll path.
|
||||
transcript := []string{first}
|
||||
// Sixty seconds, not thirty. Starting this helper was measured here at 0.74s
|
||||
// to 4.79s with every core saturated, on a box faster than a CI runner, and
|
||||
// windows-latest additionally scans a freshly linked test binary on its first
|
||||
// execution. A deadline of the same order as the latency it tolerates is the
|
||||
// mistake that produced the 500ms budget; the point is a bound, and every
|
||||
// genuine failure below leaves before reaching it anyway.
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
poll := writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
"chars": "",
|
||||
"yield_time_ms": 250,
|
||||
})
|
||||
transcript = append(transcript, poll.Output)
|
||||
if addr := validListeningAddress(poll.Output); addr != "" {
|
||||
return addr
|
||||
}
|
||||
// FAST-FAIL, BECAUSE THE LOOP HAS NO PACING OF ITS OWN. All the spacing
|
||||
// comes from collect() waiting out its yield inside a LIVE session; every
|
||||
// error return happens in microseconds. Continue removes the process the
|
||||
// moment it observes the exit, so without these two the loop free-runs on
|
||||
// a dead id: measured at 3028 polls in 5 seconds, which on a 4-vCPU runner
|
||||
// would peg a core for the whole deadline and hand t.Fatalf hundreds of
|
||||
// kilobytes of the same sentence. The exit result is also delivered
|
||||
// exactly once, so folding it into the blob and continuing loses the one
|
||||
// poll that says what went wrong.
|
||||
if poll.Status != StatusOK {
|
||||
t.Fatalf("the exec session was gone before the server reported an address:\n%s", strings.Join(transcript, "\n--- poll ---\n"))
|
||||
}
|
||||
if strings.Contains(poll.Output, "exit_code:") {
|
||||
t.Fatalf("the server process exited before it reported an address:\n%s", strings.Join(transcript, "\n--- poll ---\n"))
|
||||
}
|
||||
}
|
||||
t.Fatalf("the server never reported a listening address within the deadline:\n%s", strings.Join(transcript, "\n--- poll ---\n"))
|
||||
return ""
|
||||
}
|
||||
|
||||
// validListeningAddress is parseListeningAddress plus the check that what it
|
||||
// found is actually an address.
|
||||
//
|
||||
// A read DRAINS, and the formatter wraps each drain in its own body, so a line
|
||||
// split across two reads arrives as two fragments with banner text wedged between
|
||||
// them and no concatenation can rejoin it. The formatter also terminates the
|
||||
// partial chunk, so the fragment looks like a whole line and the bare parse
|
||||
// returns a truncated address: the test would then fail on an unreachable port
|
||||
// and point the next reader at the network instead of at the parse. Rejecting
|
||||
// anything that is not host:port keeps polling instead.
|
||||
func validListeningAddress(output string) string {
|
||||
addr := parseListeningAddress(output)
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil || host == "" {
|
||||
return ""
|
||||
}
|
||||
if number, err := strconv.Atoi(port); err != nil || number <= 0 {
|
||||
return ""
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func parseListeningAddress(output string) string {
|
||||
@@ -427,7 +576,12 @@ func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
|
||||
t.Fatalf("session_id is not numeric: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
// Ten seconds, not two. Inside this the helper must cold-start, run its 250ms
|
||||
// sleep, exit and pass the reap, and a cold start alone was measured at up to
|
||||
// 1.44s under load: two seconds was a budget with almost nothing left in it. The
|
||||
// loop returns as soon as the session is gone, so the bound only costs anything
|
||||
// when the reap genuinely never happens.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if _, ok := manager.Snapshot(sessionID); !ok {
|
||||
return
|
||||
@@ -739,6 +893,13 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
|
||||
t.Fatalf("session_id is not numeric: %v", err)
|
||||
}
|
||||
|
||||
// SAME SHAPE AS THE SERVER TEST ABOVE, one window narrower.
|
||||
//
|
||||
// The shell is started with a 10ms yield, so it has almost certainly not
|
||||
// reached `read` yet, and the echo then had exactly one fixed 1000ms window to
|
||||
// travel back through the PTY. Writing early is fine, the terminal buffers it,
|
||||
// but a loaded runner can spend that whole second still getting the shell up.
|
||||
// So the echo is polled for rather than demanded within one window.
|
||||
result := writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
"chars": "hello\n",
|
||||
@@ -747,11 +908,39 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("write_stdin status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "got:hello") {
|
||||
t.Fatalf("expected PTY input output, got %q", result.Output)
|
||||
var transcript strings.Builder
|
||||
transcript.WriteString(result.Output)
|
||||
exitCode := result.Meta["exit_code"]
|
||||
// BOTH THE ECHO AND THE EXIT, because the test asserts both.
|
||||
//
|
||||
// collect can return the drained echo on a yield timer before markDone has
|
||||
// run, so a poll can carry got:hello with no exit_code meta yet. Stopping on
|
||||
// the echo alone would then assert an exit that had not been observed. The
|
||||
// condition deliberately keeps the echo term as well: gating on exit_code
|
||||
// alone would stop polling before a slow shell start delivers the echo, which
|
||||
// is the race this whole change exists to remove.
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for (!strings.Contains(transcript.String(), "got:hello") || exitCode == "") && time.Now().Before(deadline) {
|
||||
poll := writeTool.Run(context.Background(), map[string]any{
|
||||
"session_id": sessionID,
|
||||
"chars": "",
|
||||
"yield_time_ms": 250,
|
||||
})
|
||||
transcript.WriteString(poll.Output)
|
||||
if code := poll.Meta["exit_code"]; code != "" {
|
||||
exitCode = code
|
||||
}
|
||||
if poll.Status != StatusOK {
|
||||
// The session is gone. Either the echo already arrived, which the loop
|
||||
// condition will see, or it never will and the assertions below say so.
|
||||
break
|
||||
}
|
||||
}
|
||||
if result.Meta["exit_code"] != "0" {
|
||||
t.Fatalf("expected exited session, got meta=%#v output=%q", result.Meta, result.Output)
|
||||
if !strings.Contains(transcript.String(), "got:hello") {
|
||||
t.Fatalf("expected PTY input output, got %q", transcript.String())
|
||||
}
|
||||
if exitCode != "0" {
|
||||
t.Fatalf("expected exited session, got exit_code=%q output=%q", exitCode, transcript.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -548,11 +548,11 @@ func browserActionArgs(args map[string]any) (string, []string, error) {
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
command = strings.ToLower(strings.TrimSpace(command))
|
||||
spec, ok := browserActionSpecs[command]
|
||||
command, ok := NormalizedBrowserActionCommand(command)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("command must be one of: %s", strings.Join(browserActionCommandNames(), ", "))
|
||||
}
|
||||
spec := browserActionSpecs[command]
|
||||
values, err := stringArrayArg(args, "args")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
@@ -570,6 +570,16 @@ func browserActionArgs(args map[string]any) (string, []string, error) {
|
||||
return command, commandArgs, nil
|
||||
}
|
||||
|
||||
// NormalizedBrowserActionCommand returns the exact action that browser_action
|
||||
// will execute after normalizing its command argument. ACP uses it only for a
|
||||
// permission title, so an unrecognised command is never reflected as text that
|
||||
// the tool would reject.
|
||||
func NormalizedBrowserActionCommand(command string) (string, bool) {
|
||||
command = strings.ToLower(strings.TrimSpace(command))
|
||||
_, ok := browserActionSpecs[command]
|
||||
return command, ok
|
||||
}
|
||||
|
||||
func browserActionCommandArgs(command string, spec browserActionSpec, values []string) ([]string, error) {
|
||||
switch command {
|
||||
case "connect":
|
||||
@@ -657,6 +667,13 @@ func browserOpenURLArg(args map[string]any) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return NormalizeBrowserOpenURL(rawURL)
|
||||
}
|
||||
|
||||
// NormalizeBrowserOpenURL applies the browser_open URL rules before execution.
|
||||
// Keeping this exported within the internal package lets permission displays
|
||||
// describe the same destination the browser helper will open.
|
||||
func NormalizeBrowserOpenURL(rawURL string) (string, error) {
|
||||
normalized := strings.TrimSpace(rawURL)
|
||||
if !strings.Contains(normalized, "://") {
|
||||
normalized = "https://" + normalized
|
||||
|
||||
Reference in new issue
Block a user