Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -898,12 +898,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
tool, toolFound := registry.Get(call.Name)
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
modeName := string(permissionMode)
if permissionMode == PermissionModePlan {
modeName = "plan"
} else {
modeName = "spec-draft"
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`,
DenialReason: DenialFiltered,
}, nil
}
Expand Down Expand Up @@ -2857,6 +2863,9 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
if permissionMode == PermissionModeSpecDraft {
return toolAdvertisedInSpecDraft(tool)
}
if permissionMode == PermissionModePlan {
return toolAdvertisedInPlan(tool)
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
}
Expand Down Expand Up @@ -2889,6 +2898,18 @@ func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}

// toolAdvertisedInPlan mirrors toolAdvertisedInSpecDraft: the agent may only
// read the workspace, ask the user, and shape the plan with update_plan. No
// mutating tool is advertised, so plan mode stays strictly read-only.
func toolAdvertisedInPlan(tool tools.Tool) bool {
switch tool.Name() {
case "ask_user", "update_plan":
return true
}
safety := tool.Safety()
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}

func stopReasonFromToolResult(result ToolResult) StopReason {
if result.Meta == nil {
return ""
Expand Down
6 changes: 6 additions & 0 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const (
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode toggled from
// the TUI with /plan. It applies to the CURRENT session (unlike spec-draft,
// which drafts in a separate session): the agent may inspect the workspace
// and shape the plan with update_plan/ask_user, but no mutating tool is
// advertised, so it cannot write files, run shell, or implement while planning.
PermissionModePlan PermissionMode = "plan"
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
// advertises the in-workspace mutators a member needs to build (write/edit +
// shell) on top of the Auto set, while the sandbox engine still gates them at
Expand Down
31 changes: 30 additions & 1 deletion internal/cron/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
)

Expand Down Expand Up @@ -144,7 +146,7 @@ func (s *Store) writeJob(job Job) error {
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(dir, "metadata.json"))
return renameWithRetry(tmp, filepath.Join(dir, "metadata.json"))
}

func (s *Store) Get(id string) (Job, error) {
Expand Down Expand Up @@ -322,3 +324,30 @@ func (s *Store) Runs(id string) ([]RunRecord, error) {
}
return runs, scanner.Err()
}

func renameWithRetry(src, dst string) error {
var err error
for i := 0; i < 10; i++ {
err = os.Rename(src, dst)
if err == nil {
return nil
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
}
break
}
return err
}

func isWindowsSharingViolation(err error) bool {
var errno syscall.Errno
if errors.As(err, &errno) {
const ERROR_SHARING_VIOLATION syscall.Errno = 32
return errno == ERROR_SHARING_VIOLATION
}
return false
}
12 changes: 11 additions & 1 deletion internal/sandbox/windows_token_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,24 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
if err != nil {
return 0, fmt.Errorf("create world SID: %w", err)
}
usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid)
if err != nil {
return 0, fmt.Errorf("create users SID: %w", err)
}
authUserSID, err := windows.CreateWellKnownSid(windows.WinAuthenticatedUserSid)
if err != nil {
return 0, fmt.Errorf("create authenticated user SID: %w", err)
}

entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+2)
entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+4)
for _, sid := range capabilitySIDs {
entries = append(entries, windows.SIDAndAttributes{Sid: sid.sid})
}
entries = append(entries,
windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)},
windows.SIDAndAttributes{Sid: worldSID},
windows.SIDAndAttributes{Sid: usersSID},
windows.SIDAndAttributes{Sid: authUserSID},
)

var restricted windows.Token
Expand Down
32 changes: 31 additions & 1 deletion internal/sandbox/windows_unelevated.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
)

const windowsUnelevatedSetupMarkerSchemaVersion = 1
Expand Down Expand Up @@ -138,9 +141,36 @@ func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnele
_ = os.Remove(tmpPath)
return fmt.Errorf("close windows unelevated setup marker temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
if err := renameWithRetry(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("replace windows unelevated setup marker: %w", err)
}
return nil
}

func renameWithRetry(src, dst string) error {
var err error
for i := 0; i < 10; i++ {
err = os.Rename(src, dst)
if err == nil {
return nil
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
}
break
}
return err
}

func isWindowsSharingViolation(err error) bool {
var errno syscall.Errno
if errors.As(err, &errno) {
const ERROR_SHARING_VIOLATION syscall.Errno = 32
return errno == ERROR_SHARING_VIOLATION
}
return false
}
32 changes: 30 additions & 2 deletions internal/sessions/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)

Expand Down Expand Up @@ -841,7 +842,7 @@ func (store *Store) writeMetadata(session Metadata) error {
if err := writeFileSync(tmp, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write zero session metadata: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
if err := renameWithRetry(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace zero session metadata: %w", err)
}
Expand Down Expand Up @@ -904,7 +905,7 @@ func (store *Store) writeFileAtomicSync(path string, content []byte, perm os.Fil
if err := writeFileSync(tmp, content, perm); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
if err := renameWithRetry(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
Expand Down Expand Up @@ -1091,3 +1092,30 @@ func applySpecRecord(session *Metadata, input RecordSpecInput, status SpecStatus
session.SpecImplSessionID = implID
}
}

func renameWithRetry(src, dst string) error {
var err error
for i := 0; i < 10; i++ {
err = os.Rename(src, dst)
if err == nil {
return nil
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
}
break
}
return err
}

func isWindowsSharingViolation(err error) bool {
var errno syscall.Errno
if errors.As(err, &errno) {
const ERROR_SHARING_VIOLATION syscall.Errno = 32
return errno == ERROR_SHARING_VIOLATION
}
return false
}
31 changes: 30 additions & 1 deletion internal/swarm/mailbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync/atomic"
"syscall"
"time"
)

Expand Down Expand Up @@ -322,7 +324,7 @@ func atomicWriteJSON(path string, data any) error {
if err := tmp.Close(); err != nil {
return fmt.Errorf("swarm: close temp inbox: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
if err := renameWithRetry(tmpName, path); err != nil {
return fmt.Errorf("swarm: commit inbox: %w", err)
}
return nil
Expand Down Expand Up @@ -392,3 +394,30 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
time.Sleep(2 * time.Millisecond)
}
}

func renameWithRetry(src, dst string) error {
var err error
for i := 0; i < 10; i++ {
err = os.Rename(src, dst)
if err == nil {
return nil
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
}
break
}
return err
}

func isWindowsSharingViolation(err error) bool {
var errno syscall.Errno
if errors.As(err, &errno) {
const ERROR_SHARING_VIOLATION syscall.Errno = 32
return errno == ERROR_SHARING_VIOLATION
}
return false
}
1 change: 1 addition & 0 deletions internal/swarm/mailbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ func TestMailboxConcurrentSends(t *testing.T) {
defer wg.Done()
if err := mb.Send("team", "bob", Message{From: "a", Body: "concurrent"}); err != nil {
failures.Add(1)
t.Logf("Send error: %v", err)
}
}()
}
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ type model struct {
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
// program is the live Bubble Tea program, set right before Run so /plan open
// can suspend the TUI, launch $EDITOR, and resume on exit.
program *tea.Program
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
Expand Down Expand Up @@ -4204,8 +4207,7 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.debugText()})
return m, nil
case commandPlan:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()})
return m, nil
return m.handlePlanCommand(command.text)
case commandDoctor:
return m.startDoctorCommand(command.text)
case commandSearch:
Expand Down
Loading
Loading