Files
zero/internal/sessions/exec_session.go
PierrunoYTandCopilot Autofix powered by AI 008bc9b3f3 fix: address bugs found in a multi-agent codebase audit (#481)
* fix: address bugs found in a multi-agent codebase audit

Five parallel review agents each swept a different area (self-update,
TUI keybindings, agent/session handling, sandbox/shell safety, and
auth/credential storage) and surfaced nine concrete defects, all fixed
here with regression tests:

- sandbox: grant_scope path comparisons were case-sensitive, letting a
  persisted deny grant be bypassed by spelling a Windows path with
  different case
- securefile/oauth: createSecretFile's Windows ERROR_ACCESS_DENIED
  handling was only applied to the oauth token store, not the shared
  credstore backend; both also masked the real lock-contention error
  behind a misleading "file does not exist" timeout
- cli: firstUsableProvider ignored OAuth logins when picking a
  fallback provider, forcing unnecessary re-onboarding for an
  already-authenticated user
- sessions: exec prompt summarization truncated on a raw byte offset,
  which could split a multi-byte UTF-8 rune and embed invalid UTF-8
  into a resumed session's prompt
- tui: configurable keybindings had no collision detection, so a
  remapped chord could silently make a hardcoded shortcut (or another
  configured binding) permanently unreachable
- tools: the Windows shell-syntax pre-flight check wasn't quote-aware
  and could block harmless commands whose quoted arguments merely
  contained operator-like text
- update: checksum verification didn't cross-check the archive
  filename it verified against the one being extracted; extractZip
  didn't reject symlink-mode entries like extractTarGz already did;
  replaceBinary's restore-on-failure rename had no retry, risking a
  permanently missing binary on a transient Windows file lock

* fix: close remaining PR 481 review findings

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix(tools): drop stale ; test case after rebasing onto main's MSYS check

Rebasing fix/bug-hunt-findings onto main pulled in #476, which replaced
this file's regex-based windows_shell_syntax detection with a
segment/word-anchored check and deliberately stopped treating ; as a
command separator on Windows (cmd.exe doesn't split on ; the way bash
does). detectShellCommandIssue itself came out of the rebase unchanged
from main (our quote-masking version was fully superseded by #476's
more thorough segment-based approach), but the quoted-content test
this PR added still asserted the old bash-style semantics for
`echo "foo" ; ls -la`, which now correctly returns no issue since it's
a single echo invocation with literal arguments under cmd.exe, not a
real ls invocation.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-06 18:48:28 +08:00

278 lines
7.1 KiB
Go

package sessions
import (
"encoding/json"
"fmt"
"log"
"strings"
"unicode/utf8"
)
type ExecMode string
const (
ModeNew ExecMode = "new"
ModeResume ExecMode = "resume"
ModeFork ExecMode = "fork"
)
type ExecError struct {
message string
}
func (err ExecError) Error() string {
return err.message
}
type PrepareExecOptions struct {
Store *Store
SessionID string
Title string
Cwd string
ModelID string
Provider string
Tag string
Depth int
CallingSessionID string
CallingToolUseID string
AgentName string
TaskID string
Resume string
ResumeLatest bool
Fork string
}
type PreparedExec struct {
Mode ExecMode
Session Metadata
ContextEvents []Event
Store *Store
}
func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
resumeID := strings.TrimSpace(options.Resume)
forkID := strings.TrimSpace(options.Fork)
if (resumeID != "" || options.ResumeLatest) && forkID != "" {
return PreparedExec{}, ExecError{"Use either --resume or --fork, not both."}
}
store := options.Store
if store == nil {
store = NewStore(StoreOptions{})
}
if forkID != "" {
parent, err := store.Get(forkID)
if err != nil {
return PreparedExec{}, err
}
if parent == nil {
return PreparedExec{}, ExecError{"Zero session not found: " + forkID}
}
contextEvents, err := readExecContextEvents(store, parent.SessionID)
if err != nil {
return PreparedExec{}, err
}
session, err := store.Fork(parent.SessionID, ForkInput{
SessionID: options.SessionID,
Title: firstNonEmpty(options.Title, forkTitle(parent.Title)),
Cwd: options.Cwd,
ModelID: options.ModelID,
Provider: options.Provider,
})
if err != nil {
return PreparedExec{}, err
}
return PreparedExec{Mode: ModeFork, Session: session, ContextEvents: contextEvents, Store: store}, nil
}
if resumeID != "" || options.ResumeLatest {
sessionID := resumeID
if sessionID == "" && options.ResumeLatest {
latest, err := store.Latest()
if err != nil {
return PreparedExec{}, err
}
if latest == nil {
return PreparedExec{}, ExecError{"No Zero sessions available to resume."}
}
sessionID = latest.SessionID
}
session, err := store.Get(sessionID)
if err != nil {
return PreparedExec{}, err
}
if session == nil {
return PreparedExec{}, ExecError{"Zero session not found: " + sessionID}
}
contextEvents, err := readExecContextEvents(store, session.SessionID)
if err != nil {
return PreparedExec{}, err
}
return PreparedExec{Mode: ModeResume, Session: *session, ContextEvents: contextEvents, Store: store}, nil
}
createInput := CreateInput{
SessionID: options.SessionID,
Title: options.Title,
Cwd: options.Cwd,
ModelID: options.ModelID,
Provider: options.Provider,
Tag: options.Tag,
Depth: options.Depth,
}
if strings.TrimSpace(options.CallingSessionID) != "" {
parentSessionID := strings.TrimSpace(options.CallingSessionID)
parent, err := store.Get(parentSessionID)
if err != nil {
return PreparedExec{}, err
}
if parent == nil {
return PreparedExec{}, ExecError{"Zero parent session not found: " + parentSessionID}
}
createInput.SessionKind = SessionKindChild
createInput.ParentSessionID = parent.SessionID
createInput.RootSessionID = firstNonEmpty(parent.RootSessionID, parent.SessionID)
createInput.AgentName = strings.TrimSpace(options.AgentName)
createInput.TaskID = strings.TrimSpace(firstNonEmpty(options.TaskID, options.SessionID))
createInput.SpawnedFromEventID = strings.TrimSpace(options.CallingToolUseID)
}
session, err := store.Create(createInput)
if err != nil {
return PreparedExec{}, err
}
return PreparedExec{Mode: ModeNew, Session: session, ContextEvents: []Event{}, Store: store}, nil
}
func readExecContextEvents(store *Store, sessionID string) ([]Event, error) {
contextEvents, err := store.ReadRehydratedEvents(sessionID)
if err == nil {
return contextEvents, nil
}
rawEvents, rawErr := store.ReadEvents(sessionID)
if rawErr != nil {
return nil, err
}
log.Printf("zero sessions: failed to rehydrate compaction events for %s; falling back to raw events: %v", sessionID, err)
return rawEvents, nil
}
func FormatExecPrompt(prompt string, prepared PreparedExec) string {
if prepared.Mode == ModeNew || len(prepared.ContextEvents) == 0 {
return prompt
}
events := promptContextEvents(prepared.ContextEvents)
lines := []string{}
for _, event := range events {
lines = append(lines, fmt.Sprintf("- #%d %s: %s", event.Sequence, event.Type, summarizePayload(event.Payload)))
}
label := "Continuing"
sessionID := prepared.Session.SessionID
if prepared.Mode == ModeFork {
label = "Forked from"
if prepared.Session.ParentSessionID != "" {
sessionID = prepared.Session.ParentSessionID
}
}
return strings.Join([]string{
fmt.Sprintf("%s Zero session %s.", label, sessionID),
"Previous session context:",
strings.Join(lines, "\n"),
"",
"Current user request:",
prompt,
}, "\n")
}
func promptContextEvents(events []Event) []Event {
const maxPromptContextEvents = 80
filtered := make([]Event, 0, len(events))
for _, event := range events {
switch event.Type {
case EventMessage, EventCompaction, EventSessionFork, EventSessionChild, EventSpecialistStart, EventSpecialistStop, EventError:
filtered = append(filtered, event)
}
}
if len(filtered) == 0 {
filtered = append(filtered, events...)
}
if len(filtered) > maxPromptContextEvents {
filtered = filtered[len(filtered)-maxPromptContextEvents:]
}
return filtered
}
func forkTitle(title string) string {
if title == "" {
return ""
}
return title + " (fork)"
}
func summarizePayload(payload any) string {
text := extractText(payload)
text = strings.Join(strings.Fields(text), " ")
if text == "" {
data, err := json.Marshal(payload)
if err != nil {
return "{}"
}
text = string(data)
}
if len(text) > 500 {
return truncateUTF8(text, 500)
}
return text
}
// truncateUTF8 returns the longest prefix of s that is at most n bytes,
// backing off to the nearest rune boundary so a multi-byte character isn't
// split — a split rune here would embed invalid UTF-8 into the exec prompt.
func truncateUTF8(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
func extractText(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.RawMessage:
var decoded any
if err := json.Unmarshal(typed, &decoded); err == nil {
return extractText(decoded)
}
return string(typed)
case float64, bool, int:
return fmt.Sprint(typed)
case []any:
parts := []string{}
for _, item := range typed {
if text := extractText(item); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, " ")
case map[string]any:
if summary, ok := typed["summary"].(string); ok && strings.TrimSpace(summary) != "" {
return summary
}
parts := []string{}
for _, item := range typed {
if text := extractText(item); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, " ")
default:
return ""
}
}