Compare commits

..
98 changed files with 304 additions and 9414 deletions

No files matched your search

-1
View File
@@ -24,7 +24,6 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
-5
View File
@@ -34,15 +34,12 @@ jobs:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Checkout requested ref
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
@@ -132,8 +129,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
-12
View File
@@ -5,8 +5,6 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
push:
branches:
@@ -14,8 +12,6 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
permissions:
@@ -31,14 +27,6 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- name: Run action-summary test suite
run: node --test scripts/action-summary.test.mjs
- name: Parse action.yml and assert documented inputs
shell: bash
run: |
+1 -1
View File
@@ -1 +1 @@
{".":"0.9.0"}
{".":"0.8.0"}
-55
View File
@@ -6,61 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
### Features
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
### Bug Fixes
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
### Performance Improvements
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
+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
@@ -20,14 +20,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"`
}
+18 -6
View File
@@ -8,7 +8,6 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -112,11 +111,7 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
testutil.WaitFor(t, "worker idle", func() bool {
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
return diagnostics.working == nil
})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
@@ -128,6 +123,23 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
+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)
}
-23
View File
@@ -49,14 +49,6 @@ const (
exitInterrupted = 130
)
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
// An interactive run can be continued past the shared interactive default by the
// user; an exec run has nobody to unstick it, so evals and automation would
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
// still finite against a genuinely runaway loop, which the loop's empty-turn
// guard also cuts independently of this budget.
const defaultExecMaxTurns = config.MaxTurnsCeiling
type execOutputFormat string
type execInputFormat string
@@ -328,7 +320,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
resolved.MaxTurns = execTurnBudget(resolved)
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
@@ -1231,20 +1222,6 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
}
// execTurnBudget applies the headless default to the resolved turn budget: an
// interactive run can be continued past the shared default by the user, but an
// exec run has nobody to unstick it, so an unconfigured budget starts at the
// documented ceiling instead of truncating mid-task. Explicit sources
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
// MaxTurnsSet and pass through; an exec-profile budget still wins via
// applyProfileTurnBudget afterward.
func execTurnBudget(resolved config.ResolvedConfig) int {
if resolved.MaxTurnsSet {
return resolved.MaxTurns
}
return defaultExecMaxTurns
}
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
// The profile displaces the RESOLVED value, not the flag: a pinned budget
// always wins and the profile backs off entirely, while an env/config budget
-20
View File
@@ -1975,23 +1975,3 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
}
}
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
cases := []struct {
name string
resolved config.ResolvedConfig
want int
}{
// A headless run has no user to continue past the shared interactive
// default, so an unconfigured budget starts at the ceiling.
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
}
for _, tc := range cases {
if got := execTurnBudget(tc.resolved); got != tc.want {
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
}
}
}
-5
View File
@@ -166,7 +166,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
MaxTurnsSet: cfg.maxTurnsSet,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
@@ -235,7 +234,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
}
for _, provider := range src.Providers {
mergeProvider(dst, provider)
@@ -296,7 +294,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
}
for _, provider := range src.Providers {
candidate := providerMergeCandidate(*dst, provider)
@@ -609,7 +606,6 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
cfg.maxTurnsSet = true
}
}
@@ -730,7 +726,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
}
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
cfg.maxTurnsSet = true
}
if overrides.Sandbox.Enabled != nil {
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
-42
View File
@@ -2252,45 +2252,3 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
t.Fatalf("error = %v", err)
}
}
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
const providerJSON = `{
"activeProvider": "p",
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
}`
providerWithTurns := strings.Replace(providerJSON, `"activeProvider": "p"`, `"activeProvider": "p", "maxTurns": 40`, 1)
cases := []struct {
name string
opts ResolveOptions
set bool
}{
{"built-in default only", ResolveOptions{
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, false},
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
{"project config", ResolveOptions{
UserConfigPath: writeConfig(t, providerJSON),
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
}, true},
{"env", ResolveOptions{
Env: map[string]string{MaxTurnsEnv: "120"},
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, true},
{"cli override", ResolveOptions{
Overrides: Overrides{
MaxTurns: 42,
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
},
}, true},
}
for _, tc := range cases {
resolved, err := Resolve(tc.opts)
if err != nil {
t.Fatalf("%s: Resolve() error = %v", tc.name, err)
}
if resolved.MaxTurnsSet != tc.set {
t.Fatalf("%s: MaxTurnsSet = %v, want %v (MaxTurns=%d)", tc.name, resolved.MaxTurnsSet, tc.set, resolved.MaxTurns)
}
}
}
+4 -13
View File
@@ -364,10 +364,6 @@ type FileConfig struct {
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
// maxTurnsSet records that some merge source supplied a positive maxTurns —
// i.e. the value is configured, not the built-in default. Unexported like
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
maxTurnsSet bool
// Extra preserves top-level fields written by newer Zero versions or
// extensions so a read-modify-write through this version is non-destructive.
Extra map[string]json.RawMessage `json:"-"`
@@ -465,15 +461,10 @@ type Overrides struct {
}
type ResolvedConfig struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
// MaxTurnsSet reports whether MaxTurns came from an explicit source (a
// config file, a provider command, ZERO_MAX_TURNS, or CLI overrides) rather
// than the built-in default — callers that need a different default can
// tell the two apart.
MaxTurnsSet bool
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
+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
}
+14 -4
View File
@@ -7,8 +7,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
// --- test doubles ---------------------------------------------------------
@@ -193,7 +191,7 @@ func TestPoolQueuesWhenFull(t *testing.T) {
}()
<-started
// Wait until the first run holds the only slot.
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
secondDone := make(chan struct{})
go func() {
@@ -226,7 +224,7 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
close(runDone)
}()
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
pool.Drain() // KillTimeout elapses, straggler is force-killed
if atomic.LoadInt32(&straggler.killed) != 1 {
@@ -244,6 +242,18 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("condition not met within timeout")
}
func TestPoolRunSurfacesStdoutReadError(t *testing.T) {
// A worker stdout read error must surface as a failure (not be swallowed and
// reported as clean success), and the worker is killed so it can't block on an
+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
+16 -334
View File
@@ -2,202 +2,20 @@ package daemon
import (
"errors"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/testutil"
)
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() }()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
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)
@@ -210,7 +28,19 @@ 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) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("file %s did not appear within timeout", path)
}
func TestServerEndToEnd(t *testing.T) {
@@ -220,7 +50,7 @@ func TestServerEndToEnd(t *testing.T) {
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
waitForFile(t, paths.Status)
// --- run a session and collect its stream-json output ---
runClient, err := Dial(paths.Socket)
@@ -306,154 +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() }()
movedStatus := filepath.Join(movedDir, "d.status")
testutil.WaitFor(t, "file "+movedStatus, func() bool { _, err := os.Stat(movedStatus); return err == nil })
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)
@@ -462,7 +144,7 @@ func TestServerSecondInstanceFails(t *testing.T) {
serveErr := make(chan error, 1)
go func() { serveErr <- srv1.Serve() }()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
waitForFile(t, paths.Status)
// A second server on the same paths must refuse to start (single instance).
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
@@ -483,7 +165,7 @@ func TestServerRejectsUnknownCommand(t *testing.T) {
srv, paths := newTestServer(t, launcher)
go func() { _ = srv.Serve() }()
defer srv.Shutdown()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
waitForFile(t, paths.Status)
client, err := Dial(paths.Socket)
if err != nil {
+2 -4
View File
@@ -6,8 +6,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
func drain(t *testing.T, buffered []string, live <-chan string) []string {
@@ -113,7 +111,7 @@ func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
testutil.WaitFor(t, "session running", func() bool { return sa.State() == SessionRunning })
waitFor(t, func() bool { return sa.State() == SessionRunning })
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
// b must stay queued (its worker not yet launched) while a holds the slot.
@@ -199,7 +197,7 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
testutil.WaitFor(t, "both sessions running", func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
if _, ok := mgr.Get("r1"); !ok {
t.Fatal("a running session must never be evicted, even past the cap")
}
+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,
-3
View File
@@ -216,9 +216,6 @@ func (client *networkClient) request(ctx context.Context, method string, params
if err != nil {
return err
}
if message.isRequestOrNotification() {
return fmt.Errorf("MCP %s expected a response from server %s, got method %q instead", method, client.server.Name, message.Method)
}
if !rpcIDMatches(message.ID, id) {
return fmt.Errorf("MCP %s response id mismatch for server %s", method, client.server.Name)
}
-44
View File
@@ -316,50 +316,6 @@ func TestNonOAuthServerIsUnaffected(t *testing.T) {
}
}
func TestNetworkClientRejectsServerInitiatedRequestAsResponse(t *testing.T) {
// A streamable-HTTP server that answers a tools/call POST with a
// method-bearing frame (a server-initiated request) must surface a protocol
// error rather than an empty, error-free result.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
message := readHTTPRPCMessage(t, r)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, w, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/call":
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(mustRaw(message.ID)) + `,"method":"roots/list","params":{}}`)); err != nil {
t.Errorf("write server request: %v", err)
}
default:
writeHTTPRPCResponse(t, w, message.ID, map[string]any{})
}
}))
defer upstream.Close()
client, err := Connect(ctx, Server{Name: "plain", Type: ServerTypeHTTP, URL: upstream.URL})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer func() {
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}()
result, err := client.CallTool(ctx, "lookup", map[string]any{"query": "zero"})
if err == nil {
t.Fatalf("CallTool() error = nil, result = %#v, want protocol error", result)
}
if !strings.Contains(err.Error(), `"roots/list"`) {
t.Fatalf("CallTool() error = %q, want it to name the unexpected method", err)
}
}
func TestDecodeSSERPCMessageSkipsNotifications(t *testing.T) {
// A leading server notification (has a method) on the POST's event stream must
// be skipped so the actual response (no method) is returned, instead of the
+4 -30
View File
@@ -20,7 +20,6 @@ type LoopbackListener struct {
state string
result chan callbackResult
server *http.Server
done chan struct{}
}
type callbackResult struct {
@@ -39,14 +38,6 @@ func NewLoopbackListener(state string) (*LoopbackListener, error) {
// port (0 = OS-assigned). Used by ChatGPT OAuth which requires a fixed
// redirect_uri of http://localhost:1455/auth/callback.
func NewLoopbackListenerOnPort(state string, port int) (*LoopbackListener, error) {
return newLoopbackListener(state, port, nil)
}
// newLoopbackListener is NewLoopbackListenerOnPort with an optional ConnState
// hook, set before the Serve goroutine starts so tests can synchronize on
// connection lifecycle events (e.g. a stalled request header being accepted)
// without racing the running server.
func newLoopbackListener(state string, port int, connState func(net.Conn, http.ConnState)) (*LoopbackListener, error) {
if strings.TrimSpace(state) == "" {
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
}
@@ -58,20 +49,9 @@ func newLoopbackListener(state string, port int, connState func(net.Conn, http.C
listener: ln,
state: state,
result: make(chan callbackResult, 1),
done: make(chan struct{}),
}
l.server = &http.Server{
Handler: http.HandlerFunc(l.handle),
// This is a single-use, loopback-only callback server: a client that
// never finishes sending its request header must not be able to hold
// the accepted connection (and Close, below) open indefinitely.
ReadHeaderTimeout: 5 * time.Second,
ConnState: connState,
}
go func() {
_ = l.server.Serve(ln)
close(l.done)
}()
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
go func() { _ = l.server.Serve(ln) }()
return l, nil
}
@@ -119,17 +99,11 @@ func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
}
}
// Close shuts the listener down (bounded), idempotent. If a client leaves a
// connection open past the shutdown deadline (e.g. a stalled request header),
// Close forcibly closes it rather than leaking it past the end of the flow,
// and waits for the Serve goroutine to finish before returning.
// Close shuts the listener down (bounded), idempotent.
func (l *LoopbackListener) Close() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := l.server.Shutdown(shutdownCtx); err != nil {
_ = l.server.Close()
}
<-l.done
_ = l.server.Shutdown(shutdownCtx)
}
// parseCallback validates the redirect query and returns the authorization code,
-99
View File
@@ -3,9 +3,6 @@ package oauth
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"testing"
@@ -125,99 +122,3 @@ func TestLoopbackProviderError(t *testing.T) {
t.Fatalf("Wait err = %v, want provider error", err)
}
}
func TestLoopbackCloseForceClosesSlowHeaderConnection(t *testing.T) {
accepted := make(chan struct{}, 1)
l, err := newLoopbackListener("audit-state", 0, func(_ net.Conn, state http.ConnState) {
// StateNew fires as soon as Serve accepts the connection, before it
// blocks reading the (never-completed) header. That's the earliest
// point at which net/http's Shutdown will treat this connection as
// non-idle, which is what this test needs to exercise.
if state == http.StateNew {
select {
case accepted <- struct{}{}:
default:
}
}
})
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
t.Cleanup(l.Close)
addr := l.listener.Addr().String()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
// Wait for Serve to accept the connection before sending a request line
// and a header key with no terminating CRLF, so Serve is guaranteed to
// be blocked reading a partial header (rather than a fixed sleep guessing
// that Accept has run).
select {
case <-accepted:
case <-time.After(3 * time.Second):
t.Fatal("server never accepted the connection")
}
fmt.Fprintf(conn, "GET /callback?code=audit-code&state=audit-state HTTP/1.1\r\nHost: %s\r\nX-Stall:", addr)
started := time.Now()
l.Close()
if elapsed := time.Since(started); elapsed < 900*time.Millisecond {
t.Fatalf("Close returned after %s; expected it to wait out its one-second shutdown deadline first", elapsed)
}
conn.SetReadDeadline(time.Now().Add(time.Second))
var one [1]byte
_, readErr := conn.Read(one[:])
if readErr == nil {
t.Fatal("read after Close succeeded; wanted the stalled connection to be closed")
}
if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() {
t.Fatalf("read after Close timed out (%v); wanted the stalled connection closed rather than left open", readErr)
}
}
func TestLoopbackCloseIsIdempotent(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
l.Close()
l.Close() // must not panic or hang
}
func TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
t.Cleanup(l.Close)
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
resp, err := http.Get(l.RedirectURI() + "?code=x&state=s")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := l.Wait(ctx); err != nil {
t.Fatalf("Wait: %v", err)
}
// Join the client goroutine so the response is fully read (and its
// connection returned to idle) before timing Close, instead of racing it.
select {
case <-requestDone:
case <-time.After(3 * time.Second):
t.Fatal("http.Get goroutine did not complete")
}
started := time.Now()
l.Close()
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("Close took %s after a completed, idle callback; want a prompt return", elapsed)
}
}
+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()
}
+2 -6
View File
@@ -90,7 +90,6 @@ type Options struct {
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
StreamIdleTimeout time.Duration
SessionHeader string
}
// Provider streams completions from Anthropic's Messages API.
@@ -109,7 +108,6 @@ type Provider struct {
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
sessionHeader providerio.SessionHeader
}
// New creates an Anthropic provider.
@@ -145,7 +143,6 @@ func New(options Options) (*Provider, error) {
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
}, nil
}
@@ -166,12 +163,12 @@ func (provider *Provider) StreamCompletion(
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
provider.stream(ctx, body, request.PromptCacheKey, events)
provider.stream(ctx, body, events)
}()
return events, nil
}
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
// the request, which unblocks the SSE reader goroutine.
streamCtx, cancelStream := context.WithCancel(ctx)
@@ -188,7 +185,6 @@ func (provider *Provider) stream(ctx context.Context, body []byte, sessionID str
},
provider.oauthResolver,
func(request *http.Request) {
provider.sessionHeader.Apply(request, sessionID)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("anthropic-version", provider.version)
if provider.beta != "" {
-20
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/Gitlawb/zero/internal/config"
@@ -54,11 +53,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
return newCodexProvider(profile, resolved, options)
}
sessionHeader := sessionHeaderForProfile(profile, resolved)
if sessionHeader != "" && strings.TrimSpace(options.UserAgent) == "" {
options.UserAgent = "zero"
}
switch resolved.providerKind {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
// prompt_cache_key is an OpenAI-only chat-completions field. Strict
@@ -80,7 +74,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
UserAgent: options.UserAgent,
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
DisablePromptCacheKey: resolved.providerKind == config.ProviderKindOpenAICompatible,
SessionHeader: sessionHeader,
})
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return anthropic.New(anthropic.Options{
@@ -95,7 +88,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
SessionHeader: sessionHeader,
})
case config.ProviderKindGoogle:
return gemini.New(gemini.Options{
@@ -353,18 +345,6 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
return providercatalog.NormalizeID(profile.CatalogID) == "chatgpt"
}
func sessionHeaderForProfile(profile config.ProviderProfile, resolved resolvedProfile) string {
switch providercatalog.NormalizeID(profile.CatalogID) {
case "opencode", "opencode-go", "opencode-go-anthropic-compatible":
return "x-opencode-session"
}
endpoint, err := url.Parse(resolved.baseURL)
if err == nil && strings.EqualFold(endpoint.Hostname(), "opencode.ai") && (endpoint.Path == "/zen" || strings.HasPrefix(endpoint.Path, "/zen/")) {
return "x-opencode-session"
}
return ""
}
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
-567
View File
@@ -3,17 +3,13 @@ package providers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -798,566 +794,3 @@ func TestResolveRuntimeMetadataRejectsModelOutsideProviderAllowlist(t *testing.T
t.Fatalf("error = %q, want it to name the rejected model", err.Error())
}
}
func TestOpenCodeSessionHeader(t *testing.T) {
const sessionHeader = "x-opencode-session"
tests := []struct {
name string
profile config.ProviderProfile
response string
wantHeader bool
wantAuth map[string]string
}{
{
name: "catalog opencode-go default URL",
profile: config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc-go",
Model: "deepseek-v4-pro",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-oc-go"},
},
{
name: "catalog opencode-go-anthropic-compatible",
profile: config.ProviderProfile{
Name: "opencode-go-anthropic",
CatalogID: "opencode-go-anthropic-compatible",
ProviderKind: config.ProviderKindAnthropicCompat,
APIKey: "sk-oc-ant",
Model: "minimax-m3",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
wantHeader: true,
wantAuth: map[string]string{"x-api-key": "sk-oc-ant"},
},
{
name: "catalog opencode Zen",
profile: config.ProviderProfile{
Name: "opencode-zen",
CatalogID: "opencode",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-zen",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-zen"},
},
{
name: "renamed catalog profile with custom proxy URL",
profile: config.ProviderProfile{
Name: "my-opencode",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://proxy.example/v1",
APIKey: "sk-proxy",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-proxy"},
},
{
name: "catalog-less OpenAI-compatible OpenCode Go URL",
profile: config.ProviderProfile{
Name: "manual-go",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opencode.ai/zen/go/v1",
APIKey: "sk-manual",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-manual"},
},
{
name: "catalog-less Anthropic-compatible OpenCode Go URL",
profile: config.ProviderProfile{
Name: "manual-go-anthropic",
ProviderKind: config.ProviderKindAnthropicCompat,
BaseURL: "https://opencode.ai/zen/go",
APIKey: "sk-manual-ant",
Model: "test-model",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
wantHeader: true,
wantAuth: map[string]string{"x-api-key": "sk-manual-ant"},
},
{
name: "official OpenAI",
profile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
APIKey: "sk-openai",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-openai"},
},
{
name: "generic OpenAI-compatible",
profile: config.ProviderProfile{
Name: "generic",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://provider.example/v1",
APIKey: "sk-generic",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-generic"},
},
{
name: "official Anthropic",
profile: config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
APIKey: "sk-ant",
Model: "test-model",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
wantHeader: false,
wantAuth: map[string]string{"x-api-key": "sk-ant"},
},
{
name: "lookalike subdomain is not OpenCode",
profile: config.ProviderProfile{
Name: "deceptive-host",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opencode.ai.example/zen/go/v1",
APIKey: "sk-fake",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
},
{
name: "lookalike path is not OpenCode",
profile: config.ProviderProfile{
Name: "deceptive-path",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://example.com/opencode.ai/zen/go",
APIKey: "sk-fake",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
},
{
name: "zenith path is not Zen",
profile: config.ProviderProfile{
Name: "zenith",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opencode.ai/zenith",
APIKey: "sk-zenith",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-zenith"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
transport := &captureTransport{responseBody: test.response}
provider, err := New(test.profile, Options{
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
got := transport.request.Header.Get(sessionHeader)
if test.wantHeader && got != "conversation-a" {
t.Fatalf("%s = %q, want %q", sessionHeader, got, "conversation-a")
}
if !test.wantHeader && got != "" {
t.Fatalf("%s = %q, want no session header", sessionHeader, got)
}
for header, want := range test.wantAuth {
if got := transport.request.Header.Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
if got := transport.request.Header.Get("User-Agent"); got != "zero-test" {
t.Fatalf("User-Agent = %q, want zero-test", got)
}
if test.profile.ProviderKind == config.ProviderKindOpenAICompatible {
var body map[string]any
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if _, ok := body["prompt_cache_key"]; ok {
t.Fatalf("openai-compatible body carried prompt_cache_key: %#v", body["prompt_cache_key"])
}
}
})
}
}
func TestOpenCodeSessionHeaderFollowsConversationKey(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
profile := config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}
options := Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"}
provider, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
for _, key := range []string{"conversation-a", "conversation-a", "conversation-b"} {
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: key,
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
}
got := transport.sessionHeaders()
want := []string{"conversation-a", "conversation-a", "conversation-b"}
if len(got) != len(want) {
t.Fatalf("captured %d requests, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("request %d x-opencode-session = %q, want %q", i, got[i], want[i])
}
}
reconstructed, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := reconstructed.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
got = transport.sessionHeaders()
if last := got[len(got)-1]; last != "conversation-a" {
t.Fatalf("reconstructed provider sent %s = %q, want conversation-a", "x-opencode-session", last)
}
}
func TestOpenCodeSessionHeaderCustomOverride(t *testing.T) {
custom := map[string]string{"X-Opencode-Session": "explicit-session"}
profile := config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
CustomHeaders: custom,
}
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(profile, Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
if got := transport.request.Header.Get("x-opencode-session"); got != "explicit-session" {
t.Fatalf("x-opencode-session = %q, want explicit custom value", got)
}
if len(custom) != 1 || custom["X-Opencode-Session"] != "explicit-session" {
t.Fatalf("profile CustomHeaders mutated: %#v", custom)
}
}
func TestOpenCodeSessionHeaderFallback(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
profile := config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}
options := Options{HTTPClient: &http.Client{Transport: transport}}
first, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
second, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
}
for _, provider := range []zeroruntime.Provider{first, first, second} {
stream, err := provider.StreamCompletion(context.Background(), request)
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
}
headers := transport.sessionHeaders()
if len(headers) != 3 {
t.Fatalf("captured %d requests, want 3", len(headers))
}
if headers[0] == "" || headers[1] != headers[0] {
t.Fatalf("fallback headers = %q, %q — want one stable nonempty value per provider", headers[0], headers[1])
}
if headers[2] == headers[0] {
t.Fatalf("distinct provider instances shared fallback %q", headers[0])
}
}
func TestOpenCodeSessionHeaderDefaultsUserAgent(t *testing.T) {
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
if got := transport.request.Header.Get("User-Agent"); got != "zero" {
t.Fatalf("User-Agent = %q, want zero", got)
}
}
func TestOpenCodeSessionHeaderConcurrent(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
var wg sync.WaitGroup
var errsMu sync.Mutex
var errs []string
for _, session := range []string{"session-a", "session-b"} {
wg.Add(1)
go func(session string) {
defer wg.Done()
for i := 0; i < 5; i++ {
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("req-%s-%d", session, i)}},
PromptCacheKey: session,
})
if err != nil {
errsMu.Lock()
errs = append(errs, err.Error())
errsMu.Unlock()
return
}
for event := range stream {
if event.Type == zeroruntime.StreamEventError {
errsMu.Lock()
errs = append(errs, event.Error)
errsMu.Unlock()
}
}
}
}(session)
}
wg.Wait()
for _, errText := range errs {
t.Errorf("concurrent stream error: %s", errText)
}
requests := transport.recorded()
if len(requests) != 10 {
t.Fatalf("captured %d requests, want 10", len(requests))
}
for _, request := range requests {
var payload struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal([]byte(request.body), &payload); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(payload.Messages) == 0 {
t.Fatal("request carried no user message")
}
content := payload.Messages[0].Content
want := "session-a"
if strings.Contains(content, "session-b") {
want = "session-b"
}
if got := request.header.Get("x-opencode-session"); got != want {
t.Fatalf("request for %q carried x-opencode-session %q, want %q", content, got, want)
}
}
}
func TestOpenCodeSessionHeaderAuthRetry(t *testing.T) {
for _, test := range []struct {
name string
profile config.ProviderProfile
response string
}{
{
name: "openai-compatible",
profile: config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
},
response: "data: [DONE]\n\n",
},
{
name: "anthropic-compatible",
profile: config.ProviderProfile{
Name: "opencode-go-anthropic",
CatalogID: "opencode-go-anthropic-compatible",
ProviderKind: config.ProviderKindAnthropicCompat,
APIKey: "sk-oc-ant",
Model: "minimax-m3",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
},
} {
t.Run(test.name, func(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: test.response, unauthorized: 1}
var calls atomic.Int32
resolver := providerio.TokenResolver(func(context.Context, bool) (string, string, bool, error) {
n := calls.Add(1)
return "Authorization", fmt.Sprintf("Bearer tok-%d", n), true, nil
})
provider, err := New(test.profile, Options{
HTTPClient: &http.Client{Transport: transport},
OAuthResolver: resolver,
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
requests := transport.recorded()
if len(requests) != 2 {
t.Fatalf("captured %d requests, want 2 (401 + retry)", len(requests))
}
for i, request := range requests {
if got := request.header.Get("x-opencode-session"); got != "conversation-a" {
t.Fatalf("attempt %d x-opencode-session = %q, want conversation-a", i, got)
}
}
if first, second := requests[0].header.Get("Authorization"), requests[1].header.Get("Authorization"); first == second {
t.Fatalf("Authorization not refreshed across retry: %q", first)
}
})
}
}
func drainStream(t *testing.T, stream <-chan zeroruntime.StreamEvent) {
t.Helper()
for event := range stream {
if event.Type == zeroruntime.StreamEventError {
t.Fatalf("stream error: %s", event.Error)
}
}
}
type recordedRequest struct {
header http.Header
body string
}
type recordingTransport struct {
mu sync.Mutex
requests []recordedRequest
status int
body string
unauthorized int
}
func (transport *recordingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
var body string
if request.Body != nil {
data, _ := io.ReadAll(request.Body)
body = string(data)
}
transport.mu.Lock()
transport.requests = append(transport.requests, recordedRequest{header: request.Header.Clone(), body: body})
attempt := len(transport.requests)
transport.mu.Unlock()
status := transport.status
if attempt <= transport.unauthorized {
status = http.StatusUnauthorized
}
return &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d", status),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(transport.body)),
Request: request,
}, nil
}
func (transport *recordingTransport) recorded() []recordedRequest {
transport.mu.Lock()
defer transport.mu.Unlock()
return append([]recordedRequest(nil), transport.requests...)
}
func (transport *recordingTransport) sessionHeaders() []string {
requests := transport.recorded()
headers := make([]string, 0, len(requests))
for _, request := range requests {
headers = append(headers, request.header.Get("x-opencode-session"))
}
return headers
}
+2 -6
View File
@@ -65,7 +65,6 @@ type Options struct {
// enabled; the ZERO_DISABLE_PROMPT_CACHE_KEY env kill switch still applies
// on top for any endpoint.
DisablePromptCacheKey bool
SessionHeader string
}
// Provider streams completions from an OpenAI-compatible chat completions API.
@@ -86,7 +85,6 @@ type Provider struct {
parseThinkTags bool
setRequestExtra func(*http.Request)
disablePromptCacheKey bool
sessionHeader providerio.SessionHeader
}
// New creates an OpenAI-compatible provider.
@@ -142,7 +140,6 @@ func New(options Options) (*Provider, error) {
parseThinkTags: options.ParseThinkTags,
setRequestExtra: options.SetRequestExtra,
disablePromptCacheKey: options.DisablePromptCacheKey,
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
}, nil
}
@@ -159,13 +156,13 @@ func (provider *Provider) StreamCompletion(
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
provider.stream(ctx, body, request.PromptCacheKey, events)
provider.stream(ctx, body, events)
}()
return events, nil
}
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
endpoint := provider.endpoint
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
@@ -189,7 +186,6 @@ func (provider *Provider) stream(ctx context.Context, body []byte, sessionID str
},
provider.oauthResolver,
func(request *http.Request) {
provider.sessionHeader.Apply(request, sessionID)
request.Header.Set("Content-Type", "application/json")
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
-23
View File
@@ -1,7 +1,6 @@
package providerio
import (
"crypto/rand"
"net/http"
"strings"
)
@@ -53,28 +52,6 @@ func ApplyAuthHeaders(request *http.Request, options AuthHeaders) {
request.Header.Set(header, value)
}
type SessionHeader struct {
name string
fallback string
}
func NewSessionHeader(name string) SessionHeader {
if name == "" {
return SessionHeader{}
}
return SessionHeader{name: name, fallback: rand.Text()}
}
func (header SessionHeader) Apply(request *http.Request, sessionID string) {
if header.name == "" {
return
}
if sessionID = strings.TrimSpace(sessionID); sessionID == "" {
sessionID = header.fallback
}
request.Header.Set(header.name, sessionID)
}
func CopyHeaders(headers map[string]string) map[string]string {
if headers == nil {
return nil
@@ -169,53 +169,10 @@ var sharedHTTPClient = func() *http.Client {
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
// Periodically close idle connections to prevent stale HTTP/2
// connections from causing PROTOCOL_ERROR on the next request.
// OpenRouter (and other HTTP/2 backends) can silently RST or
// GOAWAY a connection that has been idle in the pool; the next
// request over that connection gets a PROTOCOL_ERROR. Closing
// idle connections every 30s forces a fresh dial so the error
// window is bounded to ~30s while reuse still benefits healthy
// connections. No-op on darwin where DisableKeepAlives already
// prevents pooling.
if runtime.GOOS != "darwin" {
startIdleConnCloser(transport, defaultIdleConnCloseInterval)
}
transport.DisableKeepAlives = runtime.GOOS == "darwin"
return &http.Client{Transport: transport}
}()
const defaultIdleConnCloseInterval = 30 * time.Second
// startIdleConnCloser launches a background goroutine that periodically closes
// idle keep-alive connections on transport until the returned stop function is
// invoked.
func startIdleConnCloser(transport *http.Transport, interval time.Duration) func() {
if interval <= 0 || transport == nil {
return func() {}
}
ticker := time.NewTicker(interval)
done := make(chan struct{})
go func() {
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
transport.CloseIdleConnections()
}
}
}()
return func() {
select {
case <-done:
default:
close(done)
}
}
}
// HTTPClient returns the configured client or the shared, stall-hardened default.
func HTTPClient(client *http.Client) *http.Client {
if client != nil {
@@ -6,8 +6,6 @@ import (
"io"
"math"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"strings"
"testing"
@@ -373,97 +371,3 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
t.Fatal("an explicit client must be returned unchanged")
}
}
// startIdleConnCloser periodically closes idle pooled connections so stale HTTP/2
// connections are not reused across long idle periods.
func TestStartIdleConnCloserClosesIdleConnections(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DisableKeepAlives = false
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr}
// First request creates a connection in the pool.
resp1, err := client.Get(server.URL)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
_, _ = io.Copy(io.Discard, resp1.Body)
_ = resp1.Body.Close()
// Immediate second request reuses the pooled connection.
var reusedFirst bool
trace1 := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
reusedFirst = info.Reused
},
}
req2, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace1), http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("new request failed: %v", err)
}
resp2, err := client.Do(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
_, _ = io.Copy(io.Discard, resp2.Body)
_ = resp2.Body.Close()
if !reusedFirst {
t.Fatal("expected immediate second request to reuse keep-alive connection")
}
// Start closer with a short interval and wait for it to fire.
stop := startIdleConnCloser(tr, 20*time.Millisecond)
defer stop()
// Poll until the idle closer evicts the connection and a fresh dial occurs.
deadline := time.Now().Add(2 * time.Second)
var closed bool
for time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
var reused bool
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
reused = info.Reused
},
}
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("new request failed: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if !reused {
closed = true
break
}
}
if !closed {
t.Fatal("expected connection to be closed by idle closer and not reused")
}
}
func TestStartIdleConnCloserEdgeCases(t *testing.T) {
// Zero or negative interval returns a no-op stop func without starting goroutine.
stopZero := startIdleConnCloser(nil, 0)
stopZero()
tr := http.DefaultTransport.(*http.Transport).Clone()
stopNil := startIdleConnCloser(tr, -1*time.Second)
stopNil()
// Calling stop multiple times is idempotent.
stop := startIdleConnCloser(tr, 100*time.Millisecond)
stop()
stop()
}
+2 -20
View File
@@ -33,14 +33,8 @@ 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"`
// 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"`
NoInherit bool `json:"noInherit,omitempty"`
Materialize bool `json:"materialize,omitempty"`
}
type WindowsACLPlan struct {
@@ -63,22 +57,10 @@ 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,
})
}
}
+1 -26
View File
@@ -23,10 +23,7 @@ const (
)
type windowsACLPathGroup struct {
Path string
// Anchor is the write root Path was derived from, empty when the operator
// named the path. See verifyWindowsACLHandleUnderAnchor.
Anchor string
Path string
Entries []WindowsACLEntry
Materialize bool
}
@@ -72,12 +69,6 @@ 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 {
@@ -113,13 +104,6 @@ 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)
}
@@ -139,15 +123,6 @@ 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))
@@ -1,228 +0,0 @@
//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
}
@@ -1,231 +0,0 @@
//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,117 +240,3 @@ 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 {
+37 -27
View File
@@ -8,8 +8,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
// controllableLauncher records every launched spec and lets a test control each
@@ -87,6 +85,18 @@ func newSwarmForWithSize(t *testing.T, l MemberLauncher, maxTeamSize int) *Swarm
return sw
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func okFor(spec MemberSpec, _ int) (MemberResult, error) {
return MemberResult{Result: "ok:" + spec.Task, SessionID: "sess-" + spec.ID}, nil
}
@@ -98,7 +108,7 @@ func TestSpawnCompletes(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "task done", func() bool {
waitFor(t, "task done", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
@@ -115,7 +125,7 @@ func TestSpawnInheritsPolicy(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
spec := l.recorded()[0]
if spec.Model != "orch-model" {
t.Fatalf("member model = %q, want inherited orch-model", spec.Model)
@@ -148,7 +158,7 @@ func TestConcurrencyCapAndQueueDrains(t *testing.T) {
}
// Release everyone; the queue should drain one-per-slot until all are done.
close(gate)
testutil.WaitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
waitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
if team.Running() != 0 || team.QueueDepth() != 0 {
t.Fatalf("after drain running=%d queue=%d, want 0/0", team.Running(), team.QueueDepth())
}
@@ -166,7 +176,7 @@ func TestRetryOnTemporaryError(t *testing.T) {
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
testutil.WaitFor(t, "task recovered", func() bool {
waitFor(t, "task recovered", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
@@ -185,7 +195,7 @@ func TestRetryExhaustionFails(t *testing.T) {
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
testutil.WaitFor(t, "task failed", func() bool {
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
@@ -200,7 +210,7 @@ func TestPermanentErrorNoRetry(t *testing.T) {
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
testutil.WaitFor(t, "task failed", func() bool {
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
@@ -286,7 +296,7 @@ func TestClosePreventsMemberRetry(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -315,7 +325,7 @@ func TestCloseWaitsForMemberWatchers(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "task running", func() bool {
waitFor(t, "task running", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusRunning
})
@@ -355,7 +365,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
sw := newSwarmFor(t, l)
pol := Policy{Model: "m"}
origID, _ := sw.Spawn(pol, "team", "teammate", "original task", "/w")
testutil.WaitFor(t, "original running", func() bool {
waitFor(t, "original running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
@@ -379,7 +389,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
}
// The new member carries the handoff note in its task and preserves cwd.
close(gate)
testutil.WaitFor(t, "spec for new member", func() bool {
waitFor(t, "spec for new member", func() bool {
for _, s := range l.recorded() {
if s.ID == newID {
return true
@@ -395,7 +405,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
}
}
// A handoff of an already-terminal task is rejected.
testutil.WaitFor(t, "new task done", func() bool {
waitFor(t, "new task done", func() bool {
task, ok := sw.Coordinator().Get(newID)
return ok && task.Status == StatusDone
})
@@ -460,7 +470,7 @@ func TestHandoffMailboxFailureRestoresSourceCompletion(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "original running", func() bool {
waitFor(t, "original running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
@@ -475,7 +485,7 @@ func TestHandoffMailboxFailureRestoresSourceCompletion(t *testing.T) {
}
close(gate)
testutil.WaitFor(t, "source completion after aborted handoff", func() bool {
waitFor(t, "source completion after aborted handoff", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusDone
})
@@ -522,7 +532,7 @@ func TestHandoffSuccessorCollisionDoesNotStopSource(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "original running", func() bool {
waitFor(t, "original running", func() bool {
task, ok := coord.Get(origID)
return ok && task.Status == StatusRunning
})
@@ -547,7 +557,7 @@ func TestHandoffSuccessorCollisionDoesNotStopSource(t *testing.T) {
t.Fatalf("collision misdelivered %d handoff notes to the existing task", len(msgs))
}
close(gate)
testutil.WaitFor(t, "source completion after rejected collision", func() bool {
waitFor(t, "source completion after rejected collision", func() bool {
task, ok := coord.Get(origID)
return ok && task.Status == StatusDone
})
@@ -633,7 +643,7 @@ func TestCloseReleasesBlockedHandoffBeforeMemberExit(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("Close did not return after the source member exited")
}
testutil.WaitFor(t, "source completion after handoff cancellation", func() bool {
waitFor(t, "source completion after handoff cancellation", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusDone
})
@@ -694,7 +704,7 @@ func TestHandoffTimesOutWithoutStartingSuccessor(t *testing.T) {
}
close(releaseOriginal)
testutil.WaitFor(t, "source completion after handoff timeout", func() bool {
waitFor(t, "source completion after handoff timeout", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusDone
})
@@ -866,7 +876,7 @@ func TestHandoffRemovesQueuedOriginalBeforeDispatchingSuccessor(t *testing.T) {
if _, err := sw.Spawn(pol, "team", "teammate", "slot blocker", "/w"); err != nil {
t.Fatalf("Spawn blocker: %v", err)
}
testutil.WaitFor(t, "blocker running", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "blocker running", func() bool { return len(l.recorded()) == 1 })
origID, err := sw.Spawn(pol, "team", "teammate", "queued original", "/w")
if err != nil {
t.Fatalf("Spawn queued original: %v", err)
@@ -880,7 +890,7 @@ func TestHandoffRemovesQueuedOriginalBeforeDispatchingSuccessor(t *testing.T) {
t.Fatalf("Handoff: %v", err)
}
close(gate)
testutil.WaitFor(t, "successor launch", func() bool {
waitFor(t, "successor launch", func() bool {
for _, spec := range l.recorded() {
if spec.ID == newID {
return true
@@ -913,7 +923,7 @@ func TestAdoptOrphans(t *testing.T) {
t.Fatalf("adopted = %v, want [orphan-1]", adopted)
}
// The orphan is relaunched under a fresh agent and completes.
testutil.WaitFor(t, "orphan completed", func() bool {
waitFor(t, "orphan completed", func() bool {
task, ok := sw.Coordinator().Get("orphan-1")
return ok && task.Status == StatusDone
})
@@ -954,7 +964,7 @@ func TestCollectScopesToTeam(t *testing.T) {
sw := newSwarmFor(t, l)
a, _ := sw.Spawn(Policy{}, "alpha", "teammate", "ta", "")
_, _ = sw.Spawn(Policy{}, "beta", "teammate", "tb", "")
testutil.WaitFor(t, "alpha done", func() bool {
waitFor(t, "alpha done", func() bool {
task, ok := sw.Coordinator().Get(a)
return ok && task.Status == StatusDone
})
@@ -1100,7 +1110,7 @@ func TestAdmittedDispatchDoesNotLaunchAfterClose(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -1160,7 +1170,7 @@ func TestCloseBetweenLaunchPrecheckAndReturn(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -1258,7 +1268,7 @@ func TestCloseBetweenRetryLaunchPrecheckAndReturn(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -1450,7 +1460,7 @@ func TestCloseFailsQueuedSpecOnLateCreatedTeam(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
+8 -10
View File
@@ -4,8 +4,6 @@ import (
"math"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
// testTicker returns a ticker factory backed by a single unbounded-handshake
@@ -41,11 +39,11 @@ func TestSchedulerFiresAndCountsRuns(t *testing.T) {
for i := 0; i < 3; i++ {
ticks <- time.Time{}
want := i + 1
testutil.WaitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == want })
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == want })
}
// After MaxRuns the job retires itself.
testutil.WaitFor(t, "job retired", func() bool { _, ok := findJob(sched.List(), id); return !ok })
waitFor(t, "job retired", func() bool { _, ok := findJob(sched.List(), id); return !ok })
if got := len(l.recorded()); got != 3 {
t.Fatalf("spawned %d members, want 3", got)
}
@@ -70,11 +68,11 @@ func TestSchedulerSkipsWhilePreviousRuns(t *testing.T) {
// Fire 1: spawns and the member stays running (gated).
ticks <- time.Time{}
testutil.WaitFor(t, "first spawn", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "first spawn", func() bool { return len(l.recorded()) == 1 })
// Fire 2: previous still running => skipped, no new spawn.
ticks <- time.Time{}
testutil.WaitFor(t, "skip recorded", func() bool {
waitFor(t, "skip recorded", func() bool {
j, ok := findJob(sched.List(), id)
return ok && j.Skipped == 1
})
@@ -84,14 +82,14 @@ func TestSchedulerSkipsWhilePreviousRuns(t *testing.T) {
// Release the first member, then fire 3: previous terminal => spawns again.
close(gate)
testutil.WaitFor(t, "first done", func() bool { return sw.Coordinator().Summarize().Done == 1 })
waitFor(t, "first done", func() bool { return sw.Coordinator().Summarize().Done == 1 })
ticks <- time.Time{}
testutil.WaitFor(t, "second spawn", func() bool { return len(l.recorded()) == 2 })
waitFor(t, "second spawn", func() bool { return len(l.recorded()) == 2 })
// fireIfIdle's spawn (what "second spawn" observes via the launcher) and
// run's subsequent job.incRuns() are sequential but distinct steps in the
// scheduler's goroutine; wait for Runs itself rather than assuming the
// launcher recording it means the job's counter is updated too.
testutil.WaitFor(t, "second run recorded", func() bool {
waitFor(t, "second run recorded", func() bool {
j, ok := findJob(sched.List(), id)
return ok && j.Runs == 2
})
@@ -217,7 +215,7 @@ func TestSchedulerDailyRecomputesNextDelay(t *testing.T) {
}
for i := 0; i < 2; i++ {
ticks <- time.Time{}
testutil.WaitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == i+1 })
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == i+1 })
}
if len(delays) < 2 {
t.Fatalf("expected at least two requested delays, got %v", delays)
+6 -7
View File
@@ -8,7 +8,6 @@ import (
"time"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -95,7 +94,7 @@ func TestSpawnToolThroughRegistry(t *testing.T) {
if id == "" {
t.Fatal("spawn must return a task_id in Meta")
}
testutil.WaitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
spec := l.recorded()[0]
if spec.Model != "m1" || spec.Cwd != "/work" {
t.Fatalf("policy/cwd not threaded into member: model=%q cwd=%q", spec.Model, spec.Cwd)
@@ -166,7 +165,7 @@ func TestStatusAndCollectTools(t *testing.T) {
"agent_type": "teammate", "task": "compute", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
id := spawn.Meta["task_id"]
testutil.WaitFor(t, "task done", func() bool {
waitFor(t, "task done", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
@@ -214,7 +213,7 @@ func TestCollectBlocksUntilMembersFinish(t *testing.T) {
"agent_type": "teammate", "task": "compute", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
id := spawn.Meta["task_id"]
testutil.WaitFor(t, "member running", func() bool {
waitFor(t, "member running", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusRunning
})
@@ -259,7 +258,7 @@ func TestCollectReturnsPartialOnTimeout(t *testing.T) {
"agent_type": "teammate", "task": "compute", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
id := spawn.Meta["task_id"]
testutil.WaitFor(t, "member running", func() bool {
waitFor(t, "member running", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusRunning
})
@@ -325,7 +324,7 @@ func TestNewWithUserDefinitions(t *testing.T) {
if _, err := sw.Spawn(Policy{Model: "m"}, "team", "researcher", "find things", ""); err != nil {
t.Fatalf("Spawn user-defined agent: %v", err)
}
testutil.WaitFor(t, "researcher used", func() bool { return used })
waitFor(t, "researcher used", func() bool { return used })
// A bad definition makes New fail closed.
if _, err := New(Options{BaseDir: t.TempDir(), Launcher: newLauncher(okFor), Definitions: []Definition{{AgentType: " "}}}); err == nil {
@@ -342,7 +341,7 @@ func TestHandoffToolThroughRegistry(t *testing.T) {
"agent_type": "teammate", "task": "long", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
origID := spawn.Meta["task_id"]
testutil.WaitFor(t, "running", func() bool {
waitFor(t, "running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
-23
View File
@@ -1,23 +0,0 @@
// Package testutil provides small, shared helpers for tests across the
// codebase.
package testutil
import (
"testing"
"time"
)
// WaitFor polls cond until it returns true, or fails the test if it does not
// become true within a generous deadline. what names the condition being
// waited for so a timeout failure message is actionable.
func WaitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
+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:
+10 -29
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
tea "charm.land/bubbletea/v2"
@@ -366,30 +365,17 @@ func (defaultModelSwitchCompactionPolicy) BeforeModelSwitch(request modelSwitchC
var modelSwitchCompactionGuard modelSwitchCompactionPolicy = defaultModelSwitchCompactionPolicy{}
// sanitizeCardField strips the card protocol's separator byte and every
// control rune from user-controlled values (titles can legally contain
// anything --session-title was given, and a directory name can legally
// contain an ESC byte on Unix). Line separators and tabs become spaces so
// words stay apart; the remaining controls — ESC/CSI/OSC initiators, BEL, BS,
// VT, NEL, NUL — are dropped outright, since any of them can repaint the
// terminal or shift cells when the value lands in a rendered row.
// sanitizeCardField strips the card protocol's separator bytes from
// user-controlled values (titles can legally contain anything --session-title
// was given), so a hostile or accidental \x1f / newline cannot shift fields
// or leak control characters into the transcript.
func sanitizeCardField(value string) string {
var out strings.Builder
for _, r := range value {
switch {
case r == '\x1f' || r == '\n' || r == '\r' || r == '\t':
out.WriteRune(' ')
case unicode.IsControl(r):
default:
out.WriteRune(r)
}
}
return out.String()
value = strings.ReplaceAll(value, sessionsCardFieldSep, " ")
value = strings.ReplaceAll(value, "\n", " ")
return strings.ReplaceAll(value, "\x00", "")
}
// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"), falling
// back to the month/day this year and the bare date for sessions older than a
// month, so deep history reads as a calendar date rather than "400d ago"; ""
// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"); ""
// when the timestamp does not parse, so the card simply omits it.
func relativeAge(timestamp string, now time.Time) string {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
@@ -399,19 +385,14 @@ func relativeAge(timestamp string, now time.Time) string {
age := now.Sub(parsed)
switch {
case age < time.Minute:
return "now"
return "just now"
case age < time.Hour:
return fmt.Sprintf("%dm ago", int(age.Minutes()))
case age < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(age.Hours()))
case age <= 30*24*time.Hour:
default:
return fmt.Sprintf("%dd ago", int(age.Hours()/24))
}
parsed, now = parsed.Local(), now.Local()
if parsed.Year() == now.Year() {
return parsed.Format("Jan _2")
}
return parsed.Format("2006-01-02")
}
// handleModelCommand applies a model switch against the ACTIVE provider (the
-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 {
+7 -7
View File
@@ -1087,16 +1087,16 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
}
func TestSessionPickerLabelAlignsTitles(t *testing.T) {
recent := sessionPickerLabel("2h ago", "Recent title")
older := sessionPickerLabel("2006-01-02", "Older title")
today := sessionPickerLabel("20:47:50", "Today title")
older := sessionPickerLabel("Jul 24 10:47", "Older title")
recentColumn := strings.Index(recent, "Recent title")
todayColumn := strings.Index(today, "Today title")
olderColumn := strings.Index(older, "Older title")
if recentColumn < 0 || olderColumn < 0 {
t.Fatalf("sessionPickerLabel omitted a title: recent=%q older=%q", recent, older)
if todayColumn < 0 || olderColumn < 0 {
t.Fatalf("sessionPickerLabel omitted a title: today=%q older=%q", today, older)
}
if recentColumn != olderColumn {
t.Fatalf("title columns differ: recent=%d (%q), older=%d (%q)", recentColumn, recent, olderColumn, older)
if todayColumn != olderColumn {
t.Fatalf("title columns differ: today=%d (%q), older=%d (%q)", todayColumn, today, olderColumn, older)
}
}
+4 -11
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 {
@@ -513,11 +513,10 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
return mouseSelectionTarget{}, false
}
}
maxVisible := pickerMaxVisible(m.picker, width)
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
start := selectableListStart(len(m.picker.items), maxVisible, selected)
visible := m.picker.items[start : start+maxVisible]
details := pickerShowsDetails(m.picker, width)
// y=0 titled top border, y=1 search line, y=2 separator; rows begin at y=3.
line := 3
lastGroup := ""
@@ -529,18 +528,12 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
line++
lastGroup = item.Group
}
// A rendered detail line belongs to its item: a click anywhere on the
// two-line block selects the same row.
rowLines := 1
if details && item.Detail != "" {
rowLines = 2
}
if hit.y >= line && hit.y < line+rowLines {
if hit.y == line {
index := start + offset
m.picker.selected = index
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line += rowLines
line++
}
return mouseSelectionTarget{}, false
}
+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 -5
View File
@@ -34,15 +34,12 @@ const (
// pickerItem is one selectable row: Label is shown, Value is passed to the
// underlying command handler when chosen. Meta is the optional right-aligned
// readout (ctx window · capabilities); the dot flags mark provider locality
// for model rows (accent = remote, blue = local). Detail is an optional faint
// second line under the row (project · model · size for sessions), rendered
// only on medium-and-wider tiers where the extra line reads cleanly.
// for model rows (accent = remote, blue = local).
type pickerItem struct {
Group string
Label string
Value string
Meta string
Detail string
Provider string // display tag (catalog id / locality)
// OwnerProvider is the saved provider profile name a model belongs to, so the
// /model picker can switch providers when a model from a non-active provider is
@@ -170,7 +167,7 @@ func (p *commandPicker) applyQuery() {
// the joined haystack, and a fuzzy subsequence is the last-resort match.
func scorePickerItem(item pickerItem, query string) (int, bool) {
label := strings.ToLower(item.Label)
hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta, item.Detail}, " "))
hay := strings.ToLower(strings.Join([]string{item.Group, item.Label, item.Value, item.Meta}, " "))
switch {
case label == query:
return 0, true
+2 -22
View File
@@ -6,7 +6,6 @@ import (
"strings"
"testing"
"time"
"unicode"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
@@ -1974,27 +1973,8 @@ func TestPermissionCollapseIsRunScoped(t *testing.T) {
}
func TestSessionsCardFieldsAreSanitized(t *testing.T) {
inputs := []string{
"evil\x1ftitle",
"line\nbreak",
"line\rbreak",
"nul\x00byte",
"esc\x1b[31mred",
"csi\x1b[2J\x1b[H",
"osc\x1b]0;pwned\a",
"bell\aring",
"back\bspace",
"tab\there",
"vert\vtab",
"nel\u0085here",
}
for _, input := range inputs {
got := sanitizeCardField(input)
for _, r := range got {
if unicode.IsControl(r) {
t.Fatalf("sanitizeCardField(%q) left control rune %q in %q", input, r, got)
}
}
if got := sanitizeCardField("evil\x1ftitle\nwith\x00bytes"); strings.ContainsAny(got, "\x1f\n\x00") {
t.Fatalf("sanitizeCardField left separator bytes: %q", got)
}
}
+27 -45
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
@@ -359,10 +360,28 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st
})
}
// sessionWhen formats a session's RFC3339 timestamp for the picker: a precise
// clock time (with seconds) for today so same-minute sessions stay distinct, the
// month/day and time earlier this year, else the date. Empty on a parse error.
func sessionWhen(timestamp string, now time.Time) string {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
if err != nil {
return ""
}
parsed, now = parsed.Local(), now.Local()
switch {
case parsed.Year() == now.Year() && parsed.YearDay() == now.YearDay():
return parsed.Format("15:04:05")
case parsed.Year() == now.Year():
return parsed.Format("Jan _2 15:04")
default:
return parsed.Format("2006-01-02")
}
}
// newSessionPicker builds the interactive /resume picker (mirrors /model & /provider):
// one row per resumable session — age + title (Label), session id (Value), and a
// project/model/size line (Detail). Returns nil when there are no resumable
// sessions so the caller falls back to the text path.
// one row per resumable session — title (Label) + id and relative age (Meta). Returns
// nil when there are no resumable sessions so the caller falls back to the text path.
func (m model) newSessionPicker() *commandPicker {
if m.sessionStore == nil {
return nil
@@ -395,17 +414,13 @@ func (m model) newSessionPicker() *commandPicker {
// Lead with a fixed-width timestamp so titles form one scannable column.
// The raw id remains the selection/search value but stays out of the row:
// rendering it consumed half the picker and truncated the useful title.
// Session metadata is persisted, user-controlled text — a title from
// --session-title or /rename can legally contain newlines, which would
// break the picker's fixed row geometry.
label := displayValue(sanitizeCardField(meta.Title), "untitled")
if when := relativeAge(meta.UpdatedAt, now); when != "" {
label := displayValue(meta.Title, "untitled")
if when := sessionWhen(meta.UpdatedAt, now); when != "" {
label = sessionPickerLabel(when, label)
}
items = append(items, pickerItem{
Label: label,
Value: meta.SessionID,
Detail: m.sessionPickerDetail(meta),
Label: label,
Value: meta.SessionID,
})
}
if len(items) == 0 {
@@ -420,45 +435,12 @@ func (m model) newSessionPicker() *commandPicker {
}
}
const sessionPickerTimeWidth = len("2006-01-02")
const sessionPickerTimeWidth = len("Jan 02 15:04")
func sessionPickerLabel(when, title string) string {
return fmt.Sprintf("%-*s %s", sessionPickerTimeWidth, when, title)
}
// sessionPickerDetail composes the faint second line under a /resume row:
// the session's project directory (~/-contracted), the model it ran on, and a
// short status chip when the session is not a plain mainline run (forks,
// tagged sessions such as btw side-chats). Missing fields are simply omitted —
// the picker is workspace-scoped, so the project column is a confirmation,
// not a disambiguator.
func (m model) sessionPickerDetail(meta sessions.Metadata) string {
parts := make([]string, 0, 3)
if project := displayPath(m.cwd, meta.Cwd); project != "" {
parts = append(parts, project)
}
if modelID := strings.TrimSpace(meta.ModelID); modelID != "" {
parts = append(parts, modelID)
}
if status := sessionPickerStatus(meta); status != "" {
parts = append(parts, status)
}
return sanitizeCardField(strings.Join(parts, " · "))
}
// sessionPickerStatus is the concise status chip for a /resume row: an
// explicit tag when the session carries one, otherwise a marker for forked
// sessions. Plain mainline sessions get none — their shape is the default.
func sessionPickerStatus(meta sessions.Metadata) string {
if tag := strings.TrimSpace(meta.Tag); tag != "" {
return tag
}
if meta.SessionKind == sessions.SessionKindFork {
return "fork"
}
return ""
}
// sessionHasResumableContent reports whether a session has anything worth
// resuming: a tool call/result, or a non-user message with real content (not the
// no-output guardrail stop). Empty/failed runs return false and are hidden from
+2 -188
View File
@@ -9,7 +9,6 @@ import (
"sync/atomic"
"testing"
"time"
"unicode"
tea "charm.land/bubbletea/v2"
@@ -301,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++
@@ -408,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())
@@ -1250,188 +1249,3 @@ func assertPayloadFieldContains(t *testing.T, event sessions.Event, key string,
t.Fatalf("expected payload %s to contain %q, got %#v in %#v", key, want, payload[key], payload)
}
}
func TestRelativeAgeFormatsLastActivity(t *testing.T) {
now := time.Date(2026, 9, 12, 15, 0, 0, 0, time.UTC)
cases := []struct {
name string
ts time.Time
want string
}{
{"just now", now.Add(-10 * time.Second), "now"},
{"slight clock skew", now.Add(30 * time.Second), "now"},
{"minutes", now.Add(-42 * time.Minute), "42m ago"},
{"hours", now.Add(-3 * time.Hour), "3h ago"},
{"days", now.Add(-12 * 24 * time.Hour), "12d ago"},
{"exactly one month", now.Add(-30 * 24 * time.Hour), "30d ago"},
{"this year", time.Date(2026, 1, 5, 9, 0, 0, 0, time.UTC), "Jan 5"},
{"last year", time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), "2025-06-01"},
}
for _, tc := range cases {
if got := relativeAge(tc.ts.Format(time.RFC3339), now); got != tc.want {
t.Errorf("%s: sessionAge = %q, want %q", tc.name, got, tc.want)
}
}
if got := relativeAge("not-a-time", now); got != "" {
t.Errorf("unparseable timestamp = %q, want empty", got)
}
if got := relativeAge("", now); got != "" {
t.Errorf("empty timestamp = %q, want empty", got)
}
}
func TestSessionPickerDetailComposesProjectAndModel(t *testing.T) {
m := model{cwd: "/repo"}
detail := m.sessionPickerDetail(sessions.Metadata{
Title: "work",
Cwd: "/repo",
ModelID: "gpt-5",
})
for _, want := range []string{"/repo", "gpt-5"} {
if !strings.Contains(detail, want) {
t.Fatalf("detail %q missing %q", detail, want)
}
}
// Missing fields are omitted rather than rendered as empty segments.
if sparse := m.sessionPickerDetail(sessions.Metadata{Title: "bare"}); sparse != "" {
t.Fatalf("empty metadata should produce no detail line, got %q", sparse)
}
}
func TestSessionPickerDetailStatusChips(t *testing.T) {
m := model{}
if got := m.sessionPickerDetail(sessions.Metadata{SessionKind: sessions.SessionKindFork}); !strings.Contains(got, "fork") {
t.Fatalf("fork session detail %q should carry the fork chip", got)
}
if got := m.sessionPickerDetail(sessions.Metadata{Tag: "btw"}); !strings.Contains(got, "btw") {
t.Fatalf("tagged session detail %q should carry the tag", got)
}
if got := m.sessionPickerDetail(sessions.Metadata{Title: "plain"}); got != "" {
t.Fatalf("plain session detail %q should have no status chip", got)
}
}
func TestResumePickerDetailRendersOnlyAtMediumWidth(t *testing.T) {
store := testSessionStore(t)
sess, err := store.Create(sessions.CreateInput{Title: "Detail row", ModelID: "gpt-5-pickertest", Provider: "openai"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": "hi"},
}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/resume")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if next.picker == nil || next.picker.kind != pickerSession {
t.Fatalf("expected /resume to open the session picker, got %#v", next.picker)
}
next.width = 120
if wide := viewString(next.View()); !strings.Contains(wide, "gpt-5-pickertest") {
t.Fatalf("wide picker should render the detail line with the model:\n%s", wide)
}
next.width = 60
if narrow := viewString(next.View()); strings.Contains(narrow, "gpt-5-pickertest") {
t.Fatalf("narrow picker should keep the single-line fallback:\n%s", narrow)
}
}
func TestResumePickerFilterMatchesDetailFields(t *testing.T) {
store := testSessionStore(t)
mk := func(title, modelID string) {
sess, err := store.Create(sessions.CreateInput{Title: title, ModelID: modelID, Provider: "openai"})
if err != nil {
t.Fatalf("Create %q returned error: %v", title, err)
}
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": "hi"},
}); err != nil {
t.Fatalf("AppendEvent %q returned error: %v", title, err)
}
}
mk("alpha", "gpt-5")
mk("beta", "claude-sonnet-4.5")
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/resume")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if next.picker == nil {
t.Fatal("expected /resume to open the session picker")
}
// The model id lives only on the detail line; it must still filter rows.
next.picker.query = "claude-sonnet"
next.picker.applyQuery()
if len(next.picker.items) != 1 || !strings.Contains(next.picker.items[0].Label, "beta") {
t.Fatalf("filter by model should keep only the matching session, got %#v", next.picker.items)
}
}
func TestResumePickerSanitizesMultilineMetadata(t *testing.T) {
// Persisted session metadata is user-controlled: a title or model id can
// legally contain newlines, which must not break the picker's fixed
// one-or-two-line row geometry (a stray line shifts every row below it and
// misaligns mouse hit-testing).
store := testSessionStore(t)
sess, err := store.Create(sessions.CreateInput{
Title: "alpha\nbravo",
ModelID: "gpt\x1b[31m5",
Cwd: "/repo",
Tag: "side\x1b]0;x\achat",
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(sess.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": "hi"},
}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
m := newModel(context.Background(), Options{SessionStore: store, Cwd: "/repo"})
m.input.SetValue("/resume")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if next.picker == nil || len(next.picker.items) != 1 {
t.Fatalf("expected one picker item, got %#v", next.picker)
}
item := next.picker.items[0]
for _, r := range item.Label + item.Detail {
if unicode.IsControl(r) {
t.Fatalf("picker row leaked control rune %q: label=%q detail=%q", r, item.Label, item.Detail)
}
}
}
func TestGenericPickerMouseSelectsItemFromDetailLine(t *testing.T) {
m := mouseTestModel()
m.width = 120
m.picker = &commandPicker{
kind: pickerSession,
items: []pickerItem{
{Label: "one", Value: "s1", Detail: "meta one"},
{Label: "two", Value: "s2", Detail: "meta two"},
},
}
m.picker.allItems = append([]pickerItem{}, m.picker.items...)
width := chatWidth(m.width)
overlay := m.pickerOverlay(width)
rect := m.overlayMouseRect(len(viewLines(overlay)), width)
// Rows begin at y=3 inside the overlay; item 1's detail line is y=6.
// Click near the horizontal middle so the point lands inside the
// centered overlay block.
target, ok := m.selectGenericPickerAtMouse(testMouseClick(tea.MouseLeft, width/2, rect.y+6))
if !ok {
t.Fatal("click on a detail line should select its item")
}
if target.Value != "s2" {
t.Fatalf("detail-line click selected %q, want s2", target.Value)
}
}
-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,
+1 -42
View File
@@ -773,7 +773,7 @@ func (m model) pickerOverlay(width int) string {
overlayWidth = width
}
innerWidth := maxInt(1, overlayWidth-4)
maxVisible := pickerMaxVisible(m.picker, width)
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
start := 0
visible := []pickerItem{}
if len(m.picker.items) > 0 {
@@ -781,7 +781,6 @@ func (m model) pickerOverlay(width int) string {
start = selectableListStart(len(m.picker.items), maxVisible, m.picker.selected)
visible = m.picker.items[start : start+maxVisible]
}
showDetails := pickerShowsDetails(m.picker, width)
lines := make([]string, 0, len(visible)+7)
title := strings.TrimSpace(m.picker.title)
@@ -822,16 +821,6 @@ func (m model) pickerOverlay(width int) string {
gap := innerWidth - lipgloss.Width(left) - lipgloss.Width(right)
line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right
lines = append(lines, fitStyledLine(line, innerWidth))
if showDetails && item.Detail != "" {
// The detail line sits under the row on the same selection band,
// indented to the label column so it reads as part of the item.
// Pad the remainder like the row above so the band is full-width.
detailText := " " + item.Detail
detailGap := innerWidth - lipgloss.Width(detailText)
detail := surface(zeroTheme.faint).Render(detailText) +
surface(zeroTheme.faint).Render(strings.Repeat(" ", maxInt(0, detailGap)))
lines = append(lines, fitStyledLine(detail, innerWidth))
}
}
if len(visible) == 0 {
if m.picker.loading {
@@ -856,36 +845,6 @@ func (m model) pickerOverlay(width int) string {
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
}
// pickerShowsDetails reports whether the overlay renders each row's optional
// Detail line: only when a row actually carries one and the terminal is at
// least medium width, where the two-line treatment reads cleanly. Narrower
// tiers keep the single-line list fallback.
func pickerShowsDetails(p *commandPicker, width int) bool {
if p == nil || widthTier(width) < tierMedium {
return false
}
for _, item := range p.items {
if item.Detail != "" {
return true
}
}
return false
}
// pickerMaxVisible caps the visible window in items. When detail lines render,
// each item costs two rows, so the budget halves to keep the overlay the same
// height as the single-line list.
func pickerMaxVisible(p *commandPicker, width int) int {
if p == nil {
return 0
}
budget := pickerOverlayMaxVisible
if pickerShowsDetails(p, width) {
budget = pickerOverlayMaxVisible / 2
}
return minInt(budget, len(p.items))
}
// themePickerOverlay keeps candidate rendering inside the picker. Moving through
// themes therefore shows their hierarchy and code treatment without repainting the
// transcript, terminal canvas, or any other active UI.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@gitlawb/zero",
"version": "0.9.0",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"version": "0.9.0",
"version": "0.8.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitlawb/zero",
"version": "0.9.0",
"version": "0.8.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",