mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* 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]>
278 lines
7.1 KiB
Go
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 ""
|
|
}
|
|
}
|