Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 c4fff2b1de test(sandbox): give the cross-platform anchor tests native paths
Moving these beside the other BuildWindowsACLPlan tests made them run on
Linux and macOS, where their Windows path literals stop meaning what they
say: pathWithinRoot is filepath.Rel underneath, a backslash is an ordinary
character off Windows, and C:\workspace\.git\hooks is then one component
that is not under C:\workspace, so every anchor came back empty. 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.

Paths are built with filepath.Join from temp roots now, so the containment
question is asked in the separator the running platform actually uses.
2026-09-12 09:47:03 +05:30
Vasanthdev2004 38c1e333b1 test(sandbox): check the plan anchoring on every platform, out-of-root case included
The plan builder is cross-platform and its anchor tests were in a Windows-only
file, so nothing checked the wiring on Linux or macOS. Moved beside the other
BuildWindowsACLPlan tests, with the case review raised: an out-of-root
ReadOnlySubpath stays unanchored, and an in-root one is still held to its
write root.
2026-09-12 09:47:03 +05:30
Vasanthdev2004 28360a2dff fix(sandbox): anchor only the carveouts that are under the root, and put the DACL back
Two from review, both right.

ReadOnlySubpaths is a profile field an operator can set to any path, and one
placed outside the write root is a configuration that works today; anchoring
it unconditionally turned that into a containment refusal. Only paths
lexically under the root are anchored now, which is where the derived
carveouts are anyway, and anything else keeps the final-component guard it
always had.

The successful-apply tests left their deny ACE in place, so t.TempDir could
fail to remove the tree. Restoring the snapshot exposed the sharper half:
the ACE denied the group the test runs as, which revoked its own WRITE_DAC
and left the rollback unable to reopen the target. The tests deny a group
this process is not a member of instead, which is what a capability SID is
in production, and roll back afterwards.
2026-09-12 09:46:12 +05:30
Vasanthdev2004 ad78725568 fix(sandbox): reject the reparse ancestor itself before materializing
The pre-create check asked where the deepest existing ancestor lives, and
a junction answers with its own path: the open does not follow a
final-component reparse point, so <root>/.git came back as <root>/.git and
matched. os.MkdirAll does follow it. What disqualifies that ancestor is
that it IS a reparse point, not where it reports living.

Found by reverting the check and watching the test still pass, which said
the guard was doing nothing rather than that the test was weak. Pinned
against the function now, because through the whole apply the create is
made and then removed on the failure path, so the filesystem afterwards
looks identical either way. The plan wiring gets its own test for the same
reason: every apply-level case here hands the group an anchor directly, so
none of them would notice the builder never setting one.
2026-09-12 09:46:12 +05:30
Vasanthdev2004 a9aa37e267 fix(sandbox): keep a derived ACL target inside the write root it came from
FILE_FLAG_OPEN_REPARSE_POINT refuses a reparse point at the final path
component and resolves every component above it, which is right for a path
the operator named and not enough for one this package 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 before setup runs. mklink /J needs no
privilege, so a junction there had the apply open <junction-target>/hooks,
an ordinary directory with nothing wrong about its final component, and
zero sandbox setup wrote a deny ACE on it as Administrator, outside the
workspace.

A derived entry now carries the root it came from, and the apply requires
the object it finally holds to still live under it. The check is on the
handle rather than the name: GetFinalPathNameByHandle answers where the
open object actually is, and both sides go through it so the spelling
normalizes the same way. Materialization is checked before it creates,
since os.MkdirAll follows the same reparse points.

Deliberately not a refusal of every reparse point on the path. Above the
write root the path is the operator's, who may keep a workspace under a
junction or a mapped directory; only the tail the sandbox appended is held
strict.

Closes #1024
2026-09-12 09:46:12 +05:30
62 changed files with 752 additions and 7249 deletions

No files matched your search

+2 -3
View File
@@ -302,9 +302,8 @@ Conceptually, one requested tool goes through these phases:
the model can inspect it and decide what to do next.
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
writer, or ACP client. TUI and exec record rich tool/session events for
resume/history; ACP persists conversational messages and completed tool-call
starts/results for load replay. Live streaming details and permission
exchanges remain client notifications rather than durable history.
resume/history; ACP currently persists conversational messages and streams
tool/permission updates to the client.
Tool success and failure are both informative context. A successful `read_file`
result gives the model file contents; a failed `edit_file` result gives the model
+54 -546
View File
@@ -2,14 +2,10 @@ package acp
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
@@ -44,12 +40,8 @@ type Deps struct {
// ResolveWorkspaceRoot validates + normalizes a client-supplied cwd (must be an
// existing directory; never the bare root). It is the file-tool confinement root.
ResolveWorkspaceRoot func(cwd string) (string, error)
// PersistEvents is an optional test seam for injecting event-store failures.
// It receives a COMPLETED TURN as one batch, never one event at a time: the
// batch is the unit the shared store keeps contiguous (see persistTurn).
PersistEvents func(sessionID string, inputs []sessions.AppendEventInput) error
Store *sessions.Store
AgentInfo Implementation
Store *sessions.Store
AgentInfo Implementation
}
// Workspace is the per-turn execution environment passed to the agent. ACP does
@@ -114,8 +106,6 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
conn.Handle(MethodInitialize, a.handleInitialize)
conn.Handle(MethodSessionNew, a.handleSessionNew)
conn.Handle(MethodSessionLoad, a.handleSessionLoad)
conn.Handle(MethodSessionList, a.handleSessionList)
conn.Handle(MethodSessionResume, a.handleSessionResume)
conn.Handle(MethodSessionPrompt, a.handleSessionPrompt)
conn.Handle(MethodSessionSetMode, a.handleSetMode)
conn.Handle(MethodSessionSetConfigOption, a.handleSetConfigOption)
@@ -144,11 +134,11 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
return InitializeResult{
ProtocolVersion: negotiated,
AgentCapabilities: AgentCapabilities{
// ACP v1 optional methods are advertised as empty capability objects;
// clients must gate session/list and session/resume on their presence.
LoadSession: true,
PromptCapabilities: PromptCapabilities{Image: true},
SessionCapabilities: &SessionCapabilities{List: &struct{}{}, Resume: &struct{}{}},
// Only advertise what ZERO actually implements: session/load (loadSession)
// and image prompts. session/resume + the session-capability sub-object
// are intentionally omitted since there is no resume handler yet.
LoadSession: true,
PromptCapabilities: PromptCapabilities{Image: true},
},
AgentInfo: &info,
// ZERO owns credentials (BYOK) and does not delegate auth to the editor.
@@ -158,41 +148,12 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
// ---- session lifecycle ----
// requestedWorkspace is the single rule for a cwd that arrived from the client,
// applied before the value can reach ResolveWorkspaceRoot.
//
// THE REQUEST'S OWN CWD IS THE ONLY THING ALLOWED TO PICK A ROOT. Every params
// type spells cwd as a plain string, so an absent field and an empty one decode
// to exactly the same "", and the resolver treats "" as "use the directory this
// process was started in" and joins a relative cwd onto it. Those two defaults
// meet in the middle: `{"sessionId":"known"}` used to activate a persisted
// session, and `{}` used to create one, both rooted at wherever the editor
// happened to spawn `zero acp` — which then becomes the sandbox and file-tool
// confinement root. The client never named that directory, so it is a root the
// server invented, and ACP requires an absolute cwd on every method carrying
// one. Rejecting here is what keeps a missing field from being answered with a
// guess.
func requestedWorkspace(cwd string) (string, error) {
trimmed := strings.TrimSpace(cwd)
if trimmed == "" {
return "", RPCError(codeInvalidParams, "cwd is required and must be an absolute path")
}
if !filepath.IsAbs(trimmed) {
return "", RPCError(codeInvalidParams, "cwd must be an absolute path: "+trimmed)
}
return trimmed, nil
}
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
var p NewSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/new params")
}
cwd, err := requestedWorkspace(p.Cwd)
if err != nil {
return nil, err
}
root, err := a.deps.ResolveWorkspaceRoot(cwd)
root, err := a.deps.ResolveWorkspaceRoot(p.Cwd)
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
@@ -204,7 +165,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
@@ -217,90 +178,22 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/load params")
}
return a.activatePersistedSession(ctx, p, persistedSessionLoad)
}
func (a *Agent) handleSessionResume(ctx context.Context, params json.RawMessage) (any, error) {
var p ResumeSessionParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/resume params")
}
return a.activatePersistedSession(ctx, p, persistedSessionResume)
}
type persistedSessionOperation uint8
const (
persistedSessionLoad persistedSessionOperation = iota
persistedSessionResume
)
// activatePersistedSession restores the agent's internal conversation context
// for both lifecycle methods. session/load additionally replays user-visible
// history as ordered session/update notifications; session/resume deliberately
// does not, which makes it safe for an already-rendered desktop reconnect.
func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParams, operation persistedSessionOperation) (any, error) {
// BOTH METHODS, BEFORE ANYTHING IS LOOKED UP. session/resume shares this
// params type with session/load, so the wire cannot tell an omitted cwd from
// an empty one; the blank case used to be answered with the persisted cwd,
// which made {"sessionId":"known"} a complete, successful activation request.
// Validating here rather than in one handler is what keeps the two entry
// points from drifting apart.
cwd, err := requestedWorkspace(p.Cwd)
if err != nil {
return nil, err
}
meta, err := a.deps.Store.Get(p.SessionID)
if err != nil || meta == nil {
return nil, RPCError(codeInvalidParams, "session not found: "+p.SessionID)
}
if operation == persistedSessionResume && !sessions.IsResumableKind(meta.SessionKind) {
return nil, RPCError(codeInvalidParams, "session is not resumable: "+p.SessionID)
cwdInput := p.Cwd
if strings.TrimSpace(cwdInput) == "" {
cwdInput = meta.Cwd
}
if strings.TrimSpace(meta.Cwd) == "" {
return nil, RPCError(codeInvalidParams, "session has no persisted workspace: "+p.SessionID)
}
// SAME RULE ON THE WAY IN. Omitting a relative entry from the listing is not
// enough: a stored "." still resolves against this process's directory, so a
// client that hands back that same directory as its cwd would be sold the
// invented root as a match. A workspace that cannot be identified is not one
// this session can be restored into.
if !filepath.IsAbs(meta.Cwd) {
return nil, RPCError(codeInvalidParams, "session workspace is not an absolute path, so it cannot be identified: "+p.SessionID)
}
persistedRoot, err := a.deps.ResolveWorkspaceRoot(meta.Cwd)
if err != nil {
return nil, RPCError(codeInvalidParams, "persisted session workspace is unavailable: "+err.Error())
}
root, err := a.deps.ResolveWorkspaceRoot(cwd)
root, err := a.deps.ResolveWorkspaceRoot(cwdInput)
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
// ACP session cwd is immutable. Loading history under a different root
// would give a conversation from one workspace access to another
// workspace's configuration, files, and tools.
if !sameWorkspace(root, persistedRoot) {
return nil, RPCError(codeInvalidParams, "session cwd does not match its persisted workspace")
}
// Load history BEFORE publishing the session so no concurrent prompt observes
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
requireHistoryLog := operation == persistedSessionResume && meta.EventCount > 0
history, messages, historyWarning, historyErr := a.loadHistory(meta.SessionID, requireHistoryLog)
// RESUME PROMISES RESTORED CONTEXT, SO A FAILED RESTORATION IS A FAILED
// RESUME. historyErr used to do nothing but suppress replay and raise a
// warning: the session was registered and reported ready regardless, so a
// corrupt record or an unreadable events file left the caller holding a live,
// promptable session ID whose next prompt ran as a brand-new conversation
// under the old identity. Nothing in the response said so.
//
// Publication is now gated on restoration for resume. LOAD keeps the
// best-effort policy deliberately rather than by inheriting this helper: it
// replays what it has and warns about the rest, which is the right trade for
// a client rebuilding a transcript it can still scroll. Reported by @jatmn.
if operation == persistedSessionResume && historyErr != nil {
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
}
history, historyErr := a.loadHistory(meta.SessionID)
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
@@ -311,36 +204,12 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
}
}
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
if existed {
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
if operation == persistedSessionResume && historyErr != nil {
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
}
}
note := &notifier{conn: a.conn, sessionID: sess.id}
if operation == persistedSessionLoad && historyErr == nil {
for _, message := range messages {
if message.tool != nil {
note.send(*message.tool)
continue
}
note.send(replayMessageChunk(message.role, replayMessageID(message.eventID), message.content))
}
}
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
a.warnPersistence(
note,
"rehydrate session compaction",
"Could not apply session compaction. Raw session history was restored instead.",
historyWarning,
operation == persistedSessionLoad,
)
a.warnPersistence(
note,
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
historyErr,
operation == persistedSessionLoad,
)
return LoadSessionResult{
ConfigOptions: a.configOptions(sess),
@@ -348,80 +217,6 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
}, nil
}
func (a *Agent) handleSessionList(_ context.Context, params json.RawMessage) (any, error) {
var p ListSessionsParams
if len(params) > 0 {
if err := json.Unmarshal(params, &p); err != nil {
return nil, RPCError(codeInvalidParams, "invalid session/list params")
}
}
if p.Cursor != "" {
return nil, RPCError(codeInvalidParams, "invalid session/list cursor")
}
items, err := a.deps.Store.ListResumable()
if err != nil {
return nil, RPCError(codeInternalError, "list sessions: "+err.Error())
}
// The filter is the one cwd ACP leaves optional, so a blank one means "no
// filter" rather than an error — but a relative one is rebased onto this
// process's directory exactly like the others, and would then silently answer
// about a workspace the client never named.
var cwd string
if strings.TrimSpace(p.Cwd) != "" {
filter, err := requestedWorkspace(p.Cwd)
if err != nil {
return nil, err
}
cwd, err = a.deps.ResolveWorkspaceRoot(filter)
if err != nil {
return nil, RPCError(codeInvalidParams, err.Error())
}
}
result := ListSessionsResult{Sessions: make([]SessionInfo, 0, len(items))}
for _, item := range items {
// EVERY ENTRY IS RESOLVED, FILTER OR NO FILTER. Listing is a menu, and
// every item on it has to be orderable: activatePersistedSession
// resolves the persisted workspace and refuses what it cannot reach, so
// anything this loop cannot resolve is something the client would be
// offered and then denied.
//
// Resolving only when a cwd filter was supplied left two shapes through —
// a session whose workspace has since been deleted, and a legacy entry
// holding a relative path, which was then reported as cwd "." even though
// ACP requires SessionInfo.cwd to be absolute.
if strings.TrimSpace(item.Cwd) == "" {
continue
}
// A RELATIVE PERSISTED CWD HAS NO RECOVERABLE IDENTITY. Resolving one
// rebases it onto wherever this ACP server happens to be running and
// advertises that invented absolute path as the session's workspace — so a
// conversation created for one project could be resumed against another
// project's configuration, files and tools. The original base is not
// knowable from the metadata, so the honest answer is to omit the entry
// rather than to guess at it.
if !filepath.IsAbs(item.Cwd) {
continue
}
itemRoot, err := a.deps.ResolveWorkspaceRoot(item.Cwd)
if err != nil {
continue
}
if cwd != "" && !sameWorkspace(itemRoot, cwd) {
continue
}
result.Sessions = append(result.Sessions, SessionInfo{
SessionID: item.SessionID,
Title: item.Title,
// The RESOLVED root, not the stored string: absolute as ACP
// requires, and the same value the client will hand back on resume.
Cwd: itemRoot,
UpdatedAt: item.UpdatedAt,
Meta: &SessionInfoMeta{ModelID: item.ModelID, CreatedAt: item.CreatedAt},
})
}
return result, nil
}
// ---- prompt turn ----
func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage) (any, error) {
@@ -498,15 +293,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
note.text("\n\n[zero warning] " + text + "\n")
}
}
// Buffer the complete turn until RunAgent has a valid ACP outcome. A hard run
// failure is an aborted turn: neither live history nor durable history may
// retain its user/tool prefix under an RPC error. StopCancelled is a valid
// outcome and commits through the same boundary below.
pendingEvents := make([]sessions.AppendEventInput, 0, 4)
queue := func(input sessions.AppendEventInput) {
pendingEvents = append(pendingEvents, input)
}
queue(messageEvent("user", userText))
opts := agent.Options{
Cwd: sess.cwd,
@@ -521,12 +307,8 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
Images: images,
OnText: note.text,
OnReasoning: note.thought,
OnToolCall: func(call agent.ToolCall) {
queue(toolCallEvent(call))
note.toolCall(call)
},
OnToolCall: note.toolCall,
OnToolResult: func(result agent.ToolResult) {
queue(toolResultEvent(result))
note.toolResult(result)
if result.Name == "update_plan" {
a.emitPlan(registry, note)
@@ -539,36 +321,19 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
if result.FinalAnswer != "" {
queue(messageEvent("assistant", result.FinalAnswer))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
return "", RPCError(codeInternalError, stopErr.Error())
}
// ONE BATCH, ONE LOCK. The outcome gate above decides WHETHER the turn is
// committed; the store batch decides WHICH RECORDS STAY TOGETHER relative to
// other writers, and these are different guarantees. Persisting the buffer
// one event per call took and released the store's session lock between
// events, so a second ACP instance holding the same session — its turnMu is
// its own, only the store is shared — could append its whole turn in the
// gap: user A, user B, answer B, answer A. Every write was individually
// locked and the log was valid, but a fresh load pairs prose by order and
// reconstructed three wrong turns from two right ones. Before this branch,
// persistTurn handed the user and assistant events to AppendEvents together;
// buffering until the outcome is known was correct, splitting the commit
// into singleton writes was not. The store writes a batch under one lock and
// one file write, which is the contiguity restored here — not a disk
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
persistenceErr := a.persistTurn(sess.id, pendingEvents)
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
a.warnPersistence(
note,
"save session history",
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
persistenceErr,
true,
)
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
a.warnPersistence(
note,
"save session history",
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
err,
)
}
return reason, nil
}
@@ -827,159 +592,39 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
// ---- persistence + continuity ----
// persistTurn commits a completed turn's buffered events as one store batch, so
// no other writer's records can land between them. A failure is the failure of
// the whole batch: nothing after a failed prerequisite is appended on its own.
func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput) error {
if len(inputs) == 0 {
return nil
}
if a.deps.PersistEvents != nil {
return a.deps.PersistEvents(sessionID, inputs)
}
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
if a.deps.Store == nil {
return nil
}
_, err := a.deps.Store.AppendEvents(sessionID, inputs)
events := []sessions.AppendEventInput{
{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
},
}
if assistant != "" {
events = append(events, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
_, err := a.deps.Store.AppendEvents(sess.id, events)
return err
}
func messageEvent(role, content string) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": role, "content": content},
}
}
func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolCall,
Payload: map[string]any{
"toolCallId": call.ID,
"name": call.Name,
"arguments": call.Arguments,
},
}
}
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolResult,
Payload: map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": result.Status,
"output": result.Output,
"changedFiles": append([]string(nil), result.ChangedFiles...),
},
}
}
// persistedMessage is one entry of the restored transcript in stored order. It
// is either a message (role/content) or a tool-call notification (tool), never
// both: keeping them in ONE ordered slice is what preserves the interleaving a
// client needs to redraw the conversation as it originally happened.
type persistedMessage struct {
eventID string
role string
content string
// tool is set for a replayed tool call or its result, and is already in wire
// shape — it comes from the same toolCallStart/toolCallResult mapping the
// live turn uses, so a replayed tool call renders identically to a fresh one
// and there is no second mapping to drift out of sync.
tool *ToolCallUpdate
}
func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if a.deps.Store == nil {
return nil, nil, nil, nil
return nil, nil
}
// THE EFFECTIVE CONVERSATION, NOT THE RAW LOG. A compacted session keeps its
// original prefix on disk alongside an EventCompaction that names the events
// it replaced and carries their durable summary. ReadEvents hands back both,
// so restoring from it replayed superseded turns AND dropped the summary that
// replaced them — resume then seeded the next turn with context the
// conversation had already moved past, and load visibly resurrected
// transcript entries the user had seen compacted away.
//
// ReadRehydratedEvents is the projection the TUI and exec paths already use
// (internal/tui/session.go, internal/sessions/exec_session.go), so all three
// now agree on what the conversation IS. Switching readers alone is not
// enough: rehydration substitutes the compaction event in place of the events
// it replaced, so a loop that skips everything but EventMessage would drop the
// summary exactly as before. It is projected below. Reported by @jatmn.
events, eventLogPresent, err := a.deps.Store.ReadRehydratedEventsWithPresence(sessionID)
var rehydrateWarning error
events, err := a.deps.Store.ReadEvents(sessionID)
if err != nil {
rehydrateWarning = err
events, eventLogPresent, err = a.deps.Store.ReadEventsWithPresence(sessionID)
if err != nil {
return nil, nil, nil, err
}
}
if requireHistoryLog && !eventLogPresent {
return nil, nil, nil, fmt.Errorf("zero session %s event log is missing", sessionID)
return nil, err
}
var records []turnRecord
var messages []persistedMessage
activeToolCalls := make(map[string]string)
var pendingUser string
havePending := false
for _, e := range events {
if e.Type == sessions.EventCompaction {
// The summary stands in for the turns it replaced, so it enters the
// history as an assistant message: it is prior context the next turn
// must see, and the wire has no separate role for it.
raw, marshalErr := json.Marshal(e.Payload)
if marshalErr != nil {
continue
}
var payload struct {
Summary string `json:"summary"`
}
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.Summary) == "" {
continue
}
messages = append(messages, persistedMessage{
eventID: persistedMessageIdentity(sessionID, e),
role: "assistant",
content: payload.Summary,
})
records = append(records, turnRecord{user: pendingUser, assistant: payload.Summary})
pendingUser = ""
havePending = false
continue
}
// TOOL ACTIVITY IS PART OF THE TRANSCRIPT, NOT DECORATION. A restored
// conversation that shows only the prose reads as if the agent asserted
// its edits and command output from nowhere: the user sees "I updated
// scope.go" with no record that any tool ran. These are collected for
// replay only. They deliberately do NOT enter turnRecord, so the prompt
// context a resumed turn receives stays byte-identical to what it was —
// load and resume continue to consume the SAME effective history, and
// only the wire rendering differs. Reported by @jatmn.
if e.Type == sessions.EventToolCall || e.Type == sessions.EventToolResult {
if upd := replayToolUpdate(e); upd != nil {
rawID := upd.ToolCallID
if e.Type == sessions.EventToolResult {
replayID, ok := activeToolCalls[rawID]
if !ok {
continue
}
upd.ToolCallID = replayID
delete(activeToolCalls, rawID)
} else {
replayID := replayToolCallID(persistedMessageIdentity(sessionID, e))
activeToolCalls[rawID] = replayID
upd.ToolCallID = replayID
}
messages = append(messages, persistedMessage{
eventID: persistedMessageIdentity(sessionID, e),
tool: upd,
})
}
continue
}
if e.Type != sessions.EventMessage {
continue
}
@@ -996,14 +641,12 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
}
switch msg.Role {
case "user":
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
pendingUser = msg.Content
havePending = true
case "assistant":
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
records = append(records, turnRecord{user: pendingUser, assistant: msg.Content})
pendingUser = ""
havePending = false
@@ -1012,89 +655,10 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
return records, messages, rehydrateWarning, nil
return records, nil
}
// replayToolUpdate rebuilds the ACP notification for one stored tool event.
//
// The stored toolCallId is returned here as a correlation key. loadHistory maps
// each call occurrence to a stable session-wide replay identity and applies that
// identity to its result. Older records wrote the field as "id", so both
// spellings are accepted — the TUI projection already does the same
// (internal/tui/session.go).
//
// A record that carries no usable id is skipped rather than replayed under a
// synthesized one: an unpairable update is worse than a missing row.
func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
raw, err := json.Marshal(event.Payload)
if err != nil {
return nil
}
var payload struct {
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil
}
id := payload.ToolCallID
if id == "" {
id = payload.ID
}
if id == "" {
return nil
}
if event.Type == sessions.EventToolCall {
upd := toolCallStart(agent.ToolCall{ID: id, Name: payload.Name, Arguments: payload.Arguments})
return &upd
}
status := tools.Status(payload.Status)
if status == "" {
status = tools.StatusOK
}
upd := toolCallResult(agent.ToolResult{
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: payload.Output,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
})
return &upd
}
func persistedMessageIdentity(sessionID string, event sessions.Event) string {
if event.ID != "" {
return event.ID
}
return fmt.Sprintf("%s:%d", sessionID, event.Sequence)
}
// replayMessageID maps ZERO's stable event identity to a standards-shaped UUID
// without leaking or parsing the event id on the wire. The same stored message
// receives the same opaque id across loads, which also gives clients an exact
// chunk boundary when two adjacent persisted messages have the same role.
func replayMessageID(eventID string) string {
return replayOpaqueID("message", eventID)
}
func replayToolCallID(eventID string) string {
return replayOpaqueID("tool-call", eventID)
}
func replayOpaqueID(kind, eventID string) string {
sum := sha256.Sum256([]byte("zero-acp-" + kind + ":" + eventID))
b := sum[:16]
b[6] = (b[6] & 0x0f) | 0x50
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error, notify bool) {
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
if err == nil {
return
}
@@ -1103,7 +667,7 @@ func (a *Agent) warnPersistence(note *notifier, action string, message string, e
sessionID = note.sessionID
}
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
if note != nil && notify {
if note != nil {
note.text("\n\n[zero warning] " + message + "\n")
}
}
@@ -1150,34 +714,18 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// registerSession publishes a session under the agent's lock. If one is already
// registered for id (e.g. a re-load of an in-flight session) the existing live
// session is returned rather than orphaning its turn or resetting its mode/model.
// The bool reports that case so persisted activation can re-read and apply disk
// history under turnMu. history is set BEFORE first publication so no concurrent
// prompt can read a half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) (*acpSession, bool) {
// session is returned unchanged rather than orphaning its turn or resetting its
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
// half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing, true
return existing
}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
a.sessions[id] = sess
return sess, false
}
// refreshSessionHistory is the activation apply boundary for an already-live
// session. The history is read while prompts are excluded, then applied to the
// same object the prompt path uses. Re-reading here avoids overwriting a turn
// that committed between activatePersistedSession's pre-publication read and
// discovering that another activation had already registered the session.
func (a *Agent) refreshSessionHistory(sess *acpSession, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
history, messages, warning, err := a.loadHistory(sess.id, requireHistoryLog)
if err == nil {
sess.setHistory(history)
}
return history, messages, warning, err
return sess
}
func (a *Agent) session(id string) *acpSession {
@@ -1260,48 +808,8 @@ func (s *acpSession) appendHistory(rec turnRecord) {
s.mu.Unlock()
}
func (s *acpSession) setHistory(history []turnRecord) {
s.mu.Lock()
s.history = append([]turnRecord(nil), history...)
s.mu.Unlock()
}
func (s *acpSession) snapshotHistory() []turnRecord {
s.mu.Lock()
defer s.mu.Unlock()
return append([]turnRecord(nil), s.history...)
}
// sameWorkspace reports whether two resolved roots name the same directory.
//
// STRING EQUALITY IS NOT DIRECTORY IDENTITY. ResolveWorkspaceRoot is abs plus
// filepath.Clean plus a stat: it does not fold case and does not resolve
// junctions, so one directory reached by two spellings produces two different
// roots. A session persisted from the TUI was then unresumable from an editor
// holding a different spelling of the same project folder, and session/list
// filtered by the other spelling returned nothing — not a failed resume but an
// invisible one, on exactly the case this feature exists for.
//
// filepath.EvalSymlinks is NOT the fix on Windows: it normalises a drive letter
// but returns a junction path unchanged, so the alias survives it. Junctions need
// no privilege, so this is ordinary rather than exotic. os.SameFile compares the
// filesystem's own identity for the two directories, which is the question being
// asked.
//
// The string comparison stays as the fast path, and a stat failure falls back to
// it rather than widening the match — this gate refuses access to another
// workspace's files and configuration, so an unanswerable comparison denies.
func sameWorkspace(left, right string) bool {
if left == right {
return true
}
leftInfo, err := os.Stat(left)
if err != nil {
return false
}
rightInfo, err := os.Stat(right)
if err != nil {
return false
}
return os.SameFile(leftInfo, rightInfo)
}
+13 -1684
View File
File diff suppressed because it is too large. Load diff
+10 -43
View File
@@ -89,13 +89,6 @@ type Conn struct {
handlers map[string]HandlerFunc
notifiers map[string]NotifyFunc
// Notifications for one method are an ordered stream: session/update in
// particular is stateful, so dispatching every frame in an unrelated
// goroutine can make a later chunk overtake an earlier one. Different
// methods retain independent tails, keeping session/cancel responsive while
// an update handler is busy.
notifyMu sync.Mutex
notifyTails map[string]chan struct{}
mu sync.Mutex
nextID int64
@@ -113,12 +106,11 @@ type Conn struct {
// lifetime.
func NewConn(r io.Reader, w io.Writer) *Conn {
return &Conn{
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
notifyTails: make(map[string]chan struct{}),
pending: make(map[int64]chan rpcMessage),
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
pending: make(map[int64]chan rpcMessage),
}
}
@@ -311,7 +303,11 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}(msg)
case msg.isNotify():
if fn := c.notifiers[msg.Method]; fn != nil {
c.dispatchNotification(ctx, msg, fn)
c.wg.Add(1)
go func(m rpcMessage) {
defer c.wg.Done()
fn(ctx, m.Params)
}(msg)
}
default:
// Malformed frame; reply only if we can identify a request id.
@@ -321,35 +317,6 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}
}
// dispatchNotification preserves wire order within one method without making
// unrelated notification methods wait for each other. The read loop installs
// each tail before starting its goroutine, so goroutine scheduling cannot
// reorder the chain it observes.
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
c.notifyMu.Lock()
previous := c.notifyTails[msg.Method]
done := make(chan struct{})
c.notifyTails[msg.Method] = done
c.notifyMu.Unlock()
c.wg.Add(1)
go func() {
defer c.wg.Done()
defer func() {
close(done)
c.notifyMu.Lock()
if c.notifyTails[msg.Method] == done {
delete(c.notifyTails, msg.Method)
}
c.notifyMu.Unlock()
}()
if previous != nil {
<-previous
}
fn(ctx, msg.Params)
}()
}
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
fn := c.handlers[msg.Method]
if fn == nil {
-80
View File
@@ -360,86 +360,6 @@ func TestConnNotification(t *testing.T) {
}
}
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
firstStarted := make(chan struct{})
releaseFirst := make(chan struct{})
delivered := make(chan int, 2)
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
var in struct{ Sequence int }
_ = json.Unmarshal(params, &in)
if in.Sequence == 1 {
close(firstStarted)
<-releaseFirst
}
delivered <- in.Sequence
})
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
t.Fatalf("first notify: %v", err)
}
select {
case <-firstStarted:
case <-time.After(2 * time.Second):
t.Fatal("first notification did not start")
}
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
t.Fatalf("second notify: %v", err)
}
select {
case sequence := <-delivered:
t.Fatalf("notification %d overtook the blocked first notification", sequence)
case <-time.After(25 * time.Millisecond):
}
close(releaseFirst)
for want := 1; want <= 2; want++ {
select {
case got := <-delivered:
if got != want {
t.Fatalf("notification order = ...,%d; want %d", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("notification %d was not delivered", want)
}
}
}
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
updateStarted := make(chan struct{})
releaseUpdate := make(chan struct{})
cancelDelivered := make(chan struct{})
b.HandleNotify("update", func(context.Context, json.RawMessage) {
close(updateStarted)
<-releaseUpdate
})
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
close(cancelDelivered)
})
if err := a.Notify("update", nil); err != nil {
t.Fatalf("update notify: %v", err)
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("update notification did not start")
}
if err := a.Notify("cancel", nil); err != nil {
t.Fatalf("cancel notify: %v", err)
}
select {
case <-cancelDelivered:
case <-time.After(2 * time.Second):
t.Fatal("different notification method was blocked behind update")
}
close(releaseUpdate)
}
func TestConnMethodNotFound(t *testing.T) {
a, _, stop := connPair(t)
defer stop()
-8
View File
@@ -19,14 +19,6 @@ func agentMessageChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
}
func replayMessageChunk(role, messageID, text string) ContentChunk {
update := UpdateAgentMessageChunk
if role == "user" {
update = UpdateUserMessageChunk
}
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
}
func agentThoughtChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
}
+5 -54
View File
@@ -12,8 +12,6 @@ const (
MethodAuthenticate = "authenticate"
MethodSessionNew = "session/new"
MethodSessionLoad = "session/load"
MethodSessionList = "session/list"
MethodSessionResume = "session/resume"
MethodSessionPrompt = "session/prompt"
MethodSessionCancel = "session/cancel" // notification
MethodSessionUpdate = "session/update" // notification (agent -> client)
@@ -64,16 +62,8 @@ type PromptCapabilities struct {
}
type AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
}
// Empty capability objects are presence flags in ACP v1. Pointers preserve
// the wire distinction between an advertised `{}` and an omitted capability.
type SessionCapabilities struct {
List *struct{} `json:"list,omitempty"`
Resume *struct{} `json:"resume,omitempty"`
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
}
type AuthMethod struct {
@@ -127,14 +117,9 @@ type McpServer struct {
}
type NewSessionParams struct {
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
// AdditionalDirectories is declared by the protocol and consumed nowhere in
// this repo yet. When it is wired up it must go through requestedWorkspace
// like Cwd does: these are client-supplied paths with no absoluteness rule of
// their own, which is the same shape as the resume-cwd hole that made
// {"sessionId":"known"} activate a session against this process's directory.
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
}
type NewSessionResult struct {
@@ -155,39 +140,6 @@ type LoadSessionResult struct {
Modes *SessionModeState `json:"modes,omitempty"`
}
// ListSessionsParams and the following types implement ACP v1 session/list.
// Transcript contents stay behind session/load; this method returns metadata
// only and leaves the optional pagination cursor opaque.
type ListSessionsParams struct {
Cwd string `json:"cwd,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
type SessionInfoMeta struct {
ModelID string `json:"modelId,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
type SessionInfo struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd"`
Title string `json:"title,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Meta *SessionInfoMeta `json:"_meta,omitempty"`
}
type ListSessionsResult struct {
Sessions []SessionInfo `json:"sessions"`
NextCursor string `json:"nextCursor,omitempty"`
}
// session/resume takes session/load's wire shape, so its cwd is a plain string
// too and an omitted one decodes exactly like an empty one. Sharing the type is
// safe only because activatePersistedSession requires an absolute cwd from the
// request itself — nothing downstream may supply a default for either method.
type ResumeSessionParams = LoadSessionParams
type ResumeSessionResult = LoadSessionResult
// ---- prompt turn ----
type PromptParams struct {
@@ -222,7 +174,6 @@ type SessionNotification struct {
// ContentBlock under "content"; the variant is set via SessionUpdate.
type ContentChunk struct {
SessionUpdate string `json:"sessionUpdate"`
MessageID string `json:"messageId,omitempty"`
Content ContentBlock `json:"content"`
}
+6 -1
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -146,7 +147,11 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
+2 -31
View File
@@ -30,35 +30,15 @@ var processAlive = osProcessAlive
// only to enrich a contention error with the current holder's PID; the kernel
// lock, not PID metadata, is authoritative.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
return lockutil.TryAcquireFileLock(path)
}, func() (int, error) {
return readPidFile(path)
})
}
func acquireLockRoot(root *os.Root, name, displayPath string, isAlive func(pid int) bool) (*fileLock, error) {
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
return lockutil.TryAcquireFileLockRoot(root, name, displayPath)
}, func() (int, error) {
return readPidFileRoot(root, name)
})
}
func acquireLockWith(
isAlive func(pid int) bool,
acquire func() (*lockutil.FileLock, error),
readPID func() (int, error),
) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
lock, err := acquire()
lock, err := lockutil.TryAcquireFileLock(path)
if err != nil {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, err
}
pid, perr := readPID()
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
@@ -82,15 +62,6 @@ func (l *fileLock) release() error {
// readPidFile reads and parses the PID recorded in a lock file.
func readPidFile(path string) (int, error) {
data, err := os.ReadFile(path)
return parsePidFile(data, err)
}
func readPidFileRoot(root *os.Root, name string) (int, error) {
data, err := root.ReadFile(name)
return parsePidFile(data, err)
}
func parsePidFile(data []byte, err error) (int, error) {
if err != nil {
return 0, err
}
+7 -162
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
)
@@ -24,23 +23,6 @@ type Server struct {
listener net.Listener
conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return
lock *fileLock
// runtimeRoot is retained for the full default-daemon lifecycle. Every file
// child is addressed relative to this capability; runtimeDir is used only to
// verify the unavoidable AF_UNIX pathname bind still names the same object.
runtimeRoot *os.Root
runtimeDir string
// socketRoot captures the directory that actually received the pathname
// bind. It can differ from runtimeRoot only when the configured directory is
// exchanged in the final pre-bind window; retaining it makes rollback target
// the socket that was really created rather than re-resolving the pathname.
socketRoot *os.Root
socketName string
// statusRoot binds status publication and shutdown cleanup to the same
// directory object. statusCommitted is set only after this server publishes
// its document, so a failed startup never removes a previous daemon's status.
statusRoot *os.Root
statusName string
statusCommitted bool
ctx context.Context
cancel context.CancelFunc
@@ -59,14 +41,6 @@ type ServerOptions struct {
Now func() time.Time
Log func(string)
isAlive func(int) bool // test hook for the single-instance lock
// beforeStatusReplace, replaceStatusFile, and syncStatusParent are test hooks
// for the status-file commit boundary. nil selects production behavior.
beforeStatusReplace func()
replaceStatusFile func(root *os.Root, src, dst string) error
syncStatusParent func(root *os.Root) error
afterRuntimeRootOpen func() // test hook at the default-root trust boundary
beforeSocketBind func() // test hook before the final root verification
afterSocketPreflight func() // test hook in the unavoidable pathname-bind window
}
// NewServer validates options and builds a Server.
@@ -106,41 +80,11 @@ func (s *Server) Serve() error {
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
return err
}
defaultRoot, isDefault, err := openDefaultRuntimeRoot(s.opts.Paths)
if err != nil {
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
return err
}
if isDefault {
s.runtimeRoot = defaultRoot
s.runtimeDir = filepath.Dir(s.opts.Paths.Socket)
s.statusRoot = defaultRoot
s.statusName = filepath.Base(s.opts.Paths.Status)
if s.opts.afterRuntimeRootOpen != nil {
s.opts.afterRuntimeRootOpen()
}
if err := runtimeRootStillNamesPath(defaultRoot, s.runtimeDir); err != nil {
s.closeRuntimeRoots()
return err
}
} else {
if err := secureCustomRuntimeParents(s.opts.Paths); err != nil {
return err
}
statusRoot, err := openStatusRoot(s.opts.Paths.Status)
if err != nil {
return fmt.Errorf("daemon: open status directory: %w", err)
}
s.statusRoot = statusRoot
s.statusName = filepath.Base(s.opts.Paths.Status)
}
var lock *fileLock
if s.runtimeRoot != nil {
lock, err = acquireLockRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Lock), s.opts.Paths.Lock, s.opts.isAlive)
} else {
lock, err = acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
}
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
if err != nil {
s.closeRuntimeRoots()
return err
}
s.lock = lock
@@ -148,22 +92,7 @@ func (s *Server) Serve() error {
// A leftover socket file from an unclean exit would make Listen fail with
// "address already in use"; we hold the lock, so any socket here is stale.
if s.runtimeRoot != nil {
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("daemon: remove stale control socket: %w", err)
}
if s.opts.beforeSocketBind != nil {
s.opts.beforeSocketBind()
}
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
return err
}
if s.opts.afterSocketPreflight != nil {
s.opts.afterSocketPreflight()
}
} else {
_ = os.Remove(s.opts.Paths.Socket)
}
_ = os.Remove(s.opts.Paths.Socket)
listener, err := net.Listen("unix", s.opts.Paths.Socket)
if err != nil {
@@ -172,22 +101,6 @@ func (s *Server) Serve() error {
s.mu.Lock()
s.listener = listener
s.mu.Unlock()
if s.runtimeRoot != nil {
boundRoot, boundName, err := captureBoundSocketRoot(s.opts.Paths.Socket)
if err != nil {
_ = listener.Close()
return fmt.Errorf("daemon: capture bound control socket: %w", err)
}
s.socketRoot = boundRoot
s.socketName = boundName
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
return err
}
info, err := s.runtimeRoot.Lstat(filepath.Base(s.opts.Paths.Socket))
if err != nil || info.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("daemon: bound control socket is outside the secured runtime directory")
}
}
// If Shutdown already fired during the bind window, close now and bail so a
// shutdown requested at startup is never lost (the accept loop would otherwise
// block forever waiting for a connection that never comes) (D4).
@@ -198,7 +111,7 @@ func (s *Server) Serve() error {
return nil
default:
}
if err := s.hardenSocket(); err != nil {
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
return fmt.Errorf("daemon: harden control socket: %w", err)
}
s.startedAt = s.opts.Now()
@@ -277,56 +190,11 @@ func (s *Server) cleanup() {
if s.listener != nil {
_ = s.listener.Close()
}
if s.socketRoot != nil {
if err := s.socketRoot.Remove(s.socketName); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logf("daemon: remove control socket: %v", err)
}
} else if s.runtimeRoot != nil {
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logf("daemon: remove control socket: %v", err)
}
} else {
_ = os.Remove(s.opts.Paths.Socket)
}
if s.statusRoot != nil {
if s.statusCommitted {
if err := s.statusRoot.Remove(s.statusName); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logf("daemon: remove status file: %v", err)
}
}
}
_ = os.Remove(s.opts.Paths.Socket)
_ = os.Remove(s.opts.Paths.Status)
if s.lock != nil {
_ = s.lock.release()
}
s.closeRuntimeRoots()
}
func (s *Server) hardenSocket() error {
if s.runtimeRoot != nil {
return hardenSocketFileRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Socket))
}
return hardenSocketFile(s.opts.Paths.Socket)
}
func (s *Server) closeRuntimeRoots() {
if s.socketRoot != nil {
if err := s.socketRoot.Close(); err != nil {
s.logf("daemon: close bound socket directory: %v", err)
}
}
s.socketRoot = nil
if s.statusRoot != nil && s.statusRoot != s.runtimeRoot {
if err := s.statusRoot.Close(); err != nil {
s.logf("daemon: close status directory: %v", err)
}
}
s.statusRoot = nil
if s.runtimeRoot != nil {
if err := s.runtimeRoot.Close(); err != nil {
s.logf("daemon: close runtime directory: %v", err)
}
}
s.runtimeRoot = nil
}
func (s *Server) writeStatusFile() error {
@@ -340,30 +208,7 @@ func (s *Server) writeStatusFile() error {
if err != nil {
return err
}
root := s.statusRoot
ownedRoot := false
if root == nil {
var err error
root, err = openStatusRoot(s.opts.Paths.Status)
if err != nil {
return fmt.Errorf("daemon: write status file: %w", err)
}
ownedRoot = true
}
committed, err := writeStatusFileAtomicallyRoot(root, filepath.Base(s.opts.Paths.Status), data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent)
if ownedRoot {
if closeErr := root.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("close status directory: %w", closeErr))
}
}
if committed {
s.statusCommitted = true
}
if err != nil {
if committed {
s.logf("daemon: status file publication committed with warning: %v", err)
return nil
}
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
return fmt.Errorf("daemon: write status file: %w", err)
}
return nil
+1 -329
View File
@@ -2,201 +2,20 @@ package daemon
import (
"errors"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/observability"
)
func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permission compatibility regression")
}
dir, err := os.MkdirTemp("/tmp", "zero-serve-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
paths := Paths{
Socket: filepath.Join(dir, "daemon.sock"),
Lock: filepath.Join(dir, "daemon.lock"),
Status: filepath.Join(dir, "daemon.status"),
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
waitForFile(t, paths.Status)
srv.Shutdown()
if err := <-serveErr; err != nil {
t.Fatalf("Serve with 0755 custom runtime directory: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o755 {
t.Fatalf("custom runtime directory permissions = %04o, want unchanged 0755", got)
}
}
func TestServeKeepsDefaultRootBoundAcrossCoordination(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
}
home, err := os.MkdirTemp("/tmp", "zero-root-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
paths, err := DefaultPaths()
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(paths.Socket)
moved := filepath.Join(home, "bound-runtime")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
srv.opts.beforeSocketBind = func() {
if err := os.Rename(dir, moved); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
}
err = srv.Serve()
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
t.Fatalf("Serve error = %v, want replaced-runtime rejection", err)
}
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
t.Fatalf("rooted lock was not created in the bound directory: %v", err)
}
for _, path := range []string{paths.Lock, paths.Socket, paths.Status} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("substitute runtime entry %s was touched: %v", path, err)
}
}
}
func TestServeRemovesSocketBoundAfterFinalRuntimeSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
}
home, err := os.MkdirTemp("/tmp", "zero-bind-swap-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
paths, err := DefaultPaths()
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(paths.Socket)
moved := filepath.Join(home, "trusted-runtime")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
srv.opts.afterSocketPreflight = func() {
if err := os.Rename(dir, moved); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
}
err = srv.Serve()
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
t.Fatalf("Serve error = %v, want post-bind runtime replacement rejection", err)
}
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
t.Fatalf("rooted lock was not retained in the trusted directory: %v", err)
}
if _, err := os.Lstat(paths.Socket); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("substitute runtime retained stale socket: %v", err)
}
listener, err := net.Listen("unix", paths.Socket)
if err != nil {
t.Fatalf("substitute runtime remained unbindable after rollback: %v", err)
}
if err := listener.Close(); err != nil {
t.Fatal(err)
}
}
func TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows DACL hardening has platform-specific coverage")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
paths, err := DefaultPaths()
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(paths.Socket)
if err := os.MkdirAll(dir, 0o777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
file, logPath, err := OpenRuntimeLog(paths)
if err != nil {
t.Fatalf("OpenRuntimeLog: %v", err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if logPath != filepath.Join(dir, "daemon.log") {
t.Fatalf("log path = %q", logPath)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got&0o077 != 0 {
t.Fatalf("default runtime directory permissions = %04o, want owner-only", got)
}
}
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
t.Helper()
dir := t.TempDir()
secureStatusTestDir(t, dir)
paths := Paths{
Socket: filepath.Join(dir, "d.sock"),
Lock: filepath.Join(dir, "d.lock"),
Status: filepath.Join(dir, "d.status"),
}
return newTestServerWithPaths(t, launcher, paths), paths
}
func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Server {
t.Helper()
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
if err != nil {
t.Fatalf("NewPool: %v", err)
@@ -209,7 +28,7 @@ func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Serve
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return srv
return srv, paths
}
func waitForFile(t *testing.T, path string) {
@@ -317,153 +136,6 @@ func TestServerEndToEnd(t *testing.T) {
}
}
func TestServePreservesPreviousStatusWhenReplacementFails(t *testing.T) {
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
dir, err := os.MkdirTemp("", "zero-daemon-status-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
secureStatusTestDir(t, dir)
paths := Paths{Socket: filepath.Join(dir, "d.sock"), Lock: filepath.Join(dir, "d.lock"), Status: filepath.Join(dir, "d.status")}
srv := newTestServerWithPaths(t, launcher, paths)
previous := []byte(`{"pid":7,"socket":"previous","version":1}`)
if err := os.WriteFile(paths.Status, previous, 0o600); err != nil {
t.Fatal(err)
}
srv.opts.replaceStatusFile = func(*os.Root, string, string) error {
return errors.New("injected replacement failure")
}
if err := srv.Serve(); err == nil || !strings.Contains(err.Error(), "injected replacement failure") {
t.Fatal("Serve succeeded despite injected status replacement failure")
}
got, err := os.ReadFile(paths.Status)
if err != nil {
t.Fatalf("previous status was removed after failed publication: %v", err)
}
if string(got) != string(previous) {
t.Fatalf("previous status changed after failed publication: %q", got)
}
}
func TestServeCleansStatusThroughBoundDirectoryAfterSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not permit renaming the directory containing the live socket")
}
parent, err := os.MkdirTemp("", "zero-daemon-swap-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(parent) })
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
secureStatusTestDir(t, dir)
paths := Paths{
Socket: filepath.Join(dir, "d.sock"),
Lock: filepath.Join(dir, "d.lock"),
Status: filepath.Join(dir, "d.status"),
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
srv := newTestServerWithPaths(t, launcher, paths)
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
srv.opts.beforeStatusReplace = func() {
if err := os.Rename(dir, movedDir); err != nil {
t.Fatalf("move status directory: %v", err)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute directory: %v", err)
}
if err := os.WriteFile(paths.Status, substitute, 0o600); err != nil {
t.Fatalf("write substitute status: %v", err)
}
}
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
waitForFile(t, filepath.Join(movedDir, "d.status"))
srv.Shutdown()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
if _, err := os.Stat(filepath.Join(movedDir, "d.status")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("bound status survived cleanup: %v", err)
}
got, err := os.ReadFile(paths.Status)
if err != nil {
t.Fatalf("substitute status was removed by pathname cleanup: %v", err)
}
if string(got) != string(substitute) {
t.Fatalf("substitute status changed during cleanup: %q", got)
}
}
func TestServerPublishesDefaultStatusAfterCrashReportCreatesRuntimeDirectory(t *testing.T) {
home, err := os.MkdirTemp("", "zero-home-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
if _, err := observability.WriteCrashReport(
observability.DefaultCrashDir(),
"cli",
"boom",
[]byte("stack"),
time.Now(),
); err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
paths, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if paths.Status != filepath.Join(home, ".zero", "daemon.status") {
t.Fatalf("default status path = %q, want path beneath temporary home", paths.Status)
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
deadline := time.NewTimer(3 * time.Second)
defer deadline.Stop()
for {
if _, err := os.Stat(paths.Status); err == nil {
break
}
select {
case err := <-serveErr:
t.Fatalf("Serve returned before publishing status: %v", err)
case <-deadline.C:
t.Fatal("daemon did not publish its default status file")
case <-time.After(2 * time.Millisecond):
}
}
srv.Shutdown()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
}
func TestServerSecondInstanceFails(t *testing.T) {
block := make(chan struct{})
defer close(block)
+4 -179
View File
@@ -1,12 +1,9 @@
package daemon
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/privatedir"
)
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
@@ -15,182 +12,10 @@ import (
// safe cross-platform ceiling.
const maxUnixSocketPath = 103
func secureCustomRuntimeParents(paths Paths) error {
parents := []struct {
name string
path string
}{
{name: "socket", path: filepath.Dir(paths.Socket)},
{name: "lock", path: filepath.Dir(paths.Lock)},
{name: "status", path: filepath.Dir(paths.Status)},
}
seen := make(map[string]struct{}, len(parents))
for _, parent := range parents {
absolute, err := filepath.Abs(parent.path)
if err != nil {
return fmt.Errorf("daemon: resolve %s directory: %w", parent.name, err)
}
if _, ok := seen[absolute]; ok {
continue
}
seen[absolute] = struct{}{}
if err := os.MkdirAll(absolute, 0o700); err != nil {
return fmt.Errorf("daemon: create %s directory: %w", parent.name, err)
}
root, err := os.OpenRoot(absolute)
if err != nil {
return fmt.Errorf("daemon: open custom %s directory: %w", parent.name, err)
}
validateErr := validateStatusRoot(root)
closeErr := root.Close()
if validateErr != nil {
return fmt.Errorf("daemon: validate custom %s directory: %w", parent.name, validateErr)
}
if closeErr != nil {
return fmt.Errorf("daemon: close custom %s directory: %w", parent.name, closeErr)
}
}
return nil
}
// OpenRuntimeLog secures the default runtime root before acquiring the
// detached daemon's log descriptor. Custom endpoint layouts remain supported,
// but their existing parent permissions are caller-owned and left untouched.
func OpenRuntimeLog(paths Paths) (*os.File, string, error) {
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
root, isDefault, err := openDefaultRuntimeRoot(paths)
if err != nil {
return nil, logPath, err
}
if isDefault {
defer root.Close()
file, err := openRootAppendRegular(root, filepath.Base(logPath))
if err != nil {
return nil, logPath, fmt.Errorf("daemon: open runtime log: %w", err)
}
return file, logPath, nil
}
parent := filepath.Dir(logPath)
if err := os.MkdirAll(parent, 0o700); err != nil {
return nil, logPath, fmt.Errorf("daemon: create custom log directory: %w", err)
}
info, err := os.Lstat(logPath)
if err == nil && !info.Mode().IsRegular() {
return nil, logPath, fmt.Errorf("daemon: custom runtime log is not a regular file")
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, logPath, fmt.Errorf("daemon: inspect custom runtime log: %w", err)
}
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, logPath, fmt.Errorf("daemon: open custom runtime log: %w", err)
}
return file, logPath, nil
}
func openDefaultRuntimeRoot(paths Paths) (*os.Root, bool, error) {
defaults, err := DefaultPaths()
if err != nil {
// A caller-supplied layout remains usable even when the process has no
// resolvable home directory. Production default paths can only reach this
// helper after DefaultPaths has already succeeded.
return nil, false, nil
}
matches, err := samePaths(paths, defaults)
if err != nil {
return nil, false, err
}
if !matches {
return nil, false, nil
}
root, err := privatedir.Open(filepath.Dir(defaults.Socket))
if err != nil {
return nil, false, fmt.Errorf("daemon: secure default runtime directory: %w", err)
}
return root, true, nil
}
func samePaths(left, right Paths) (bool, error) {
pairs := [][2]string{
{left.Socket, right.Socket},
{left.Lock, right.Lock},
{left.Status, right.Status},
}
for _, pair := range pairs {
leftAbs, err := filepath.Abs(pair[0])
if err != nil {
return false, fmt.Errorf("daemon: resolve runtime path: %w", err)
}
rightAbs, err := filepath.Abs(pair[1])
if err != nil {
return false, fmt.Errorf("daemon: resolve default runtime path: %w", err)
}
if filepath.Clean(leftAbs) != filepath.Clean(rightAbs) {
return false, nil
}
}
return true, nil
}
func runtimeRootStillNamesPath(root *os.Root, path string) error {
opened, err := root.Stat(".")
if err != nil {
return fmt.Errorf("inspect bound runtime directory: %w", err)
}
current, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect runtime directory entry: %w", err)
}
if current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
return fmt.Errorf("daemon: default runtime directory changed after it was secured")
}
return nil
}
func captureBoundSocketRoot(path string) (*os.Root, string, error) {
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return nil, "", fmt.Errorf("open bound socket directory: %w", err)
}
name := filepath.Base(path)
info, err := root.Lstat(name)
if err != nil {
_ = root.Close()
return nil, "", fmt.Errorf("inspect bound socket: %w", err)
}
if info.Mode()&os.ModeSocket == 0 {
_ = root.Close()
return nil, "", fmt.Errorf("bound socket path is not a socket")
}
return root, name, nil
}
func openRootAppendRegular(root *os.Root, name string) (*os.File, error) {
info, err := root.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
return root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY|os.O_APPEND, 0o600)
}
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("runtime log is not a regular file")
}
file, err := root.OpenFile(name, os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
}
opened, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
if !opened.Mode().IsRegular() {
_ = file.Close()
return nil, fmt.Errorf("runtime log changed while opening")
}
return file, nil
// secureSocketParent creates the socket's parent directory owner-only (0700 on
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
func secureSocketParent(socketPath string) error {
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
}
// checkSocketPathLength rejects an over-long unix socket path before bind.
-4
View File
@@ -10,7 +10,3 @@ import "os"
func hardenSocketFile(path string) error {
return os.Chmod(path, 0o600)
}
func hardenSocketFileRoot(root *os.Root, name string) error {
return root.Chmod(name, 0o600)
}
-4
View File
@@ -2,12 +2,8 @@
package daemon
import "os"
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
// mode bits, and the daemon places the socket under the per-user profile
// directory, which is already ACL-restricted to the owner. A dedicated restricted
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
func hardenSocketFile(string) error { return nil }
func hardenSocketFileRoot(*os.Root, string) error { return nil }
-20
View File
@@ -1,20 +0,0 @@
//go:build !windows
package daemon
import (
"fmt"
"os"
"syscall"
)
func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("status directory ownership metadata is unavailable")
}
if int(stat.Uid) != os.Geteuid() {
return fmt.Errorf("status directory is owned by uid %d, not the current user", stat.Uid)
}
return nil
}
@@ -1,51 +0,0 @@
//go:build !windows
package daemon
import (
"os"
"strings"
"syscall"
"testing"
"time"
)
func secureStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
if err := os.Chmod(dir, 0o700); err != nil {
t.Fatal(err)
}
}
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
}
func TestCheckStatusDirOwnerRejectsMissingMetadata(t *testing.T) {
err := checkStatusDirOwner(nil, statusDirOwnerTestInfo{})
if err == nil || !strings.Contains(err.Error(), "metadata is unavailable") {
t.Fatalf("checkStatusDirOwner error = %v, want unavailable metadata rejection", err)
}
}
func TestCheckStatusDirOwnerRejectsDifferentUser(t *testing.T) {
info := statusDirOwnerTestInfo{sys: &syscall.Stat_t{Uid: uint32(os.Geteuid() + 1)}}
err := checkStatusDirOwner(nil, info)
if err == nil || !strings.Contains(err.Error(), "not the current user") {
t.Fatalf("checkStatusDirOwner error = %v, want owner mismatch rejection", err)
}
}
type statusDirOwnerTestInfo struct {
sys any
}
func (statusDirOwnerTestInfo) Name() string { return "." }
func (statusDirOwnerTestInfo) Size() int64 { return 0 }
func (statusDirOwnerTestInfo) Mode() os.FileMode { return os.ModeDir | 0o700 }
func (statusDirOwnerTestInfo) ModTime() time.Time { return time.Time{} }
func (statusDirOwnerTestInfo) IsDir() bool { return true }
func (info statusDirOwnerTestInfo) Sys() any { return info.sys }
-137
View File
@@ -1,137 +0,0 @@
//go:build windows
package daemon
import (
"errors"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
const statusDirectoryWriteAccess = windows.ACCESS_MASK(
windows.GENERIC_ALL |
windows.GENERIC_WRITE |
windows.DELETE |
windows.WRITE_DAC |
windows.WRITE_OWNER |
windows.FILE_WRITE_DATA |
windows.FILE_APPEND_DATA |
windows.FILE_WRITE_ATTRIBUTES |
windows.FILE_WRITE_EA |
0x40, // FILE_DELETE_CHILD
)
// checkStatusDirOwner validates ownership and write access through a handle
// opened beneath root. Path-based ACL inspection would recreate the ancestor
// swap race that Root is intended to close.
func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open status directory for access validation: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close status directory access handle: %w", err))
}
}()
raw, err := directory.SyscallConn()
if err != nil {
return fmt.Errorf("access status directory handle: %w", err)
}
var descriptor *windows.SECURITY_DESCRIPTOR
var queryErr error
if err := raw.Control(func(handle uintptr) {
descriptor, queryErr = windows.GetSecurityInfo(
windows.Handle(handle),
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
}); err != nil {
return fmt.Errorf("inspect status directory access: %w", err)
}
if queryErr != nil {
return fmt.Errorf("inspect status directory owner and DACL: %w", queryErr)
}
if descriptor == nil {
return fmt.Errorf("status directory security descriptor is unavailable")
}
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return fmt.Errorf("resolve current Windows user: %w", err)
}
tokenOwner, err := currentWindowsTokenOwner(token)
if err != nil {
return fmt.Errorf("resolve current Windows token owner: %w", err)
}
owner, _, err := descriptor.Owner()
if err != nil {
return fmt.Errorf("read status directory owner: %w", err)
}
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
return fmt.Errorf("status directory is not owned by the current Windows token")
}
dacl, _, err := descriptor.DACL()
if err != nil {
return fmt.Errorf("read status directory DACL: %w", err)
}
if dacl == nil {
return fmt.Errorf("status directory has an unrestricted Windows DACL")
}
for index := uint16(0); index < dacl.AceCount; index++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
return fmt.Errorf("read status directory DACL entry %d: %w", index, err)
}
switch ace.Header.AceType {
case windows.ACCESS_DENIED_ACE_TYPE:
continue
case windows.ACCESS_ALLOWED_ACE_TYPE:
default:
return fmt.Errorf("status directory DACL entry %d has unsupported type %d", index, ace.Header.AceType)
}
if ace.Mask&statusDirectoryWriteAccess == 0 {
continue
}
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
if !allowedStatusDirectoryTrustee(trustee, user.User.Sid) {
return fmt.Errorf("status directory DACL grants write access to unexpected trustee %s", trustee.String())
}
}
return nil
}
type statusDirectoryTokenOwner struct {
owner *windows.SID
}
func currentWindowsTokenOwner(token windows.Token) (*windows.SID, error) {
var size uint32
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
if err != windows.ERROR_INSUFFICIENT_BUFFER {
return nil, err
}
buffer := make([]byte, size)
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
return nil, err
}
owner := (*statusDirectoryTokenOwner)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
func allowedStatusDirectoryTrustee(trustee, user *windows.SID) bool {
return trustee != nil && (trustee.Equals(user) ||
trustee.IsWellKnown(windows.WinLocalSystemSid) ||
trustee.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
trustee.IsWellKnown(windows.WinCreatorOwnerSid) ||
trustee.IsWellKnown(windows.WinCreatorOwnerRightsSid))
}
@@ -1,97 +0,0 @@
//go:build windows
package daemon
import (
"fmt"
"os"
"runtime"
"strings"
"testing"
"golang.org/x/sys/windows"
)
func secureStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
t.Fatal(err)
}
descriptor, err := windows.SecurityDescriptorFromString(
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
)
if err != nil {
t.Fatal(err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
t.Fatal(err)
}
if err := windows.SetNamedSecurityInfo(
dir,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
dacl,
nil,
); err != nil {
t.Fatal(err)
}
}
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
if err != nil {
t.Fatal(err)
}
var pinner runtime.Pinner
pinner.Pin(worldSID)
defer pinner.Unpin()
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
AccessPermissions: windows.GENERIC_ALL,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
},
}}, nil)
if err != nil {
t.Fatal(err)
}
if err := windows.SetNamedSecurityInfo(
dir,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
broadDACL,
nil,
); err != nil {
t.Fatal(err)
}
}
func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) {
dir := t.TempDir()
secureStatusTestDirPlatform(t, dir)
broadenStatusTestDirPlatform(t, dir)
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
info, err := root.Stat(".")
if err != nil {
t.Fatal(err)
}
err = checkStatusDirOwner(root, info)
if err == nil || !strings.Contains(err.Error(), "unexpected trustee") {
t.Fatalf("checkStatusDirOwner error = %v, want broad DACL rejection", err)
}
}
-169
View File
@@ -1,169 +0,0 @@
package daemon
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/Gitlawb/zero/internal/fsutil"
)
const (
statusTempPrefix = ".daemon-status-"
statusTempPattern = statusTempPrefix + "*"
)
func openStatusRoot(path string) (*os.Root, error) {
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return nil, fmt.Errorf("open status directory: %w", err)
}
if err := validateStatusRoot(root); err != nil {
_ = root.Close()
return nil, err
}
return root, nil
}
// writeStatusFileAtomicallyRoot publishes through a caller-owned, already
// bound directory root. The returned boolean crosses the commit boundary: once
// true, err is a durability/cleanup warning and the complete document is live.
func writeStatusFileAtomicallyRoot(
root *os.Root,
statusName string,
data []byte,
perm os.FileMode,
beforeReplace func(),
replace func(root *os.Root, src, dst string) error,
syncParent func(root *os.Root) error,
) (committed bool, returnErr error) {
if err := validateStatusRoot(root); err != nil {
return false, err
}
temp, tempName, err := createStatusTemp(root, perm)
if err != nil {
return false, err
}
closed := false
defer func() {
if !closed {
if err := temp.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close temporary status file during cleanup: %w", err))
}
closed = true
}
if err := root.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary status file: %w", err))
}
}()
if err := temp.Chmod(perm); err != nil {
return false, fmt.Errorf("set temporary status file permissions: %w", err)
}
if _, err := temp.Write(data); err != nil {
return false, fmt.Errorf("write temporary status file: %w", err)
}
if err := temp.Sync(); err != nil {
return false, fmt.Errorf("sync temporary status file: %w", err)
}
closeErr := temp.Close()
closed = true
if closeErr != nil {
return false, fmt.Errorf("close temporary status file: %w", closeErr)
}
if beforeReplace != nil {
beforeReplace()
}
if err := prepareStatusReplacement(root, tempName, statusName); err != nil {
return false, fmt.Errorf("prepare status replacement: %w", err)
}
rename := root.Rename
if replace != nil {
rename = func(src, dst string) error { return replace(root, src, dst) }
}
// On Windows an existing reader that omitted FILE_SHARE_DELETE can block
// this replacement even though an in-place write would succeed. Keep the
// atomic publication boundary: RenameWithRetry bounds transient sharing
// failures to ten short attempts, and a persistent failure leaves the old
// status document intact instead of exposing a partially written one.
var committedWarning error
if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil {
var committedReplacement *fsutil.CommittedReplacementCleanupError
if !errors.As(err, &committedReplacement) {
return false, fmt.Errorf("replace status file: %w", err)
}
committedWarning = fmt.Errorf("clean up replaced status file: %w", err)
}
committed = true
if syncParent == nil {
syncParent = syncStatusRoot
}
if err := syncParent(root); err != nil {
committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err))
}
if committedWarning != nil {
return committed, committedWarning
}
return committed, nil
}
func validateStatusRoot(root *os.Root) error {
info, err := root.Stat(".")
if err != nil {
return fmt.Errorf("inspect status directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("status directory is not a directory")
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("status directory permissions are %04o, want no group/other write access", info.Mode().Perm())
}
if err := checkStatusDirOwner(root, info); err != nil {
return err
}
return nil
}
func createStatusTemp(root *os.Root, perm os.FileMode) (*os.File, string, error) {
for range 100 {
var suffix [16]byte
if _, err := rand.Read(suffix[:]); err != nil {
return nil, "", fmt.Errorf("generate temporary status file name: %w", err)
}
name := statusTempPrefix + hex.EncodeToString(suffix[:])
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
if err == nil {
return file, name, nil
}
if errors.Is(err, os.ErrExist) {
continue
}
return nil, "", fmt.Errorf("create temporary status file: %w", err)
}
return nil, "", fmt.Errorf("create temporary status file: exhausted unique names")
}
// syncStatusRoot makes the replacement directory entry durable through the
// same bound root used for creation and replacement. Windows directory sync is
// best-effort because Go cannot fsync a directory there.
func syncStatusRoot(root *os.Root) error {
if runtime.GOOS == "windows" {
return nil
}
directory, err := root.Open(".")
if err != nil {
return err
}
syncErr := directory.Sync()
closeErr := directory.Close()
if syncErr != nil {
return syncErr
}
return closeErr
}
-476
View File
@@ -1,476 +0,0 @@
package daemon
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
"github.com/Gitlawb/zero/internal/privatedir"
)
func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
previous := []byte(`{"pid":7,"socket":"old.sock","version":1,"startedAt":"2026-08-01T00:00:00Z"}`)
if err := os.WriteFile(path, previous, 0o600); err != nil {
t.Fatal(err)
}
replaceErr := errors.New("injected replacement failure")
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Status: path},
Version: 2,
replaceStatusFile: func(root *os.Root, src, dst string) error {
staged, err := root.ReadFile(src)
if err != nil {
t.Fatalf("read staged status: %v", err)
}
var decoded StatusFile
if err := json.Unmarshal(staged, &decoded); err != nil {
t.Fatalf("staged status is incomplete JSON: %v", err)
}
current, err := root.ReadFile(dst)
if err != nil {
t.Fatalf("read previous status at commit boundary: %v", err)
}
if string(current) != string(previous) {
t.Fatalf("previous status changed before commit: %q", current)
}
return replaceErr
},
},
}
err := server.writeStatusFile()
if !errors.Is(err, replaceErr) {
t.Fatalf("writeStatusFile error = %v, want injected replacement failure", err)
}
current, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read preserved status: %v", err)
}
if string(current) != string(previous) {
t.Fatalf("status after failed publication = %q, want previous document", current)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFilePublishesCompleteRestrictedDocument(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o644); err != nil {
t.Fatal(err)
}
startedAt := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
parentSynced := false
server := &Server{
startedAt: startedAt,
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 3,
syncStatusParent: func(root *os.Root) error {
if _, err := root.Stat("."); err != nil {
t.Fatalf("stat bound status parent: %v", err)
}
parentSynced = true
return nil
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var status StatusFile
if err := json.Unmarshal(data, &status); err != nil {
t.Fatalf("published status is invalid JSON: %v", err)
}
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 3 || !status.StartedAt.Equal(startedAt) {
t.Fatalf("published status = %+v", status)
}
if !parentSynced {
t.Fatal("status parent directory was not synced")
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("status mode = %04o, want 0600", got)
}
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
previous := StatusFile{
PID: 7,
Socket: filepath.Join(dir, "old.sock"),
Version: 1,
StartedAt: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
}
previousData, err := json.Marshal(previous)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, previousData, 0o600); err != nil {
t.Fatal(err)
}
replacementReady := make(chan struct{})
publish := make(chan struct{})
writeDone := make(chan error, 1)
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "new.sock"), Status: path},
Version: 2,
replaceStatusFile: func(root *os.Root, src, dst string) error {
close(replacementReady)
<-publish
return fsutil.RenameWithRetry(src, dst, root.Rename)
},
},
}
go func() {
writeDone <- server.writeStatusFile()
}()
<-replacementReady
for range 100 {
status := readStatusDocument(t, path)
if status != previous {
t.Fatalf("status before commit = %+v, want previous document %+v", status, previous)
}
}
close(publish)
if err := <-writeDone; err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
status := readStatusDocument(t, path)
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 2 || !status.StartedAt.Equal(server.startedAt) {
t.Fatalf("status after commit = %+v", status)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
syncErr := errors.New("injected directory sync failure")
var logs []string
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 4,
Log: func(message string) { logs = append(logs, message) },
syncStatusParent: func(*os.Root) error { return syncErr },
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
}
status := readStatusDocument(t, path)
if status.Version != 4 {
t.Fatalf("published status = %+v, want version 4", status)
}
if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) {
t.Fatalf("logs = %q, want directory sync warning", logs)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
cleanupErr := errors.New("injected backup cleanup failure")
parentSynced := false
var logs []string
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 5,
Log: func(message string) { logs = append(logs, message) },
replaceStatusFile: func(root *os.Root, src, dst string) error {
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
return err
}
return &fsutil.CommittedReplacementCleanupError{
BackupPath: filepath.Join(dir, ".zero-replace-backup"),
Cause: cleanupErr,
}
},
syncStatusParent: func(*os.Root) error {
parentSynced = true
return nil
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
}
if !parentSynced {
t.Fatal("status directory was not synced after committed replacement warning")
}
status := readStatusDocument(t, path)
if status.Version != 5 {
t.Fatalf("published status = %+v, want version 5", status)
}
if len(logs) != 1 || !strings.Contains(logs[0], cleanupErr.Error()) {
t.Fatalf("logs = %q, want committed cleanup warning", logs)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileContinuesAfterCommittedTempCleanupWarning(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
var tempName string
var logs []string
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 6,
Log: func(message string) { logs = append(logs, message) },
replaceStatusFile: func(root *os.Root, src, dst string) error {
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
return err
}
tempName = src
if err := root.Mkdir(src, 0o700); err != nil {
t.Fatalf("recreate staged path as directory: %v", err)
}
keep, err := root.OpenFile(filepath.Join(src, "keep"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatalf("make staged directory non-empty: %v", err)
}
if err := keep.Close(); err != nil {
t.Fatalf("close staged directory marker: %v", err)
}
return nil
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
}
status := readStatusDocument(t, path)
if status.Version != 6 {
t.Fatalf("published status = %+v, want version 6", status)
}
if len(logs) != 1 || !strings.Contains(logs[0], "remove temporary status file") {
t.Fatalf("logs = %q, want temporary cleanup warning", logs)
}
if err := os.Remove(filepath.Join(dir, tempName, "keep")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(dir, tempName)); err != nil {
t.Fatal(err)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
var swapErr error
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 6,
beforeStatusReplace: func() {
swapErr = os.Rename(dir, movedDir)
if swapErr != nil {
if runtime.GOOS == "windows" {
return
}
t.Fatalf("move bound status directory: %v", swapErr)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute status directory: %v", err)
}
if err := os.WriteFile(path, substitute, 0o600); err != nil {
t.Fatalf("write substitute status: %v", err)
}
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
if swapErr != nil {
if runtime.GOOS != "windows" {
t.Fatalf("unexpected directory-swap error: %v", swapErr)
}
status := readStatusDocument(t, path)
if status.Version != 6 {
t.Fatalf("status after blocked swap = %+v, want version 6", status)
}
if _, err := os.Lstat(movedDir); !os.IsNotExist(err) {
t.Fatalf("moved directory exists after blocked Windows swap: %v", err)
}
assertNoStatusTemps(t, dir)
return
}
if got, err := os.ReadFile(path); err != nil {
t.Fatal(err)
} else if string(got) != string(substitute) {
t.Fatalf("substitute destination changed: %q", got)
}
status := readStatusDocument(t, filepath.Join(movedDir, "daemon.status"))
if status.Version != 6 {
t.Fatalf("bound status = %+v, want version 6", status)
}
assertNoStatusTemps(t, dir)
assertNoStatusTemps(t, movedDir)
}
func TestWriteStatusFileAllowsReadOnlyCustomStatusDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "daemon.status")
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Status: path},
Version: 6,
},
}
err := server.writeStatusFile()
if err != nil {
t.Fatalf("writeStatusFile in 0755 directory: %v", err)
}
if _, err := os.Lstat(path); err != nil {
t.Fatalf("status file not created in read-only custom directory: %v", err)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileRejectsWritableStatusDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "daemon.status")
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{Paths: Paths{Status: path}, Version: 6},
}
err := server.writeStatusFile()
if err == nil || !strings.Contains(err.Error(), "no group/other write access") {
t.Fatalf("writeStatusFile error = %v, want writable-directory rejection", err)
}
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("status file created in writable directory: %v", err)
}
assertNoStatusTemps(t, dir)
}
func TestPrivateDirHardensBroadCurrentUserStatusDirectory(t *testing.T) {
dir := t.TempDir()
broadenStatusTestDirPlatform(t, dir)
if err := privatedir.Ensure(dir); err != nil {
t.Fatalf("privatedir.Ensure: %v", err)
}
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := validateStatusRoot(root); err != nil {
t.Fatalf("validate hardened status root: %v", err)
}
}
func readStatusDocument(t *testing.T, path string) StatusFile {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read status: %v", err)
}
var status StatusFile
if err := json.Unmarshal(data, &status); err != nil {
t.Fatalf("status is incomplete JSON: %v (content %q)", err, data)
}
return status
}
func assertNoStatusTemps(t *testing.T, dir string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, statusTempPattern))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary status files remain: %v", matches)
}
}
func secureStatusTestDir(t *testing.T, dir string) {
t.Helper()
secureStatusTestDirPlatform(t, dir)
}
@@ -1,63 +0,0 @@
//go:build windows
package daemon
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestWriteStatusFilePreservesExistingProtectedDACL(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
if err != nil {
t.Skipf("cannot build restrictive DACL: %v", err)
}
dacl, _, err := restricted.DACL()
if err != nil {
t.Skipf("cannot read restrictive DACL: %v", err)
}
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, dacl, nil); err != nil {
t.Skipf("cannot apply restrictive DACL: %v", err)
}
want := statusDACL(t, path)
server := &Server{
startedAt: time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 2,
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
if got := statusDACL(t, path); got != want {
t.Fatalf("status DACL after replacement = %q, want %q", got, want)
}
}
func statusDACL(t *testing.T, path string) string {
t.Helper()
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Skipf("cannot read status DACL: %v", err)
}
text := descriptor.String()
if index := strings.Index(text, "D:"); index >= 0 {
return text[index:]
}
return text
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package daemon
import "os"
func prepareStatusReplacement(*os.Root, string, string) error { return nil }
-136
View File
@@ -1,136 +0,0 @@
//go:build windows
package daemon
import (
"errors"
"fmt"
"os"
"path/filepath"
"unsafe"
"golang.org/x/sys/windows"
)
// prepareStatusReplacement copies the existing status document's DACL to the
// complete staging file before Root.Rename replaces it. Both files are opened
// relative to the already-bound Root directory handle, so preserving Windows
// security metadata does not reintroduce a pathname/ancestor-swap boundary.
func prepareStatusReplacement(root *os.Root, src, dst string) (returnErr error) {
if filepath.Base(src) != src || filepath.Base(dst) != dst {
return fmt.Errorf("status replacement names must be root-relative base names")
}
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open bound status directory for DACL preservation: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close bound status directory for DACL preservation: %w", err))
}
}()
raw, err := directory.SyscallConn()
if err != nil {
return fmt.Errorf("access bound status directory handle: %w", err)
}
var destination, source windows.Handle
var openErr error
if err := raw.Control(func(rootHandle uintptr) {
destination, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), dst, windows.READ_CONTROL|windows.FILE_READ_ATTRIBUTES)
if isMissingStatusObject(openErr) {
openErr = nil // no destination descriptor exists to preserve
return
}
if openErr != nil {
return
}
source, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), src, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES)
}); err != nil {
return fmt.Errorf("open status files through bound directory: %w", err)
}
if destination != 0 {
defer windows.CloseHandle(destination)
}
if source != 0 {
defer windows.CloseHandle(source)
}
if openErr != nil {
return fmt.Errorf("open status files for DACL preservation: %w", openErr)
}
if destination == 0 {
return nil
}
descriptor, err := windows.GetSecurityInfo(destination, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("read existing status DACL: %w", err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
return fmt.Errorf("decode existing status DACL: %w", err)
}
if dacl == nil {
return fmt.Errorf("existing status file has an unrestricted Windows DACL")
}
control, _, err := descriptor.Control()
if err != nil {
return fmt.Errorf("read existing status DACL control: %w", err)
}
securityInfo := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION)
if control&windows.SE_DACL_PROTECTED != 0 {
securityInfo |= windows.SECURITY_INFORMATION(windows.PROTECTED_DACL_SECURITY_INFORMATION)
} else {
securityInfo |= windows.SECURITY_INFORMATION(windows.UNPROTECTED_DACL_SECURITY_INFORMATION)
}
if err := windows.SetSecurityInfo(source, windows.SE_FILE_OBJECT, securityInfo, nil, nil, dacl, nil); err != nil {
return fmt.Errorf("preserve existing status DACL on replacement: %w", err)
}
return nil
}
func openStatusSecurityHandle(root windows.Handle, name string, access uint32) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, err
}
attributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: root,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var handle windows.Handle
var status windows.IO_STATUS_BLOCK
err = windows.NtCreateFile(
&handle,
access,
attributes,
&status,
nil,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if err != nil {
return 0, err
}
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
windows.CloseHandle(handle)
return 0, err
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
windows.CloseHandle(handle)
return 0, fmt.Errorf("refusing status DACL operation on reparse point %q", name)
}
return handle, nil
}
func isMissingStatusObject(err error) bool {
return errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
}
-43
View File
@@ -224,49 +224,6 @@ 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)
}
-121
View File
@@ -2,7 +2,6 @@ package execution
import (
"context"
"errors"
"io"
"os"
"os/exec"
@@ -86,126 +85,6 @@ 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)
-4
View File
@@ -11,7 +11,3 @@ 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")
}
-16
View File
@@ -48,9 +48,6 @@ 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()
@@ -83,16 +80,3 @@ 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),
})
}
-57
View File
@@ -64,28 +64,6 @@ func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
if err != nil {
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
}
return acquireOpenedFileLock(file)
}
// TryAcquireFileLockRoot attempts to lock name relative to an already-bound
// directory root. It is intended for lifecycle owners that must not resolve the
// root pathname again between validation and lock acquisition.
func TryAcquireFileLockRoot(root *os.Root, name, displayPath string) (*FileLock, error) {
if root == nil {
return nil, errors.New("lockutil: lock root is nil")
}
name = filepath.Clean(name)
if name == "." || name == ".." || filepath.IsAbs(name) || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("lockutil: lock name %q escapes root", name)
}
file, err := openLockFileRoot(root, name, displayPath)
if err != nil {
return nil, fmt.Errorf("lockutil: open rooted lock file: %w", err)
}
return acquireOpenedFileLock(file)
}
func acquireOpenedFileLock(file *os.File) (*FileLock, error) {
state, contended, err := tryLockFile(file)
if err != nil {
_ = file.Close()
@@ -104,41 +82,6 @@ func acquireOpenedFileLock(file *os.File) (*FileLock, error) {
return lock, nil
}
func openLockFileRoot(root *os.Root, name, displayPath string) (*os.File, error) {
info, err := root.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
file, createErr := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
if createErr != nil {
return nil, createErr
}
if err := validateRootLockFile(file); err != nil {
return nil, errors.Join(err, file.Close())
}
return file, nil
}
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("refusing non-regular rooted lock file")
}
file, err := root.OpenFile(name, os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
opened, err := file.Stat()
if err != nil {
return nil, errors.Join(err, file.Close())
}
if !os.SameFile(info, opened) {
return nil, errors.Join(errors.New("rooted lock file changed while opening"), file.Close())
}
if err := validateRootLockFile(file); err != nil {
return nil, errors.Join(err, file.Close())
}
return file, nil
}
// WriteMetadata replaces the diagnostic contents of the lock file while the
// caller holds it. Metadata does not establish ownership; the kernel lock does.
func (lock *FileLock) WriteMetadata(data []byte) error {
-14
View File
@@ -76,20 +76,6 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
return file, nil
}
func validateRootLockFile(file *os.File) error {
var stat unix.Stat_t
if err := unix.Fstat(int(file.Fd()), &stat); err != nil {
return fmt.Errorf("inspect rooted lock file: %w", err)
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
return errors.New("refusing non-regular rooted lock file")
}
if stat.Nlink != 1 {
return errors.New("refusing multiply-linked rooted lock file")
}
return nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
-4
View File
@@ -73,10 +73,6 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
return file, nil
}
func validateRootLockFile(file *os.File) error {
return validateWindowsLockHandle(windows.Handle(file.Fd()), false)
}
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
handle, err := openLockPathAt(
parent,
+5 -225
View File
@@ -7,50 +7,17 @@
package observability
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
"time"
"github.com/Gitlawb/zero/internal/privatedir"
)
// crashExitCode is returned when a top-level panic is recovered.
const crashExitCode = 1
const crashTempPrefix = ".crash-report-"
// ErrCrashReportCommitted marks an error that happened after a complete crash
// report was published. Callers may still use the returned path and should
// surface the error as a cleanup warning rather than as a failed publication.
var ErrCrashReportCommitted = errors.New("crash report publication committed")
type crashReportCommittedError struct {
cause error
}
func (err *crashReportCommittedError) Error() string {
return fmt.Sprintf("%v with warning: %v", ErrCrashReportCommitted, err.cause)
}
func (err *crashReportCommittedError) Unwrap() []error {
return []error{ErrCrashReportCommitted, err.cause}
}
type crashReportHooks struct {
beforePublish func()
write func(*os.File, []byte) (int, error)
link func(*os.Root, string, string) error
renameNoReplace func(*os.Root, string, string) error
remove func(*os.Root, string) error
}
// FormatCrashReport renders a human-readable crash report.
func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time) string {
return fmt.Sprintf("zero crash report\ntime: %s\nlabel: %s\npanic: %v\n\nstack:\n%s\n",
@@ -59,189 +26,16 @@ func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time)
// WriteCrashReport writes a crash report file into dir and returns its path.
func WriteCrashReport(dir, label string, recovered any, stack []byte, ts time.Time) (string, error) {
return writeCrashReport(dir, label, recovered, stack, ts, crashReportHooks{})
}
func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Time, hooks crashReportHooks) (path string, returnErr error) {
absoluteDir, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("resolve crash report directory: %w", err)
}
root, err := openCrashDirectory(dir)
if err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
committed := false
defer func() {
if err := root.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close crash report directory: %w", err))
}
if committed && returnErr != nil && !errors.Is(returnErr, ErrCrashReportCommitted) {
returnErr = &crashReportCommittedError{cause: returnErr}
}
if !committed && returnErr != nil {
path = ""
}
}()
name := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
path = filepath.Join(dir, name)
report, tempName, err := createCrashTemp(root)
if err != nil {
path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log")
if err := os.WriteFile(path, []byte(FormatCrashReport(label, recovered, stack, ts)), 0o600); err != nil {
return "", err
}
defer func() {
if tempName == "" {
return
}
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary crash report: %w", err))
}
}()
data := []byte(FormatCrashReport(label, recovered, stack, ts))
writeReport := hooks.write
if writeReport == nil {
writeReport = func(file *os.File, data []byte) (int, error) { return file.Write(data) }
}
written, err := writeReport(report, data)
if err == nil && written != len(data) {
err = io.ErrShortWrite
}
if err != nil {
closeErr := report.Close()
if closeErr != nil {
return "", errors.Join(fmt.Errorf("write crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
}
return "", fmt.Errorf("write crash report: %w", err)
}
if err := report.Sync(); err != nil {
closeErr := report.Close()
if closeErr != nil {
return "", errors.Join(fmt.Errorf("sync crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
}
return "", fmt.Errorf("sync crash report: %w", err)
}
if err := report.Close(); err != nil {
return "", fmt.Errorf("close crash report: %w", err)
}
if hooks.beforePublish != nil {
hooks.beforePublish()
}
publishName := name
link := root.Link
if hooks.link != nil {
link = func(oldname, newname string) error { return hooks.link(root, oldname, newname) }
}
if linkErr := link(tempName, name); linkErr != nil {
publishName, err = publishCrashFallback(root, name, tempName, hooks.renameNoReplace)
if err != nil {
return "", errors.Join(fmt.Errorf("publish crash report with hard link: %w", linkErr), err)
}
committed = true
tempName = ""
} else {
committed = true
}
path = filepath.Join(dir, publishName)
if !crashPathUsesRoot(root, absoluteDir) {
path = ""
}
if tempName != "" {
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
tempName = ""
return path, fmt.Errorf("remove temporary crash report: %w", err)
}
tempName = ""
}
return path, nil
}
// publishCrashFallback atomically renames the complete staging file to an
// unpredictable unused name. The no-replace operation is the collision check;
// there is no check-then-rename window in which another report can be replaced.
func publishCrashFallback(root *os.Root, timestampName, tempName string, rename func(*os.Root, string, string) error) (string, error) {
if rename == nil {
rename = renameNoReplace
}
base := strings.TrimSuffix(timestampName, filepath.Ext(timestampName))
if suffix := strings.TrimPrefix(tempName, crashTempPrefix); suffix != "" && suffix != tempName {
candidate := base + "-" + suffix + filepath.Ext(timestampName)
if err := rename(root, tempName, candidate); err == nil {
return candidate, nil
} else if !errors.Is(err, os.ErrExist) {
return "", fmt.Errorf("publish fallback crash report: %w", err)
}
}
for range 100 {
var suffix [16]byte
if _, err := rand.Read(suffix[:]); err != nil {
return "", fmt.Errorf("generate fallback crash report name: %w", err)
}
candidate := base + "-" + hex.EncodeToString(suffix[:]) + filepath.Ext(timestampName)
if err := rename(root, tempName, candidate); err == nil {
return candidate, nil
} else if !errors.Is(err, os.ErrExist) {
return "", fmt.Errorf("publish fallback crash report: %w", err)
}
}
return "", fmt.Errorf("generate fallback crash report name: exhausted unique names")
}
func removeCrashTemp(hooks crashReportHooks, root *os.Root, name string) error {
if hooks.remove != nil {
return hooks.remove(root, name)
}
return root.Remove(name)
}
func createCrashTemp(root *os.Root) (*os.File, string, error) {
for range 100 {
var suffix [16]byte
if _, err := rand.Read(suffix[:]); err != nil {
return nil, "", fmt.Errorf("generate temporary crash report name: %w", err)
}
name := crashTempPrefix + hex.EncodeToString(suffix[:])
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
return file, name, nil
}
if errors.Is(err, os.ErrExist) {
continue
}
return nil, "", fmt.Errorf("create temporary crash report: %w", err)
}
return nil, "", fmt.Errorf("create temporary crash report: exhausted unique names")
}
func crashPathUsesRoot(root *os.Root, dir string) bool {
bound, err := root.Stat(".")
if err != nil {
return false
}
current, err := os.Stat(filepath.Clean(dir))
return err == nil && os.SameFile(bound, current)
}
func openCrashDirectory(dir string) (*os.Root, error) {
clean := filepath.Clean(dir)
defaultDir := filepath.Clean(DefaultCrashDir())
parent := filepath.Dir(clean)
// The default layout shares ~/.zero with the daemon runtime fallback. Keep
// both the shared parent and the crash-report child private. Custom crash
// destinations are hardened at the caller-supplied boundary only.
if clean == defaultDir && filepath.Base(clean) == "crashes" && filepath.Base(parent) == ".zero" {
if err := privatedir.Ensure(parent); err != nil {
return nil, fmt.Errorf("secure crash report parent: %w", err)
}
}
root, err := privatedir.Open(clean)
if err != nil {
return nil, fmt.Errorf("secure crash report directory: %w", err)
}
return root, nil
}
// DefaultCrashDir is where crash reports are written by default.
func DefaultCrashDir() string {
if home, err := os.UserHomeDir(); err == nil && home != "" {
@@ -260,22 +54,8 @@ func Recover(dir, label string, stderr io.Writer, code *int) {
return
}
stack := debug.Stack()
reportRecoveredCrash(stderr, code, recovered, stack, func() (string, error) {
return WriteCrashReport(dir, label, recovered, stack, time.Now())
})
}
func reportRecoveredCrash(stderr io.Writer, code *int, recovered any, stack []byte, write func() (string, error)) {
path, err := write()
if err == nil || errors.Is(err, ErrCrashReportCommitted) {
if path != "" {
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
} else {
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved, but its current path could not be determined\n", recovered)
}
if err != nil {
fmt.Fprintf(stderr, "Warning: %v\n", err)
}
if path, err := WriteCrashReport(dir, label, recovered, stack, time.Now()); err == nil {
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
} else {
fmt.Fprintf(stderr, "zero crashed: %v\n%s\n", recovered, stack)
}
-419
View File
@@ -2,10 +2,7 @@ package observability
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -30,400 +27,6 @@ func TestWriteAndFormatCrashReport(t *testing.T) {
}
}
func TestWriteCrashReportKeepsTwoReportsFromTheSameSecond(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC)
first, err := WriteCrashReport(dir, "cli", "first panic", []byte("first stack"), ts)
if err != nil {
t.Fatal(err)
}
second, err := WriteCrashReport(dir, "cli", "second panic", []byte("second stack"), ts)
if err != nil {
t.Fatalf("second report in the same second: %v", err)
}
if first == second {
t.Fatalf("same-second reports shared path %q", first)
}
firstData, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
secondData, err := os.ReadFile(second)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(firstData), "first panic") || strings.Contains(string(firstData), "second panic") {
t.Fatalf("first report was replaced: %q", firstData)
}
if !strings.Contains(string(secondData), "second panic") {
t.Fatalf("second report was not persisted: %q", secondData)
}
}
func TestWriteCrashReportCreatesPrivateDefaultDirectories(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
dir := DefaultCrashDir()
if dir != filepath.Join(home, ".zero", "crashes") {
t.Fatalf("DefaultCrashDir = %q, want path beneath temporary home", dir)
}
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
if runtime.GOOS == "windows" {
return
}
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got&0o077 != 0 {
t.Fatalf("directory %s permissions = %04o, want owner-only", path, got)
}
}
}
func TestWriteCrashReportHardensPreexistingDefaultDirectories(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows DACL migration is covered by the daemon integration test")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
dir := DefaultCrashDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
if err := os.Chmod(path, 0o755); err != nil {
t.Fatal(err)
}
}
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got&0o077 != 0 {
t.Fatalf("directory %s permissions = %04o after migration, want owner-only", path, got)
}
}
}
func TestWriteCrashReportBindsDirectoryDuringSwap(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
var swapErr error
path, err := writeCrashReport(dir, "cli", "secret panic", []byte("secret stack"), ts, crashReportHooks{
beforePublish: func() {
swapErr = os.Rename(dir, movedDir)
if swapErr != nil {
return
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute crash directory: %v", err)
}
},
})
if err != nil {
t.Fatalf("writeCrashReport: %v", err)
}
if swapErr != nil {
if runtime.GOOS != "windows" {
t.Fatalf("swap crash directory: %v", swapErr)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("report missing after Windows blocked directory swap: %v", err)
}
return
}
if path != "" {
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
}
reportName := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
data, err := os.ReadFile(filepath.Join(movedDir, reportName))
if err != nil {
t.Fatalf("read report through originally bound directory: %v", err)
}
if !strings.Contains(string(data), "secret panic") {
t.Fatalf("report in bound directory missing panic: %q", data)
}
if _, err := os.Stat(filepath.Join(dir, reportName)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("substitute directory received crash report: %v", err)
}
}
func TestWriteCrashReportPublishesOnlyCompleteContent(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
finalPath := filepath.Join(dir, "crash-20260824-120000.log")
staged := make(chan struct{})
release := make(chan struct{})
result := make(chan crashWriteResult, 1)
go func() {
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
beforePublish: func() {
close(staged)
<-release
},
})
result <- crashWriteResult{path: path, err: err}
}()
select {
case <-staged:
case written := <-result:
t.Fatalf("writeCrashReport returned before publication hook: %v", written.err)
case <-time.After(time.Second):
t.Fatal("writeCrashReport did not reach publication hook")
}
defer func() {
select {
case <-release:
default:
close(release)
}
}()
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final report visible before complete publication: %v", err)
}
close(release)
written := <-result
if written.err != nil {
t.Fatalf("writeCrashReport: %v", written.err)
}
data, err := os.ReadFile(written.path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("published report is incomplete: %q", data)
}
}
func TestWriteCrashReportRemovesTempAfterWriteFailure(t *testing.T) {
dir := t.TempDir()
injected := errors.New("injected write failure")
_, err := writeCrashReport(dir, "cli", "boom", []byte("stack"), time.Now(), crashReportHooks{
write: func(*os.File, []byte) (int, error) { return 0, injected },
})
if !errors.Is(err, injected) {
t.Fatalf("writeCrashReport error = %v, want injected failure", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("crash directory contains partial files after failure: %v", entries)
}
}
func TestWriteCrashReportDoesNotOverwriteExistingReport(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
path := filepath.Join(dir, "crash-20260824-120000.log")
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
published, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), ts)
if err != nil {
t.Fatalf("WriteCrashReport with existing timestamp: %v", err)
}
if published == path {
t.Fatal("new report reused the occupied timestamp path")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "existing" {
t.Fatalf("existing report overwritten: %q", data)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("crash directory should contain existing and uniquely published reports: %v", entries)
}
}
func TestWriteCrashReportPreservesPathAfterCommittedCleanupWarning(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
injected := errors.New("injected cleanup failure")
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
remove: func(*os.Root, string) error { return injected },
})
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
}
wantPath := filepath.Join(dir, "crash-20260824-120000.log")
if path != wantPath {
t.Fatalf("writeCrashReport path = %q, want %q", path, wantPath)
}
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("read committed crash report: %v", readErr)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("committed crash report is incomplete: %q", data)
}
}
func TestWriteCrashReportDoesNotReturnStalePathWithCleanupWarning(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
injected := errors.New("injected cleanup failure")
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
var swapErr error
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
beforePublish: func() {
swapErr = os.Rename(dir, movedDir)
if swapErr == nil {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute crash directory: %v", err)
}
}
},
remove: func(*os.Root, string) error { return injected },
})
if swapErr != nil {
if runtime.GOOS == "windows" {
t.Skipf("Windows kept the open crash directory from being renamed: %v", swapErr)
}
t.Fatalf("swap crash directory: %v", swapErr)
}
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
}
if path != "" {
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
}
report := filepath.Join(movedDir, "crash-20260824-120000.log")
data, readErr := os.ReadFile(report)
if readErr != nil {
t.Fatalf("read committed report through moved directory: %v", readErr)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("committed report is incomplete: %q", data)
}
}
func TestWriteCrashReportFallsBackWhenHardLinksAreUnavailable(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
unsupported := errors.New("hard links unavailable")
var collisionName string
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
link: func(root *os.Root, oldname, _ string) error {
suffix := strings.TrimPrefix(oldname, crashTempPrefix)
collisionName = "crash-20260824-120000-" + suffix + ".log"
if err := root.WriteFile(collisionName, []byte("existing"), 0o600); err != nil {
t.Fatalf("create fallback-name collision: %v", err)
}
return unsupported
},
})
if err != nil {
t.Fatalf("writeCrashReport fallback: %v", err)
}
if path == filepath.Join(dir, collisionName) {
t.Fatalf("fallback replaced the existing report %q", collisionName)
}
if !strings.HasPrefix(filepath.Base(path), "crash-20260824-120000-") {
t.Fatalf("fallback path = %q, want timestamp and random suffix", path)
}
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("read fallback crash report: %v", readErr)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("fallback crash report is incomplete: %q", data)
}
existing, readErr := os.ReadFile(filepath.Join(dir, collisionName))
if readErr != nil {
t.Fatalf("read existing collision: %v", readErr)
}
if string(existing) != "existing" {
t.Fatalf("existing fallback collision was overwritten: %q", existing)
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 2 {
t.Fatalf("crash directory contains partial files after fallback: %v", entries)
}
}
func TestWriteCrashReportFallbackDoesNotReplaceCommitTimeCollision(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
unsupported := errors.New("hard links unavailable")
var collisionName string
attempts := 0
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
link: func(*os.Root, string, string) error { return unsupported },
renameNoReplace: func(root *os.Root, oldname, newname string) error {
attempts++
if attempts == 1 {
collisionName = newname
if err := root.WriteFile(newname, []byte("concurrent report"), 0o600); err != nil {
t.Fatalf("create commit-time collision: %v", err)
}
}
return renameNoReplace(root, oldname, newname)
},
})
if err != nil {
t.Fatalf("writeCrashReport fallback: %v", err)
}
if attempts < 2 {
t.Fatalf("fallback rename attempts = %d, want collision retry", attempts)
}
if filepath.Base(path) == collisionName {
t.Fatalf("fallback replaced the concurrent report %q", collisionName)
}
existing, err := os.ReadFile(filepath.Join(dir, collisionName))
if err != nil {
t.Fatal(err)
}
if string(existing) != "concurrent report" {
t.Fatalf("concurrent report overwritten: %q", existing)
}
published, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(published), "complete stack") {
t.Fatalf("published fallback is incomplete: %q", published)
}
}
type crashWriteResult struct {
path string
err error
}
func TestRecoverCapturesPanic(t *testing.T) {
dir := t.TempDir()
var stderr bytes.Buffer
@@ -460,25 +63,3 @@ func TestRecoverNoPanicIsNoop(t *testing.T) {
t.Fatalf("unexpected output without a panic: %q", stderr.String())
}
}
func TestReportRecoveredCrashReportsCommittedCleanupWarning(t *testing.T) {
var stderr bytes.Buffer
code := 0
injected := errors.New("injected cleanup failure")
reportRecoveredCrash(&stderr, &code, "kaboom", []byte("secret stack"), func() (string, error) {
return "/tmp/crash.log", &crashReportCommittedError{cause: injected}
})
if code != crashExitCode {
t.Fatalf("exit code = %d, want %d", code, crashExitCode)
}
output := stderr.String()
for _, want := range []string{"kaboom", "saved to /tmp/crash.log", "Warning:", injected.Error()} {
if !strings.Contains(output, want) {
t.Fatalf("crash notice missing %q: %q", want, output)
}
}
if strings.Contains(output, "secret stack") {
t.Fatalf("committed report warning fell back to inline stack: %q", output)
}
}
@@ -1,19 +0,0 @@
//go:build darwin
package observability
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open crash directory for publication: %w", err)
}
defer directory.Close()
return unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL)
}
@@ -1,19 +0,0 @@
//go:build linux
package observability
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open crash directory for publication: %w", err)
}
defer directory.Close()
return unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE)
}
@@ -1,12 +0,0 @@
//go:build !darwin && !linux && !windows
package observability
import (
"errors"
"os"
)
func renameNoReplace(*os.Root, string, string) error {
return errors.ErrUnsupported
}
@@ -1,84 +0,0 @@
//go:build windows
package observability
import (
"errors"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
type fileRenameInformation struct {
replaceIfExists uint32
rootDirectory windows.Handle
fileNameLength uint32
fileName [1]uint16
}
func renameNoReplace(root *os.Root, oldname, newname string) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return err
}
defer directory.Close()
raw, err := directory.SyscallConn()
if err != nil {
return err
}
if err := raw.Control(func(rawHandle uintptr) {
rootHandle := windows.Handle(rawHandle)
oldObjectName, nameErr := windows.NewNTUnicodeString(oldname)
if nameErr != nil {
returnErr = nameErr
return
}
attributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: rootHandle,
ObjectName: oldObjectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var source windows.Handle
var status windows.IO_STATUS_BLOCK
returnErr = windows.NtCreateFile(
&source,
windows.DELETE|windows.SYNCHRONIZE,
attributes,
&status,
nil,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if returnErr != nil {
return
}
defer windows.CloseHandle(source)
newName, nameErr := windows.UTF16FromString(newname)
if nameErr != nil {
returnErr = nameErr
return
}
nameBytes := (len(newName) - 1) * 2
var layout fileRenameInformation
bufferSize := int(unsafe.Offsetof(layout.fileName)) + nameBytes
buffer := make([]byte, bufferSize)
info := (*fileRenameInformation)(unsafe.Pointer(&buffer[0]))
info.rootDirectory = rootHandle
info.fileNameLength = uint32(nameBytes)
copy((*[windows.MAX_LONG_PATH]uint16)(unsafe.Pointer(&info.fileName[0]))[:nameBytes/2:nameBytes/2], newName)
returnErr = windows.NtSetInformationFile(source, &status, &buffer[0], uint32(bufferSize), windows.FileRenameInformation)
}); err != nil {
return err
}
if errors.Is(returnErr, windows.STATUS_OBJECT_NAME_COLLISION) || errors.Is(returnErr, windows.STATUS_OBJECT_NAME_EXISTS) {
return os.ErrExist
}
return returnErr
}
-85
View File
@@ -1,85 +0,0 @@
// Package privatedir creates and safely hardens current-user-owned state
// directories without applying permission changes through a path-only check.
package privatedir
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// Ensure creates path when needed and enforces owner-only access. Existing
// directories are hardened only after ownership is verified through a bound
// directory handle; foreign-owned paths fail closed.
func Ensure(path string) error {
root, err := Open(path)
if err != nil {
return err
}
if err := root.Close(); err != nil {
return fmt.Errorf("close private directory: %w", err)
}
return nil
}
// Open creates path when needed, enforces owner-only access, and returns a
// traversal-resistant handle that callers can retain across subsequent I/O.
func Open(path string) (*os.Root, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve private directory: %w", err)
}
if err := os.MkdirAll(absolute, 0o700); err != nil {
return nil, fmt.Errorf("create private directory: %w", err)
}
before, err := os.Lstat(absolute)
if err != nil {
return nil, fmt.Errorf("inspect private directory entry: %w", err)
}
if before.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("private directory entry is a symbolic link")
}
if !before.IsDir() {
return nil, fmt.Errorf("private directory path is not a directory")
}
root, err := os.OpenRoot(absolute)
if err != nil {
return nil, fmt.Errorf("open private directory: %w", err)
}
opened, err := root.Stat(".")
if err != nil {
_ = root.Close()
return nil, fmt.Errorf("inspect opened private directory: %w", err)
}
after, err := os.Lstat(absolute)
if err != nil {
_ = root.Close()
return nil, fmt.Errorf("reinspect private directory entry: %w", err)
}
if after.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, after) {
_ = root.Close()
return nil, fmt.Errorf("private directory entry changed while opening")
}
if err := harden(root); err != nil {
closeErr := root.Close()
if closeErr != nil {
return nil, errors.Join(err, fmt.Errorf("close private directory: %w", closeErr))
}
return nil, err
}
current, err := os.Lstat(absolute)
if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
closeErr := root.Close()
if err == nil {
err = fmt.Errorf("private directory entry changed while hardening")
} else {
err = fmt.Errorf("verify private directory entry after hardening: %w", err)
}
if closeErr != nil {
err = errors.Join(err, fmt.Errorf("close private directory: %w", closeErr))
}
return nil, err
}
return root, nil
}
-35
View File
@@ -1,35 +0,0 @@
package privatedir
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestOpenRejectsFinalDirectorySymlinkWithoutHardeningTarget(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating directory symlinks requires privileges on some Windows runners")
}
parent := t.TempDir()
target := filepath.Join(parent, "target")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(parent, "private")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := Open(link); err == nil || !strings.Contains(err.Error(), "symbolic link") {
t.Fatalf("Open symlink error = %v, want symbolic-link rejection", err)
}
info, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o755 {
t.Fatalf("symlink target permissions = %04o, want unchanged 0755", got)
}
}
-47
View File
@@ -1,47 +0,0 @@
//go:build !windows
package privatedir
import (
"errors"
"fmt"
"os"
"syscall"
)
func harden(root *os.Root) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open private directory hardening handle: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory hardening handle: %w", err))
}
}()
info, err := directory.Stat()
if err != nil {
return fmt.Errorf("inspect private directory before hardening: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("private directory path is not a directory")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("private directory ownership metadata is unavailable")
}
if int(stat.Uid) != os.Geteuid() {
return fmt.Errorf("private directory is owned by uid %d, not the current user", stat.Uid)
}
if err := directory.Chmod(0o700); err != nil {
return fmt.Errorf("harden private directory permissions: %w", err)
}
info, err = directory.Stat()
if err != nil {
return fmt.Errorf("verify private directory permissions: %w", err)
}
if info.Mode().Perm()&0o077 != 0 {
return fmt.Errorf("private directory permissions are %04o, want owner-only", info.Mode().Perm())
}
return nil
}
-135
View File
@@ -1,135 +0,0 @@
//go:build windows
package privatedir
import (
"errors"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
func harden(root *os.Root) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open private directory hardening handle: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory hardening handle: %w", err))
}
}()
raw, err := directory.SyscallConn()
if err != nil {
return fmt.Errorf("access private directory hardening handle: %w", err)
}
// Root.Open uses NtCreateFile, so ReOpenFile cannot accept its handle. An
// empty relative NT object name reopens the bound directory itself with the
// security rights needed for the DACL update and no pathname re-resolution.
var securityHandle windows.Handle
var openErr error
if err := raw.Control(func(rawHandle uintptr) {
objectName, nameErr := windows.NewNTUnicodeString("")
if nameErr != nil {
openErr = nameErr
return
}
openErr = windows.NtCreateFile(
&securityHandle,
windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
&windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: windows.Handle(rawHandle),
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
},
&windows.IO_STATUS_BLOCK{},
nil,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT,
0,
0,
)
}); err != nil {
return fmt.Errorf("open private directory security handle: %w", err)
}
if openErr != nil {
return fmt.Errorf("open private directory with security access: %w", openErr)
}
if securityHandle == 0 || securityHandle == windows.InvalidHandle {
return fmt.Errorf("open private directory with security access: invalid handle")
}
defer func() {
if err := windows.CloseHandle(securityHandle); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory security handle: %w", err))
}
}()
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return fmt.Errorf("resolve current Windows user: %w", err)
}
tokenOwner, err := windowsTokenOwner(token)
if err != nil {
return fmt.Errorf("resolve current Windows token owner: %w", err)
}
current, err := windows.GetSecurityInfo(securityHandle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("read private directory owner before hardening: %w", err)
}
owner, _, err := current.Owner()
if err != nil {
return fmt.Errorf("read private directory owner before hardening: %w", err)
}
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
return fmt.Errorf("private directory is not owned by the current Windows token")
}
desired, err := windows.SecurityDescriptorFromString(
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
)
if err != nil {
return fmt.Errorf("build private Windows directory DACL: %w", err)
}
dacl, _, err := desired.DACL()
if err != nil {
return fmt.Errorf("read private Windows directory DACL: %w", err)
}
if err := windows.SetSecurityInfo(
securityHandle,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
dacl,
nil,
); err != nil {
return fmt.Errorf("harden private Windows directory DACL: %w", err)
}
return nil
}
type tokenOwnerInfo struct {
owner *windows.SID
}
func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
var size uint32
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
if err != windows.ERROR_INSUFFICIENT_BUFFER {
return nil, err
}
buffer := make([]byte, size)
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
return nil, err
}
owner := (*tokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
+20 -2
View File
@@ -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,
})
}
}
+26 -1
View File
@@ -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)
}
}
+114
View File
@@ -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)
}
}
+3 -12
View File
@@ -226,20 +226,11 @@ func CompactionPayloadFromPlan(summary string, plan CompactionPlan) (CompactionP
}
func (store *Store) ReadRehydratedEvents(sessionID string) ([]Event, error) {
events, _, err := store.ReadRehydratedEventsWithPresence(sessionID)
return events, err
}
// ReadRehydratedEventsWithPresence carries the underlying event-log presence
// through compaction projection without changing ReadRehydratedEvents' existing
// empty-on-missing contract.
func (store *Store) ReadRehydratedEventsWithPresence(sessionID string) ([]Event, bool, error) {
events, present, err := store.ReadEventsWithPresence(sessionID)
events, err := store.ReadEvents(sessionID)
if err != nil {
return nil, present, err
return nil, err
}
rehydrated, err := RehydrateEvents(events)
return rehydrated, present, err
return RehydrateEvents(events)
}
func (store *Store) ReadReplayEvents(sessionID string) ([]Event, error) {
+5 -14
View File
@@ -813,24 +813,15 @@ func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, err
}
func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
events, _, err := store.ReadEventsWithPresence(sessionID)
return events, err
}
// ReadEventsWithPresence preserves the distinction between an intentionally
// empty event log and a missing one. Most readers accept both as empty history,
// but activation protocols that promise to restore previously populated
// context need the stronger signal.
func (store *Store) ReadEventsWithPresence(sessionID string) ([]Event, bool, error) {
if !ValidSessionID(sessionID) {
return nil, false, fmt.Errorf("invalid zero session id %q", sessionID)
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
data, err := os.ReadFile(store.eventsPath(sessionID))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []Event{}, false, nil
return []Event{}, nil
}
return nil, false, fmt.Errorf("read zero session events: %w", err)
return nil, fmt.Errorf("read zero session events: %w", err)
}
// A genuine torn tail is an INCOMPLETE final write — a crash mid-append leaves
// the last line without its terminating newline. If the file ends with a
@@ -860,11 +851,11 @@ func (store *Store) ReadEventsWithPresence(sessionID string) ([]Event, bool, err
if index == lastNonEmpty && tornTailPossible {
break
}
return nil, true, fmt.Errorf("invalid json in zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err)
return nil, fmt.Errorf("invalid json in zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err)
}
events = append(events, event)
}
return events, true, nil
return events, nil
}
func (store *Store) timestamp() string {
+5 -32
View File
@@ -47,11 +47,8 @@ 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 {
@@ -98,7 +95,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. 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},
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes.", Default: false},
},
Required: []string{"cmd"},
AdditionalProperties: false,
@@ -134,10 +131,6 @@ 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)
}
@@ -146,14 +139,6 @@ 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 {
@@ -187,14 +172,8 @@ 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)
}
// 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
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
return interactiveBlockResult(interactive)
}
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
if err != nil {
@@ -474,7 +453,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, input.tty)
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
meta["shell_issue"] = issue.Kind
@@ -596,7 +575,7 @@ func executionChangeSummaries(changes []execution.Change) []execution.Change {
return summaries
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool, tty bool) string {
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
if output != "" {
@@ -614,18 +593,12 @@ 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")
}
-53
View File
@@ -894,56 +894,3 @@ 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)
}
}
+1 -1
View File
@@ -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.terminalAttach != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
+4 -94
View File
@@ -6,8 +6,6 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -32,21 +30,15 @@ func (m model) backgroundTerminalSessions() []tools.ExecSessionSnapshot {
}
func (m model) backgroundTerminalSummary() string {
sessions := m.backgroundTerminalSessions()
if len(sessions) == 0 {
count := len(m.backgroundTerminalSessions())
if count == 0 {
return ""
}
plural := ""
if len(sessions) != 1 {
if count != 1 {
plural = "s"
}
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
return fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", count, plural)
}
func (m model) stopAllBackgroundTerminalSessions() []int {
@@ -79,12 +71,6 @@ 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)
}
@@ -126,82 +112,6 @@ 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 {
-141
View File
@@ -6,7 +6,6 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -14,11 +13,6 @@ 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 }
@@ -41,15 +35,6 @@ 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 {
@@ -69,35 +54,6 @@ 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)
@@ -171,103 +127,6 @@ 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"}},
-4
View File
@@ -306,10 +306,6 @@ 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
View File
@@ -17,7 +17,6 @@ const (
commandPermissions
commandPS
commandStop
commandAttach
commandSandboxSetup
commandProvider
commandModel
@@ -141,13 +140,6 @@ 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",
-2
View File
@@ -977,8 +977,6 @@ 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)
+1 -63
View File
@@ -253,9 +253,6 @@ 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
@@ -1248,7 +1245,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.terminalAttach == nil
m.sttKeyPrompt == nil && m.renamePrompt == nil
}
func (m model) quit() (tea.Model, tea.Cmd) {
@@ -1450,11 +1447,6 @@ 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)
}
@@ -1541,11 +1533,6 @@ 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 {
@@ -1578,20 +1565,6 @@ 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
@@ -1631,11 +1604,6 @@ 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)
}
@@ -2489,9 +2457,6 @@ 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 {
@@ -2667,7 +2632,6 @@ 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;
@@ -3171,11 +3135,8 @@ 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 != "":
@@ -3243,13 +3204,6 @@ 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")
@@ -4785,8 +4739,6 @@ 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:
@@ -5288,7 +5240,6 @@ 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
@@ -5446,7 +5397,6 @@ 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
@@ -5810,18 +5760,6 @@ 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 {
+1 -1
View File
@@ -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.terminalAttach != nil || m.suggestionsActive())
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive())
}
func (m model) setupWantsMouseCapture() bool {
+1 -1
View File
@@ -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.terminalAttach != nil
m.sttKeyPrompt != nil || m.renamePrompt != nil
}
func (m model) petComposerReservedColumns(width int) int {
+2 -2
View File
@@ -300,7 +300,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, interactiveExecStartMsg:
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
continue
}
received++
@@ -407,7 +407,7 @@ func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
updated, _ = next.Update(runtimeMsg)
next = updated.(model)
switch runtimeMsg.(type) {
case toolCallStreamStartMsg, toolCallStreamDeltaMsg, interactiveExecStartMsg:
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
continue
case permissionRequestMsg:
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
-446
View File
@@ -1,446 +0,0 @@
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())
}
-748
View File
@@ -1,748 +0,0 @@
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)
}
}
})
}
}
+1 -1
View File
@@ -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.terminalAttach != nil || m.suggestionsActive()
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
}
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,