mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4359d5352 | ||
|
|
2c418fc8ff | ||
|
|
571af701bd | ||
|
|
2eb9d94fac | ||
|
|
eefa978f61 | ||
|
|
bea2230633 | ||
|
|
df67edec13 | ||
|
|
42eb6ab5fb | ||
|
|
8a68660161 | ||
|
|
ab018b3098 | ||
|
|
1697b7a5ce | ||
|
|
6b5852dc18 | ||
|
|
aa52040498 | ||
|
|
547330197b | ||
|
|
7f11491078 | ||
|
|
0324c1d410 | ||
|
|
b9ef1d271a | ||
|
|
9520f2bad7 | ||
|
|
e2d18c50f0 | ||
|
|
e20b762426 | ||
|
|
7f8e5f08bb | ||
|
|
ff04d2fcd1 | ||
|
|
94c90879fd | ||
|
|
dc3ec65100 | ||
|
|
7fa20170db | ||
|
|
ff28d8fa56 | ||
|
|
2bacb7e6ab | ||
|
|
4cfbb885ed | ||
|
|
c0f4742841 | ||
|
|
5fe047a564 | ||
|
|
4f1dbd97b8 | ||
|
|
316c898cfd | ||
|
|
9aabaf6d26 | ||
|
|
ac1efe3747 | ||
|
|
8fc019e8c5 | ||
|
|
99721c762f |
No files matched your search
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -313,7 +313,7 @@ zero context context-budget report
|
||||
zero repo-map deterministic repository map
|
||||
zero repo-info local repository summary
|
||||
zero search | find search local session history
|
||||
zero sessions inspect, resume, fork, and rewind sessions
|
||||
zero sessions inspect, resume, fork, rewind, and prune sessions
|
||||
zero spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
@@ -331,6 +331,13 @@ zero update --check check for newer releases
|
||||
zero upgrade download, verify, and install the latest release
|
||||
```
|
||||
|
||||
Cron keeps the newest 1,000 run outcomes per job in `runs.jsonl`. Each new run
|
||||
atomically replaces the history with the retained tail; existing larger histories
|
||||
are trimmed on their next run. Archive the file before that run if you need older
|
||||
outcomes. This retention does not delete session directories or reset fire counts.
|
||||
Malformed records and legacy lines of 1 MiB or more are skipped when reading or
|
||||
compacting history; new records of that size are rejected.
|
||||
|
||||
## Extending Zero
|
||||
|
||||
### Project and personal instructions
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.6+。
|
||||
源码构建需要 Go 1.26.9+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -218,7 +218,7 @@ zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero sessions 检查、恢复、分叉、回滚和清理会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
|
||||
+2
-2
@@ -153,7 +153,7 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
|
||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
||||
```
|
||||
|
||||
This builds from source, so it needs Go 1.26.6+ and it does not go through the
|
||||
This builds from source, so it needs Go 1.26.9+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
||||
@@ -180,7 +180,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.6
|
||||
go 1.26.9
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
|
||||
+91
-5
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -106,6 +107,14 @@ type acpSession struct {
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
|
||||
// readOnly marks a session published by session/load for a kind that is
|
||||
// not resumable (a child, side or spec sub-run). Load still replays its
|
||||
// transcript, which is the render-only use a client needs, but a prompt
|
||||
// would continue the sub-run as a standalone conversation, which every
|
||||
// other resume path refuses (sessions.IsResumableKind). Set once when the
|
||||
// session is first registered and never changed, so it is read without mu.
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -204,7 +213,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, false)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -301,6 +310,12 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
|
||||
}
|
||||
// Load stays best effort about a history it cannot read, but not about one
|
||||
// prune holds: publishing it would leave the client using a session prune may
|
||||
// be removing, one this process could not hold open.
|
||||
if errors.Is(historyErr, sessions.ErrPruning) {
|
||||
return nil, RPCError(codeInternalError, historyErr.Error())
|
||||
}
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
@@ -311,7 +326,10 @@ 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)
|
||||
// Resume has already refused a non-resumable kind above, so only load can
|
||||
// reach here with one, and it publishes that session for viewing only.
|
||||
readOnly := !sessions.IsResumableKind(meta.SessionKind)
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
|
||||
if existed {
|
||||
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
@@ -433,6 +451,9 @@ func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if sess.readOnly {
|
||||
return nil, RPCError(codeInvalidParams, "session is not resumable, so it was loaded for viewing only and cannot be prompted: "+p.SessionID)
|
||||
}
|
||||
|
||||
// Serialize turns for this session so two prompts can't interleave history or
|
||||
// fight over the single cancel slot. session/cancel still works concurrently
|
||||
@@ -508,6 +529,30 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
}
|
||||
queue(messageEvent("user", userText))
|
||||
|
||||
visionCache := make(map[string]bool)
|
||||
var visionCacheMu sync.Mutex
|
||||
supportsVision := func(modelID string) bool {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return false
|
||||
}
|
||||
visionCacheMu.Lock()
|
||||
defer visionCacheMu.Unlock()
|
||||
if cached, ok := visionCache[modelID]; ok {
|
||||
return cached
|
||||
}
|
||||
supported := a.modelSupportsVision(ctx, resolved.Provider, modelID)
|
||||
visionCache[modelID] = supported
|
||||
return supported
|
||||
}
|
||||
effectivePrompt := userText
|
||||
if len(images) > 0 && !supportsVision(resolved.Provider.Model) {
|
||||
msg := fmt.Sprintf("Model %s does not support image input; ignoring %d prompt image(s).", resolved.Provider.Model, len(images))
|
||||
note.text("[zero] " + msg + "\n\n")
|
||||
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
|
||||
images = nil
|
||||
}
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
SessionID: sess.id,
|
||||
@@ -519,6 +564,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
DeferThreshold: workspace.DeferThreshold,
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Images: images,
|
||||
SupportsVision: supportsVision,
|
||||
OnText: note.text,
|
||||
OnReasoning: note.thought,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
@@ -537,7 +583,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
},
|
||||
}
|
||||
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
|
||||
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
|
||||
if result.FinalAnswer != "" {
|
||||
queue(messageEvent("assistant", result.FinalAnswer))
|
||||
@@ -908,9 +954,20 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
|
||||
// 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.
|
||||
//
|
||||
// The session was picked from its metadata a moment ago. Make sure it is
|
||||
// still there, and held, before restoring it: see sessions.HoldToContinue.
|
||||
if err := a.deps.Store.HoldToContinue(sessionID); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
events, eventLogPresent, err := a.deps.Store.ReadRehydratedEventsWithPresence(sessionID)
|
||||
var rehydrateWarning error
|
||||
if err != nil {
|
||||
if errors.Is(err, sessions.ErrPruning) {
|
||||
// Not a rehydration failure: the raw read would continue the session
|
||||
// without holding it while zero sessions prune may be removing it.
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
rehydrateWarning = err
|
||||
events, eventLogPresent, err = a.deps.Store.ReadEventsWithPresence(sessionID)
|
||||
if err != nil {
|
||||
@@ -1154,13 +1211,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
// 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) {
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing, true
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
|
||||
a.sessions[id] = sess
|
||||
return sess, false
|
||||
}
|
||||
@@ -1305,3 +1362,32 @@ func sameWorkspace(left, right string) bool {
|
||||
}
|
||||
return os.SameFile(leftInfo, rightInfo)
|
||||
}
|
||||
|
||||
func (a *Agent) modelSupportsVision(ctx context.Context, profile config.ProviderProfile, modelID string) bool {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if a.deps.DiscoverModels != nil {
|
||||
if discovered, err := a.deps.DiscoverModels(ctx, profile); err == nil {
|
||||
for _, dm := range discovered {
|
||||
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
|
||||
if len(dm.InputModalities) > 0 {
|
||||
for _, mod := range dm.InputModalities {
|
||||
if strings.EqualFold(strings.TrimSpace(mod), "image") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reg, _ := modelregistry.DefaultRegistry()
|
||||
if entry, known := reg.Resolve(trimmed); known {
|
||||
return entry.Supports(modelregistry.ModelCapabilityVision)
|
||||
}
|
||||
return modelregistry.SupportsVision(reg, trimmed)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -847,6 +849,48 @@ func TestACPRunTurnClosesWorkspaceAfterSuccessAndFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPWiresSupportsVision(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
captured = opts
|
||||
return agent.Result{FinalAnswer: "done"}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "custom-vision", InputModalities: []string{"text", "image"}},
|
||||
{ID: "custom-text", InputModalities: []string{"text"}},
|
||||
}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
{Type: "text", Text: "hello"},
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if captured.SupportsVision == nil {
|
||||
t.Fatal("SupportsVision was not wired into agent.Options")
|
||||
}
|
||||
if !captured.SupportsVision("custom-vision") {
|
||||
t.Fatal("SupportsVision(custom-vision) = false, want true")
|
||||
}
|
||||
if captured.SupportsVision("custom-text") {
|
||||
t.Fatal("SupportsVision(custom-text) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
|
||||
// resolver rejects the client cwd (e.g. filesystem root).
|
||||
func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
@@ -1251,6 +1295,85 @@ func TestACPResumeAppliesStandaloneSessionKindPolicy(t *testing.T) {
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load should retain render-only access: %v", err)
|
||||
}
|
||||
// The check above only showed the refused resume never registered the
|
||||
// session. Load does register it, so the prompt that matters is the one
|
||||
// AFTER the load: render-only means the transcript can be read and the
|
||||
// sub-run cannot be continued.
|
||||
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("must stay closed after load")},
|
||||
}, &PromptResult{})
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// LOAD PUBLISHES A SUB-RUN FOR VIEWING, NOT FOR CONTINUING. Every other resume
|
||||
// path (the TUI, exec sessions, the exec CLI, and ACP's own session/resume)
|
||||
// refuses a kind sessions.IsResumableKind rejects. session/load has a real
|
||||
// render-only use, a client rebuilding a transcript it can scroll, so it keeps
|
||||
// loading those sessions; what it must not do is publish one that accepts a
|
||||
// prompt, because the prompt would carry on a child, side or spec sub-run as a
|
||||
// standalone conversation. Checked in both directions, so a gate that refused
|
||||
// everything would fail on the resumable kinds.
|
||||
func TestACPLoadPublishesSubRunsReadOnly(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kind sessions.SessionKind
|
||||
readOnly bool
|
||||
}{
|
||||
{name: "regular", kind: ""},
|
||||
{name: "fork", kind: sessions.SessionKindFork},
|
||||
{name: "child", kind: sessions.SessionKindChild, readOnly: true},
|
||||
{name: "side", kind: sessions.SessionKindSide, readOnly: true},
|
||||
{name: "spec draft", kind: sessions.SessionKindSpecDraft, readOnly: true},
|
||||
{name: "spec impl", kind: sessions.SessionKindSpecImpl, readOnly: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := !sessions.IsResumableKind(tc.kind); got != tc.readOnly {
|
||||
t.Fatalf("SETUP INVALID: IsResumableKind(%q) disagrees with this table (readOnly=%v), so the cases no longer mean what they say", tc.kind, tc.readOnly)
|
||||
}
|
||||
deps := testDeps(t)
|
||||
workspace := t.TempDir()
|
||||
created, err := deps.Store.Create(sessions.CreateInput{
|
||||
SessionID: "load-" + strings.ReplaceAll(tc.name, " ", "-"), Cwd: workspace, SessionKind: tc.kind,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
|
||||
SessionID: created.SessionID, Cwd: workspace,
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load of a %s session: %v", tc.name, err)
|
||||
}
|
||||
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")},
|
||||
}, &PromptResult{})
|
||||
// A resumable session has to be genuinely promptable, not merely "not
|
||||
// refused as view-only": any other failure would hide a broken
|
||||
// writable path behind a passing test.
|
||||
if !tc.readOnly {
|
||||
if err != nil {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want success", tc.name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The decision is the RPC code; the message only distinguishes the
|
||||
// view-only refusal from other invalid-params errors such as an
|
||||
// unknown session id.
|
||||
var rpcErr *rpcError
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
|
||||
}
|
||||
if !strings.Contains(rpcErr.Message, "loaded for viewing only") {
|
||||
t.Fatalf("session/prompt after load of a %s session was refused for another reason: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2418,3 +2541,265 @@ func payloadString(payload any, key string) string {
|
||||
value, _ := decoded[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// Load is best effort about a history it cannot read, but a session that zero
|
||||
// sessions prune holds is refused by load and resume alike, and not published:
|
||||
// this process could not hold it open while prune may be removing it.
|
||||
func TestACPLoadAndResumeAreRefusedWhilePruneHoldsTheSession(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
cwd := t.TempDir()
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
// Created by an earlier process, which has since exited.
|
||||
deps.Store.Release(meta.SessionID)
|
||||
|
||||
release, locked, err := sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir}).HoldExclusive(meta.SessionID)
|
||||
if err != nil || !locked {
|
||||
t.Fatalf("take the lease the way prune does: locked=%v, %v", locked, err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
|
||||
t.Errorf("session/load while prune holds the session: err = %v, want it refused", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionResume, ResumeSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &ResumeSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
|
||||
t.Errorf("session/resume while prune holds the session: err = %v, want it refused", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: meta.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("carry on")},
|
||||
}, &PromptResult{}); err == nil {
|
||||
t.Fatal("a session refused while prune held it was still promptable")
|
||||
}
|
||||
}
|
||||
|
||||
// Activation reads the session's metadata before it restores the history. A
|
||||
// session prune removes in between is refused as removed, which activation then
|
||||
// refuses to publish for load as well as resume, instead of restoring it as an
|
||||
// empty conversation.
|
||||
func TestACPLoadHistoryRefusesASessionRemovedAfterItWasPicked(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
deps.Store.Release(meta.SessionID)
|
||||
dir := filepath.Join(deps.Store.RootDir, meta.SessionID)
|
||||
if err := os.Remove(filepath.Join(dir, sessions.MetadataFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := &Agent{deps: deps}
|
||||
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
|
||||
t.Errorf("load history of a session prune is removing: err = %v, want it refused", err)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
|
||||
t.Errorf("load history of a session prune removed: err = %v, want it refused", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPromptUnsupportedModelDropsImagesAndNotifies(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "fake-model", InputModalities: []string{"text"}},
|
||||
}, nil
|
||||
}
|
||||
var capturedOpts agent.Options
|
||||
var capturedPrompt string
|
||||
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
capturedPrompt = prompt
|
||||
capturedOpts = opts
|
||||
return agent.Result{FinalAnswer: "Answered without images"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
|
||||
rawPng := "\x89PNG\r\n\x1a\nfake-image-bytes"
|
||||
b64Png := base64.StdEncoding.EncodeToString([]byte(rawPng))
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
TextBlock("look at this image"),
|
||||
ImageBlock(b64Png, "image/png"),
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "does not support image input")
|
||||
})
|
||||
if !strings.Contains(got, "Model fake-model does not support image input; ignoring 1 prompt image(s).") {
|
||||
t.Fatalf("streamed text = %q, want drop notice", got)
|
||||
}
|
||||
if len(capturedOpts.Images) != 0 {
|
||||
t.Fatalf("captured %d images, want 0 (images should be withheld)", len(capturedOpts.Images))
|
||||
}
|
||||
if !strings.Contains(capturedPrompt, "[Note: Model fake-model does not support image input; ignoring 1 prompt image(s).]") {
|
||||
t.Fatalf("captured prompt = %q, want prompt note", capturedPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPSessionPrompt_SupportsVisionMemoizedPerRun(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
var discoverCount int
|
||||
var discoverMu sync.Mutex
|
||||
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
discoverMu.Lock()
|
||||
discoverCount++
|
||||
discoverMu.Unlock()
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "fake-model", InputModalities: []string{"text", "image"}},
|
||||
}, nil
|
||||
}
|
||||
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
for i := 0; i < 5; i++ {
|
||||
if !opts.SupportsVision(opts.Model) {
|
||||
t.Error("expected SupportsVision to report true")
|
||||
}
|
||||
}
|
||||
return agent.Result{FinalAnswer: "Done"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
|
||||
discoverMu.Lock()
|
||||
discoverCount = 0
|
||||
discoverMu.Unlock()
|
||||
|
||||
rawPng := "\x89PNG\r\n\x1a\nfake-image-bytes"
|
||||
b64Png := base64.StdEncoding.EncodeToString([]byte(rawPng))
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
TextBlock("look at this"),
|
||||
ImageBlock(b64Png, "image/png"),
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
discoverMu.Lock()
|
||||
count := discoverCount
|
||||
discoverMu.Unlock()
|
||||
if count != 1 {
|
||||
t.Fatalf("DiscoverModels was called %d times, want exactly 1 call (memoized per run)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPEndToEndImagePromptSupportedModel(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "discovered-vision-model", InputModalities: []string{"text", "image"}},
|
||||
}, nil
|
||||
}
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "discovered-vision-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
Provider: config.ProviderProfile{Name: "fake", Model: model},
|
||||
MaxTurns: 4,
|
||||
}, nil
|
||||
}
|
||||
var capturedOpts agent.Options
|
||||
var capturedPrompt string
|
||||
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
capturedPrompt = prompt
|
||||
capturedOpts = opts
|
||||
return agent.Result{FinalAnswer: "I can see the image!"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
|
||||
rawPng := []byte("\x89PNG\r\n\x1a\nreal-png-bytes")
|
||||
b64Png := base64.StdEncoding.EncodeToString(rawPng)
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
TextBlock("analyze this image"),
|
||||
ImageBlock(b64Png, "image/png"),
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
if len(capturedOpts.Images) != 1 {
|
||||
t.Fatalf("captured %d images, want 1", len(capturedOpts.Images))
|
||||
}
|
||||
if capturedOpts.Images[0].MediaType != "image/png" {
|
||||
t.Fatalf("mediaType = %q, want image/png", capturedOpts.Images[0].MediaType)
|
||||
}
|
||||
if !bytes.Equal(capturedOpts.Images[0].Data, rawPng) {
|
||||
t.Fatalf("image bytes mismatch: got %v, want %v", capturedOpts.Images[0].Data, rawPng)
|
||||
}
|
||||
if strings.Contains(capturedPrompt, "does not support image input") {
|
||||
t.Fatalf("prompt unexpectedly contains refusal note: %q", capturedPrompt)
|
||||
}
|
||||
}
|
||||
+103
-31
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -25,14 +26,18 @@ const maxTurnsAnswer = "Agent reached maximum number of turns without a final an
|
||||
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
|
||||
|
||||
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
|
||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||
// safe recovery for a stalled/dead pooled connection.
|
||||
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
|
||||
// on a fresh connection before giving up. Incomplete tool-call previews are
|
||||
// allowed through the gate (they are never dispatched before the error return).
|
||||
// The safety claim is only that visible answer prose is not duplicated on retry —
|
||||
// not that every partial stream shape is excluded. Covers a stalled/dead pooled
|
||||
// connection or a socket abort before tool dispatch.
|
||||
//
|
||||
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||||
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||||
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||||
// while bounding the worst case to ~2× the idle timeout.
|
||||
// while bounding the worst case to ~2× the idle timeout. Mid-stream transport
|
||||
// aborts share this bound (do not raise it for #973).
|
||||
const maxStreamStallRetries = 1
|
||||
|
||||
const (
|
||||
@@ -374,7 +379,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
rec.StampFirstToken()
|
||||
}
|
||||
if onText != nil {
|
||||
forwardedVisibleText = true
|
||||
// Zero-length chunks are not committed answer prose; counting
|
||||
// them would block an eligible mid-stream abort retry.
|
||||
if s != "" {
|
||||
forwardedVisibleText = true
|
||||
}
|
||||
onText(s)
|
||||
}
|
||||
}
|
||||
@@ -450,9 +459,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
// A stream idle/stall timeout is safely re-issued when the turn committed NO
|
||||
// answer text — no forwarded visible prose (forwardedVisibleText) and no
|
||||
// collected final text (collected.Text). This covers two cases:
|
||||
// A stream idle/stall timeout OR mid-stream transport abort (#973 — wsarecv /
|
||||
// connection reset / unexpected EOF / connection closed / broken pipe) is
|
||||
// safely re-issued when the turn committed NO answer text — no forwarded
|
||||
// visible prose (forwardedVisibleText) and no collected final text
|
||||
// (collected.Text). The abort gate matches midStreamAbortNeedles only, not
|
||||
// the full shouldReconnect list: connect-phase timeouts and connection
|
||||
// refused are NOT retried here (a slow healthy server must not cost a
|
||||
// second prefill). This covers three cases:
|
||||
// 1. Nothing streamed at all before the connection died (the original
|
||||
// macOS stale-pooled-connection hang past the response-header timeout).
|
||||
// 2. The model streamed transient reasoning and began a tool call (e.g. a
|
||||
@@ -463,13 +477,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// re-render is transient previews, not duplicated answer text. This is
|
||||
// why the gate no longer excludes collected.ToolCalls: an incomplete
|
||||
// tool call from a timed-out stream is discard-and-retry, not output.
|
||||
// 3. Mid-stream socket abort after connect succeeded (Windows WSAECONNABORTED /
|
||||
// wsarecv, connection reset by peer). Same no-tool-executed safety: the
|
||||
// error returns before tool dispatch, so a bounded retry is safe.
|
||||
// A turn that forwarded real prose is NOT retried (it would duplicate visible
|
||||
// answer text) and falls through to the error return below. Capped +
|
||||
// exponential backoff, with a user-visible notice per attempt.
|
||||
for attempt := 1; attempt <= maxStreamStallRetries &&
|
||||
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
|
||||
(isStreamTimeoutError(collected.Error) || isMidStreamTransportAbort(collected.Error)) &&
|
||||
!forwardedVisibleText &&
|
||||
collected.Text == ""; attempt++ {
|
||||
if notify := stallRetryNoticeFor(options); notify != nil {
|
||||
var notify reconnectNotifier
|
||||
if isStreamTimeoutError(collected.Error) {
|
||||
notify = stallRetryNoticeFor(options)
|
||||
} else {
|
||||
notify = reconnectNoticeFor(options)
|
||||
// Count this post-connect reissue even when the replacement
|
||||
// connect succeeds immediately. streamWithReconnect only
|
||||
// increments reconnect_count after a failed connect attempt.
|
||||
options.Trace.Counter(trace.CounterReconnectCount, 1)
|
||||
}
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamStallRetries)
|
||||
}
|
||||
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
|
||||
@@ -482,12 +510,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
if ctx.Err() != nil {
|
||||
return result, ctx.Err()
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
stallGenSpan := options.Trace.Span(trace.SpanGeneration)
|
||||
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
|
||||
stallGenSpan.End()
|
||||
}
|
||||
// Recheck ctx after a retried CollectStream: helpers.go stores
|
||||
// ctx.Err().Error() in collected.Error, and errors.New of that string
|
||||
// drops the context.Canceled sentinel ACP branches on.
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
// Route a reissued stream's non-stall error through the SAME recovery as
|
||||
// the initial stream (image-rejection wrapping / context-limit compaction)
|
||||
@@ -498,6 +536,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, stop
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, errors.New(collected.Error)
|
||||
@@ -646,9 +688,25 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// recorded. They travel as user messages, and a user message between two
|
||||
// tool_results breaks strict provider replay — Anthropic coalesces them
|
||||
// into one user block list and requires the tool_result blocks first, so
|
||||
// interleaving yields [tool_result, text, image, tool_result] and a 400.
|
||||
// Same reason the self-correction feedback below is deferred.
|
||||
var toolImageMessages []zeroruntime.Message
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate. Vision-gate evaluation is deferred until
|
||||
// after any model switch this turn resolves, so images can reach an
|
||||
// escalated vision-capable model.
|
||||
var toolResultsWithImages []ToolResult
|
||||
buildToolImageMessages := func() []zeroruntime.Message {
|
||||
var out []zeroruntime.Message
|
||||
for _, tr := range toolResultsWithImages {
|
||||
if imageMessage, ok := toolResultImageMessage(tr, options); ok {
|
||||
out = append(out, imageMessage)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
@@ -705,15 +763,8 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate.
|
||||
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
|
||||
toolImageMessages = append(toolImageMessages, imageMessage)
|
||||
if len(toolResult.Images) > 0 {
|
||||
toolResultsWithImages = append(toolResultsWithImages, toolResult)
|
||||
}
|
||||
|
||||
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
|
||||
@@ -725,13 +776,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
if abortErr != nil {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, abortErr
|
||||
}
|
||||
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolResult.ModelOutput()
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -755,7 +806,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// messages stay valid for a strict provider replay (Anthropic
|
||||
// rejects a tool_use with no answering tool_result).
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -774,10 +825,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
// Every tool_result for this turn is now recorded, including aborted
|
||||
// placeholders, so the images can follow without splitting them.
|
||||
messages = append(messages, toolImageMessages...)
|
||||
toolImageMessages = nil
|
||||
|
||||
// Run post-edit self-correction once over the union of files this turn
|
||||
// changed, then append any feedback after every tool_result is recorded so
|
||||
@@ -865,6 +912,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_result, self-correct notice, and model-switch update for this
|
||||
// turn is now in place; evaluate and append image messages against the
|
||||
// effective (potentially escalated) model.
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
toolResultsWithImages = nil
|
||||
|
||||
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
|
||||
// calls executed above; surface the dropped call too so it is never
|
||||
// silently ignored just because the turn also did real work. This is
|
||||
@@ -3464,8 +3517,10 @@ func copyMessages(messages []Message) []Message {
|
||||
//
|
||||
// The text names the tool so the model can tell which call an image came from
|
||||
// when several ran in one turn — the images arrive detached from their tool
|
||||
// result, so nothing else associates them.
|
||||
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
// result, so nothing else associates them. If the effective model cannot
|
||||
// accept images, the tool's text result is left unchanged and this follow-up
|
||||
// is a notice with no image parts, matching the CLI/TUI vision gate.
|
||||
func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Message, bool) {
|
||||
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
|
||||
for _, image := range result.Images {
|
||||
if len(image.Data) == 0 {
|
||||
@@ -3486,9 +3541,26 @@ func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
if label == "" {
|
||||
label = "tool"
|
||||
}
|
||||
if !modelAcceptsToolImages(options) {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + " was not sent because the current model does not support image input. Switch to a vision-capable model before retrying image delivery, including any images skipped by the tool's image budget.",
|
||||
}, true
|
||||
}
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + ":",
|
||||
Images: images,
|
||||
}, true
|
||||
}
|
||||
|
||||
func modelAcceptsToolImages(options Options) bool {
|
||||
if options.SupportsVision != nil {
|
||||
return options.SupportsVision(options.Model)
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return modelregistry.VisionCapableByName(options.Model)
|
||||
}
|
||||
return modelregistry.SupportsVision(registry, options.Model)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// midStreamAbortProvider connects successfully but emits a transport-abort
|
||||
// StreamEventError on the first abortBefore calls, then succeeds with "done".
|
||||
// hangOnCall, if > 0, makes that 1-based call block until ctx is done so a
|
||||
// cancel during the retried CollectStream can be reproduced.
|
||||
// connectFailFrom, if > 0, makes that 1-based call and later return a
|
||||
// reconnectable connect error so streamWithReconnect enters its backoff.
|
||||
type midStreamAbortProvider struct {
|
||||
calls int32
|
||||
abortBefore int32
|
||||
abortError string
|
||||
partialText string
|
||||
emptyTextEvent bool
|
||||
partialToolCall string
|
||||
hangOnCall int32
|
||||
connectFailFrom int32
|
||||
connectErr string
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (p *midStreamAbortProvider) StreamCompletion(ctx context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
if p.connectFailFrom > 0 && n >= p.connectFailFrom {
|
||||
if p.started != nil {
|
||||
select {
|
||||
case <-p.started:
|
||||
default:
|
||||
close(p.started)
|
||||
}
|
||||
}
|
||||
errMsg := p.connectErr
|
||||
if errMsg == "" {
|
||||
errMsg = "connection reset by peer"
|
||||
}
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
if p.hangOnCall > 0 && n == p.hangOnCall {
|
||||
if p.started != nil {
|
||||
close(p.started)
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
if n <= p.abortBefore {
|
||||
if p.emptyTextEvent {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: ""}
|
||||
}
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
}
|
||||
if p.partialToolCall != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
|
||||
}
|
||||
errMsg := p.abortError
|
||||
if errMsg == "" {
|
||||
errMsg = "provider stream error: read: connection reset by peer"
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: errMsg,
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamConnectionReset(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream connection reset should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamWindowsAbort(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a Windows mid-stream abort should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// wsarecv alone (no "connection was aborted" substring) must still retry, so
|
||||
// deleting the wsarecv needle cannot go green.
|
||||
func TestRunRetriesMidStreamWsarecvNeedle(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: 10053",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("wsarecv without 'connection was aborted' should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryMidStreamAbortAfterPartialOutput(t *testing.T) {
|
||||
sawText := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialText: "partial"}
|
||||
_, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) { sawText++ },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a mid-stream abort after partial output must NOT be retried; want an error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("partial-then-abort must not retry, got %d calls", got)
|
||||
}
|
||||
if sawText == 0 {
|
||||
t.Fatal("OnText must have fired so forwardedVisibleText is the named guard")
|
||||
}
|
||||
}
|
||||
|
||||
// A zero-length OnText chunk is not committed answer prose, so an eligible
|
||||
// transport abort still retries.
|
||||
func TestRunRetriesMidStreamAbortAfterEmptyTextEvent(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1, emptyTextEvent: true}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty OnText must not block mid-stream abort retry, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamAbortAfterIncompleteToolCall(t *testing.T) {
|
||||
starts := 0
|
||||
dispatched := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialToolCall: "write_file"}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnToolCallStart: func(string, string) { starts++ },
|
||||
OnToolCallDelta: func(string, string) {},
|
||||
OnToolCall: func(ToolCall) { dispatched++ },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream abort mid-incomplete-tool-call should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
if starts != 1 {
|
||||
t.Fatalf("the incomplete tool call should have been forwarded once before the abort, got %d starts", starts)
|
||||
}
|
||||
if dispatched != 0 {
|
||||
t.Fatalf("OnToolCall must never fire for an incomplete aborted tool call (no dispatch), got %d", dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGivesUpAfterMaxMidStreamAbortRetries(t *testing.T) {
|
||||
if maxStreamStallRetries != 1 {
|
||||
t.Fatalf("maxStreamStallRetries = %d, want 1 (#973 must not raise this bound)", maxStreamStallRetries)
|
||||
}
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{abortBefore: 99}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a persistent mid-stream abort must surface an error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry); pin the bound at 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortUsesReconnectNotice(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
var notices string
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("transport abort must use reconnect wording, notices = %q", notices)
|
||||
}
|
||||
if strings.Contains(lower, "model stalled") {
|
||||
t.Fatalf("transport abort must not use stall wording, notices = %q", notices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringMidStreamRetryPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, hangOnCall: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for retried stream")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during retried stream")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during retried stream must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringReplacementConnectBackoffPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = 200 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, connectFailFrom: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for replacement connect failure")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during replacement connect backoff")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during replacement connect backoff must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortRecordsReconnectCountOnImmediateReplacement(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
rec := trace.NewRecorder("midstream-abort", "run-1", "test")
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
Trace: rec,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 immediate replacement), got %d", got)
|
||||
}
|
||||
gotTrace := rec.Finish()
|
||||
if got := gotTrace.Counter(trace.CounterReconnectCount); got != 1 {
|
||||
t.Fatalf("reconnect_count = %d, want 1 for a successful replacement connection", got)
|
||||
}
|
||||
if got := gotTrace.Counter(trace.CounterModelRequests); got != 2 {
|
||||
t.Fatalf("model_requests = %d, want 2 (initial + replacement)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryResponseHeaderTimeout(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 99,
|
||||
abortError: "net/http: timeout awaiting response headers",
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a response-header timeout must not be retried as a mid-stream abort")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("header timeout must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMidStreamTransportAbort(t *testing.T) {
|
||||
aborts := []string{
|
||||
"provider stream error: read: connection reset by peer",
|
||||
"provider stream error: unexpected EOF",
|
||||
"provider stream error: read: wsarecv: 10053",
|
||||
"provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
"provider stream error: read: connection closed",
|
||||
"write: broken pipe",
|
||||
"provider stream error: server closed the connection",
|
||||
"provider stream error: software caused connection abort",
|
||||
}
|
||||
for _, m := range aborts {
|
||||
if !isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("want mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
notAborts := []string{
|
||||
"",
|
||||
"context length exceeded",
|
||||
"rate limit error: slow down",
|
||||
"model not found",
|
||||
"net/http: timeout awaiting response headers",
|
||||
"dial tcp 10.0.0.1:443: connect: connection refused",
|
||||
"i/o timeout",
|
||||
"500 Internal Server Error",
|
||||
"502 Bad Gateway",
|
||||
"503 Service Unavailable",
|
||||
"504 Gateway Timeout",
|
||||
"provider request error: request does not satisfy oneOf schema",
|
||||
"provider request error: connection closed is not a supported finish reason",
|
||||
"provider error: connection closed is not a supported finish reason",
|
||||
"provider error: upstream connection reset by peer",
|
||||
"provider error: unexpected EOF while reading upstream response",
|
||||
"provider error: 502 Bad Gateway",
|
||||
"auth error: connection reset by peer",
|
||||
"rate limit error: server closed",
|
||||
"schema error: property oneOf is invalid",
|
||||
}
|
||||
for _, m := range notAborts {
|
||||
if isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("must NOT classify as mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryClassifiedProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: connection closed is not a supported finish reason",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("classified provider error with 'connection closed' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection closed") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider error: unexpected EOF while reading upstream response",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("provider error with 'unexpected EOF' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if got := err.Error(); got != p.abortError {
|
||||
t.Fatalf("error = %q, want original provider error %q", got, p.abortError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryApplicationErrorContainingEOFSubstring(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: request does not satisfy oneOf schema",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("application error with 'oneOf' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "oneOf") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamUnexpectedEOF(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: unexpected EOF",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected EOF should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("expected reconnect notice, got %q", notices)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,15 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"i/o timeout",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
// Windows mid-stream aborts (WSAECONNABORTED / wsarecv) — host or AV
|
||||
// forcibly closes an established socket during CollectStream.
|
||||
// "software caused connection abort" is the Unix/macOS ECONNABORTED
|
||||
// text; it also appears in midStreamAbortNeedles, which must stay a
|
||||
// subset of this list.
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
} {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
@@ -152,6 +161,86 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// midStreamAbortNeedles are the transport-abort classes retried AFTER connect
|
||||
// succeeded and the stream body has started. This is a subset of shouldReconnect:
|
||||
// connect-phase signals (timeout, connection refused, "temporarily unavailable")
|
||||
// stay on the connect retry path, because matching them here would re-prefill a
|
||||
// healthy-but-slow server (ollama cloud header timeouts) and tell the user the
|
||||
// connection was lost. Issue #973 names WSAECONNABORTED / connection reset /
|
||||
// unexpected stream EOF.
|
||||
var midStreamAbortNeedles = []string{
|
||||
"unexpected end",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"server closed",
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
}
|
||||
|
||||
var classifiedNonTransportPrefixes = []string{
|
||||
"provider request error:",
|
||||
"provider error:",
|
||||
"auth error:",
|
||||
"rate limit error:",
|
||||
}
|
||||
|
||||
// isMidStreamTransportAbort reports whether a collected stream error string is a
|
||||
// retryable mid-stream transport abort. It does NOT delegate to shouldReconnect:
|
||||
// that list is a connect-phase argument ("no response was received"). The stream
|
||||
// body has already started here, so only abort/reset/EOF/close classes retry.
|
||||
//
|
||||
// Used for failures DURING the stream body AFTER a successful connect and BEFORE
|
||||
// tool dispatch — the incomplete turn committed no answer text and executed no
|
||||
// tools, so a bounded re-issue is safe (same safety rules as the stall path).
|
||||
func isMidStreamTransportAbort(message string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowered == "" || isContextLimitError(lowered) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range classifiedNonTransportPrefixes {
|
||||
if strings.Contains(lowered, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if errhint.HasStatusCode(lowered, "500", "502", "503", "504") {
|
||||
return false
|
||||
}
|
||||
if containsWordBoundary(lowered, "eof") {
|
||||
return true
|
||||
}
|
||||
for _, needle := range midStreamAbortNeedles {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsWordBoundary(text, word string) bool {
|
||||
start := 0
|
||||
for {
|
||||
idx := strings.Index(text[start:], word)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
pos := start + idx
|
||||
endPos := pos + len(word)
|
||||
leftBoundary := pos == 0 || !isAlphaNumByte(text[pos-1])
|
||||
rightBoundary := endPos == len(text) || !isAlphaNumByte(text[endPos])
|
||||
if leftBoundary && rightBoundary {
|
||||
return true
|
||||
}
|
||||
start = pos + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isAlphaNumByte(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
}
|
||||
|
||||
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
|
||||
@@ -92,6 +92,12 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
// Windows socket aborts also remain reconnectable at connect time.
|
||||
"wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"read: connection was aborted",
|
||||
// Unix/macOS ECONNABORTED text.
|
||||
"read: software caused connection abort",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
|
||||
@@ -10,6 +10,24 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestNonVisionImageNoticeQualifiesBudgetRecovery(t *testing.T) {
|
||||
result := ToolResult{
|
||||
Name: "mcp_screenshot",
|
||||
Output: "One image exceeded this result's remaining image budget. Retrying with fewer images can recover this payload.",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("image")}},
|
||||
}
|
||||
notice, ok := toolResultImageMessage(result, Options{
|
||||
SupportsVision: func(string) bool { return false },
|
||||
})
|
||||
if !ok || len(notice.Images) != 0 {
|
||||
t.Fatal("non-vision delivery must produce a text-only notice")
|
||||
}
|
||||
if !strings.Contains(notice.Content, "Switch to a vision-capable model before retrying") ||
|
||||
!strings.Contains(notice.Content, "budget") {
|
||||
t.Fatalf("notice does not qualify budget recovery: %s", notice.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// imageTool returns a tool result carrying an image, the way a screenshot tool
|
||||
// should be able to.
|
||||
type imageTool struct {
|
||||
@@ -62,6 +80,7 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -86,11 +105,16 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
|
||||
// The tool-result pairing must be untouched: one tool result per tool call.
|
||||
toolResults := 0
|
||||
var toolText string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults++
|
||||
toolText = message.Content
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved", toolText)
|
||||
}
|
||||
if toolResults != 1 {
|
||||
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
|
||||
}
|
||||
@@ -136,7 +160,7 @@ func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2, Model: "gpt-4o"})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -205,3 +229,176 @@ func messageShape(messages []zeroruntime.Message) string {
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
|
||||
func TestRunDropsToolResultImagesForANonVisionModel(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "ok"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "totally-made-up-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var toolText string
|
||||
var noticed bool
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolText = message.Content
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
t.Fatalf("non-vision model received image bytes on %q: %s", message.Role, messageShape(result.Messages))
|
||||
}
|
||||
if strings.Contains(message.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved on the non-vision path", toolText)
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatalf("non-vision model was not told the image was dropped; recorded %s", messageShape(result.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
type switchAndCaptureTool struct {
|
||||
targetModel string
|
||||
}
|
||||
|
||||
func (switchAndCaptureTool) Name() string { return "switch_and_capture" }
|
||||
func (switchAndCaptureTool) Description() string { return "Switches model and captures image" }
|
||||
func (switchAndCaptureTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (switchAndCaptureTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t switchAndCaptureTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "[image returned by tool]",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("\x89PNG\r\n\x1a\nfake")}},
|
||||
Meta: map[string]string{"escalate_to_model": t.targetModel},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunToolImagesRespectsModelSwitch(t *testing.T) {
|
||||
t.Run("Non-vision to vision model escalation forwards images", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "gpt-4o"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "I see it on gpt-4o."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switched := false
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
switched = true
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "non-vision-initial",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !switched {
|
||||
t.Fatal("expected model switch to occur")
|
||||
}
|
||||
|
||||
var carrier *zeroruntime.Message
|
||||
for index := range result.Messages {
|
||||
if len(result.Messages[index].Images) > 0 {
|
||||
carrier = &result.Messages[index]
|
||||
}
|
||||
if strings.Contains(result.Messages[index].Content, "does not support image input") {
|
||||
t.Fatalf("images were unexpectedly dropped after switch to vision model: %v", result.Messages[index])
|
||||
}
|
||||
}
|
||||
if carrier == nil {
|
||||
t.Fatal("expected image carrier message after escalation to vision model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vision to non-vision model switch drops images with notice", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "text-only-dest"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "ok."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
var noticed bool
|
||||
var toolOutputSeen bool
|
||||
for _, m := range result.Messages {
|
||||
if len(m.Images) > 0 {
|
||||
t.Fatalf("image delivered to non-vision destination model: %v", m)
|
||||
}
|
||||
if strings.Contains(m.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
if strings.Contains(m.Content, "[image returned by tool]") {
|
||||
toolOutputSeen = true
|
||||
}
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatal("expected drop notice when switching to non-vision model")
|
||||
}
|
||||
if !toolOutputSeen {
|
||||
t.Fatal("expected tool output preserved in message history")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -341,6 +341,11 @@ type Options struct {
|
||||
// nil for text-only runs (the seeded message then carries no images, exactly
|
||||
// as before).
|
||||
Images []zeroruntime.ImageBlock
|
||||
// SupportsVision, when set, reports whether the effective model accepts
|
||||
// image input. Tool-produced images are dropped at the shared delivery
|
||||
// boundary when this is false. nil uses the curated catalog plus the
|
||||
// name heuristic via modelregistry.SupportsVision.
|
||||
SupportsVision func(modelID string) bool
|
||||
// ContextWindow is the model's maximum input token budget. When > 0 the agent
|
||||
// loop compacts long conversations once the estimated size crosses a fraction
|
||||
// of this window. 0 DISABLES compaction entirely (every existing caller/test
|
||||
|
||||
@@ -4,6 +4,7 @@ package background
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -46,7 +47,13 @@ func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
||||
// WithHandle refuses only a process that has already been waited or
|
||||
// released, so it has finished. Say so in the form exec.Cmd understands:
|
||||
// a Cancel error wrapping os.ErrProcessDone is "nothing to cancel", where
|
||||
// the unexported "already released" error it would otherwise carry made
|
||||
// Wait report a spurious cancel failure. A dead root has no tree left to
|
||||
// find either way.
|
||||
return true, fmt.Errorf("pin process %d for termination: %w: %w", cmd.Process.Pid, os.ErrProcessDone, err)
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
|
||||
@@ -73,6 +73,29 @@ func TerminateCommand(cmd *exec.Cmd) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminateCommandGroup stops a started command's process group on POSIX, or its
|
||||
// process tree on Windows, WITHOUT reaping it.
|
||||
//
|
||||
// It is TerminateCommand without the Wait, for a caller whose Wait already runs
|
||||
// somewhere else: a worker pool waiting on the command in its own goroutine, or
|
||||
// the command's own Cancel hook, which exec.Cmd invokes while its Wait is in
|
||||
// progress. TerminateCommand would be a second Wait there. Both used to pass
|
||||
// cmd.Process.Pid to TerminateProcess, which rediscovers the group at signal time
|
||||
// with Getpgid, and once the leader has exited that lookup fails, so only the dead
|
||||
// leader was signalled and its descendants kept running. When cmd was configured
|
||||
// by ConfigureChildProcessGroup the group now comes from that launch-time
|
||||
// configuration instead. On Windows it is the tree kill TerminateProcess performs,
|
||||
// with the process handle pinned while it runs so the PID cannot be recycled
|
||||
// underneath it; once the command has been waited that pin fails with an error
|
||||
// wrapping os.ErrProcessDone.
|
||||
func TerminateCommandGroup(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := terminateOwnedProcess(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
||||
// terminated is expected to report one (or a signal), so the only interesting
|
||||
// failure is Wait itself not working.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TerminateCommandGroup LEAVES THE WAIT TO ITS CALLER. Its callers already have a
|
||||
// Wait running or about to run: the daemon pool waits on its workers, and
|
||||
// exec.Cmd calls the Cancel hook from inside Wait. Reaping here, the way
|
||||
// TerminateCommand does, would make their Wait fail with "Wait was already
|
||||
// called" instead of reporting how the command ended.
|
||||
func TestTerminateCommandGroupLeavesTheWaitToTheCaller(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30")
|
||||
}
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
t.Fatalf("TerminateCommandGroup: %v", err)
|
||||
}
|
||||
|
||||
waited := make(chan error, 1)
|
||||
go func() { waited <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-waited:
|
||||
var exitErr *exec.ExitError
|
||||
if err != nil && !errors.As(err, &exitErr) {
|
||||
t.Fatalf("the caller's own Wait failed after TerminateCommandGroup: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatal("the command was still running 10s after TerminateCommandGroup")
|
||||
}
|
||||
}
|
||||
|
||||
// A command that was never started, or a nil one, is nothing to stop.
|
||||
func TestTerminateCommandGroupWithNoProcessIsANoOp(t *testing.T) {
|
||||
if err := TerminateCommandGroup(nil); err != nil {
|
||||
t.Fatalf("nil command: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(exec.Command("sleep", "1")); err != nil {
|
||||
t.Fatalf("unstarted command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// After the caller has waited, Windows cannot pin the process any more. The error
|
||||
// must still read as "already finished", which is what exec.Cmd's Cancel contract
|
||||
// and the pool both treat as nothing wrong.
|
||||
func TestTerminateCommandGroupAfterWaitReportsProcessDone(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("POSIX signals the group, which is simply gone after the wait")
|
||||
}
|
||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
t.Fatalf("after Wait the error is %v, want nil or one wrapping os.ErrProcessDone", err)
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -695,7 +695,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
@@ -727,7 +727,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
// Fresh-onboarding reset, but the user's notification preference is
|
||||
// NOT config-to-redo: clearing it here would surface as an empty
|
||||
// Options.Notify, and the TUI's unconfigured default (both/unfocused)
|
||||
// would then resurrect alerts a user explicitly turned off while the
|
||||
// setup wizard runs (maintainer review, PR #1001). Carry the stored
|
||||
// block through; other wizard-visible state starts clean as before.
|
||||
resolved = config.ResolvedConfig{Notify: resolved.Notify}
|
||||
forceSetup = true
|
||||
}
|
||||
}
|
||||
@@ -959,6 +965,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
// From the engine the session's commands run through, so the notice
|
||||
// describes the sandbox they actually get.
|
||||
startupNotices := []string{sandboxEngine.DegradedNotice()}
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
@@ -1046,6 +1055,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
StartupNotices: startupNotices,
|
||||
STT: resolved.STT,
|
||||
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
|
||||
ShutdownDictationServer: sttServerManager.Shutdown,
|
||||
|
||||
@@ -377,6 +377,73 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a stored explicit notify opt-out must
|
||||
// survive the provider-recovery startup path. With a stale activeProvider and
|
||||
// another usable saved provider, Resolve returns the partial config alongside
|
||||
// ErrNoActiveProvider and the recovery branch forwards it — dropping the
|
||||
// notify policy there would surface an empty Options.Notify, and the TUI's
|
||||
// unconfigured default (both) would resurrect alerts the user explicitly
|
||||
// turned off. The same recovery branch that clears the resolved config for
|
||||
// the setup wizard carries the stored notify block through.
|
||||
func TestRunNoArgsPreservesNotifyOptOutThroughProviderRecovery(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Stale activeProvider + usable saved provider, and the user's
|
||||
// config.json explicitly opts out of notifications. The fixed
|
||||
// resolver carries the parsed notify block through the error path.
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
Notify: config.NotifyConfig{Mode: "off", FocusMode: "always"},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &cliFakeProvider{}, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
// The explicit opt-out reaches the TUI intact — NOT the both/unfocused
|
||||
// unconfigured default.
|
||||
if launchedOptions.Notify.Mode != "off" || launchedOptions.Notify.FocusMode != "always" {
|
||||
t.Fatalf("Options.Notify = %+v, want the stored off/always opt-out preserved through recovery", launchedOptions.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
@@ -26,6 +26,30 @@ type modelSummary = zerocommands.ModelSnapshot
|
||||
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot
|
||||
|
||||
func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
// The first non-flag argument is a subcommand. With no positional argument
|
||||
// (or only flag arguments), the read-only summary path runs — this keeps
|
||||
// `zero config` and `zero config --json` working unchanged.
|
||||
command := "summary"
|
||||
rest := args
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
command = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
rest = args[1:]
|
||||
}
|
||||
switch command {
|
||||
case "summary":
|
||||
return runConfigSummary(rest, stdout, stderr, deps)
|
||||
case "notify":
|
||||
return runConfigNotify(rest, stdout, stderr, deps)
|
||||
case "help":
|
||||
if err := writeConfigHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown config command %q", command))
|
||||
}
|
||||
|
||||
func runConfigSummary(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseCommandCenterArgs(args, false, false)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -466,8 +490,12 @@ func formatProviderCatalogValue(value string, fallback string) string {
|
||||
func writeConfigHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero config [flags]
|
||||
zero config notify [flags]
|
||||
|
||||
Inspects resolved Go configuration without printing secrets.
|
||||
Inspects resolved Go configuration without printing secrets. The notify
|
||||
subcommand reads or updates the stored global notification preference,
|
||||
which controls both the completion and needs-input alerts —
|
||||
run "zero config notify --help" for details.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -40,7 +40,11 @@ var completionRoot = completionNode{
|
||||
},
|
||||
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
|
||||
{names: []string{"setup"}},
|
||||
{names: []string{"config"}},
|
||||
{names: []string{"config"}, flags: []string{"-h", "--help", "--json"}, children: []completionNode{
|
||||
{names: []string{"notify"}, flags: []string{
|
||||
"-h", "--help", "--mode", "--focus", "--reset", "--json",
|
||||
}},
|
||||
}},
|
||||
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
|
||||
{names: []string{"providers"}, children: []completionNode{
|
||||
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
|
||||
|
||||
@@ -165,6 +165,11 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
|
||||
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
|
||||
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
|
||||
// `config` gained the notify subcommand; the completion tree must expose
|
||||
// it (and its flags) so shell completion cannot go stale for new CLI
|
||||
// surfaces (maintainer review, PR #1001).
|
||||
assertCandidates(t, byPath["config"], "notify", "--json", "--help")
|
||||
assertCandidates(t, byPath["config notify"], "--mode", "--focus", "--reset", "--json", "--help")
|
||||
}
|
||||
|
||||
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// runConfigNotify implements `zero config notify`: with no flags it prints the
|
||||
// stored mode/focusMode; --mode/--focus update them via the same
|
||||
// config.SetNotify writer the TUI /notify command uses, so all surfaces stay
|
||||
// in lockstep; --reset blanks both fields so the TUI's effective default
|
||||
// applies again (an unconfigured headless run stays silent).
|
||||
//
|
||||
// The command manages a user preference, so it talks ONLY to the user's own
|
||||
// config file (config.UserNotify / config.SetNotify) and never runs the full
|
||||
// config resolution: resolving providers would fail with ErrNoActiveProvider
|
||||
// for a brand-new user, locking them out of setting notifications before they
|
||||
// have even configured a provider (CodeRabbit review, PR #1001). The display
|
||||
// therefore reports the USER'S stored values — a project config that
|
||||
// overrides notify for one repo is not shown here, by the same logic the
|
||||
// maintainer applied to the write path.
|
||||
func runConfigNotify(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseConfigNotifyArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeConfigNotifyHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
if options.mode != "" || options.focus != "" || options.reset {
|
||||
// One serialized read-merge-write transaction: the lock covers
|
||||
// reading the stored block, applying only the explicit fields, and
|
||||
// replacing the file, so two concurrent partial updates (e.g.
|
||||
// --mode off and --focus always from two terminals) cannot lose
|
||||
// each other's change (maintainer review, PR #1001). Omitted
|
||||
// fields preserve the values stored in the user's OWN file — blank
|
||||
// stays blank; --reset is the only path that clears both fields.
|
||||
_, err := config.UpdateNotify(configPath, func(current config.NotifyConfig) config.NotifyConfig {
|
||||
if options.reset {
|
||||
return config.NotifyConfig{}
|
||||
}
|
||||
if options.mode != "" {
|
||||
current.Mode = options.mode
|
||||
}
|
||||
if options.focus != "" {
|
||||
current.FocusMode = options.focus
|
||||
}
|
||||
return current
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// Report the stored values (blank renders as "(default)").
|
||||
current, err := config.UserNotify(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"mode": current.Mode,
|
||||
"focusMode": current.FocusMode,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
lines := []string{
|
||||
"Notify",
|
||||
"mode: " + displayCLIValue(current.Mode, "(default)"),
|
||||
"focusMode: " + displayCLIValue(current.FocusMode, "(default)"),
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type configNotifyOptions struct {
|
||||
mode string
|
||||
focus string
|
||||
reset bool
|
||||
json bool
|
||||
}
|
||||
|
||||
func parseConfigNotifyArgs(args []string) (configNotifyOptions, bool, error) {
|
||||
options := configNotifyOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--reset":
|
||||
options.reset = true
|
||||
case arg == "--mode":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--mode="):
|
||||
value, err := requiredInlineFlagValue(arg, "--mode")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
case arg == "--focus":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--focus="):
|
||||
value, err := requiredInlineFlagValue(arg, "--focus")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func writeConfigNotifyHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, "Usage:\n"+
|
||||
" zero config notify [flags]\n"+
|
||||
"\n"+
|
||||
"Print or update the stored global notification preference.\n"+
|
||||
"\n"+
|
||||
"The preference controls BOTH notification kinds: the turn-completion\n"+
|
||||
"(\"Zero: ready\") alert and the needs-input alert. mode off silences both;\n"+
|
||||
"the focus mode (unfocused, always, focused) applies to both.\n"+
|
||||
"\n"+
|
||||
"When run with no flag, prints the stored mode and focusMode; a field you\n"+
|
||||
"never set shows as (default) — the TUI alerts with bell + notification,\n"+
|
||||
"firing only when the terminal is unfocused, while an unconfigured\n"+
|
||||
"headless run stays silent. Omitted flags preserve the values stored in\n"+
|
||||
"YOUR config file; --reset clears both so the TUI's effective default\n"+
|
||||
"applies again. If --reset is combined with --mode/--focus, --reset wins\n"+
|
||||
"and the explicit values are ignored.\n"+
|
||||
"\n"+
|
||||
"Examples:\n"+
|
||||
" zero config notify\n"+
|
||||
" zero config notify --json\n"+
|
||||
" zero config notify --mode both --focus unfocused\n"+
|
||||
" zero config notify --mode off\n"+
|
||||
" zero config notify --reset # clear the stored preference\n"+
|
||||
"\n"+
|
||||
"Flags:\n"+
|
||||
" --mode <off|bell|notify|both> Notification mechanism (both kinds)\n"+
|
||||
" --focus <unfocused|always|focused> When the alert fires\n"+
|
||||
" --reset Clear the stored preference so the TUI effective default applies\n"+
|
||||
" --json Machine-readable output\n"+
|
||||
" -h, --help Show this help\n")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero config notify` with no flag and a fresh config: nothing is configured,
|
||||
// the resolver leaves the fields blank (defaults deliberately live in the TUI,
|
||||
// not the resolver — maintainer review, PR #1001), and the command reports
|
||||
// "(default)" for both.
|
||||
func TestRunConfigNotifyPrintsUnconfiguredAsDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
// A valid openai profile so the resolver does not error with
|
||||
// ErrNoActiveProvider. The notify defaults are applied independently of
|
||||
// the provider resolution path.
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured mode as (default), got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "focusMode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured focus as (default), got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --json` emits a machine-readable payload. A configured
|
||||
// pair round-trips; unconfigured fields are empty strings (never defaults
|
||||
// filled in), so scripts can distinguish "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSON(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" {
|
||||
t.Errorf("mode = %v, want bell", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "always" {
|
||||
t.Errorf("focusMode = %v, want always", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// The unconfigured JSON shape: fields are empty strings, never defaults
|
||||
// filled in, so scripts can tell "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSONUnconfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "" {
|
||||
t.Errorf("mode = %v, want empty (unconfigured)", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "" {
|
||||
t.Errorf("focusMode = %v, want empty (unconfigured)", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --mode off` writes the new value to disk and prints
|
||||
// confirmation. The user can read it back by running the command again.
|
||||
func TestRunConfigNotifyWritesModeChange(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "both", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
// A mode-only update must preserve the configured focusMode, not wipe it.
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want preserved %q", cfg.Notify.FocusMode, "always")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: off") {
|
||||
t.Errorf("stdout should confirm the change, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A focus-only update preserves the configured mode.
|
||||
func TestRunConfigNotifyFocusOnlyPreservesMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "unfocused"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" {
|
||||
t.Errorf("Notify.Mode = %q, want preserved %q", cfg.Notify.Mode, "bell")
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a partial update must seed from the USER'S
|
||||
// OWN file, never from the resolved view. A project .zero/config.json setting
|
||||
// mode=off resolves into the session, but `--focus always` inside that repo
|
||||
// must NOT copy the project's off into the user's global config.
|
||||
func TestRunConfigNotifyDoesNotCopyProjectNotifyIntoUserConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "user.json")
|
||||
projectPath := filepath.Join(t.TempDir(), "project.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(projectPath, []byte(`{"notify": {"mode": "off"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
// Resolve EXACTLY the way production does: user config + project config
|
||||
// merged, so resolved.Notify.Mode is the project's "off".
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{
|
||||
UserConfigPath: configPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||
})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
// The user's file must keep an unspecified mode unspecified — the
|
||||
// project's "off" stays in the project file where it belongs.
|
||||
if cfg.Notify.Mode != "" {
|
||||
t.Errorf("user Notify.Mode = %q, want blank (project's off must not leak into the user file)", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
// The project file is untouched.
|
||||
project := readFileConfig(t, projectPath)
|
||||
if project.Notify.Mode != "off" {
|
||||
t.Errorf("project Notify.Mode = %q, want untouched off", project.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): with a clean config, `--mode off` must not
|
||||
// also pin focusMode as an explicit choice — blank means "use the built-in
|
||||
// default", and a partial update keeps it that way.
|
||||
func TestRunConfigNotifyDoesNotPinDefaultsAsExplicitChoices(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify.FocusMode = %q, want blank (unspecified stays unspecified; the default must not be pinned)", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode` and `--focus` together update both fields in one call.
|
||||
func TestRunConfigNotifyWritesModeAndFocus(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "both", "--focus", "unfocused"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Errorf("Notify = %+v, want mode=both focusMode=unfocused", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode loud` is a usage error. The config must not be mutated.
|
||||
func TestRunConfigNotifyRejectsInvalidMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "loud"}, &stdout, &stderr, deps)
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected failure for invalid mode, got success; stdout=%s", stdout.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off (unchanged after failed write)", cfg.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--reset` blanks both fields so the TUI's effective default applies again
|
||||
// (an unconfigured headless run stays silent). Useful for "go back to the
|
||||
// recommended setup" after a custom value.
|
||||
func TestRunConfigNotifyResetClearsStoredValues(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify after reset = %+v, want empty (defaults apply)", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// CodeRabbit regression (PR #1001): the command manages a user preference and
|
||||
// must not require provider resolution. A brand-new user with NO provider
|
||||
// configured (the resolver would return ErrNoActiveProvider) can still read,
|
||||
// set, and reset their notification preference.
|
||||
func TestRunConfigNotifyWorksWithoutAnyProviderConfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// resolveConfig fails the way the real resolver does for a fresh user —
|
||||
// the command must never call it, so a panic-free stub is enough to prove
|
||||
// the point; use the failing resolver to catch any regression to the
|
||||
// resolve-first shape.
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}})
|
||||
}
|
||||
|
||||
// Read works and shows defaults.
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("read: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("read should show (default), got: %s", stdout.String())
|
||||
}
|
||||
|
||||
// Write works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--mode", "bell", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("write: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" || cfg.Notify.FocusMode != "always" {
|
||||
t.Fatalf("write: Notify = %+v, want bell/always", cfg.Notify)
|
||||
}
|
||||
|
||||
// JSON read reflects the stored pair.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("json: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json output invalid: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" || payload["focusMode"] != "always" {
|
||||
t.Errorf("json = mode %v focus %v, want bell/always", payload["mode"], payload["focusMode"])
|
||||
}
|
||||
|
||||
// Reset works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("reset: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg = readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("reset: Notify = %+v, want empty", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config` (no subcommand) still works after the dispatch change.
|
||||
func TestRunConfigSummaryStillWorks(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, commandCenterDeps(t))
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Config") {
|
||||
t.Errorf("stdout should show the config summary, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -39,3 +43,115 @@ func TestFireJobClaimPreventsDoubleFire(t *testing.T) {
|
||||
t.Fatalf("NextRunAt must advance past now, got %s", d.NextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobOverlappingAdjacentSlots(t *testing.T) {
|
||||
for _, olderFirst := range []bool{false, true} {
|
||||
name := "newer completes first"
|
||||
if olderFirst {
|
||||
name = "older completes first"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stores := []*cron.Store{
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
}
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := stores[0].Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now, FireCount: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var release, done [2]chan struct{}
|
||||
for i := range stores {
|
||||
release[i], done[i] = make(chan struct{}), make(chan struct{})
|
||||
// Always release blocked runners, including on assertion failures.
|
||||
defer func() {
|
||||
select {
|
||||
case <-release[i]:
|
||||
default:
|
||||
close(release[i])
|
||||
}
|
||||
<-done[i]
|
||||
}()
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done[i])
|
||||
fireJob(stores[i], func() time.Time { return now.Add(time.Duration(i) * time.Minute) }, job, io.Discard, io.Discard,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
close(started)
|
||||
<-release[i]
|
||||
return i // Include a failed execution in the accounting.
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-done[i]:
|
||||
t.Fatal("runner did not claim its slot")
|
||||
}
|
||||
}
|
||||
order := []int{1, 0}
|
||||
if olderFirst {
|
||||
order = []int{0, 1}
|
||||
}
|
||||
for n, i := range order {
|
||||
close(release[i])
|
||||
<-done[i]
|
||||
current, err := stores[0].Get(job.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.FireCount != 8+n || !current.NextRunAt.Equal(now.Add(2*time.Minute)) {
|
||||
t.Fatalf("after completion %d: FireCount=%d NextRunAt=%s; want count %d and next %s", n+1, current.FireCount, current.NextRunAt, 8+n, now.Add(2*time.Minute))
|
||||
}
|
||||
claimed, err := claimFire(stores[1], now.Add(time.Minute), job.ID)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("already claimed slot became available: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
runs, err := stores[0].Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 2 {
|
||||
t.Fatalf("runs=%v err=%v", runs, err)
|
||||
}
|
||||
for n, i := range order {
|
||||
if !runs[n].At.Equal(now.Add(time.Duration(i)*time.Minute)) || runs[n].ExitCode != i {
|
||||
t.Fatalf("unexpected run record: %+v", runs[n])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobCompletionReadErrorDoesNotOverwriteState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: root})
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := store.Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := filepath.Join(root, job.ID, "metadata.json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
fireJob(store, func() time.Time { return now }, job, &stdout, &stderr,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
// A corrupt read must not cause completion to replace unknown newer state
|
||||
// with the stale pre-execution job. This works without permission tricks.
|
||||
if err := os.WriteFile(metadata, []byte("unreadable job"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
data, err := os.ReadFile(metadata)
|
||||
if err != nil || string(data) != "unreadable job" {
|
||||
t.Fatalf("completion overwrote unreadable state: %q, err=%v", data, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "failed to persist job state") {
|
||||
t.Fatalf("missing persistence warning: %s", stderr.String())
|
||||
}
|
||||
if want := "fired " + job.ID + " -> exit 0 (next: unknown)\n"; stdout.String() != want {
|
||||
t.Fatalf("completion summary = %q; want %q", stdout.String(), want)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 1 || runs[0].ExitCode != 0 {
|
||||
t.Fatalf("execution should still be recorded: runs=%v err=%v", runs, err)
|
||||
}
|
||||
}
|
||||
+19
-38
@@ -129,17 +129,15 @@ func reconcileOverdue(store *cron.Store, now func() time.Time, ids []string, std
|
||||
}
|
||||
}
|
||||
|
||||
// fireJob runs one job via the exec runner, records the outcome, advances the
|
||||
// schedule, and persists. The foreground loop is single-goroutine, so the
|
||||
// previous fire has already returned before the next tick — no overlap.
|
||||
// fireJob claims one job, runs it via the exec runner, and records the outcome.
|
||||
// Separate scheduler processes can have adjacent slots executing concurrently.
|
||||
func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Writer, stderr io.Writer, exec execRunner) {
|
||||
fired := now()
|
||||
// Atomically claim this fire before running it so concurrent schedulers (or
|
||||
// --once overlapping the forever loop) can't both execute the same due job: the
|
||||
// winner advances NextRunAt under the per-job lock, and a loser sees the
|
||||
// advanced time and skips. The post-exec advance recomputes the same
|
||||
// NextRunAt (sched.Next(fired) is deterministic), so the claim serializes the
|
||||
// fire decision without changing any persisted schedule state (M9).
|
||||
// advanced time and skips. Only the claim writes schedule state; completion
|
||||
// must not overwrite a later slot's claim with this run's stale job copy.
|
||||
claimed, claimErr := claimFire(store, fired, job.ID)
|
||||
if claimErr != nil {
|
||||
if errors.Is(claimErr, cron.ErrJobNotFound) {
|
||||
@@ -178,44 +176,25 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
rec.Error = cronTruncate(detail, 500)
|
||||
}
|
||||
|
||||
job.FireCount++
|
||||
// Advance the schedule. If the expression can no longer produce a future run
|
||||
// (became invalid, or is an impossible spec whose Next is zero), pause the job
|
||||
// so it cannot re-fire on every tick.
|
||||
// Explain schedules that claimFire paused because they cannot advance.
|
||||
if sched, perr := cron.Parse(job.Expr); perr != nil {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "invalid schedule; job paused: " + perr.Error()
|
||||
}
|
||||
} else if nxt := sched.Next(fired.Truncate(time.Minute)); nxt.IsZero() {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "schedule no longer fires; job paused"
|
||||
}
|
||||
} else {
|
||||
// Minute-aligned input keeps this advance identical to claimFire's and lets
|
||||
// the DST fall-back collapse guard engage (AUDIT-M4).
|
||||
job.NextRunAt = nxt
|
||||
}
|
||||
// Re-read and persist the advanced state ATOMICALLY under the per-job lock. The
|
||||
// job may have been paused or removed while it executed, and this in-memory copy
|
||||
// is stale from tick start. Mutate closes the read-modify-write window a single
|
||||
// scheduler alone could only narrow (Store.Mutate took a cross-process lock): a
|
||||
// concurrent scheduler or an external pause/remove landing here can no longer be
|
||||
// clobbered.
|
||||
// Count this completion against current state under the cross-process lock.
|
||||
// The claim already advanced or paused the schedule. Leave that state alone:
|
||||
// another scheduler may have claimed a later slot or a user may have edited it.
|
||||
persisted, err := store.Mutate(job.ID, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
if readErr != nil {
|
||||
// A transient read failure (IO/permission) is NOT removal — persist the
|
||||
// computed next state anyway so the schedule advances and the job does not
|
||||
// re-fire next tick.
|
||||
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
|
||||
return job, nil
|
||||
}
|
||||
current.FireCount = job.FireCount
|
||||
current.NextRunAt = job.NextRunAt
|
||||
if current.Status != cron.StatusPaused {
|
||||
current.Status = job.Status
|
||||
// Never replace unknown newer state with the pre-execution snapshot.
|
||||
return current, readErr
|
||||
}
|
||||
current.FireCount++
|
||||
return current, nil
|
||||
})
|
||||
if errors.Is(err, cron.ErrJobNotFound) {
|
||||
@@ -229,19 +208,21 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
if aerr := store.AppendRun(job.ID, rec); aerr != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, aerr)
|
||||
}
|
||||
next := "unknown"
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to persist job state for %s: %v\n", job.ID, err)
|
||||
persisted = job
|
||||
} else {
|
||||
next = formatCronTime(persisted.NextRunAt)
|
||||
}
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(persisted.NextRunAt))
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, next)
|
||||
}
|
||||
|
||||
// claimFire atomically claims a due job's fire under the per-job lock and reports
|
||||
// whether THIS caller should run it. It returns false only when the job was paused
|
||||
// externally or another scheduler already advanced NextRunAt past `fired` (so a
|
||||
// valid recurring job never double-fires). A valid due job has its NextRunAt
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is left for the
|
||||
// caller to fire-once and pause (preserving existing single-scheduler behavior).
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is paused
|
||||
// here and the caller still fires once (preserving single-scheduler behavior).
|
||||
func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
claimed := true
|
||||
_, err := store.Mutate(id, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
@@ -268,8 +249,8 @@ func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
}
|
||||
// Unparseable or unadvanceable schedule (an impossible spec whose Next is
|
||||
// zero): pause the job inside this same locked claim so a concurrent scheduler
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once
|
||||
// and fireJob's post-exec keeps it paused. Without this, both callers leave
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once.
|
||||
// Without this, both callers leave
|
||||
// NextRunAt unchanged, both see the job due, and both fire the same slot
|
||||
// (AUDIT-M5).
|
||||
current.Status = cron.StatusPaused
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestCronRunOnceFiresDueJobs(t *testing.T) {
|
||||
if d.FireCount != 1 || !d.NextRunAt.After(now) {
|
||||
t.Fatalf("due job not advanced: %+v", d)
|
||||
}
|
||||
runs, _ := store.Runs(due.ID)
|
||||
runs, _ := store.Runs(due.ID, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 run record, got %d", len(runs))
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestCronRunPausesUnadvanceableJob(t *testing.T) {
|
||||
if d.Status != cron.StatusPaused {
|
||||
t.Fatalf("unadvanceable job must be paused, got status=%q", d.Status)
|
||||
}
|
||||
runs, _ := store.Runs(job.ID)
|
||||
runs, _ := store.Runs(job.ID, 0)
|
||||
if len(runs) != 1 || runs[0].Error == "" {
|
||||
t.Fatalf("expected one run record with an error, got %+v", runs)
|
||||
}
|
||||
|
||||
+15
-1
@@ -181,7 +181,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||
// front — before tool-filter validation and the --list-tools branch — so a
|
||||
@@ -347,6 +347,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
// Before any tool runs, and before the MCP servers and plugins below start
|
||||
// through the same sandbox, so a run that goes ahead with reduced isolation
|
||||
// says so where its output starts, not only when someone runs `zero doctor`.
|
||||
// A notice that cannot be written stops the run, as the image and effort
|
||||
// notices further down do: going ahead would mean reduced isolation and no
|
||||
// warning delivered.
|
||||
if notice := sandboxEngine.DegradedNotice(); notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, "[zero] "+notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
if permissionMode != agent.PermissionModePlan {
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
@@ -660,6 +671,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -108,8 +108,8 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -176,8 +176,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
if !closed {
|
||||
t.Fatal("MCP runtime was not closed after --list-tools")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") {
|
||||
t.Fatalf("expected MCP tool in list output, got %q", stdout.String())
|
||||
@@ -323,8 +323,8 @@ func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -388,8 +388,8 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -460,7 +460,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String())
|
||||
}
|
||||
if stderr.String() != "" {
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("approved exec wrote stderr = %q", stderr.String())
|
||||
}
|
||||
approvedEvents := decodeJSONLines(t, stdout.String())
|
||||
|
||||
@@ -118,12 +118,15 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
||||
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(run.modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Profile: run.profilePolicy,
|
||||
Cwd: run.workspaceRoot,
|
||||
|
||||
+20
-20
@@ -568,8 +568,8 @@ func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Fatalf("expected prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,8 +696,8 @@ func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
|
||||
if !strings.Contains(stdout, "inline prompt\n\nfile prompt") {
|
||||
t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,8 +715,8 @@ func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
|
||||
if !strings.Contains(stdout, "file only prompt") {
|
||||
t.Fatalf("expected file prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,8 +798,8 @@ func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -833,8 +833,8 @@ func TestRunExecResolvesCanonicalModelAlias(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr for active model, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice for active model, got %q", stderr)
|
||||
}
|
||||
events := decodeJSONLines(t, stdout)
|
||||
if got := events[0]["model"]; got != "gpt-4.1" {
|
||||
@@ -926,8 +926,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -970,10 +970,10 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
"name": "local",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "` + server.URL + `",
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,8 +988,8 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
if strings.TrimSpace(stdout.String()) != "provider ok" {
|
||||
t.Fatalf("stdout = %q, want provider response", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer sk-local" {
|
||||
t.Fatalf("Authorization = %q, want project config token", gotAuth)
|
||||
@@ -1204,8 +1204,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// hostSandboxNotice is the degraded-sandbox line exec prints on THIS machine for
|
||||
// a run under the default sandbox policy, or "" where the sandbox is not
|
||||
// degraded. Exec tests that expect a quiet stderr compare against it rather than
|
||||
// against "": unless a test pins a backend, the run gets the host's own, and a
|
||||
// Linux runner without the sandbox helper is degraded where macOS and Windows
|
||||
// are not.
|
||||
func hostSandboxNotice(t *testing.T) string {
|
||||
t.Helper()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.SelectBackend(sandbox.BackendOptions{}),
|
||||
})
|
||||
notice := engine.DegradedNotice()
|
||||
if notice == "" {
|
||||
return ""
|
||||
}
|
||||
return "[zero] " + notice + "\n"
|
||||
}
|
||||
|
||||
// clearSandboxNestingMarkers keeps a test that pins the notice from inheriting
|
||||
// the nesting markers when the suite itself runs inside a Zero sandbox.
|
||||
func clearSandboxNestingMarkers(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(sandbox.EnvSandboxed, "")
|
||||
t.Setenv(sandbox.EnvSandboxBackend, "")
|
||||
}
|
||||
|
||||
// unavailableTestSandbox is the backend a Linux box without the helper gets,
|
||||
// pinned so a test sees the degraded case on every platform.
|
||||
func unavailableTestSandbox(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"}
|
||||
}
|
||||
|
||||
const unavailableTestSandboxNotice = "[zero] Sandbox enforcement is degraded: Linux sandbox helper is not available. See `zero sandbox policy --effective`.\n"
|
||||
|
||||
func runExecWithSandbox(t *testing.T, args []string, backend func(sandbox.BackendOptions) sandbox.Backend, sandboxConfig config.SandboxConfig) (int, string, string) {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "echo-model",
|
||||
},
|
||||
MaxTurns: 3,
|
||||
Sandbox: sandboxConfig,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return echoExecProvider{}, nil
|
||||
},
|
||||
selectSandboxBackend: backend,
|
||||
})
|
||||
return exitCode, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
// A RUN THAT GOES AHEAD WITH REDUCED ISOLATION SAYS SO. Before this only `zero
|
||||
// doctor` and `zero sandbox policy` did, so `zero exec` ran every tool with the
|
||||
// native sandbox effectively off and printed nothing about it. #1041.
|
||||
func TestExecSaysOnStderrWhenTheSandboxIsDegraded(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != unavailableTestSandboxNotice {
|
||||
t.Fatalf("stderr = %q, want exactly the degraded notice %q", stderr, unavailableTestSandboxNotice)
|
||||
}
|
||||
}
|
||||
|
||||
// The notice goes to stderr in the JSON modes as well, so stdout stays one JSON
|
||||
// object per line for whatever reads it.
|
||||
func TestExecKeepsTheDegradedNoticeOffStdoutInJSONMode(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
exitCode, stdout, stderr := runExecWithSandbox(t, []string{"exec", "--output-format", "json", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != unavailableTestSandboxNotice {
|
||||
t.Fatalf("stderr = %q, want exactly the degraded notice", stderr)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("stdout carries a line that is not JSON: %q (%v)", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sandbox the user turned off is their choice, not a degradation.
|
||||
func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
off := false
|
||||
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{Enabled: &off})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("stderr = %q, want nothing for a disabled sandbox", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// countingExecProvider is echoExecProvider, counting the completions it streams.
|
||||
type countingExecProvider struct {
|
||||
echoExecProvider
|
||||
streamed *int
|
||||
}
|
||||
|
||||
func (provider countingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
*provider.streamed++
|
||||
return provider.echoExecProvider.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
// A degraded notice that cannot be written stops the run before the model is
|
||||
// asked for anything, as the image and effort notices do, instead of going ahead
|
||||
// with reduced isolation and no warning delivered.
|
||||
func TestExecStopsWhenTheDegradedNoticeCannotBeWritten(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
isolateCLIUserState(t)
|
||||
var stdout bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
streamed := 0
|
||||
exitCode := runWithDeps([]string{"exec", "hello"}, &stdout, failingWriter{}, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "echo-model",
|
||||
},
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return countingExecProvider{streamed: &streamed}, nil
|
||||
},
|
||||
selectSandboxBackend: unavailableTestSandbox,
|
||||
})
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("exit code = %d, want %d when the degraded notice cannot be written", exitCode, exitCrash)
|
||||
}
|
||||
if streamed != 0 {
|
||||
t.Fatalf("the model was asked %d times: the run went ahead without its warning", streamed)
|
||||
}
|
||||
}
|
||||
|
||||
// launchTUIWithSandbox runs the root command the way a bare `zero` does, with
|
||||
// the sandbox backend pinned to the unavailable one, and returns the options the
|
||||
// TUI would have started with.
|
||||
func launchTUIWithSandbox(t *testing.T, sandboxConfig config.SandboxConfig) tui.Options {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
var launched tui.Options
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 12, Sandbox: sandboxConfig}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider should not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
selectSandboxBackend: unavailableTestSandbox,
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
return launched
|
||||
}
|
||||
|
||||
// The TUI shows the same notice when the session opens, taken from the engine
|
||||
// its commands run through.
|
||||
func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
sandbox config.SandboxConfig
|
||||
want bool
|
||||
}{
|
||||
{name: "degraded", want: true},
|
||||
{name: "turned off", sandbox: config.SandboxConfig{Enabled: func() *bool { off := false; return &off }()}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
launched := launchTUIWithSandbox(t, testCase.sandbox)
|
||||
var notices []string
|
||||
for _, notice := range launched.StartupNotices {
|
||||
if strings.TrimSpace(notice) != "" {
|
||||
notices = append(notices, notice)
|
||||
}
|
||||
}
|
||||
want := strings.TrimSuffix(strings.TrimPrefix(unavailableTestSandboxNotice, "[zero] "), "\n")
|
||||
if testCase.want && (len(notices) != 1 || notices[0] != want) {
|
||||
t.Fatalf("StartupNotices = %q, want exactly %q", notices, want)
|
||||
}
|
||||
if !testCase.want && len(notices) != 0 {
|
||||
t.Fatalf("StartupNotices = %q, want none", notices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ type sessionCommandOptions struct {
|
||||
excludeTarget bool
|
||||
preserveLast int
|
||||
maxPromptChars int
|
||||
olderThan string
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -72,6 +74,11 @@ func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return writeExecUsageError(stderr, "sessions compact-plan requires a session id")
|
||||
}
|
||||
return runSessionsCompactPlan(store, remaining[0], options, stdout, stderr)
|
||||
case "prune":
|
||||
if len(remaining) != 0 {
|
||||
return writeExecUsageError(stderr, "sessions prune does not accept positional arguments")
|
||||
}
|
||||
return runSessionsPrune(store, options, stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown sessions command %q", command))
|
||||
}
|
||||
@@ -91,6 +98,8 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
|
||||
options.json = true
|
||||
case "--exclude-target":
|
||||
options.excludeTarget = true
|
||||
case "--dry-run":
|
||||
options.dryRun = true
|
||||
case "--kind":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -168,6 +177,21 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
|
||||
}
|
||||
options.preserveLast = preserveLast
|
||||
continue
|
||||
case arg == "--older-than":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return command, remaining, options, false, err
|
||||
}
|
||||
options.olderThan = value
|
||||
index = next
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--older-than="):
|
||||
value, err := parseNonEmptySessionsFlag("--older-than", strings.TrimPrefix(arg, "--older-than="))
|
||||
if err != nil {
|
||||
return command, remaining, options, false, err
|
||||
}
|
||||
options.olderThan = value
|
||||
continue
|
||||
case arg == "--max-prompt-chars":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -225,7 +249,7 @@ func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
|
||||
|
||||
func isSessionsCommand(command string) bool {
|
||||
switch command {
|
||||
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan":
|
||||
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan", "prune":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -244,6 +268,9 @@ func validateSessionCommandFlags(command string, options sessionCommandOptions)
|
||||
if hasCompactionFlag && command != "compact-plan" {
|
||||
return execUsageError{"--preserve-last and --max-prompt-chars are only valid for sessions compact-plan"}
|
||||
}
|
||||
if (options.olderThan != "" || options.dryRun) && command != "prune" {
|
||||
return execUsageError{"--older-than and --dry-run are only valid for sessions prune"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -532,6 +559,7 @@ Commands:
|
||||
rewind-plan <id> Preview events kept and dropped by a rewind
|
||||
rewind <id> Restore workspace files and truncate the log to a checkpoint
|
||||
compact-plan <id> Preview events compacted and preserved by compaction
|
||||
prune Remove sessions last updated before a cutoff
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
@@ -541,7 +569,13 @@ Flags:
|
||||
--exclude-target Drop the target event (rewind-plan, rewind)
|
||||
--preserve-last <n> Keep recent events in compact-plan
|
||||
--max-prompt-chars <n> Limit compact-plan summary prompt
|
||||
--older-than <age> Prune cutoff, as days (30d) or a duration (720h); at least 1d.
|
||||
Without it, prune uses sessions.retentionDays from your user config.
|
||||
--dry-run List what prune would remove, and why it keeps the rest
|
||||
-h, --help Show this help
|
||||
|
||||
prune only runs when you run it. It never removes a session another Zero process
|
||||
has open, or an ancestor of a session it keeps, and --dry-run shows why.
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// maxPruneDays keeps a day count from overflowing time.Duration.
|
||||
const maxPruneDays = 36500
|
||||
|
||||
// runSessionsPrune removes sessions last updated before a cutoff, or with
|
||||
// --dry-run lists what would go and why. It runs only when asked (#971).
|
||||
func runSessionsPrune(store *sessions.Store, options sessionCommandOptions, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
olderThan, err := pruneCutoff(options, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
report, err := store.Prune(sessions.PruneOptions{OlderThan: olderThan, DryRun: options.dryRun})
|
||||
if err != nil {
|
||||
return writeSessionCommandError(stderr, err)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(report, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else if _, err := fmt.Fprint(stdout, formatPruneReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if len(report.Failed) > 0 {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// pruneCutoff is --older-than, or else sessions.retentionDays from the user's
|
||||
// own config, and never below sessions.MinimumPruneAge. Project config has no
|
||||
// say in it: see config.SessionsConfig.
|
||||
func pruneCutoff(options sessionCommandOptions, deps appDeps) (time.Duration, error) {
|
||||
var age time.Duration
|
||||
if options.olderThan != "" {
|
||||
parsed, err := parsePruneAge(options.olderThan)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
age = parsed
|
||||
} else {
|
||||
days := 0
|
||||
if deps.userConfigPath != nil {
|
||||
path, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sessions prune could not find your config: %w", err)
|
||||
}
|
||||
settings, err := config.ReadSessionsConfig(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sessions prune could not read sessions.retentionDays: %w", err)
|
||||
}
|
||||
days = settings.RetentionDays
|
||||
}
|
||||
if days == 0 {
|
||||
return 0, execUsageError{"sessions prune needs a cutoff: pass --older-than (for example --older-than 30d) or set sessions.retentionDays in your user config"}
|
||||
}
|
||||
if days > maxPruneDays {
|
||||
return 0, execUsageError{fmt.Sprintf("sessions.retentionDays %d is more than %d", days, maxPruneDays)}
|
||||
}
|
||||
age = time.Duration(days) * 24 * time.Hour
|
||||
}
|
||||
if age < sessions.MinimumPruneAge {
|
||||
return 0, execUsageError{fmt.Sprintf("sessions prune does not remove anything updated in the last %d hours; use --older-than 1d or more", int(sessions.MinimumPruneAge.Hours()))}
|
||||
}
|
||||
return age, nil
|
||||
}
|
||||
|
||||
// parsePruneAge accepts a whole number of days ("30d") or a Go duration
|
||||
// ("720h").
|
||||
func parsePruneAge(value string) (time.Duration, error) {
|
||||
invalid := execUsageError{fmt.Sprintf("invalid --older-than %q: expected days like 30d or a duration like 720h", value)}
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if days, ok := strings.CutSuffix(trimmed, "d"); ok {
|
||||
count, err := strconv.Atoi(days)
|
||||
if err != nil || count <= 0 || count > maxPruneDays {
|
||||
return 0, invalid
|
||||
}
|
||||
return time.Duration(count) * 24 * time.Hour, nil
|
||||
}
|
||||
age, err := time.ParseDuration(trimmed)
|
||||
if err != nil || age <= 0 {
|
||||
return 0, invalid
|
||||
}
|
||||
return age, nil
|
||||
}
|
||||
|
||||
func formatPruneReport(report sessions.PruneReport) string {
|
||||
var out strings.Builder
|
||||
verb := "Removed"
|
||||
if report.DryRun {
|
||||
verb = "Would remove"
|
||||
}
|
||||
if len(report.Removed) == 0 {
|
||||
fmt.Fprintf(&out, "No sessions to remove: nothing last updated before %s can go.\n", report.Cutoff)
|
||||
} else {
|
||||
var total int64
|
||||
for _, entry := range report.Removed {
|
||||
total += entry.Bytes
|
||||
}
|
||||
fmt.Fprintf(&out, "%s %d %s last updated before %s (%s):\n", verb, len(report.Removed), plural(len(report.Removed), "session", "sessions"), report.Cutoff, formatPruneBytes(total))
|
||||
for _, entry := range report.Removed {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Title)))
|
||||
}
|
||||
}
|
||||
if len(report.Kept) > 0 {
|
||||
fmt.Fprintf(&out, "Kept %d old enough to remove:\n", len(report.Kept))
|
||||
for _, entry := range report.Kept {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, entry.Reason))
|
||||
}
|
||||
}
|
||||
if len(report.Failed) > 0 {
|
||||
failed := "Could not remove"
|
||||
if report.DryRun {
|
||||
failed = "Could not check"
|
||||
}
|
||||
fmt.Fprintf(&out, "%s %d:\n", failed, len(report.Failed))
|
||||
for _, entry := range report.Failed {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Reason)))
|
||||
}
|
||||
}
|
||||
if report.DryRun {
|
||||
out.WriteString("Dry run: nothing was removed.\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func formatPruneEntry(entry sessions.PruneEntry, note string) string {
|
||||
updated := entry.UpdatedAt
|
||||
if len(updated) >= len("2006-01-02") {
|
||||
updated = updated[:len("2006-01-02")]
|
||||
}
|
||||
line := redact(entry.SessionID) + " " + updated
|
||||
if note = strings.TrimSpace(note); note != "" {
|
||||
line += " " + note
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func formatPruneBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
value, suffix := float64(bytes)/unit, "KB"
|
||||
for _, next := range []string{"MB", "GB", "TB"} {
|
||||
if value < unit {
|
||||
break
|
||||
}
|
||||
value, suffix = value/unit, next
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
|
||||
func plural(count int, one, many string) string {
|
||||
if count == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// pruneCLIFixture is a session root holding one session last written in 2020
|
||||
// and one written now, neither held open, plus deps whose user config path is
|
||||
// the returned file (which does not exist until a test writes it).
|
||||
func pruneCLIFixture(t *testing.T) (string, string, appDeps) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, session := range []struct {
|
||||
id string
|
||||
at func() time.Time
|
||||
}{
|
||||
{"old-session", func() time.Time { return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) }},
|
||||
{"recent-session", time.Now},
|
||||
} {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: root, Now: session.at})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: session.id, Title: "title of " + session.id}); err != nil {
|
||||
t.Fatalf("create %s: %v", session.id, err)
|
||||
}
|
||||
store.Release(session.id)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{
|
||||
newSessionStore: func() *sessions.Store { return sessions.NewStore(sessions.StoreOptions{RootDir: root}) },
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
}
|
||||
return root, configPath, deps
|
||||
}
|
||||
|
||||
func runPruneCLI(t *testing.T, deps appDeps, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps(append([]string{"sessions"}, args...), &stdout, &stderr, deps)
|
||||
return code, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
func pruneSessionExists(t *testing.T, root, id string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(root, id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TestSessionsPruneDryRunListsWithoutRemoving(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "30d", "--dry-run")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
for _, want := range []string{"Would remove 1 session", "old-session", "Dry run: nothing was removed."} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("output does not contain %q:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, "recent-session") {
|
||||
t.Errorf("the recent session was listed:\n%s", stdout)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a dry run removed the session")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run removes nothing, so a session it could not check is not reported
|
||||
// as one it could not remove.
|
||||
func TestSessionsPruneDryRunReportsFailuresAsUnchecked(t *testing.T) {
|
||||
failed := []sessions.PruneEntry{{SessionID: "old-session", UpdatedAt: "2020-01-01T00:00:00Z", Reason: "the session disappeared while pruning"}}
|
||||
dryRun := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", DryRun: true, Failed: failed})
|
||||
if !strings.Contains(dryRun, "Could not check 1:") || strings.Contains(dryRun, "Could not remove") {
|
||||
t.Errorf("dry run report:\n%s", dryRun)
|
||||
}
|
||||
run := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", Failed: failed})
|
||||
if !strings.Contains(run, "Could not remove 1:") {
|
||||
t.Errorf("report:\n%s", run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneRemovesOnlyOldSessions(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than=30d")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Removed 1 session") || !strings.Contains(stdout, "old-session") {
|
||||
t.Fatalf("output does not report the removal:\n%s", stdout)
|
||||
}
|
||||
if pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("the old session is still there")
|
||||
}
|
||||
if !pruneSessionExists(t, root, "recent-session") {
|
||||
t.Fatal("the recent session was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneReportsJSON(t *testing.T) {
|
||||
_, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "720h", "--json")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
var report sessions.PruneReport
|
||||
if err := json.Unmarshal([]byte(stdout), &report); err != nil {
|
||||
t.Fatalf("output is not a prune report: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(report.Removed) != 1 || report.Removed[0].SessionID != "old-session" || report.DryRun {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneRefusesABadOrShortCutoff(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"12h", "last 24 hours"},
|
||||
{"0d", "invalid --older-than"},
|
||||
{"soon", "invalid --older-than"},
|
||||
{"-5d", "invalid --older-than"},
|
||||
} {
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune", "--older-than="+tc.value)
|
||||
if code != exitUsage || !strings.Contains(stderr, tc.want) {
|
||||
t.Errorf("--older-than %s: exit %d, stderr %q, want a usage error naming %q", tc.value, code, stderr, tc.want)
|
||||
}
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a refused prune removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneNeedsACutoff(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
|
||||
t.Fatalf("exit %d, stderr %q, want a usage error asking for a cutoff", code, stderr)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("prune without a cutoff removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneUsesTheUserRetentionSetting(t *testing.T) {
|
||||
root, configPath, deps := pruneCLIFixture(t)
|
||||
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":30}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Removed 1 session") || pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatalf("the user retention setting was not applied:\n%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// A REPOSITORY CANNOT CHOOSE WHAT PRUNE DELETES. Sessions are the user's, not
|
||||
// the project's, so sessions.retentionDays in a workspace's .zero/config.json
|
||||
// is not a cutoff, even when prune runs from inside that workspace.
|
||||
func TestSessionsPruneIgnoresRetentionInProjectConfig(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
workspace := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, ".zero", "config.json"), []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps.getwd = func() (string, error) { return workspace, nil }
|
||||
t.Chdir(workspace)
|
||||
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
|
||||
t.Fatalf("exit %d, stderr %q: the project's retention setting was used", code, stderr)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a project config setting removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneFlagsBelongToPrune(t *testing.T) {
|
||||
_, _, deps := pruneCLIFixture(t)
|
||||
for _, args := range [][]string{{"list", "--dry-run"}, {"list", "--older-than", "30d"}} {
|
||||
code, _, stderr := runPruneCLI(t, deps, args...)
|
||||
if code != exitUsage || !strings.Contains(stderr, "only valid for sessions prune") {
|
||||
t.Errorf("%v: exit %d, stderr %q", args, code, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty cutoff is a usage error, never a fall back to sessions.retentionDays.
|
||||
func TestSessionsPruneRejectsAnEmptyCutoff(t *testing.T) {
|
||||
root, configPath, deps := pruneCLIFixture(t)
|
||||
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"prune", "--older-than", ""},
|
||||
{"prune", "--older-than", " "},
|
||||
{"prune", "--older-than="},
|
||||
{"list", "--older-than", ""},
|
||||
} {
|
||||
code, _, stderr := runPruneCLI(t, deps, args...)
|
||||
if code != exitUsage || !strings.Contains(stderr, "--older-than requires a value") {
|
||||
t.Errorf("%q: exit %d, stderr %q, want a usage error", args, code, stderr)
|
||||
}
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("an empty cutoff fell back to the retention setting and removed a session")
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,10 @@ func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
|
||||
{"execAdvertisesEscalateModel", func(t *testing.T) {
|
||||
_ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"})
|
||||
}},
|
||||
{"runExecWithSandbox", func(t *testing.T) {
|
||||
_, _, _ = runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
}},
|
||||
{"launchTUIWithSandbox", func(t *testing.T) { _ = launchTUIWithSandbox(t, config.SandboxConfig{}) }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
seedRoot, configPath, seeded, before := seedEmulatedUserConfig(t)
|
||||
|
||||
@@ -244,6 +244,86 @@ func petJSONObject(encodedPet []byte) []byte {
|
||||
return append(result, '}')
|
||||
}
|
||||
|
||||
// setNotifyJSONObject replaces only the notify member's value, keeping the
|
||||
// original bytes of every unrelated member so a notification save cannot
|
||||
// reorder, reformat, or drop the user's other settings — including values whose
|
||||
// EXPLICIT presence matters (tools.deferThreshold: 0, mcp servers'
|
||||
// disabled: false) and keys the typed FileConfig does not model at all. The
|
||||
// typed serializer cannot round-trip explicit zeros (omitempty drops them), so
|
||||
// notification writes go through this byte-preserving editor instead
|
||||
// (maintainer review, PR #1001). An empty notify block removes the member
|
||||
// entirely so a reset leaves no `"notify": {}` husk behind.
|
||||
//
|
||||
// A hand-edited file may contain DUPLICATE notify members. Go's decoder
|
||||
// MERGES the fields of duplicate object members into the same struct, so an
|
||||
// earlier duplicate's field survives a replacement that omits it — e.g.
|
||||
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} + a
|
||||
// replacement {"focusMode":"always"} would leave mode "off" effective.
|
||||
// The nonempty path therefore removes EVERY duplicate and inserts the
|
||||
// replacement fresh (full-replace semantics: nothing inherited), and the
|
||||
// reset path removes every member the same way. UpdateNotify's partial
|
||||
// merge is unaffected: it seeds omitted fields from the stored value BEFORE
|
||||
// calling this editor, so the value it passes is already complete.
|
||||
// Mirrors setPetPreferenceJSON's duplicate handling.
|
||||
func setNotifyJSONObject(data []byte, notify NotifyConfig) ([]byte, error) {
|
||||
encoded, err := json.Marshal(notify)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode notify preference: %w", err)
|
||||
}
|
||||
|
||||
rootStart := skipJSONSpace(data, 0)
|
||||
root, err := parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex := lastJSONMember(root.members, "notify")
|
||||
// Count duplicates: the ordinary file has exactly one notify member, and
|
||||
// for it a positional replace keeps the key where the user put it and
|
||||
// preserves their formatting (byte-preserving contract). The
|
||||
// remove-all-and-reinsert path below is ONLY for files that actually
|
||||
// contain duplicates, where Go's field-merging decode would otherwise let
|
||||
// an earlier block's fields leak into the replacement.
|
||||
duplicates := 0
|
||||
for _, member := range root.members {
|
||||
if member.key == "notify" {
|
||||
duplicates++
|
||||
}
|
||||
}
|
||||
if string(encoded) == "{}" {
|
||||
for notifyIndex >= 0 {
|
||||
data = removeJSONMember(data, root, notifyIndex)
|
||||
rootStart = skipJSONSpace(data, 0)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex = lastJSONMember(root.members, "notify")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if duplicates <= 1 {
|
||||
if notifyIndex < 0 {
|
||||
return insertJSONMember(data, root, "notify", encoded), nil
|
||||
}
|
||||
// Single member: replace its value in place — no key movement, no
|
||||
// reformatting of the surrounding bytes.
|
||||
return replaceJSONRange(data, root.members[notifyIndex].valueStart, root.members[notifyIndex].valueEnd, encoded), nil
|
||||
}
|
||||
// Multiple duplicates: Go merges their fields at decode time, so the
|
||||
// replacement must not inherit anything — remove every duplicate and
|
||||
// insert the complete value fresh.
|
||||
for notifyIndex >= 0 {
|
||||
data = removeJSONMember(data, root, notifyIndex)
|
||||
rootStart = skipJSONSpace(data, 0)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex = lastJSONMember(root.members, "notify")
|
||||
}
|
||||
return insertJSONMember(data, root, "notify", encoded), nil
|
||||
}
|
||||
|
||||
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
|
||||
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
|
||||
result = append(result, data[:start]...)
|
||||
|
||||
@@ -157,8 +157,26 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// On ErrNoActiveProvider, providers may still hold the successfully
|
||||
// normalized (but active-less) profile list — keep it so a caller can fall
|
||||
// back to an already-configured usable provider instead of treating this
|
||||
// like a config with nothing set up at all.
|
||||
return ResolvedConfig{Providers: providers}, err
|
||||
// like a config with nothing set up at all. The non-provider fields parsed
|
||||
// before the failure (notify, sandbox, tools, preferences, ...) are kept
|
||||
// too: the TUI's provider-recovery path forwards this partial config, and
|
||||
// dropping them would let the TUI's unconfigured notify default overwrite
|
||||
// a stored explicit opt-out on startup (maintainer review, PR #1001).
|
||||
partial := ResolvedConfig{
|
||||
Providers: providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
STT: cfg.STT,
|
||||
CrossSessionInbound: cfg.CrossSessionInbound,
|
||||
}
|
||||
return partial, err
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
|
||||
@@ -1151,8 +1151,13 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
if HasProviderProfile(resolved.Provider) {
|
||||
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
|
||||
}
|
||||
if resolved.MaxTurns != 0 {
|
||||
t.Fatalf("MaxTurns = %d, want zero on failed resolve", resolved.MaxTurns)
|
||||
// The error path now carries the parsed non-provider fields through
|
||||
// (maintainer review, PR #1001): the TUI's provider-recovery startup
|
||||
// forwards this partial config, and a zero MaxTurns there would silently
|
||||
// discard the user's turn budget. Matches the no-providers SUCCESS path,
|
||||
// which has always defaulted MaxTurns.
|
||||
if resolved.MaxTurns != defaultMaxTurns {
|
||||
t.Fatalf("MaxTurns = %d, want default %d carried through the partial resolve", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1864,14 +1869,43 @@ func TestResolveNotifyInvalidFocusMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyDefaultEmpty(t *testing.T) {
|
||||
path := writeConfig(t, `{}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
// An unconfigured notify block must resolve EMPTY. Defaults live in the TUI
|
||||
// (effectiveTUINotifyMode) and in `zero config notify`'s display, not in the
|
||||
// resolver: a filled-in default here leaks into headless `zero exec` (BEL +
|
||||
// OSC-9 bytes on stderr under -o json) and into the CLI/TUI preserve paths,
|
||||
// which must treat "user never chose" differently from "user chose both"
|
||||
// (maintainer review, PR #1001).
|
||||
func TestResolveNotifyUnconfiguredStaysEmpty(t *testing.T) {
|
||||
// No config file at all.
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Notify.Mode != "" || resolved.Notify.FocusMode != "" {
|
||||
t.Fatalf("unset notify should be empty, got %+v", resolved.Notify)
|
||||
t.Fatalf("no config file: notify = %+v, want empty (resolver must not default)", resolved.Notify)
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"empty config": `{}`,
|
||||
"empty block": `{"notify":{}}`,
|
||||
"mode only": `{"notify":{"mode":"off"}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: writeConfig(t, body), Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if name == "mode only" {
|
||||
if resolved.Notify.Mode != "off" {
|
||||
t.Errorf("notify.mode = %q, want preserved off", resolved.Notify.Mode)
|
||||
}
|
||||
} else if resolved.Notify.Mode != "" {
|
||||
t.Errorf("notify.mode = %q, want empty (resolver must not default)", resolved.Notify.Mode)
|
||||
}
|
||||
if resolved.Notify.FocusMode != "" {
|
||||
t.Errorf("notify.focusMode = %q, want empty (resolver must not default)", resolved.Notify.FocusMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2254,6 +2288,19 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
// A nil ResolveOptions.Env means "read the real process environment" (see
|
||||
// envValue), so a case that leaves it nil inherits whatever the developer
|
||||
// happens to export. ANTHROPIC_BASE_URL alone is enough: applyProviderEnv
|
||||
// synthesizes a second, keyless "anthropic" profile from a base URL on its
|
||||
// own, the single-provider shortcut in normalizeProviders stops applying,
|
||||
// and Resolve fails with `active provider "" not found` before this test
|
||||
// reaches its assertion. Every variable the resolver reads does it, so the
|
||||
// cases below state an EMPTY environment rather than no environment, and
|
||||
// these three are set to a hostile value so the test proves it.
|
||||
t.Setenv("ANTHROPIC_BASE_URL", "https://proxy.example/v1")
|
||||
t.Setenv("OPENAI_API_KEY", "from-the-developer-shell")
|
||||
t.Setenv(MaxTurnsEnv, "7")
|
||||
|
||||
const providerJSON = `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
|
||||
@@ -2266,10 +2313,15 @@ func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
set bool
|
||||
}{
|
||||
{"built-in default only", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
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},
|
||||
{"user config", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
UserConfigPath: writeConfig(t, providerWithTurns),
|
||||
}, true},
|
||||
{"project config", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
UserConfigPath: writeConfig(t, providerJSON),
|
||||
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
|
||||
}, true},
|
||||
@@ -2278,6 +2330,7 @@ func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, true},
|
||||
{"cli override", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{
|
||||
MaxTurns: 42,
|
||||
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ReadSessionsConfig reads the sessions settings from the config file at path,
|
||||
// which callers pass as the user config. A missing file is an empty config. See
|
||||
// SessionsConfig for why nothing else supplies these settings.
|
||||
func ReadSessionsConfig(path string) (SessionsConfig, error) {
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return SessionsConfig{}, nil
|
||||
}
|
||||
return SessionsConfig{}, err
|
||||
}
|
||||
return cfg.Sessions, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionsConfigRoundTripsAsAKnownKey(t *testing.T) {
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":14}}`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.Sessions.RetentionDays != 14 {
|
||||
t.Fatalf("retentionDays = %d, want 14", cfg.Sessions.RetentionDays)
|
||||
}
|
||||
if _, stray := cfg.Extra["sessions"]; stray {
|
||||
t.Fatal("sessions was kept as an unknown key as well as parsed")
|
||||
}
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"sessions":{"retentionDays":14}`) {
|
||||
t.Fatalf("marshalled config lost the setting: %s", data)
|
||||
}
|
||||
empty, err := json.Marshal(FileConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(empty), "sessions") {
|
||||
t.Fatalf("an unset sessions block was written out: %s", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsConfigRejectsANegativeRetention(t *testing.T) {
|
||||
var cfg FileConfig
|
||||
err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":-1}}`), &cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "retentionDays") {
|
||||
t.Fatalf("a negative retention was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A write that changes something else must not drop the setting.
|
||||
func TestSessionsConfigSurvivesAnUnrelatedWrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"sessions":{"retentionDays":21}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetTheme(path, "dark"); err != nil {
|
||||
t.Fatalf("SetTheme: %v", err)
|
||||
}
|
||||
settings, err := ReadSessionsConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSessionsConfig: %v", err)
|
||||
}
|
||||
if settings.RetentionDays != 21 {
|
||||
t.Fatalf("after an unrelated write retentionDays = %d, want 21", settings.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSessionsConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if settings, err := ReadSessionsConfig(filepath.Join(dir, "missing.json")); err != nil || settings != (SessionsConfig{}) {
|
||||
t.Fatalf("a missing config = %+v, %v; want empty and no error", settings, err)
|
||||
}
|
||||
broken := filepath.Join(dir, "broken.json")
|
||||
if err := os.WriteFile(broken, []byte(`{"sessions":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSessionsConfig(broken); err == nil {
|
||||
t.Fatal("a config that does not parse was read as empty")
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,19 @@ const (
|
||||
STTProviderDeepgram STTProviderKind = "deepgram"
|
||||
)
|
||||
|
||||
// SessionsConfig holds settings for Zero's saved sessions.
|
||||
//
|
||||
// READ FROM THE USER'S OWN CONFIG ONLY. It decides what `zero sessions prune`
|
||||
// deletes, and sessions belong to the user rather than to a repository, so a
|
||||
// project's .zero/config.json has no say: the resolver does not merge it, and
|
||||
// the prune command reads it straight from the user config file.
|
||||
type SessionsConfig struct {
|
||||
// RetentionDays is the cutoff `zero sessions prune` uses when it is given no
|
||||
// --older-than. Unset (0) means prune needs an explicit cutoff; nothing is
|
||||
// ever removed without one being asked for.
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
// STTConfig configures speech-to-text dictation. All fields are optional; empty
|
||||
// values take the documented defaults. Booleans that need a real tri-state
|
||||
// (distinguishing "unset" from "false") use *bool, matching PreferencesConfig.Recaps.
|
||||
@@ -364,6 +377,7 @@ type FileConfig struct {
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
Sessions SessionsConfig `json:"sessions,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.
|
||||
@@ -388,6 +402,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT *STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
Sessions *SessionsConfig `json:"sessions,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
@@ -408,6 +423,9 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
if !cfg.STT.Empty() {
|
||||
raw.STT = &cfg.STT
|
||||
}
|
||||
if cfg.Sessions != (SessionsConfig{}) {
|
||||
raw.Sessions = &cfg.Sessions
|
||||
}
|
||||
known, err := json.Marshal(raw)
|
||||
if err != nil || len(cfg.Extra) == 0 {
|
||||
return known, err
|
||||
@@ -546,6 +564,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
STT STTConfig `json:"stt"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound"`
|
||||
Sessions SessionsConfig `json:"sessions"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
@@ -587,6 +606,10 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
cfg.STT = raw.STT
|
||||
cfg.CrossSessionInbound = raw.CrossSessionInbound
|
||||
if raw.Sessions.RetentionDays < 0 {
|
||||
return fmt.Errorf("invalid sessions.retentionDays %d: must be >= 0", raw.Sessions.RetentionDays)
|
||||
}
|
||||
cfg.Sessions = raw.Sessions
|
||||
cfg.Extra = extra
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
@@ -756,6 +757,146 @@ func SetTheme(path string, theme string) (result FileConfig, err error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// UserNotify returns the notify block stored in the user's own config file at
|
||||
// path, trimmed. A missing file or missing/empty block returns the zero value:
|
||||
// callers that need to preserve "whatever the user already chose" on a partial
|
||||
// update must seed from THIS value, not from the resolved view — the resolver
|
||||
// merges project config and the TUI applies its own defaults, so seeding from
|
||||
// resolved copies choices the user never made into their global file (a
|
||||
// project's mode: off, or a pinned default) and across every other project.
|
||||
func UserNotify(path string) (NotifyConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return NotifyConfig{}, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NotifyConfig{}, nil
|
||||
}
|
||||
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
cfg.Notify.Mode = strings.TrimSpace(cfg.Notify.Mode)
|
||||
cfg.Notify.FocusMode = strings.TrimSpace(cfg.Notify.FocusMode)
|
||||
return cfg.Notify, nil
|
||||
}
|
||||
|
||||
// validateNotify trims both fields and checks them against the accepted vocab
|
||||
// (mode in {off,bell,notify,both}; focusMode in {unfocused,always,focused}) so
|
||||
// a bad caller cannot write a value the resolver would later reject at
|
||||
// startup. An empty Mode or FocusMode is stored as-is — blank means "use the
|
||||
// built-in defaults" (the TUI's effectiveTUINotifyMode maps an empty mode to
|
||||
// both; the notify package treats an empty focusMode as unfocused), not "off".
|
||||
func validateNotify(value NotifyConfig) (NotifyConfig, error) {
|
||||
value.Mode = strings.TrimSpace(value.Mode)
|
||||
value.FocusMode = strings.TrimSpace(value.FocusMode)
|
||||
if mode := value.Mode; mode != "" {
|
||||
switch notify.Mode(mode) {
|
||||
case notify.ModeOff, notify.ModeBell, notify.ModeNotify, notify.ModeBoth:
|
||||
default:
|
||||
return NotifyConfig{}, fmt.Errorf("invalid notify.mode %q: expected off, bell, notify, or both", mode)
|
||||
}
|
||||
}
|
||||
if focus := value.FocusMode; focus != "" {
|
||||
switch notify.FocusMode(focus) {
|
||||
case notify.FocusUnfocused, notify.FocusAlways, notify.FocusFocused:
|
||||
default:
|
||||
return NotifyConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focus)
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// UpdateNotify runs one SERIALIZED read-modify-write transaction on the user's
|
||||
// notify block. merge receives the currently stored block and returns the
|
||||
// block to store; it must preserve any field the caller is not explicitly
|
||||
// changing. The lock covers reading the stored block, applying merge, and
|
||||
// replacing the file, so two concurrent partial updates (for example
|
||||
// `--mode off` and `--focus always` from two terminals) cannot interleave
|
||||
// their reads and writes and silently undo each other's explicit change
|
||||
// (maintainer review, PR #1001 reproduced a 50/50 lost-update without it).
|
||||
//
|
||||
// The write replaces ONLY the notify member's bytes (setNotifyJSONObject),
|
||||
// preserving every unrelated value and its explicit presence — including
|
||||
// explicit zeros/falses like tools.deferThreshold: 0 or an MCP server's
|
||||
// disabled: false, which the typed serializer's omitempty cannot round-trip —
|
||||
// through the existing temp-file-and-rename atomic publish. Locking only the
|
||||
// final write would leave the stale-field merge outside the transaction, so
|
||||
// the whole sequence runs under one lock — the SAME lockConfigFileFn authority
|
||||
// every other config mutator uses (provider/theme/credential/MCP writes), not
|
||||
// a private lock: two authorities over the same file would not contend
|
||||
// (maintainer review, PR #1001). The release error is joined into the
|
||||
// result like every other writer: a failed unlock would leave the advisory
|
||||
// lock held and later updates blocked while the caller is told success.
|
||||
func UpdateNotify(path string, merge func(current NotifyConfig) NotifyConfig) (result NotifyConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return NotifyConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
if merge == nil {
|
||||
return NotifyConfig{}, fmt.Errorf("merge function is required")
|
||||
}
|
||||
release, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed release would claim a state the
|
||||
// next update cannot reproduce.
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
|
||||
current, err := UserNotify(path)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
next, err := validateNotify(merge(current))
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
|
||||
// Read the raw file and edit only the notify member. A missing file starts
|
||||
// from an empty object so a first-time write does not need a pre-seeded
|
||||
// config.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
data = []byte("{}")
|
||||
}
|
||||
updated, err := setNotifyJSONObject(data, next)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
if err := writeConfigData(path, updated); err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// SetNotify replaces the stored notification preference with value. It is a
|
||||
// thin wrapper over the transactional UpdateNotify so existing callers keep
|
||||
// their replace semantics under the same lock and byte-preserving write.
|
||||
func SetNotify(path string, value NotifyConfig) (FileConfig, error) {
|
||||
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Re-read the full config so callers that inspect unrelated fields (and
|
||||
// existing tests) keep their FileConfig-shaped result.
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg.Notify = next
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetPet persists only the terminal-pet preference while preserving every
|
||||
// unrelated user setting through the config writer's atomic replace path.
|
||||
func SetPet(path string, pet string) (result FileConfig, err error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -426,6 +428,269 @@ func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
|
||||
if err != nil {
|
||||
t.Fatalf("SetNotify() error = %v", err)
|
||||
}
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Fatalf("Notify = %+v, want mode=both focusMode=unfocused (trimmed)", cfg.Notify)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Notify.Mode != "both" || persisted.Notify.FocusMode != "unfocused" {
|
||||
t.Fatalf("persisted Notify = %+v, want mode=both focusMode=unfocused", persisted.Notify)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
|
||||
t.Fatalf("provider config was not preserved by SetNotify: %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsInvalidMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsInvalidFocusMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.focusMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsEmptyConfigPath(t *testing.T) {
|
||||
if _, err := SetNotify("", NotifyConfig{Mode: "off"}); err == nil {
|
||||
t.Fatal("expected error for empty config path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
|
||||
// An empty mode/focusMode stored on disk is a valid "use the built-in
|
||||
// defaults" signal — SetNotify must not reject blanks, and they must round
|
||||
// trip unchanged.
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := SetNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("SetNotify({}) should accept blank values, got error: %v", err)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Notify.Mode != "" || persisted.Notify.FocusMode != "" {
|
||||
t.Fatalf("blank notify values should round-trip, got %+v", persisted.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a notification save must preserve unrelated
|
||||
// values whose EXPLICIT presence matters. The typed serializer's omitempty
|
||||
// cannot round-trip tools.deferThreshold: 0 (explicit "never defer" vs unset
|
||||
// "use default 3") or an MCP server's disabled: false (explicitly enabled), so
|
||||
// SetNotify/UpdateNotify edit only the notify member's bytes instead. The
|
||||
// assertions run against the RAW file, because reading through FileConfig
|
||||
// would hide exactly the presence loss being tested.
|
||||
func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"explicit zero deferThreshold": `{
|
||||
"tools": {"deferThreshold": 0},
|
||||
"mcp": {"servers": {"firecrawl": {"command": "npx", "disabled": false}}}
|
||||
}`,
|
||||
"unknown keys stay untouched": `{
|
||||
"activeProvider": "openai",
|
||||
"customTopLevel": {"nested": [1, 2, 3]},
|
||||
"notify": {"mode": "both"}
|
||||
}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(dir, name+".json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if strings.Contains(name, "explicit zero") {
|
||||
tools, ok := obj["tools"]
|
||||
if !ok || !strings.Contains(string(tools), `"deferThreshold": 0`) {
|
||||
t.Errorf("tools.deferThreshold: 0 lost through a notify write; tools = %s", string(tools))
|
||||
}
|
||||
mcp, ok := obj["mcp"]
|
||||
if !ok || !strings.Contains(string(mcp), `"disabled": false`) {
|
||||
t.Errorf("mcp disabled: false lost through a notify write; mcp = %s", string(mcp))
|
||||
}
|
||||
}
|
||||
if strings.Contains(name, "unknown keys") {
|
||||
if _, ok := obj["customTopLevel"]; !ok {
|
||||
t.Error("unknown top-level key lost through a notify write")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, CodeRabbit follow-up): a hand-edited config
|
||||
// may contain DUPLICATE notify members (JSON decoders tolerate them and the
|
||||
// last occurrence wins). A reset must remove EVERY notify member — removing
|
||||
// only the last leaves the earlier block as the new effective preference, so
|
||||
// `zero config notify --reset` would report success while the old value still
|
||||
// applies.
|
||||
func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
duplicated := `{"notify":{"mode":"off"},"activeProvider":"openai","notify":{"mode":"bell"}}`
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SetNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("SetNotify({}) reset: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, still := obj["notify"]; still {
|
||||
t.Fatalf("reset left a notify member behind: %s", string(raw))
|
||||
}
|
||||
if obj["activeProvider"] == nil || string(obj["activeProvider"]) != `"openai"` {
|
||||
t.Errorf("unrelated member lost through the reset: %s", string(raw))
|
||||
}
|
||||
|
||||
// A reset on a file with NO notify member is a no-op, and a partial update
|
||||
// against duplicates replaces the LAST member (the effective one under
|
||||
// last-occurrence-wins decoding).
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify replace: %v", err)
|
||||
}
|
||||
raw, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("UserNotify: %v", err)
|
||||
}
|
||||
if stored.Mode != "off" || stored.FocusMode != "always" {
|
||||
t.Fatalf("after replace, effective notify = %+v, want off/always (last member replaced)", stored)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"activeProvider"`) {
|
||||
t.Errorf("unrelated member lost through the replace: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): concurrent partial updates must not lose
|
||||
// each other's explicit change. Two `zero config notify` calls (--mode off and
|
||||
// --focus always) racing from both/unfocused previously interleaved their
|
||||
// read-merge-write and one write silently undid the other (reproduced 50/50
|
||||
// without the lock). UpdateNotify serializes the whole transaction.
|
||||
func TestUpdateNotifyConcurrentPartialUpdatesLoseNoField(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
|
||||
current.Mode = "off"
|
||||
return current
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("mode update: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
|
||||
current.FocusMode = "always"
|
||||
return current
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("focus update: %w", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent update failed: %v", err)
|
||||
}
|
||||
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read stored: %v", err)
|
||||
}
|
||||
// BOTH explicit changes must survive: mode from writer 1, focus from
|
||||
// writer 2. The old race let the second writer's stale focus
|
||||
// ("unfocused") or stale mode ("both") win.
|
||||
if stored.Mode != "off" {
|
||||
t.Errorf("mode = %q, want off (concurrent focus update must not clobber it)", stored.Mode)
|
||||
}
|
||||
if stored.FocusMode != "always" {
|
||||
t.Errorf("focusMode = %q, want always (concurrent mode update must not clobber it)", stored.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// UserNotify reads the notify block from the user's own file. Partial updates
|
||||
// seed from this value so they preserve what the USER chose (blank included)
|
||||
// instead of copying a project config's setting or a pinned default into the
|
||||
// global file (maintainer review, PR #1001).
|
||||
func TestUserNotify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Missing file: zero value, no error.
|
||||
got, err := UserNotify(filepath.Join(dir, "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing file should not error: %v", err)
|
||||
}
|
||||
if got.Mode != "" || got.FocusMode != "" {
|
||||
t.Fatalf("missing file = %+v, want zero value", got)
|
||||
}
|
||||
|
||||
// Present block: trimmed values returned.
|
||||
path := filepath.Join(dir, "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{Notify: NotifyConfig{Mode: " bell ", FocusMode: " always "}}, 0o600)
|
||||
got, err = UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("UserNotify: %v", err)
|
||||
}
|
||||
if got.Mode != "bell" || got.FocusMode != "always" {
|
||||
t.Fatalf("UserNotify = %+v, want bell/always (trimmed)", got)
|
||||
}
|
||||
|
||||
// Blank path: zero value, no error.
|
||||
if got, err = UserNotify(""); err != nil || got.Mode != "" || got.FocusMode != "" {
|
||||
t.Fatalf("blank path = %+v err=%v, want zero value", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecapsPreferenceRoundTrips(t *testing.T) {
|
||||
// Default (unset) is ON.
|
||||
if !(PreferencesConfig{}).RecapsEnabled() {
|
||||
@@ -1078,3 +1343,109 @@ func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
|
||||
t.Fatalf("config was rewritten by a rejected edit")
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): notify writes must contend with EXISTING
|
||||
// config mutators, not only each other. UpdateNotify originally took a private
|
||||
// lock (acquireConfigLock); on Windows its LockFileEx range did not overlap
|
||||
// lockutil's, so `zero config notify --mode off` could race a theme (or
|
||||
// provider/credential/MCP) write and silently discard it — the later rename
|
||||
// wins and the other acknowledged change is gone. UpdateNotify now uses the
|
||||
// same lockConfigFileFn authority as every other mutator; this race holds it
|
||||
// against SetTheme and requires BOTH acknowledged edits to survive.
|
||||
func TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
|
||||
return NotifyConfig{Mode: "off", FocusMode: "always"}
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("notify write: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := SetTheme(path, "dracula"); err != nil {
|
||||
errs <- fmt.Errorf("theme write: %w", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := readConfigFixture(t, path)
|
||||
if cfg.Notify.Mode != "off" || cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("notify lost the race: %+v, want off/always — the two writers did not contend on one lock", cfg.Notify)
|
||||
}
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
t.Errorf("theme lost the race: %q, want dracula — the two writers did not contend on one lock", cfg.Preferences.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, jatmn review): Go's decoder MERGES the
|
||||
// fields of duplicate JSON members into one struct, so a full replacement that
|
||||
// omits a field could inherit it from an earlier duplicate —
|
||||
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
|
||||
// SetNotify({FocusMode:"always"}) must yield blank/always, not off/always.
|
||||
// The editor removes every duplicate and inserts the replacement fresh.
|
||||
func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
duplicated := `{"notify":{"mode":"off"},"notify":{"focusMode":"focused"},"activeProvider":"openai"}`
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SetNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Mode != "" || stored.FocusMode != "always" {
|
||||
t.Fatalf("replacement inherited a field from an earlier duplicate: %+v, want blank/always", stored)
|
||||
}
|
||||
|
||||
// Unrelated bytes still survive the rewrite.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"activeProvider":"openai"`) {
|
||||
t.Fatalf("unrelated member lost through the replace: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, Vasanthdev2004 review): the duplicate-key
|
||||
// fix must not reach the ORDINARY single-member file. Removing and re-inserting
|
||||
// the notify member there moved the key to the end of the object and collapsed
|
||||
// its formatting, so every /notify toggle reordered the user's config — JSON
|
||||
// member order carries no meaning, but a byte-preserving write should not
|
||||
// reformat anything it does not have to. A single notify member is replaced
|
||||
// in place: key position and surrounding formatting unchanged.
|
||||
func TestSetNotifySingleMemberReplacedInPlace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
original := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\": \"both\", \"focusMode\": \"unfocused\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\":\"off\",\"focusMode\":\"always\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
|
||||
if string(raw) != want {
|
||||
t.Fatalf("single-member replace moved or reformatted unrelated bytes:\n got: %q\nwant: %q", string(raw), want)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,61 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
)
|
||||
|
||||
func TestAppendRunRetainsNewestThousand(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Expr: "* * * * *", Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed an oversized legacy log, then cross the boundary again after compaction.
|
||||
var history bytes.Buffer
|
||||
for i := 0; i < 1207; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, next := range []int{1207, 1208} {
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: next}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
|
||||
if len(lines) != 1000 {
|
||||
t.Fatalf("retained %d records, want 1000", len(lines))
|
||||
}
|
||||
for i, line := range lines {
|
||||
var rec RunRecord
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.ExitCode != next-999+i {
|
||||
t.Fatalf("record %d = %d, want %d", i, rec.ExitCode, next-999+i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AppendRun on a job removed mid-run must NOT resurrect its directory (which the
|
||||
// old unconditional MkdirAll did, leaving an orphaned runs.jsonl with no
|
||||
// metadata.json).
|
||||
@@ -24,7 +75,7 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
|
||||
if _, err := os.Stat(store.jobDir(job.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("AppendRun resurrected the removed job directory (stat err = %v)", err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID)
|
||||
runs, err := store.Runs(job.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Runs: %v", err)
|
||||
}
|
||||
@@ -32,3 +83,256 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
|
||||
t.Fatalf("expected no runs for a removed job, got %d", len(runs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunsTail(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var history bytes.Buffer
|
||||
// A forward scan would fail on this old oversized line. A recent-window
|
||||
// reader must stop before reaching it, rather than merely cap its output.
|
||||
history.WriteString(strings.Repeat("x", 1024*1024+1) + "\n")
|
||||
for i := 0; i < 1103; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i, SessionTitle: strings.Repeat("界", 1500)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
history.WriteString("bad json\n\n{\"exitCode\":1103}") // no final newline
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, limit := range []int{1, 7, 999, 1000, 1001, 0, -1} {
|
||||
runs, err := store.Runs(job.ID, limit)
|
||||
want := limit
|
||||
if want <= 0 || want > 1000 {
|
||||
want = 1000
|
||||
}
|
||||
if err != nil || len(runs) != want {
|
||||
t.Fatalf("limit %d: got %d records, err %v", limit, len(runs), err)
|
||||
}
|
||||
for i, run := range runs {
|
||||
if run.ExitCode != 1104-want+i {
|
||||
t.Fatalf("limit %d, record %d = %d", limit, i, run.ExitCode)
|
||||
}
|
||||
if run.ExitCode != 1103 && run.SessionTitle != strings.Repeat("界", 1500) {
|
||||
t.Fatal("record spanning read blocks was corrupted")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRunFailurePreservesHistory(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
for _, data := range []string{"{\"exitCode\":7}\n", strings.Repeat("x", 1024*1024)} {
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := RunRecord{ExitCode: 8, Error: strings.Repeat("x", 1024*1024)}
|
||||
if err := store.AppendRun(job.ID, rec); err == nil {
|
||||
t.Fatal("expected oversized new record error")
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != data {
|
||||
t.Fatalf("failed append changed history: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRunWithOversizedHistory(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
// An oversized unterminated legacy record must not block future outcomes.
|
||||
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxRunBytes)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 1)
|
||||
if err != nil || len(runs) != 1 || runs[0].ExitCode != 42 {
|
||||
t.Fatalf("new outcome lost: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunsSkipOversizedLines(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
for _, size := range []int{maxRunBytes - 1, maxRunBytes, maxRunBytes + 4096} {
|
||||
// Valid JSON plus trailing whitespace catches accidental decoding of a
|
||||
// prefix after dropping only the oversized suffix. The exact boundary
|
||||
// also distinguishes an oversized line from the largest allowed line.
|
||||
line := `{"exitCode":99}`
|
||||
line += strings.Repeat(" ", size-len(line))
|
||||
data := line + "\n{\"exitCode\":7}\n" + line + "\n{\"exitCode\":42}\n" + line
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
want := []int{7, 42}
|
||||
if size < maxRunBytes {
|
||||
want = []int{99, 7, 99, 42, 99}
|
||||
}
|
||||
if err != nil || len(runs) != len(want) {
|
||||
t.Fatalf("size %d: got %+v, err %v; want %v", size, runs, err, want)
|
||||
}
|
||||
for i, rec := range runs {
|
||||
if rec.ExitCode != want[i] {
|
||||
t.Fatalf("size %d: record %d = %d, want %d", size, i, rec.ExitCode, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An outside reader holding runs.jsonl open makes the Windows replace fail;
|
||||
// the outcome must still be recorded rather than dropped.
|
||||
func TestAppendRunWhileHistoryHeldOpen(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatalf("AppendRun with history held open: %v", err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("outcome lost while history held open: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a sharing or lock violation falls back to appending. Any other replace
|
||||
// failure, including a persistent access denial, and a committed replacement
|
||||
// whose backup cleanup failed must leave the log untouched, so history can
|
||||
// neither grow past retention nor record an outcome twice.
|
||||
func TestAppendRunFallbackOnlyForSharingViolation(t *testing.T) {
|
||||
const seed = "{\"exitCode\":7}\n"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
append bool
|
||||
}{
|
||||
{"sharing violation", syscall.Errno(32), runtime.GOOS == "windows"},
|
||||
{"access denied", os.ErrPermission, false},
|
||||
{"other", errors.New("boom"), false},
|
||||
{"committed", &fsutil.CommittedReplacementCleanupError{Cause: syscall.Errno(32)}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte(seed), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.replace = func(string, string) error { return tc.err }
|
||||
err = store.AppendRun(job.ID, RunRecord{ExitCode: 42})
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if tc.append {
|
||||
if err != nil || !strings.HasPrefix(string(data), seed) || !strings.Contains(string(data), "\"exitCode\":42") {
|
||||
t.Fatalf("sharing violation dropped the outcome: err %v, log %q", err, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tc.err) {
|
||||
t.Fatalf("AppendRun error = %v, want %v", err, tc.err)
|
||||
}
|
||||
if string(data) != seed {
|
||||
t.Fatalf("non-transient replace failure changed the log: %q", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The fallback append must start a fresh line even when the log ends in an
|
||||
// unterminated record, so neither record is corrupted.
|
||||
func TestAppendRunLineAfterUnterminatedTail(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := appendRunLine(path, []byte("{\"exitCode\":42}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("fallback append corrupted history: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHistoryConcurrentStores(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var history bytes.Buffer
|
||||
for i := 0; i < 1000; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: -1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.jobDir(job.ID), "runs.jsonl"), history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Go(func() {
|
||||
other := NewStore(StoreOptions{RootDir: store.root})
|
||||
if err := other.AppendRun(job.ID, RunRecord{ExitCode: i}); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
runs, err := other.Runs(job.ID, 1000)
|
||||
if err != nil || len(runs) != 1000 {
|
||||
t.Errorf("inconsistent history: %d records, err %v", len(runs), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
runs, err := store.Runs(job.ID, 12)
|
||||
if err != nil || len(runs) != 12 {
|
||||
t.Fatalf("final tail: %d records, err %v", len(runs), err)
|
||||
}
|
||||
seen := make(map[int]bool)
|
||||
for _, run := range runs {
|
||||
if run.ExitCode < 0 || run.ExitCode >= 12 || seen[run.ExitCode] {
|
||||
t.Fatalf("lost or duplicate concurrent append: %+v", runs)
|
||||
}
|
||||
seen[run.ExitCode] = true
|
||||
}
|
||||
}
|
||||
+127
-20
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusPaused = "paused"
|
||||
|
||||
// MaxRunHistory is the number of recent outcomes retained per job.
|
||||
MaxRunHistory = 1000
|
||||
maxRunBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
// ErrJobNotFound is returned (wrapped) by Get when a job's metadata file is
|
||||
@@ -56,6 +61,9 @@ type StoreOptions struct {
|
||||
type Store struct {
|
||||
root string
|
||||
now func() time.Time
|
||||
// replace overrides the run-history replacement primitive in tests; nil
|
||||
// uses the platform default.
|
||||
replace func(src, dst string) error
|
||||
}
|
||||
|
||||
func NewStore(opts StoreOptions) *Store {
|
||||
@@ -228,8 +236,7 @@ func (s *Store) Update(job Job) error {
|
||||
// Remove). It re-reads the CURRENT on-disk job and passes it to mutate along with
|
||||
// any read error; mutate returns the job to persist. A removed job aborts with
|
||||
// ErrJobNotFound (no recreate). A transient read error (not removal) is surfaced
|
||||
// via readErr so the caller can still persist a best-effort state — the fire path
|
||||
// advances the schedule regardless, to avoid a re-fire.
|
||||
// via readErr; the callback can return it to abort without overwriting state.
|
||||
func (s *Store) Mutate(id string, mutate func(current Job, readErr error) (Job, error)) (Job, error) {
|
||||
if !validID(id) {
|
||||
return Job{}, fmt.Errorf("invalid cron job id %q", id)
|
||||
@@ -309,10 +316,8 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
// Bail if the job was removed (e.g. mid-run) — otherwise the MkdirAll below
|
||||
// would resurrect a deleted job's directory with an orphaned runs.jsonl and no
|
||||
// metadata.json (which Runs would then still return). Under the per-job lock
|
||||
// this stat-then-write is race-free against a concurrent Remove.
|
||||
// Do not resurrect a job removed mid-run. The lock serializes this check
|
||||
// and publication against Remove and other history writers/readers.
|
||||
if _, err := os.Stat(filepath.Join(s.jobDir(id), "metadata.json")); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
@@ -320,30 +325,98 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
|
||||
return err
|
||||
}
|
||||
dir := s.jobDir(id)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "runs.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(line) >= maxRunBytes {
|
||||
return fmt.Errorf("cron run record exceeds %d bytes", maxRunBytes-1)
|
||||
}
|
||||
runs, err := s.readRuns(id, MaxRunHistory-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Publish a complete snapshot even below the retention boundary so readers
|
||||
// never see a partial append. Legacy oversized logs compact on their next run.
|
||||
f, err := os.CreateTemp(dir, "runs-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
w := bufio.NewWriter(f)
|
||||
for _, run := range runs {
|
||||
if err := json.NewEncoder(w).Encode(run); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(append(line, '\n')); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
if err := w.Flush(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
// Surface a buffered-write failure that only materializes on Close.
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "runs.jsonl")
|
||||
// ReplaceWithRetry keeps a DACL applied to runs.jsonl itself; a rename would
|
||||
// publish the temporary file's inherited directory DACL instead.
|
||||
err = fsutil.ReplaceWithRetry(f.Name(), path, s.replace)
|
||||
var committed *fsutil.CommittedReplacementCleanupError
|
||||
if err == nil || errors.As(err, &committed) || !fsutil.IsSharingOrLockViolation(err) {
|
||||
return err
|
||||
}
|
||||
// Windows refuses to replace a log another process holds open. Append the
|
||||
// record instead so the outcome survives; compaction resumes on a later run.
|
||||
// Other failures are returned so a log that can never be replaced cannot
|
||||
// grow past the retention limit. The leading newline ends any unterminated
|
||||
// tail, and readRuns skips the blank line it may leave. Zero's own readers
|
||||
// hold the job lock.
|
||||
if appendErr := appendRunLine(path, line); appendErr != nil {
|
||||
return errors.Join(err, appendErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendRunLine(path string, line []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := append(append([]byte{'\n'}, line...), '\n')
|
||||
if _, err := f.Write(record); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (s *Store) Runs(id string) ([]RunRecord, error) {
|
||||
// Runs returns the newest limit valid records in append order (oldest first).
|
||||
// Non-positive limits and limits above MaxRunHistory use MaxRunHistory. It reads
|
||||
// backwards from the tail, including for legacy logs not yet compacted.
|
||||
func (s *Store) Runs(id string, limit int) ([]RunRecord, error) {
|
||||
if !validID(id) {
|
||||
return nil, fmt.Errorf("invalid cron job id %q", id)
|
||||
}
|
||||
if limit <= 0 || limit > MaxRunHistory {
|
||||
limit = MaxRunHistory
|
||||
}
|
||||
unlock, err := s.lockJob(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
return s.readRuns(id, limit)
|
||||
}
|
||||
|
||||
// readRuns requires the job lock. Memory is bounded by limit records plus one
|
||||
// line. Malformed and oversized legacy lines are skipped so they cannot prevent
|
||||
// recording new outcomes during compaction.
|
||||
func (s *Store) readRuns(id string, limit int) ([]RunRecord, error) {
|
||||
f, err := os.Open(filepath.Join(s.jobDir(id), "runs.jsonl"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
@@ -352,14 +425,48 @@ func (s *Store) Runs(id string) ([]RunRecord, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var runs []RunRecord
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
var line []byte
|
||||
oversized := false
|
||||
decode := func() {
|
||||
slices.Reverse(line)
|
||||
var rec RunRecord
|
||||
if json.Unmarshal(scanner.Bytes(), &rec) == nil {
|
||||
if json.Unmarshal(line, &rec) == nil {
|
||||
runs = append(runs, rec)
|
||||
}
|
||||
line = line[:0]
|
||||
}
|
||||
return runs, scanner.Err()
|
||||
var block [4096]byte
|
||||
for end := info.Size(); end > 0 && len(runs) < limit; {
|
||||
start := max(int64(0), end-int64(len(block)))
|
||||
n, err := f.ReadAt(block[:end-start], start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := n - 1; i >= 0 && len(runs) < limit; i-- {
|
||||
if block[i] == '\n' {
|
||||
if !oversized {
|
||||
decode()
|
||||
}
|
||||
oversized = false
|
||||
} else if !oversized {
|
||||
if len(line) >= maxRunBytes-1 {
|
||||
line = line[:0]
|
||||
oversized = true
|
||||
continue
|
||||
}
|
||||
line = append(line, block[i])
|
||||
}
|
||||
}
|
||||
end = start
|
||||
}
|
||||
if len(runs) < limit && len(line) > 0 {
|
||||
decode()
|
||||
}
|
||||
slices.Reverse(runs)
|
||||
return runs, nil
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func TestStoreAppendRun(t *testing.T) {
|
||||
t.Fatalf("AppendRun: %v", err)
|
||||
}
|
||||
}
|
||||
runs, err := s.Runs(job.ID)
|
||||
runs, err := s.Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 3 || runs[2].ExitCode != 2 {
|
||||
t.Fatalf("Runs=%v err=%v", runs, err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
|
||||
if _, err := s.Get(id); err == nil {
|
||||
t.Fatalf("Get(%q) must be rejected", id)
|
||||
}
|
||||
if _, err := s.Runs(id); err == nil {
|
||||
if _, err := s.Runs(id, 0); err == nil {
|
||||
t.Fatalf("Runs(%q) must be rejected", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package cron
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Compacting runs.jsonl publishes a temporary file carrying the job directory's
|
||||
// inherited DACL. A rename would replace a restrictive DACL applied to the log
|
||||
// itself, so AppendRun must keep the log's own descriptor.
|
||||
func TestAppendRunPreservesHistoryDACL(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A protected DACL granting only the owner: distinct from whatever the
|
||||
// temporary file inherits from the directory.
|
||||
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
|
||||
if err != nil {
|
||||
t.Skipf("cannot build a test security descriptor: %v", err)
|
||||
}
|
||||
dacl, _, err := restricted.DACL()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read the test 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 a restrictive DACL on this filesystem: %v", err)
|
||||
}
|
||||
want := describeDACL(t, path)
|
||||
if inherited := describeDACL(t, store.jobDir(job.ID)); strings.Contains(inherited, want) {
|
||||
t.Skip("the job directory already carries the same DACL; this filesystem cannot show the difference")
|
||||
}
|
||||
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := describeDACL(t, path); got != want {
|
||||
t.Fatalf("DACL after AppendRun = %q, want the log's own %q", got, want)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("outcome not published by replacement: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func describeDACL(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Skipf("cannot read the security descriptor of %s: %v", path, err)
|
||||
}
|
||||
text := sd.String()
|
||||
if index := strings.Index(text, "D:"); index >= 0 {
|
||||
return text[index:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -72,9 +72,10 @@ func (w *execWorker) Kill() error {
|
||||
if w.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// background.TerminateProcess is the cross-platform terminate (kills the
|
||||
// process group on POSIX, taskkill /T on Windows).
|
||||
return background.TerminateProcess(w.cmd.Process.Pid)
|
||||
// The group the worker was launched into, not one rediscovered from its PID:
|
||||
// the pool may call this after the worker has exited and been waited, and its
|
||||
// children can still be running. See background.TerminateCommandGroup.
|
||||
return background.TerminateCommandGroup(w.cmd)
|
||||
}
|
||||
|
||||
// readerLines adapts a bufio.Reader to the Lines interface. Unlike a capped
|
||||
@@ -154,13 +155,11 @@ func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
// CommandContext's default cancel sends os.Process.Kill to the LEADER only,
|
||||
// orphaning the process group we just configured (a stuck worker's children
|
||||
// would survive ctx cancellation). Terminate the whole group instead — the
|
||||
// same cross-platform group terminate Kill() uses (D11).
|
||||
// would survive ctx cancellation). Terminate the whole group instead, the
|
||||
// same launch-time group terminate Kill() uses (D11). Cancel runs while Wait
|
||||
// is in progress, so it must not reap.
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return background.TerminateProcess(cmd.Process.Pid)
|
||||
return background.TerminateCommandGroup(cmd)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A WORKER'S CHILDREN ARE STOPPED THROUGH THE GROUP IT WAS LAUNCHED INTO.
|
||||
//
|
||||
// Kill and the Cancel hook used to hand the worker's PID to
|
||||
// background.TerminateProcess, which looks the group up again with Getpgid when it
|
||||
// signals. Once the worker has exited and been waited, that lookup fails, only the
|
||||
// dead PID is signalled, and whatever the worker left running survives; on Darwin
|
||||
// the lookup already fails while the exited worker is still a zombie. The worker
|
||||
// here is a shell that backgrounds a sleep, prints its PID and exits, so after the
|
||||
// wait the sleep is the only process left in the worker's group. Reported in #861.
|
||||
func TestExecWorkerStopsWhatItLeftRunningAfterItExits(t *testing.T) {
|
||||
for _, stop := range []struct {
|
||||
name string
|
||||
run func(*execWorker) error
|
||||
}{
|
||||
{"Kill", func(worker *execWorker) error { return worker.Kill() }},
|
||||
{"Cancel hook", func(worker *execWorker) error { return worker.cmd.Cancel() }},
|
||||
} {
|
||||
t.Run(stop.name, func(t *testing.T) {
|
||||
launcher, err := NewExecLauncher(ExecLauncherConfig{
|
||||
Executable: "/bin/sh",
|
||||
BaseArgs: []string{"-c", "sleep 300 >/dev/null 2>&1 & echo $!"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExecLauncher: %v", err)
|
||||
}
|
||||
handle, err := launcher(context.Background(), WorkerSpec{})
|
||||
if err != nil {
|
||||
t.Fatalf("launch: %v", err)
|
||||
}
|
||||
worker, ok := handle.(*execWorker)
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: the launcher returned %T, not the exec worker under test", handle)
|
||||
}
|
||||
line, ok, err := worker.Stdout().Next()
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("read the child's pid: ok=%v err=%v", ok, err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse the child's pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = syscall.Kill(childPID, syscall.SIGKILL) })
|
||||
|
||||
if _, err := worker.Wait(); err != nil {
|
||||
t.Fatalf("wait for the worker: %v", err)
|
||||
}
|
||||
if childStopped(childPID) {
|
||||
t.Fatalf("SETUP INVALID: the worker's child %d is not running, so there is nothing left to stop", childPID)
|
||||
}
|
||||
|
||||
// The pool ignores what Kill returns; the outcome is what matters.
|
||||
_ = stop.run(worker)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !childStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the worker's child %d survived %s after the worker was reaped", childPID, stop.name)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// childStopped reports whether pid is gone or only waiting to be reaped.
|
||||
func childStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
}
|
||||
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
|
||||
}
|
||||
+27
-1
@@ -17,6 +17,28 @@ const (
|
||||
ExitPermanent = 76
|
||||
)
|
||||
|
||||
// Exit codes `zero exec` (internal/cli/exec.go) uses to report a run that
|
||||
// finished, or was stopped on purpose, rather than crashed. They are duplicated
|
||||
// here because the daemon cannot import the CLI package; keep them in sync.
|
||||
const (
|
||||
exitUsage = 2 // bad flags or arguments: a retry fails the same way
|
||||
exitProvider = 3 // provider failure the run already surfaced
|
||||
exitIncomplete = 4 // the agent ran (edits, commands) and stopped unfinished
|
||||
exitInterrupted = 130 // SIGINT: someone stopped it on purpose
|
||||
)
|
||||
|
||||
// isFinalExit reports whether a worker exit code means the run is over and must
|
||||
// not be retried. Retrying would repeat the agent's side effects and re-bill the
|
||||
// provider, and append a second run to the same session stream. Only crash-type
|
||||
// exits (1, signals) and launch/read errors are worth another attempt.
|
||||
func isFinalExit(code int) bool {
|
||||
switch code {
|
||||
case exitUsage, exitProvider, exitIncomplete, exitInterrupted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
|
||||
// PoolOptions field is left zero.
|
||||
const (
|
||||
@@ -176,7 +198,8 @@ type Sink interface {
|
||||
// Run leases a worker slot and dispatches spec to a worker, streaming its
|
||||
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
|
||||
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
|
||||
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// backoff; ExitPermanent, a final `zero exec` exit (usage, provider, incomplete,
|
||||
// interrupted) or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// queues when all slots are busy. The returned int is the final worker exit code.
|
||||
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
|
||||
if err := p.acquire(ctx); err != nil {
|
||||
@@ -209,6 +232,9 @@ func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error)
|
||||
case code == ExitPermanent:
|
||||
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
|
||||
return code, ErrPermanent
|
||||
case isFinalExit(code):
|
||||
p.logf("worker %d finished with exit code %d — not retrying", stat.id, code)
|
||||
return code, fmt.Errorf("%w: worker %d exited code=%d", ErrPermanent, stat.id, code)
|
||||
case code == ExitTempfail:
|
||||
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
|
||||
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
|
||||
|
||||
@@ -3,6 +3,7 @@ package daemon
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -148,6 +149,35 @@ func TestPoolRunPermanentStopsImmediately(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A `zero exec` run that ended with usage (2), provider (3), incomplete (4) or
|
||||
// interrupted (130) is over: retrying would repeat its side effects and merge a
|
||||
// second run into the same session stream.
|
||||
func TestPoolRunFinalExitCodesDoNotRetry(t *testing.T) {
|
||||
for _, code := range []int{2, 3, 4, 130} {
|
||||
t.Run(fmt.Sprintf("exit_%d", code), func(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: code, out: []string{"first run"}},
|
||||
&fakeWorker{pid: 2, exitCode: 0, out: []string{"second run"}},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 5, Backoff: func(int) time.Duration { return 0 }})
|
||||
sink := &collectSink{}
|
||||
got, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if got != code {
|
||||
t.Fatalf("Run code = %d, want the worker's exit code %d", got, code)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1 (no retry)", *calls)
|
||||
}
|
||||
if len(sink.lines) != 1 || sink.lines[0] != "first run" {
|
||||
t.Fatalf("sink lines = %v, want only the first run's output", sink.lines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunCapExhausted(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1},
|
||||
|
||||
@@ -32,6 +32,9 @@ const (
|
||||
// ErrSessionExists is returned when starting a session ID already in use.
|
||||
var ErrSessionExists = errors.New("daemon: session already exists")
|
||||
|
||||
// ErrSessionOverloaded means the manager's unfinished-session limit is reached.
|
||||
var ErrSessionOverloaded = errors.New("daemon: pending session limit reached")
|
||||
|
||||
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
|
||||
var ErrSessionNotFound = errors.New("daemon: session not found")
|
||||
|
||||
@@ -219,6 +222,9 @@ func (s *Session) isFinished() bool {
|
||||
// daemon does not accumulate finished sessions without limit.
|
||||
const defaultMaxSessions = 256
|
||||
|
||||
// defaultMaxPending bounds queued and running sessions independently of history.
|
||||
const defaultMaxPending = 256
|
||||
|
||||
// SessionManager owns the live session registry and routes each session to a
|
||||
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
|
||||
// retained (so a late `attach` sees history) up to MaxSessions, past which the
|
||||
@@ -227,8 +233,10 @@ type SessionManager struct {
|
||||
pool *Pool
|
||||
maxBuffer int
|
||||
maxSessions int
|
||||
maxPending int
|
||||
|
||||
mu sync.Mutex
|
||||
pending int // queued + running; protected by mu
|
||||
sessions map[string]*Session
|
||||
order []string // creation order, for FIFO eviction of finished sessions
|
||||
}
|
||||
@@ -240,6 +248,9 @@ type SessionManagerOptions struct {
|
||||
// MaxSessions caps the retained session registry; 0 => default. The oldest
|
||||
// FINISHED sessions are evicted once the cap is exceeded.
|
||||
MaxSessions int
|
||||
// MaxPending caps unfinished sessions (queued plus running), independently
|
||||
// of retained history. Non-positive values use the default of 256.
|
||||
MaxPending int
|
||||
}
|
||||
|
||||
// NewSessionManager builds a manager over pool.
|
||||
@@ -251,10 +262,15 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
if maxSessions <= 0 {
|
||||
maxSessions = defaultMaxSessions
|
||||
}
|
||||
maxPending := opts.MaxPending
|
||||
if maxPending <= 0 {
|
||||
maxPending = defaultMaxPending
|
||||
}
|
||||
return &SessionManager{
|
||||
pool: opts.Pool,
|
||||
maxBuffer: opts.MaxBuffer,
|
||||
maxSessions: maxSessions,
|
||||
maxPending: maxPending,
|
||||
sessions: map[string]*Session{},
|
||||
}, nil
|
||||
}
|
||||
@@ -262,13 +278,23 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
// Start creates a session for spec and dispatches it to the pool. It returns the
|
||||
// Session immediately (non-blocking); the run proceeds in the background and the
|
||||
// session transitions queued -> running -> done/failed. A duplicate ID is
|
||||
// rejected with ErrSessionExists.
|
||||
// rejected with ErrSessionExists; a full admission limit returns
|
||||
// ErrSessionOverloaded without creating a session or background goroutine.
|
||||
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if _, exists := m.sessions[spec.Session]; exists {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionExists
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
m.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if m.pending >= m.maxPending {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionOverloaded
|
||||
}
|
||||
m.pending++
|
||||
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
|
||||
m.sessions[spec.Session] = sess
|
||||
m.order = append(m.order, spec.Session)
|
||||
@@ -277,7 +303,11 @@ func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session,
|
||||
|
||||
go func() {
|
||||
code, err := m.pool.Run(ctx, spec, sess)
|
||||
m.mu.Lock()
|
||||
sess.finish(code, err)
|
||||
m.pending--
|
||||
m.pruneLocked()
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package daemon
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -208,6 +210,130 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerAdmissionBound(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
close(block)
|
||||
testutil.WaitFor(t, "all admitted sessions finished", func() bool {
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
return mgr.pending == 0
|
||||
})
|
||||
}()
|
||||
var wg sync.WaitGroup
|
||||
var accepted atomic.Int32
|
||||
for i := 0; i < 300; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := mgr.Start(ctx, WorkerSpec{Session: fmt.Sprintf("s%d", i)}); err == nil {
|
||||
accepted.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := accepted.Load(); got != 256 {
|
||||
t.Fatalf("accepted %d unfinished sessions, want default bound 256", got)
|
||||
}
|
||||
if got := len(mgr.Statuses()); got != 256 {
|
||||
t.Fatalf("registered %d sessions, want 256", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerCancellationReleasesAdmission(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
var release sync.Once
|
||||
defer release.Do(func() { close(block) })
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block}, &fakeWorker{pid: 2, exitCode: ExitPermanent}, &fakeWorker{pid: 3})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxPending: 2, MaxSessions: 10})
|
||||
first, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.WaitFor(t, "first running", func() bool { return first.State() == SessionRunning })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
queued, err := mgr.Start(ctx, WorkerSpec{Session: "queued"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"}); s != nil || !errors.Is(err, ErrSessionOverloaded) {
|
||||
t.Fatalf("overloaded Start = (%v, %v)", s, err)
|
||||
}
|
||||
if _, ok := mgr.Get("retry"); ok {
|
||||
t.Fatal("rejected session was registered")
|
||||
}
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"}); !errors.Is(err, ErrSessionExists) {
|
||||
t.Fatalf("duplicate at capacity: %v", err)
|
||||
}
|
||||
cancel()
|
||||
testutil.WaitFor(t, "queued cancellation", func() bool { return queued.State() == SessionFailed })
|
||||
if !errors.Is(queued.Err(), context.Canceled) {
|
||||
t.Fatalf("queued error = %v", queued.Err())
|
||||
}
|
||||
if s, err := mgr.Start(ctx, WorkerSpec{Session: "canceled"}); s != nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled Start = (%v, %v)", s, err)
|
||||
}
|
||||
retry, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"})
|
||||
if err != nil {
|
||||
t.Fatalf("capacity not released after cancellation: %v", err)
|
||||
}
|
||||
release.Do(func() { close(block) })
|
||||
testutil.WaitFor(t, "first completed", func() bool { return first.State() == SessionDone })
|
||||
testutil.WaitFor(t, "retry failed", func() bool { return retry.State() == SessionFailed })
|
||||
if !errors.Is(retry.Err(), ErrPermanent) {
|
||||
t.Fatalf("retry error = %v", retry.Err())
|
||||
}
|
||||
last, err := mgr.Start(context.Background(), WorkerSpec{Session: "last"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.WaitFor(t, "last completed", func() bool { return last.State() == SessionDone })
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
if mgr.pending != 0 || len(mgr.sessions) != 4 {
|
||||
t.Fatalf("pending=%d retained=%d, want 0 and 4", mgr.pending, len(mgr.sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerPrunesOnCompletion(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
var release sync.Once
|
||||
defer release.Do(func() { close(block) })
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, waitCh: block},
|
||||
&fakeWorker{pid: 2},
|
||||
&fakeWorker{pid: 3, exitCode: ExitPermanent},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
sessions := make([]*Session, 3)
|
||||
for i := range sessions {
|
||||
var err error
|
||||
sessions[i], err = mgr.Start(context.Background(), WorkerSpec{Session: fmt.Sprintf("s%d", i)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
release.Do(func() { close(block) })
|
||||
for _, s := range sessions {
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("session did not finish")
|
||||
}
|
||||
}
|
||||
if got := len(mgr.Statuses()); got != 2 {
|
||||
t.Fatalf("retained %d completed sessions without another Start, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatuses(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
|
||||
@@ -75,6 +75,14 @@ func RenameWithRetry(src, dst string, rename func(src, dst string) error) error
|
||||
return err
|
||||
}
|
||||
|
||||
// IsSharingOrLockViolation reports whether err is a Windows sharing or lock
|
||||
// violation: another open handle on the file denies the requested access. It
|
||||
// is always false on other platforms, where the same errno values mean
|
||||
// unrelated errors.
|
||||
func IsSharingOrLockViolation(err error) bool {
|
||||
return runtime.GOOS == "windows" && isWindowsSharingOrLockViolation(err)
|
||||
}
|
||||
|
||||
func isWindowsSharingOrLockViolation(err error) bool {
|
||||
var errno syscall.Errno
|
||||
if errors.As(err, &errno) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package fsutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
@@ -45,3 +46,22 @@ func TestRenameWithRetryNonRetryableError(t *testing.T) {
|
||||
t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Errno 32 and 33 are EPIPE and EDOM on Unix, so only Windows may classify them.
|
||||
func TestIsSharingOrLockViolation(t *testing.T) {
|
||||
onWindows := runtime.GOOS == "windows"
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{syscall.Errno(32), onWindows},
|
||||
{fmt.Errorf("wrapped: %w", syscall.Errno(33)), onWindows},
|
||||
{syscall.Errno(5), false}, // ERROR_ACCESS_DENIED
|
||||
{errors.New("other"), false},
|
||||
{nil, false},
|
||||
} {
|
||||
if got := IsSharingOrLockViolation(tc.err); got != tc.want {
|
||||
t.Errorf("IsSharingOrLockViolation(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-23
@@ -3,11 +3,13 @@ package mcp
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
@@ -16,6 +18,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type RemoteTool struct {
|
||||
@@ -27,10 +31,16 @@ type RemoteTool struct {
|
||||
type Content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
// MimeType names what a non-text block holds. Decoded but not yet forwarded:
|
||||
// it is what lets a dropped block be described to the model instead of
|
||||
// vanishing (#823). Servers that omit it still decode fine.
|
||||
// MimeType names what a non-text block holds. Additive and omitempty, so
|
||||
// servers that never send it decode exactly as before. Image blocks with
|
||||
// valid data are forwarded on Result.Images; MimeType is what lets a
|
||||
// remaining dropped block (audio, resource, failed decode) be described
|
||||
// instead of vanishing (#823).
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
// Data is the MCP image block's base64 payload. Additive and omitempty so a
|
||||
// result that never sent it still unmarshals. Decoded only for type
|
||||
// "image"; other types leave it unread.
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type CallToolResult struct {
|
||||
@@ -381,10 +391,6 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) ensureWriter() {
|
||||
_ = client.startWriter()
|
||||
}
|
||||
|
||||
func (client *Client) startWriter() error {
|
||||
client.writeMu.Lock()
|
||||
defer client.writeMu.Unlock()
|
||||
@@ -746,24 +752,95 @@ func TextContent(content []Content) string {
|
||||
return strings.TrimSpace(strings.Join(parts, "\n"))
|
||||
}
|
||||
|
||||
// DroppedContentSummary describes the blocks TextContent discards, e.g.
|
||||
// "1 image/png block" or "2 resource blocks, 1 audio/wav block". It returns ""
|
||||
// when a result is entirely text, so a caller adds nothing to the ordinary case.
|
||||
//
|
||||
// This exists because dropping silently is the worst available behaviour. A
|
||||
// screenshot server returns a valid image, TextContent keeps nothing, and the
|
||||
// call is reported as "(empty MCP tool result)" — so the model concludes the
|
||||
// tool produced nothing and usually retries, burning another call on the same
|
||||
// empty answer. Naming what came back costs nothing and ends that loop even
|
||||
// though the payload still cannot be forwarded.
|
||||
//
|
||||
// Counts are grouped by mime type and ordered by first appearance, so the same
|
||||
// result always produces the same sentence.
|
||||
func DroppedContentSummary(content []Content) string {
|
||||
// itemDisp is the per-item forwarding/drop disposition produced by the
|
||||
// single-pass conversion. DroppedContentSummary is built from this so a
|
||||
// valid image is never base64-decoded a second time just to name what was
|
||||
// kept versus dropped.
|
||||
type itemDisp uint8
|
||||
|
||||
const (
|
||||
dispText itemDisp = iota
|
||||
dispForwarded
|
||||
dispDropped
|
||||
dispBudgetExceeded
|
||||
dispUninspected
|
||||
)
|
||||
|
||||
// decodeImageBase64 is the MCP image payload decoder. Tests replace it to
|
||||
// count decode attempts; production uses standard base64.
|
||||
var decodeImageBase64 = base64.StdEncoding.DecodeString
|
||||
|
||||
// Bound provider content-block overhead independently of decoded image size.
|
||||
const maxForwardedImages = 16
|
||||
|
||||
// Bound decode work even when invalid or oversized-for-the-residue candidates
|
||||
// do not consume the forwarding budgets.
|
||||
const maxInspectedImages = 32
|
||||
|
||||
type imageLimitReason int
|
||||
|
||||
const (
|
||||
imageLimitNone imageLimitReason = iota
|
||||
imageLimitBudget
|
||||
imageLimitForwardCount
|
||||
imageLimitInspectionCount
|
||||
)
|
||||
|
||||
func forwardImages(content []Content) ([]zeroruntime.ImageBlock, []itemDisp, imageLimitReason) {
|
||||
disp := make([]itemDisp, len(content))
|
||||
var images []zeroruntime.ImageBlock
|
||||
remaining := imageinput.MaxImageBytes
|
||||
inspected := 0
|
||||
var limitReason imageLimitReason
|
||||
for i, item := range content {
|
||||
if item.Type == "text" {
|
||||
disp[i] = dispText
|
||||
continue
|
||||
}
|
||||
if item.Type == "image" {
|
||||
if remaining == 0 {
|
||||
disp[i] = dispUninspected
|
||||
if limitReason == imageLimitNone {
|
||||
limitReason = imageLimitBudget
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(images) >= maxForwardedImages {
|
||||
disp[i] = dispUninspected
|
||||
if limitReason == imageLimitNone {
|
||||
limitReason = imageLimitForwardCount
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inspected >= maxInspectedImages {
|
||||
disp[i] = dispUninspected
|
||||
if limitReason == imageLimitNone {
|
||||
limitReason = imageLimitInspectionCount
|
||||
}
|
||||
continue
|
||||
}
|
||||
inspected++
|
||||
if image, ok := imageBlockFromContent(item); ok {
|
||||
if len(image.Data) <= remaining {
|
||||
images = append(images, image)
|
||||
remaining -= len(image.Data)
|
||||
disp[i] = dispForwarded
|
||||
continue
|
||||
}
|
||||
disp[i] = dispBudgetExceeded
|
||||
continue
|
||||
}
|
||||
}
|
||||
disp[i] = dispDropped
|
||||
}
|
||||
return images, disp, limitReason
|
||||
}
|
||||
|
||||
func droppedContentNote(content []Content, disp []itemDisp, kinds ...itemDisp) string {
|
||||
labels := make([]string, 0, len(content))
|
||||
counts := make(map[string]int, len(content))
|
||||
for _, item := range content {
|
||||
if item.Type == "text" {
|
||||
for i, item := range content {
|
||||
if i >= len(disp) || !dispKind(disp[i], kinds) {
|
||||
continue
|
||||
}
|
||||
// Prefer the mime type: "image/png" tells the reader more than "image".
|
||||
@@ -794,3 +871,56 @@ func DroppedContentSummary(content []Content) string {
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func dispKind(got itemDisp, kinds []itemDisp) bool {
|
||||
for _, kind := range kinds {
|
||||
if got == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dispCount(disp []itemDisp, kinds ...itemDisp) int {
|
||||
count := 0
|
||||
for _, d := range disp {
|
||||
if dispKind(d, kinds) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func imageBlockFromContent(item Content) (zeroruntime.ImageBlock, bool) {
|
||||
if item.Type != "image" {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
raw := strings.TrimSpace(item.Data)
|
||||
if raw == "" {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
// EncodedLen(MaxImageBytes) is the encoded size of an image that decodes
|
||||
// to exactly the inclusive cap, including "==" padding. DecodedLen is an
|
||||
// upper bound and reports cap+2 for that input, so using it here would
|
||||
// reject a valid at-limit PNG. The post-decode len(data) check is the
|
||||
// exact backstop.
|
||||
if len(raw) > base64.StdEncoding.EncodedLen(imageinput.MaxImageBytes) {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
data, err := decodeImageBase64(raw)
|
||||
if err != nil {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
if len(data) == 0 || len(data) > imageinput.MaxImageBytes {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
sniffLen := len(data)
|
||||
if sniffLen > 512 {
|
||||
sniffLen = 512
|
||||
}
|
||||
mediaType := zeroruntime.NormalizeImageMediaType(http.DetectContentType(data[:sniffLen]))
|
||||
if mediaType == "" {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
return zeroruntime.ImageBlock{MediaType: mediaType, Data: data}, true
|
||||
}
|
||||
@@ -1420,7 +1420,7 @@ func TestWriterLoopExitsOnRepeatedClose(t *testing.T) {
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
}
|
||||
client.ensureWriter()
|
||||
_ = client.startWriter()
|
||||
if err := client.writeMessage(context.Background(), rpcMessage{Method: "notifications/ping"}); err != nil {
|
||||
t.Fatalf("writeMessage: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package mcp
|
||||
|
||||
import "github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
|
||||
// DroppedContentSummary describes the blocks that were not forwarded, e.g.
|
||||
// "1 audio/wav block" or "2 resource blocks, 1 image/png block". It returns ""
|
||||
// when every block is text or an image that ImageBlocks successfully
|
||||
// forwarded, so a caller adds nothing to the ordinary case.
|
||||
func DroppedContentSummary(content []Content) string {
|
||||
_, disp, _ := forwardImages(content)
|
||||
return droppedContentNote(content, disp, dispDropped, dispBudgetExceeded, dispUninspected)
|
||||
}
|
||||
|
||||
// ImageBlocks converts MCP image content into the same ImageBlock channel
|
||||
// capture tools already use.
|
||||
func ImageBlocks(content []Content) []zeroruntime.ImageBlock {
|
||||
images, _, _ := forwardImages(content)
|
||||
return images
|
||||
}
|
||||
@@ -657,11 +657,10 @@ func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
|
||||
return rpcMessage{}, fmt.Errorf("missing MCP SSE response data")
|
||||
}
|
||||
|
||||
// maxSSEEventBytes bounds a single SSE line/event. The previous 1 MiB cap made a
|
||||
// large but legitimate MCP message (e.g. a big tool result) hit bufio.ErrTooLong,
|
||||
// which failed the request permanently with no recovery. Raise it to a generous
|
||||
// bound that still protects against an unbounded remote server.
|
||||
const maxSSEEventBytes = 8 * 1024 * 1024
|
||||
// maxSSEEventBytes bounds a single SSE line/event. It accommodates multi-image
|
||||
// responses (such as 8 MiB + 4 MiB) in base64 (~16.8 MiB) plus JSON-RPC envelope,
|
||||
// metadata, and SSE framing overhead.
|
||||
const maxSSEEventBytes = 32 * 1024 * 1024
|
||||
|
||||
func scanSSEEvents(reader io.Reader, handle func(sseEvent) bool) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
@@ -871,7 +870,11 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no stored OAuth token for MCP server %s", source.server.Name)
|
||||
}
|
||||
refreshed, err := refreshAccessToken(ctx, source.httpClient, source.config(), token, source.now)
|
||||
cfg, client, err := refreshSettings(ctx, source.httpClient, source.server.URL, source.config(), token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
refreshed, err := refreshAccessToken(ctx, client, cfg, token, source.now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -440,3 +441,80 @@ func TestDeliverEventMessageSkipsMethodPresence(t *testing.T) {
|
||||
t.Fatal("true response must complete pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSSEEventsLargeImagePayload(t *testing.T) {
|
||||
// Construct a 10 MiB image payload (~13.98 MiB in base64).
|
||||
tenMiB := 10 * 1024 * 1024
|
||||
imgB64 := paddedPNGBase64(tenMiB)
|
||||
resultJSON, err := json.Marshal(map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "analysis screenshot"},
|
||||
{"type": "image", "mimeType": "image/png", "data": imgB64},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rpcJSON, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": json.RawMessage(resultJSON),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("Single data line with 10 MiB image", func(t *testing.T) {
|
||||
stream := "event: message\ndata: " + string(rpcJSON) + "\n\n"
|
||||
msg, err := decodeSSERPCMessage(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSSERPCMessage failed on single-line 10 MiB image: %v", err)
|
||||
}
|
||||
if !rpcIDMatches(msg.ID, 1) {
|
||||
t.Fatalf("expected id 1, got %#v", msg.ID)
|
||||
}
|
||||
var decoded CallToolResult
|
||||
if err := json.Unmarshal(msg.Result, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if len(decoded.Content) != 2 || len(decoded.Content[1].Data) != len(imgB64) {
|
||||
t.Fatalf("image payload did not survive the SSE round trip: got %d bytes, want %d",
|
||||
len(decoded.Content[1].Data), len(imgB64))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multi data lines with 10 MiB image", func(t *testing.T) {
|
||||
stream := "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\ndata: \"result\":" + string(resultJSON) + "}\n\n"
|
||||
msg, err := decodeSSERPCMessage(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSSERPCMessage failed on multi-line 10 MiB image: %v", err)
|
||||
}
|
||||
if !rpcIDMatches(msg.ID, 1) {
|
||||
t.Fatalf("expected id 1, got %#v", msg.ID)
|
||||
}
|
||||
var decoded CallToolResult
|
||||
if err := json.Unmarshal(msg.Result, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if len(decoded.Content) != 2 || len(decoded.Content[1].Data) != len(imgB64) {
|
||||
t.Fatalf("image payload did not survive the SSE round trip: got %d bytes, want %d",
|
||||
len(decoded.Content[1].Data), len(imgB64))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Oversized event exceeding 32 MiB rejected cleanly", func(t *testing.T) {
|
||||
oversizedRPC, marshalErr := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": map[string]any{"padding": strings.Repeat("A", 33*1024*1024)},
|
||||
})
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
stream := "event: message\ndata: " + string(oversizedRPC) + "\n\n"
|
||||
_, err := decodeSSERPCMessage(strings.NewReader(stream))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 33 MiB event, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,20 +2,35 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A server that returns only an image currently reports "(empty MCP tool
|
||||
// result)": TextContent keeps text blocks and drops the rest, so a successful
|
||||
// call looks like it produced nothing. The model then usually retries, which is
|
||||
// the worst outcome, and the user is never told an image existed (#823).
|
||||
// tinyPNGBase64 is a 1x1 PNG. http.DetectContentType sniffs it as image/png.
|
||||
const tinyPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
// paddedPNGBase64 is a decodable image/png of decoded length n. The 8-byte
|
||||
// PNG magic is enough for http.DetectContentType; the rest is padding so
|
||||
// tests can hit size caps without committing a multi-mebibyte fixture.
|
||||
func paddedPNGBase64(n int) string {
|
||||
raw := make([]byte, n)
|
||||
copy(raw, "\x89PNG\r\n\x1a\n")
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// A server that returns an image WITHOUT payload still cannot be forwarded, so
|
||||
// the delivered result must name the block rather than report "(empty MCP tool
|
||||
// result)". The model then usually retries, which is the worst outcome (#823).
|
||||
//
|
||||
// Carrying the payload is a separate change. Naming what was dropped is what
|
||||
// stops the retry loop, and it has to be true of the DELIVERED result, so this
|
||||
// drives registryTool.Run rather than the helper alone.
|
||||
// Image blocks that DO carry data are forwarded on Result.Images (see the
|
||||
// payload tests below). This case is the remaining drop path, and it has to be
|
||||
// true of the DELIVERED result, so this drives registryTool.Run rather than
|
||||
// the helper alone.
|
||||
func TestAnImageOnlyResultSaysWhatItReturned(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
@@ -134,6 +149,24 @@ func TestDroppedContentSummaryNamesTheBlocks(t *testing.T) {
|
||||
},
|
||||
want: "2 resource blocks, 1 audio/wav block",
|
||||
},
|
||||
{
|
||||
name: "successfully forwarded image is not named as dropped",
|
||||
content: []Content{{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "malformed image data is still named",
|
||||
content: []Content{{Type: "image", MimeType: "image/png", Data: "%%%not-base64%%%"}},
|
||||
want: "1 image/png block",
|
||||
},
|
||||
{
|
||||
name: "forwarded image plus audio names only the audio",
|
||||
content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: tinyPNGBase64},
|
||||
{Type: "audio", MimeType: "audio/wav"},
|
||||
},
|
||||
want: "1 audio/wav block",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -156,3 +189,544 @@ func (client *nonTextClient) CallTool(context.Context, string, map[string]any) (
|
||||
}
|
||||
|
||||
func (client *nonTextClient) Close() error { return nil }
|
||||
|
||||
func TestAnImageWithPayloadIsForwarded(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: tinyPNGBase64},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if result.Output != "[image returned by tool]" {
|
||||
t.Fatalf("image-only Output = %q, want [image returned by tool] so the tool_result is not an empty body", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "cannot forward") {
|
||||
t.Fatalf("a forwarded image is still described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("Images len = %d, want 1", len(result.Images))
|
||||
}
|
||||
if result.Images[0].MediaType != "image/png" {
|
||||
t.Errorf("MediaType = %q, want image/png", result.Images[0].MediaType)
|
||||
}
|
||||
if len(result.Images[0].Data) == 0 {
|
||||
t.Fatal("forwarded image has empty Data")
|
||||
}
|
||||
if DroppedContentSummary([]Content{{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}}) != "" {
|
||||
t.Fatal("drop summary named a successfully forwarded image")
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Errorf("status = %v, want OK", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioIsStillDroppedAndNamed(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "audio", MimeType: "audio/wav"},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "clip"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("audio was forwarded as an image: %#v", result.Images)
|
||||
}
|
||||
if !strings.Contains(result.Output, "audio/wav") {
|
||||
t.Errorf("the output does not name the dropped audio:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("the output does not say the audio cannot be forwarded:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextAndImageKeepsTextAndForwardsImage(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "text", Text: "captured the page"},
|
||||
{Type: "image", MimeType: "image/png", Data: tinyPNGBase64},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if !strings.Contains(result.Output, "captured the page") {
|
||||
t.Errorf("the text block was lost:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "cannot forward") {
|
||||
t.Errorf("a forwarded image is still described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "[image forwarded]") {
|
||||
t.Errorf("text+image result substituted a placeholder over the text:\n%s", result.Output)
|
||||
}
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("Images len = %d, want 1", len(result.Images))
|
||||
}
|
||||
if result.Images[0].MediaType != "image/png" {
|
||||
t.Errorf("MediaType = %q, want image/png", result.Images[0].MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedImageDataDoesNotPanic(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: "%%%not-base64%%%"},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("malformed image was forwarded: %#v", result.Images)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("malformed image was not named in the drop summary:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "(empty MCP tool result)") {
|
||||
t.Errorf("malformed image still reported as empty:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageContentJSONDecodesDataAndStaysCompatibleWithoutIt(t *testing.T) {
|
||||
var withData CallToolResult
|
||||
raw := []byte(`{"content":[{"type":"image","mimeType":"image/png","data":"` + tinyPNGBase64 + `"}]}`)
|
||||
if err := json.Unmarshal(raw, &withData); err != nil {
|
||||
t.Fatalf("unmarshal image content: %v", err)
|
||||
}
|
||||
if len(withData.Content) != 1 {
|
||||
t.Fatalf("content len = %d, want 1", len(withData.Content))
|
||||
}
|
||||
if withData.Content[0].Type != "image" || withData.Content[0].MimeType != "image/png" {
|
||||
t.Fatalf("decoded fields = %+v", withData.Content[0])
|
||||
}
|
||||
if withData.Content[0].Data != tinyPNGBase64 {
|
||||
t.Fatalf("data = %q, want tiny PNG base64", withData.Content[0].Data)
|
||||
}
|
||||
|
||||
var withoutData CallToolResult
|
||||
if err := json.Unmarshal([]byte(`{"content":[{"type":"image","mimeType":"image/png"}]}`), &withoutData); err != nil {
|
||||
t.Fatalf("unmarshal image content without data: %v", err)
|
||||
}
|
||||
if withoutData.Content[0].Data != "" {
|
||||
t.Fatalf("absent data decoded as %q, want empty", withoutData.Content[0].Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExactlyMaxImageBytesPaddedPNGIsForwarded(t *testing.T) {
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes)
|
||||
if got := base64.StdEncoding.DecodedLen(len(payload)); got <= imageinput.MaxImageBytes {
|
||||
t.Fatalf("fixture DecodedLen = %d, want > %d so the old bound would reject it", got, imageinput.MaxImageBytes)
|
||||
}
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("at-limit padded PNG was dropped: images=%d output=%q", len(result.Images), result.Output)
|
||||
}
|
||||
if got := len(result.Images[0].Data); got != imageinput.MaxImageBytes {
|
||||
t.Fatalf("forwarded size = %d, want %d", got, imageinput.MaxImageBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOversizedImageIsDroppedAndNamed(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: paddedPNGBase64(imageinput.MaxImageBytes + 1)},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("oversized image was forwarded: %#v", result.Images)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("oversized image was not named in the drop summary:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("individually oversized image should stay unforwardable, not a budget skip:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "image budget") {
|
||||
t.Errorf("individually oversized image was described as a budget skip:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateImageBudgetForwardsTheFirstAndNamesTheRest(t *testing.T) {
|
||||
// Each payload is under the per-image cap; together they exceed the
|
||||
// aggregate MaxImageBytes budget for one result. Identical bytes are
|
||||
// deliberate: DroppedContentSummary must name the second even though
|
||||
// imageBlockFromContent would accept it in isolation.
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes/2 + 1)
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("Images len = %d, want 1 (first fits the aggregate budget)", len(result.Images))
|
||||
}
|
||||
if got := len(result.Images[0].Data); got != imageinput.MaxImageBytes/2+1 {
|
||||
t.Errorf("forwarded image size = %d, want %d", got, imageinput.MaxImageBytes/2+1)
|
||||
}
|
||||
if !strings.Contains(result.Output, "[image returned by tool]") {
|
||||
t.Errorf("the forwarded first image has no placeholder:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("the dropped second image was not named:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "which exceeded this result's remaining image budget") {
|
||||
t.Errorf("the dropped second image is not described as a budget exceeded:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Retrying with fewer images can recover this payload.") {
|
||||
t.Errorf("the output does not tell the model a retry can recover the payload:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("a budget-exceeded image is described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "Retrying cannot recover this payload.") {
|
||||
t.Errorf("a budget-exceeded image is described as unrecoverable:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePayloadsAreDecodedOnceAndNotPastTheBudget(t *testing.T) {
|
||||
orig := decodeImageBase64
|
||||
t.Cleanup(func() { decodeImageBase64 = orig })
|
||||
var n int
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
n++
|
||||
return orig(s)
|
||||
}
|
||||
|
||||
// Two images fill the 10 MiB aggregate exactly, so remaining hits 0 and
|
||||
// the later candidates must not be decoded at all.
|
||||
half := imageinput.MaxImageBytes / 2
|
||||
payload := paddedPNGBase64(half)
|
||||
content := []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: "not-even-valid-base64!"},
|
||||
{Type: "image", MimeType: "image/png", Data: ""},
|
||||
{Type: "audio", MimeType: "audio/wav"},
|
||||
}
|
||||
|
||||
n = 0
|
||||
images := ImageBlocks(content)
|
||||
if n != 2 {
|
||||
t.Fatalf("ImageBlocks decoded %d payloads, want 2 (budget fills after two %d-byte images)", n, half)
|
||||
}
|
||||
if len(images) != 2 {
|
||||
t.Fatalf("ImageBlocks len = %d, want 2", len(images))
|
||||
}
|
||||
n = 0
|
||||
if got := DroppedContentSummary(content); got != "3 image/png blocks, 1 audio/wav block" {
|
||||
t.Fatalf("DroppedContentSummary() = %q, want the three skipped images and the audio", got)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("DroppedContentSummary decoded %d payloads, want 2 (same single pass as ImageBlocks)", n)
|
||||
}
|
||||
|
||||
n = 0
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if n != 2 {
|
||||
t.Fatalf("Run decoded %d payloads, want 2 (one pass; drop note must not decode again)", n)
|
||||
}
|
||||
if len(result.Images) != 2 {
|
||||
t.Fatalf("Images len = %d, want 2", len(result.Images))
|
||||
}
|
||||
if !strings.Contains(result.Output, "[image returned by tool]") {
|
||||
t.Errorf("forwarded images have no placeholder:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("skipped images were not named:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "which were not inspected because the aggregate image budget was reached") {
|
||||
t.Errorf("uninspected images are not described correctly:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "Retrying with fewer images can recover this payload.") {
|
||||
t.Errorf("uninspected images must not make unsupported recovery claims:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "audio/wav") {
|
||||
t.Errorf("audio was not named:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("audio is not described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "(empty MCP tool result)") {
|
||||
t.Errorf("forwarded images still reported as empty:\n%s", result.Output)
|
||||
}
|
||||
|
||||
n = 0
|
||||
one := []Content{{Type: "image", MimeType: "image/png", Data: payload}}
|
||||
oneResult := registryTool{
|
||||
client: &nonTextClient{content: one},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if n != 1 {
|
||||
t.Fatalf("one image decoded %d times, want 1", n)
|
||||
}
|
||||
if len(oneResult.Images) != 1 {
|
||||
t.Fatalf("one-image Images len = %d, want 1", len(oneResult.Images))
|
||||
}
|
||||
if oneResult.Output != "[image returned by tool]" {
|
||||
t.Fatalf("one-image Output = %q, want [image returned by tool]", oneResult.Output)
|
||||
}
|
||||
if strings.Contains(oneResult.Output, "cannot forward") {
|
||||
t.Fatalf("a forwarded image is still described as unforwardable:\n%s", oneResult.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBudgetNonZeroResidueAllowsSmallerLaterImage(t *testing.T) {
|
||||
// First image: 8 MiB (fits, 2 MiB left)
|
||||
// Second image: 3 MiB (exceeds remaining 2 MiB, budgetExceeded)
|
||||
// Third image: 1 MiB (fits in remaining 2 MiB, forwarded, 1 MiB left)
|
||||
img8 := paddedPNGBase64(8 * 1024 * 1024)
|
||||
img3 := paddedPNGBase64(3 * 1024 * 1024)
|
||||
img1 := paddedPNGBase64(1 * 1024 * 1024)
|
||||
|
||||
content := []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: img8},
|
||||
{Type: "image", MimeType: "image/png", Data: img3},
|
||||
{Type: "image", MimeType: "image/png", Data: img1},
|
||||
}
|
||||
|
||||
images, disp, _ := forwardImages(content)
|
||||
if len(images) != 2 {
|
||||
t.Fatalf("forwardImages len = %d, want 2 (8 MiB + 1 MiB)", len(images))
|
||||
}
|
||||
if disp[0] != dispForwarded || disp[1] != dispBudgetExceeded || disp[2] != dispForwarded {
|
||||
t.Fatalf("dispositions = %v, want [forwarded, budgetExceeded, forwarded]", disp)
|
||||
}
|
||||
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 2 {
|
||||
t.Fatalf("result Images len = %d, want 2", len(result.Images))
|
||||
}
|
||||
if !strings.Contains(result.Output, "exceeded this result's remaining image budget") {
|
||||
t.Fatalf("expected remaining budget notice in output:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Retrying with fewer images can recover this payload.") {
|
||||
t.Fatalf("expected retry recovery guidance for validated exceeded image:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageCountBudgetPreservesTextAndSkipsFurtherDecoding(t *testing.T) {
|
||||
content := []Content{{Type: "image", Data: "invalid"}}
|
||||
for range 17 {
|
||||
content = append(content, Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64})
|
||||
}
|
||||
content = append(content, Content{Type: "text", Text: "trailing text"})
|
||||
previous := decodeImageBase64
|
||||
decodes := 0
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
decodes++
|
||||
return previous(s)
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"}, remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if len(result.Images) != 16 || decodes != 17 {
|
||||
t.Fatalf("forwarded %d images with %d decodes, want 16 images and 17 attempts including invalid input", len(result.Images), decodes)
|
||||
}
|
||||
if !strings.Contains(result.Output, "trailing text") || !strings.Contains(result.Output, "1 image/png block, which was not inspected") {
|
||||
t.Fatalf("missing text or count-budget notice: %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageInspectionBudgetBoundsRejectedCandidates(t *testing.T) {
|
||||
// Use the decoder seam to exercise accounting without allocating a large
|
||||
// payload for every rejected candidate.
|
||||
previous := decodeImageBase64
|
||||
decodes := 0
|
||||
large, _ := previous(paddedPNGBase64(8 * 1024 * 1024))
|
||||
rejected, _ := previous(paddedPNGBase64(3 * 1024 * 1024))
|
||||
decodeImageBase64 = func(string) ([]byte, error) {
|
||||
decodes++
|
||||
if decodes == 1 {
|
||||
return large, nil
|
||||
}
|
||||
return rejected, nil
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
content := make([]Content, 40)
|
||||
for i := range content {
|
||||
content[i] = Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}
|
||||
}
|
||||
content = append(content, Content{Type: "text", Text: "trailing text"})
|
||||
images, disp, _ := forwardImages(content)
|
||||
if decodes != 32 || len(images) != 1 {
|
||||
t.Fatalf("got %d decodes and %d forwarded images, want 32 and 1", decodes, len(images))
|
||||
}
|
||||
for i := 1; i < 40; i++ {
|
||||
want := dispBudgetExceeded
|
||||
if i >= 32 {
|
||||
want = dispUninspected
|
||||
}
|
||||
if disp[i] != want {
|
||||
t.Errorf("disposition[%d] = %v, want %v", i, disp[i], want)
|
||||
}
|
||||
}
|
||||
if disp[40] != dispText {
|
||||
t.Fatal("inspection limit discarded trailing text")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkForwardImagesFourHalfBudget(b *testing.B) {
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes / 2)
|
||||
content := []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = forwardImages(content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPResultUninspectedImageLimitingReasonNotes(t *testing.T) {
|
||||
t.Run("forwarded count limit reached", func(t *testing.T) {
|
||||
content := make([]Content, 20)
|
||||
for i := range content {
|
||||
content[i] = Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}
|
||||
}
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if len(result.Images) != 16 {
|
||||
t.Fatalf("expected 16 forwarded images, got %d", len(result.Images))
|
||||
}
|
||||
if !strings.Contains(result.Output, "which were not inspected because the maximum forwarded image count was reached.") {
|
||||
t.Fatalf("expected maximum forwarded image count message, got:\n%s", result.Output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inspection limit reached", func(t *testing.T) {
|
||||
previous := decodeImageBase64
|
||||
large := make([]byte, 1024)
|
||||
rejected := make([]byte, imageinput.MaxImageBytes+1)
|
||||
decodes := 0
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
decodes++
|
||||
if decodes == 1 {
|
||||
return large, nil
|
||||
}
|
||||
return rejected, nil
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
content := make([]Content, 35)
|
||||
for i := range content {
|
||||
content[i] = Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}
|
||||
}
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if !strings.Contains(result.Output, "which were not inspected because the maximum image inspection limit was reached.") {
|
||||
t.Fatalf("expected maximum image inspection limit message, got:\n%s", result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The pre-decode encoded-length cap is the decode-DoS guard: it keeps a
|
||||
// hostile server from making Zero decode a payload of any size (up to the
|
||||
// inspection cap times per result) before the post-decode backstop drops it.
|
||||
// Removing the cap changes nothing that is forwarded, so no existing test
|
||||
// notices; this pins the guard on the decode hook instead. A payload one
|
||||
// byte over the encoded cap must be rejected without the decoder ever being
|
||||
// called.
|
||||
func TestOverCapEncodedPayloadIsRejectedBeforeDecoding(t *testing.T) {
|
||||
previous := decodeImageBase64
|
||||
decodes := 0
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
decodes++
|
||||
return previous(s)
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
|
||||
// An at-limit PNG would decode to exactly MaxImageBytes and forward; a
|
||||
// single extra base64 character pushes it one byte over the encoded cap,
|
||||
// so only the pre-decode guard can be responsible for rejecting it.
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes) + "A"
|
||||
content := []Content{{Type: "image", MimeType: "image/png", Data: payload}}
|
||||
|
||||
if images := ImageBlocks(content); len(images) != 0 {
|
||||
t.Fatalf("ImageBlocks forwarded %d images from an over-cap payload, want 0", len(images))
|
||||
}
|
||||
if decodes != 0 {
|
||||
t.Fatalf("decoder ran %d times, want 0: the pre-decode cap must reject before any decode work", decodes)
|
||||
}
|
||||
if got := DroppedContentSummary(content); got != "1 image/png block" {
|
||||
t.Fatalf("DroppedContentSummary() = %q, want the over-cap payload named as dropped", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The media type is sniffed from the decoded bytes, never taken from the
|
||||
// block's declared mimeType: a hostile server must not be able to relabel
|
||||
// HTML or a script as image/png and have it forwarded to the provider as an
|
||||
// image. Every other fixture pairs real PNG bytes with image/png, so
|
||||
// sniffing and trusting the declared type agree there and no test can tell
|
||||
// them apart; this fixture makes them disagree.
|
||||
func TestDeclaredMimeTypeDisagreeingWithSniffedBytesIsDropped(t *testing.T) {
|
||||
html := []byte("<!doctype html><html><body><script>alert(1)</script></body></html>")
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: base64.StdEncoding.EncodeToString(html)},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("forwarded %d images from an HTML payload declared as image/png, want 0", len(result.Images))
|
||||
}
|
||||
if strings.Contains(result.Output, "[image returned by tool]") {
|
||||
t.Errorf("a non-image payload declared as image/png is still announced as a returned image:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("the dropped block is not named in the drop summary:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
+85
-26
@@ -59,6 +59,11 @@ func tokenToStored(t oauth.Token) StoredToken {
|
||||
TokenType: t.TokenType,
|
||||
Scopes: t.Scopes,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
|
||||
TokenEndpoint: t.TokenEndpoint,
|
||||
ProtectedTokenEndpoint: t.ProtectedTokenEndpoint,
|
||||
ClientID: t.ClientID,
|
||||
ClientSecret: t.ClientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +346,53 @@ func refreshAccessToken(ctx context.Context, client *http.Client, cfg OAuthConfi
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return tokenToStored(token), nil
|
||||
refreshed := tokenToStored(token)
|
||||
// The token response never carries what Login learned about the client and
|
||||
// endpoint, so carry it over or the next refresh would lose it.
|
||||
refreshed.TokenEndpoint = current.TokenEndpoint
|
||||
refreshed.ProtectedTokenEndpoint = current.ProtectedTokenEndpoint
|
||||
refreshed.ClientID = current.ClientID
|
||||
refreshed.ClientSecret = current.ClientSecret
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// refreshSettings fills the gaps in the configured OAuth settings from what the
|
||||
// login stored: the discovered token endpoint and the dynamically registered
|
||||
// client. Explicit config always wins. A stored endpoint is re-validated before
|
||||
// credentials are posted to it, and one that came from server-advertised
|
||||
// discovery is also held to the public-network policy, with the returned client
|
||||
// pinned to it, exactly as Login did.
|
||||
func refreshSettings(ctx context.Context, client *http.Client, resourceURL string, cfg OAuthConfig, stored StoredToken) (OAuthConfig, *http.Client, error) {
|
||||
if strings.TrimSpace(cfg.ClientID) == "" && strings.TrimSpace(stored.ClientID) != "" {
|
||||
cfg.ClientID = stored.ClientID
|
||||
if strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
cfg.ClientSecret = stored.ClientSecret
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.TokenEndpoint) != "" || strings.TrimSpace(stored.TokenEndpoint) == "" {
|
||||
return cfg, client, nil
|
||||
}
|
||||
endpoint := strings.TrimSpace(stored.TokenEndpoint)
|
||||
if err := oauth.ValidateEndpointURL(endpoint); err != nil {
|
||||
return cfg, client, fmt.Errorf("mcp oauth: stored token endpoint: %w", err)
|
||||
}
|
||||
if stored.ProtectedTokenEndpoint {
|
||||
metadata := authServerMetadata{
|
||||
TokenEndpoint: endpoint,
|
||||
protectedResourceURL: strings.TrimSpace(resourceURL),
|
||||
protectTokenEndpoint: true,
|
||||
}
|
||||
if err := validateProtectedDiscoveredEndpoints(ctx, metadata); err != nil {
|
||||
return cfg, client, err
|
||||
}
|
||||
protected, err := newAdvertisedOAuthDiscoveryClient(client, metadata.protectedResourceURL)
|
||||
if err != nil {
|
||||
return cfg, client, err
|
||||
}
|
||||
client = protected
|
||||
}
|
||||
cfg.TokenEndpoint = endpoint
|
||||
return cfg, client, nil
|
||||
}
|
||||
|
||||
// registerClient performs dynamic client registration against the registration
|
||||
@@ -446,6 +497,7 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
tokenClient = protectedEndpointClient
|
||||
}
|
||||
|
||||
registeredClientID, registeredClientSecret := "", ""
|
||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||
if registration := strings.TrimSpace(metadata.RegistrationEndpoint); registration != "" {
|
||||
registrationClient := httpClient
|
||||
@@ -457,8 +509,10 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
return StoredToken{}, regErr
|
||||
}
|
||||
cfg.ClientID = clientID
|
||||
registeredClientID = clientID
|
||||
if clientSecret != "" {
|
||||
cfg.ClientSecret = clientSecret
|
||||
registeredClientSecret = clientSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,31 +548,28 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
err error
|
||||
}
|
||||
resultChan := make(chan callbackResult, 1)
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, parseErr := flow.parseCallback(r.URL.Query())
|
||||
if parseErr != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case resultChan <- callbackResult{code: code, err: parseErr}:
|
||||
default:
|
||||
}
|
||||
}),
|
||||
}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
// The shared callback server bounds header reads, force-closes a client
|
||||
// still connected when shutdown runs out of time, and waits for Serve to
|
||||
// return. The bare http.Server this used to build did none of that, so a
|
||||
// client stalled mid-header outlived Login (#1028).
|
||||
server := oauth.StartCallbackServer(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, parseErr := flow.parseCallback(r.URL.Query())
|
||||
if parseErr != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case resultChan <- callbackResult{code: code, err: parseErr}:
|
||||
default:
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
open := options.OpenBrowser
|
||||
if open == nil {
|
||||
@@ -537,6 +588,14 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
// Persist only what config does not already carry, so refresh can reuse
|
||||
// it without copying configured secrets into the token store.
|
||||
if strings.TrimSpace(options.Config.TokenEndpoint) == "" {
|
||||
token.TokenEndpoint = metadata.TokenEndpoint
|
||||
token.ProtectedTokenEndpoint = metadata.protectTokenEndpoint
|
||||
}
|
||||
token.ClientID = registeredClientID
|
||||
token.ClientSecret = registeredClientSecret
|
||||
return token, nil
|
||||
case <-loginCtx.Done():
|
||||
return StoredToken{}, fmt.Errorf("timed out waiting for OAuth authorization callback: %w", loginCtx.Err())
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
const testServerIdentity = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// discoveryOAuthServer serves a standard `auth: oauth` setup: protected-resource
|
||||
// metadata, authorization-server metadata with a registration endpoint, dynamic
|
||||
// client registration, and a token endpoint that records refresh requests.
|
||||
type discoveryOAuthServer struct {
|
||||
*httptest.Server
|
||||
refreshForms chan url.Values
|
||||
}
|
||||
|
||||
func newDiscoveryOAuthServer(t *testing.T) *discoveryOAuthServer {
|
||||
t.Helper()
|
||||
fixture := &discoveryOAuthServer{refreshForms: make(chan url.Values, 4)}
|
||||
fixture.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
base := "http://" + r.Host
|
||||
switch r.URL.Path {
|
||||
case "/mcp":
|
||||
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/resource-metadata"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case "/resource-metadata":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"resource": base + "/mcp",
|
||||
"authorization_servers": []string{base},
|
||||
})
|
||||
case "/.well-known/oauth-authorization-server":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": base + "/authorize",
|
||||
"token_endpoint": base + "/token",
|
||||
"registration_endpoint": base + "/register",
|
||||
})
|
||||
case "/register":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"client_id": "registered-client",
|
||||
"client_secret": "registered-secret",
|
||||
})
|
||||
case "/token":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("parse token form: %v", err)
|
||||
}
|
||||
if r.Form.Get("grant_type") == "refresh_token" {
|
||||
fixture.refreshForms <- r.Form
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-refreshed",
|
||||
"refresh_token": "refresh-rotated",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-initial",
|
||||
"refresh_token": "refresh-initial",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(fixture.Close)
|
||||
return fixture
|
||||
}
|
||||
|
||||
func driveLoopbackCallback(authURL string) error {
|
||||
parsed, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callbackURL := parsed.Query().Get("redirect_uri") + "?code=auth-code&state=" + url.QueryEscape(parsed.Query().Get("state"))
|
||||
go func() {
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
response, requestErr := http.Get(callbackURL)
|
||||
if requestErr == nil {
|
||||
response.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginRecordsDiscoveredEndpointAndRegisteredClient(t *testing.T) {
|
||||
fixture := newDiscoveryOAuthServer(t)
|
||||
|
||||
token, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: "notion-style",
|
||||
ServerURL: fixture.URL + "/mcp",
|
||||
Config: OAuthConfig{},
|
||||
HTTPClient: fixture.Client(),
|
||||
OpenBrowser: driveLoopbackCallback,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if token.TokenEndpoint != fixture.URL+"/token" || !token.ProtectedTokenEndpoint {
|
||||
t.Fatalf("stored token endpoint = %q protected=%v, want the discovered protected endpoint", token.TokenEndpoint, token.ProtectedTokenEndpoint)
|
||||
}
|
||||
if token.ClientID != "registered-client" || token.ClientSecret != "registered-secret" {
|
||||
t.Fatalf("stored client = %q/%q, want the dynamically registered client", token.ClientID, token.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginDoesNotCopyConfiguredClientOrEndpointIntoStore(t *testing.T) {
|
||||
fixture := newDiscoveryOAuthServer(t)
|
||||
|
||||
token, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: "configured",
|
||||
ServerURL: fixture.URL + "/mcp",
|
||||
Config: OAuthConfig{
|
||||
ClientID: "configured-client",
|
||||
ClientSecret: "configured-secret",
|
||||
TokenEndpoint: fixture.URL + "/token",
|
||||
},
|
||||
HTTPClient: fixture.Client(),
|
||||
OpenBrowser: driveLoopbackCallback,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if token.TokenEndpoint != "" || token.ProtectedTokenEndpoint || token.ClientID != "" || token.ClientSecret != "" {
|
||||
t.Fatalf("configured values were copied into the token store: %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
// The standard `auth: oauth` setup configures no token endpoint or client, so
|
||||
// refresh must work from what the discovery-based login stored.
|
||||
func TestStoreTokenSourceRefreshUsesStoredDiscoveryAndClient(t *testing.T) {
|
||||
fixture := newDiscoveryOAuthServer(t)
|
||||
server := Server{Name: "notion-style", URL: fixture.URL + "/mcp", Identity: testServerIdentity, Auth: ServerAuthOAuth}
|
||||
|
||||
loggedIn, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: server.Name,
|
||||
ServerURL: server.URL,
|
||||
HTTPClient: fixture.Client(),
|
||||
OpenBrowser: driveLoopbackCallback,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
if err := store.SaveForServer(server, loggedIn); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
|
||||
source := &storeTokenSource{server: server, store: store, httpClient: http.DefaultClient, now: time.Now}
|
||||
access, err := source.Refresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh() error = %v", err)
|
||||
}
|
||||
if access != "access-refreshed" {
|
||||
t.Fatalf("Refresh() access token = %q", access)
|
||||
}
|
||||
form := <-fixture.refreshForms
|
||||
if form.Get("client_id") != "registered-client" || form.Get("client_secret") != "registered-secret" || form.Get("refresh_token") != "refresh-initial" {
|
||||
t.Fatalf("refresh form = %v, want the registered client and stored refresh token", form)
|
||||
}
|
||||
|
||||
// The refresh must not drop what Login stored, or the next one would fail.
|
||||
saved, ok, err := store.LoadForServer(server)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadForServer() = %v, %v", ok, err)
|
||||
}
|
||||
if saved.RefreshToken != "refresh-rotated" || saved.TokenEndpoint != fixture.URL+"/token" || !saved.ProtectedTokenEndpoint ||
|
||||
saved.ClientID != "registered-client" || saved.ClientSecret != "registered-secret" {
|
||||
t.Fatalf("refreshed token lost the stored endpoint or client: %#v", saved)
|
||||
}
|
||||
if _, err := source.Refresh(context.Background()); err != nil {
|
||||
t.Fatalf("second Refresh() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSettingsConfigOverridesStored(t *testing.T) {
|
||||
cfg, client, err := refreshSettings(context.Background(), http.DefaultClient, "https://mcp.example.com/mcp",
|
||||
OAuthConfig{ClientID: "configured-client", ClientSecret: "configured-secret", TokenEndpoint: "https://configured.example.com/token"},
|
||||
StoredToken{TokenEndpoint: "https://stored.example.com/token", ProtectedTokenEndpoint: true, ClientID: "stored-client", ClientSecret: "stored-secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("refreshSettings() error = %v", err)
|
||||
}
|
||||
if cfg.TokenEndpoint != "https://configured.example.com/token" || cfg.ClientID != "configured-client" || cfg.ClientSecret != "configured-secret" {
|
||||
t.Fatalf("config did not win over stored values: %#v", cfg)
|
||||
}
|
||||
if client != http.DefaultClient {
|
||||
t.Fatal("an explicitly configured endpoint must not switch to the protected-discovery client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSettingsKeepsConfiguredSecretWithStoredClientID(t *testing.T) {
|
||||
cfg, _, err := refreshSettings(context.Background(), http.DefaultClient, "https://mcp.example.com/mcp",
|
||||
OAuthConfig{ClientSecret: "configured-secret"},
|
||||
StoredToken{ClientID: "stored-client", ClientSecret: "stored-secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("refreshSettings() error = %v", err)
|
||||
}
|
||||
if cfg.ClientID != "stored-client" || cfg.ClientSecret != "configured-secret" {
|
||||
t.Fatalf("client = %q/%q, want stored id with the configured secret", cfg.ClientID, cfg.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSettingsRejectsUnsafeStoredEndpoint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stored StoredToken
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "cleartext endpoint",
|
||||
stored: StoredToken{TokenEndpoint: "http://auth.example.com/token", ClientID: "c"},
|
||||
want: oauth.ErrInsecureTokenEndpoint,
|
||||
},
|
||||
{
|
||||
name: "advertised loopback endpoint",
|
||||
stored: StoredToken{TokenEndpoint: "https://127.0.0.1/token", ProtectedTokenEndpoint: true, ClientID: "c"},
|
||||
want: errUnsafeOAuthDiscoveryTarget,
|
||||
},
|
||||
{
|
||||
name: "advertised private endpoint",
|
||||
stored: StoredToken{TokenEndpoint: "https://10.0.0.5/token", ProtectedTokenEndpoint: true, ClientID: "c"},
|
||||
want: errUnsafeOAuthDiscoveryTarget,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := refreshSettings(context.Background(), http.DefaultClient, "https://mcp.example.com/mcp", OAuthConfig{}, tc.stored)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("refreshSettings() error = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTokenSourceRefreshDoesNotPostToUnsafeStoredEndpoint(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
loopback := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits.Add(1) }))
|
||||
defer loopback.Close()
|
||||
|
||||
// A public MCP resource must not be able to point refresh at loopback.
|
||||
server := Server{Name: "public", URL: "https://mcp.example.com/mcp", Identity: testServerIdentity, Auth: ServerAuthOAuth}
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
if err := store.SaveForServer(server, StoredToken{
|
||||
AccessToken: "a", RefreshToken: "r", ClientID: "c",
|
||||
TokenEndpoint: loopback.URL + "/token", ProtectedTokenEndpoint: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
source := &storeTokenSource{server: server, store: store, httpClient: http.DefaultClient, now: time.Now}
|
||||
if _, err := source.Refresh(context.Background()); !errors.Is(err, errUnsafeOAuthDiscoveryTarget) {
|
||||
t.Fatalf("Refresh() error = %v, want the discovery policy refusal", err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("loopback endpoint received %d request(s)", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredTokenPersistsDiscoveryFields(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
want := StoredToken{
|
||||
AccessToken: "a", RefreshToken: "r",
|
||||
TokenEndpoint: "https://auth.example.com/token", ProtectedTokenEndpoint: true,
|
||||
ClientID: "client", ClientSecret: "secret",
|
||||
}
|
||||
if err := store.Save("demo", want); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
got, ok, err := store.Load("demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load() = %v, %v", ok, err)
|
||||
}
|
||||
if got.TokenEndpoint != want.TokenEndpoint || !got.ProtectedTokenEndpoint || got.ClientID != want.ClientID || got.ClientSecret != want.ClientSecret {
|
||||
t.Fatalf("round trip lost discovery fields: %#v", got)
|
||||
}
|
||||
statuses, err := store.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status() error = %v", err)
|
||||
}
|
||||
encoded, _ := json.Marshal(statuses)
|
||||
for _, secret := range []string{"secret", "https://auth.example.com/token"} {
|
||||
if strings.Contains(string(encoded), secret) {
|
||||
t.Fatalf("status output leaked %q: %s", secret, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,15 @@ type StoredToken struct {
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
// TokenEndpoint, ClientID and ClientSecret hold what Login discovered or
|
||||
// dynamically registered, so Refresh works for the standard `auth: oauth`
|
||||
// setup whose config carries none of them. ClientSecret is sensitive.
|
||||
// ProtectedTokenEndpoint is set when the endpoint came from server-advertised
|
||||
// discovery and must pass the public-network policy again before use.
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
ProtectedTokenEndpoint bool `json:"protected_token_endpoint,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
// TokenStatus is a redaction-safe summary of a stored token. It deliberately
|
||||
@@ -393,6 +402,11 @@ func storedToOAuth(s StoredToken) oauth.Token {
|
||||
TokenType: s.TokenType,
|
||||
Scopes: s.Scopes,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
|
||||
TokenEndpoint: s.TokenEndpoint,
|
||||
ProtectedTokenEndpoint: s.ProtectedTokenEndpoint,
|
||||
ClientID: s.ClientID,
|
||||
ClientSecret: s.ClientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -668,3 +670,86 @@ func TestFullFlowUsesProtectedResourceDiscovery(t *testing.T) {
|
||||
t.Fatalf("flow hits = probe:%d resource:%d authorization:%d token:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load(), tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// A CLIENT STALLED MID-HEADER MUST NOT OUTLIVE LOGIN (#1028).
|
||||
//
|
||||
// Login built a bare http.Server and ended it with Shutdown under a one-second
|
||||
// deadline, and Shutdown only closes idle connections. A client that has sent
|
||||
// part of a request header is not idle, so it survived the deadline and kept
|
||||
// its goroutine, and the Serve goroutine, alive after Login returned.
|
||||
//
|
||||
// No sleep is needed to get the stalled connection accepted before Login ends:
|
||||
// it is dialled before the real callback's connection, Serve accepts in order,
|
||||
// and Login cannot finish until the real callback has been served.
|
||||
func TestLoginDoesNotLeaveAStalledCallbackClientOpen(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-final", "token_type": "Bearer", "expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
var stalled net.Conn
|
||||
opener := func(authURL string) error {
|
||||
parsed, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redirect, err := url.Parse(parsed.Query().Get("redirect_uri"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stalled, err = net.Dial("tcp", redirect.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(stalled, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", redirect.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
cbURL := redirect.String() + "?code=auth-code&state=" + url.QueryEscape(parsed.Query().Get("state"))
|
||||
go func() {
|
||||
if resp, err := http.Get(cbURL); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: "demo",
|
||||
ServerURL: "https://issuer.invalid",
|
||||
Config: OAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://issuer.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
},
|
||||
HTTPClient: http.DefaultClient,
|
||||
OpenBrowser: opener,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if stalled == nil {
|
||||
t.Fatal("SETUP INVALID: the stalled client never connected, so this proves nothing")
|
||||
}
|
||||
defer stalled.Close()
|
||||
requireCallbackClientClosed(t, stalled)
|
||||
}
|
||||
|
||||
// requireCallbackClientClosed fails unless the server end of conn is already
|
||||
// closed: a read has to fail with something other than a timeout.
|
||||
func requireCallbackClientClosed(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var one [1]byte
|
||||
_, err := conn.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
t.Fatal("the stalled callback client was still connected after Login returned")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("the stalled callback client received data instead of being closed")
|
||||
}
|
||||
}
|
||||
+59
-15
@@ -331,23 +331,56 @@ func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Res
|
||||
status = tools.StatusError
|
||||
}
|
||||
output := TextContent(result.Content)
|
||||
// Say what was thrown away. Without this an image-only result reads as
|
||||
// "(empty MCP tool result)", the model concludes the call produced nothing
|
||||
// and retries, and the user never learns an image came back (#823). The note
|
||||
// is appended only when something was actually dropped, so a text-only
|
||||
// result is byte-for-byte what it was before.
|
||||
images, disp, limitReason := forwardImages(result.Content)
|
||||
// Image blocks with valid data ride Result.Images, the same channel capture
|
||||
// tools already use. Everything else non-text is still named rather than
|
||||
// silently dropped, because Zero still has nowhere to put audio, embedded
|
||||
// resources, or a block whose payload could not be decoded (#823).
|
||||
//
|
||||
// It says retrying cannot RECOVER the payload rather than that a retry
|
||||
// returns the same thing. Each retry is a fresh call, so the server may well
|
||||
// answer differently; what cannot change is that Zero still has nowhere to
|
||||
// put a non-text block. Claiming the response would be identical would be a
|
||||
// promise this code is in no position to make.
|
||||
if dropped := DroppedContentSummary(result.Content); dropped != "" {
|
||||
note := "[zero] this server also returned " + dropped + ", which Zero cannot forward yet. Retrying cannot recover this payload."
|
||||
if output == "" {
|
||||
note = "[zero] this server returned " + dropped + ", which Zero cannot forward yet. Retrying cannot recover this payload."
|
||||
// Conversion happens once: the drop note is built from the same pass's
|
||||
// per-item disposition, so a valid image is not decoded again just to
|
||||
// decide whether it was forwarded.
|
||||
//
|
||||
// Image-only success has no text block, so a one-line placeholder keeps
|
||||
// the tool_result self-describing. ModelOutput and finalizeToolOutcome
|
||||
// copy Output through verbatim; an empty string would hand the model an
|
||||
// empty body next to the image.
|
||||
//
|
||||
// Notes are appended only when something was actually dropped, so a
|
||||
// text-only result is byte-for-byte what it was before, and a successfully
|
||||
// forwarded image is not described as unforwardable.
|
||||
//
|
||||
// Unforwardable blocks (audio, resource, failed decode) say retrying
|
||||
// cannot RECOVER the payload: Zero still has nowhere to put them.
|
||||
// Budget-skipped images are different — Zero can forward them, and a
|
||||
// retry with fewer images can recover the payload — so they get their
|
||||
// own sentence.
|
||||
if output == "" && len(images) > 0 {
|
||||
output = "[image returned by tool]"
|
||||
}
|
||||
if exceeded := droppedContentNote(result.Content, disp, dispBudgetExceeded); exceeded != "" {
|
||||
output = appendServerNote(output, exceeded,
|
||||
", which exceeded this result's remaining image budget. Retrying with fewer images can recover this payload.")
|
||||
}
|
||||
if uninspected := droppedContentNote(result.Content, disp, dispUninspected); uninspected != "" {
|
||||
verb := ", which were not inspected"
|
||||
if dispCount(disp, dispUninspected) == 1 {
|
||||
verb = ", which was not inspected"
|
||||
}
|
||||
output = strings.TrimSpace(output + "\n\n" + note)
|
||||
var cause string
|
||||
switch limitReason {
|
||||
case imageLimitForwardCount:
|
||||
cause = " because the maximum forwarded image count was reached."
|
||||
case imageLimitInspectionCount:
|
||||
cause = " because the maximum image inspection limit was reached."
|
||||
default:
|
||||
cause = " because the aggregate image budget was reached."
|
||||
}
|
||||
output = appendServerNote(output, uninspected, verb+cause)
|
||||
}
|
||||
if dropped := droppedContentNote(result.Content, disp, dispDropped); dropped != "" {
|
||||
output = appendServerNote(output, dropped,
|
||||
", which Zero cannot forward yet. Retrying cannot recover this payload.")
|
||||
}
|
||||
if output == "" {
|
||||
output = "(empty MCP tool result)"
|
||||
@@ -355,6 +388,7 @@ func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Res
|
||||
return tools.Result{
|
||||
Status: status,
|
||||
Output: output,
|
||||
Images: images,
|
||||
Meta: tool.meta(),
|
||||
}
|
||||
}
|
||||
@@ -399,3 +433,13 @@ func isPersistentlyApproved(store *PermissionStore, server Server, toolName stri
|
||||
})
|
||||
return err == nil && approved
|
||||
}
|
||||
|
||||
// appendServerNote joins a [zero] note to output, choosing "returned" or
|
||||
// "also returned" based on whether anything precedes it.
|
||||
func appendServerNote(output, subject, tail string) string {
|
||||
verb := "this server also returned "
|
||||
if output == "" {
|
||||
verb = "this server returned "
|
||||
}
|
||||
return strings.TrimSpace(output + "\n\n[zero] " + verb + subject + tail)
|
||||
}
|
||||
@@ -197,6 +197,24 @@ func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
|
||||
return modelsDevCached
|
||||
}
|
||||
|
||||
// startModelsDevRefresh starts a background RefreshModelsDevCache. Tests
|
||||
// replace it to see whether a refresh was started.
|
||||
var startModelsDevRefresh = func() {
|
||||
go func() { _ = RefreshModelsDevCache(context.Background()) }()
|
||||
}
|
||||
|
||||
// StartModelsDevRefresh refreshes the models.dev cache in the background when
|
||||
// the fetch is on. ZERO_DISABLE_MODELS_FETCH is read here, before anything is
|
||||
// started: a refresh goroutine reads the environment when it runs, so one
|
||||
// started by a caller that had turned the fetch off (a test, through t.Setenv)
|
||||
// could run after that setting was restored and fetch into the real cache.
|
||||
func StartModelsDevRefresh() {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return
|
||||
}
|
||||
startModelsDevRefresh()
|
||||
}
|
||||
|
||||
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
|
||||
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
|
||||
// call fire-and-forget from startup (use a goroutine); it never affects the
|
||||
|
||||
@@ -256,3 +256,25 @@ func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||
}
|
||||
t.Fatal("claude-sonnet-4.5 not found")
|
||||
}
|
||||
|
||||
// With the fetch off, no refresh is started at all, so none is left running to
|
||||
// read the environment after a test has restored it and fetch into the real
|
||||
// cache.
|
||||
func TestStartModelsDevRefreshStartsNothingWhenTheFetchIsOff(t *testing.T) {
|
||||
started := 0
|
||||
previous := startModelsDevRefresh
|
||||
startModelsDevRefresh = func() { started++ }
|
||||
t.Cleanup(func() { startModelsDevRefresh = previous })
|
||||
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
|
||||
StartModelsDevRefresh()
|
||||
if started != 0 {
|
||||
t.Fatalf("started %d refreshes with the fetch off, want none", started)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
StartModelsDevRefresh()
|
||||
if started != 1 {
|
||||
t.Fatalf("started %d refreshes with the fetch on, want 1", started)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
package modelregistry
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Match explicit multimodal local families, including common Ollama spellings.
|
||||
// Gemma 3 requires a vision-capable size: 270M and 1B are text-only.
|
||||
var localVisionFamily = regexp.MustCompile(`^(gemma-?3[-:](4b|12b|27b)|llama-?4|mistral-small-?3\.1|phi-?4-multimodal)($|[-:_.])`)
|
||||
|
||||
// SupportsVision reports whether the model identified by modelID accepts image
|
||||
// input. A model in the curated catalog is authoritative (its declared
|
||||
@@ -36,6 +43,8 @@ func VisionCapableByName(modelID string) bool {
|
||||
id = id[slash+1:] // drop a "provider/" prefix
|
||||
}
|
||||
switch {
|
||||
case localVisionFamily.MatchString(id):
|
||||
return true
|
||||
case strings.Contains(id, "gemini"):
|
||||
return true // every Gemini model is multimodal
|
||||
case strings.Contains(id, "claude-3"), strings.Contains(id, "claude-4"),
|
||||
|
||||
@@ -10,10 +10,15 @@ func TestVisionCapableByName(t *testing.T) {
|
||||
"claude-sonnet-4.5", "claude-3-haiku",
|
||||
"MiniMax-M3",
|
||||
"llava:13b", "qwen2.5-vl-7b", "llama3.2-vision", "pixtral-12b", "moondream",
|
||||
"google/gemma-3-27b-it", "gemma3:27b", "gemma3:4b", "gemma-3-12b-it",
|
||||
"llama-4-scout", "llama4:17b", "mistral-small-3.1", "mistral-small3.1:24b",
|
||||
"phi-4-multimodal", "phi4-multimodal-instruct",
|
||||
}
|
||||
textOnly := []string{
|
||||
"gpt-oss:120b", "kimi-for-coding", "deepseek-coder", "qwen2.5-coder",
|
||||
"codestral", "llama3.1-8b", "grok-text-only", "mistral-large",
|
||||
"gemma3:1b", "gemma-3-1b-it", "gemma3:270m", "gemma-3-270m-it",
|
||||
"gemma2:27b", "mistral-small3:24b", "phi4:14b",
|
||||
// Negated "vision" names must NOT match the bare-"vision" fallback.
|
||||
"my-custom-vision-less-model", "no-vision-model", "grok-vision-less",
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ type Sink interface {
|
||||
// attached Sinks. Safe for concurrent use.
|
||||
type Notifier struct {
|
||||
w io.Writer
|
||||
cfg Config // immutable after New; reads outside the lock are safe
|
||||
cfg Config // swap at runtime via Configure; reads outside the lock are safe
|
||||
|
||||
mu sync.Mutex
|
||||
focused bool
|
||||
@@ -71,6 +71,16 @@ func New(w io.Writer, cfg Config) *Notifier {
|
||||
return &Notifier{w: w, cfg: cfg}
|
||||
}
|
||||
|
||||
// Configure swaps the mode/focus policy at runtime. Sinks, focus state, and the
|
||||
// writer are preserved, so an in-session preference change (e.g. the TUI's
|
||||
// /notify command) applies from the next Notify call. Safe to call concurrently
|
||||
// with Notify.
|
||||
func (n *Notifier) Configure(cfg Config) {
|
||||
n.mu.Lock()
|
||||
n.cfg = cfg
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddSink registers an additional destination that receives every eligible
|
||||
// event (subject to the same mode/focus policy as the terminal). Sinks fire
|
||||
// even when the Notifier has no terminal writer, so a headless CI run can still
|
||||
@@ -102,18 +112,21 @@ func (n *Notifier) SetFocused(focused bool) {
|
||||
// Sinks are invoked outside the lock so a slow/blocking sink cannot stall a
|
||||
// concurrent Notify or SetFocused.
|
||||
func (n *Notifier) Notify(event Event, message string) {
|
||||
if n.cfg.Mode == ModeOff || n.cfg.Mode == "" {
|
||||
n.mu.Lock()
|
||||
// cfg is mutable at runtime (Configure), so every read happens under the
|
||||
// lock; shouldEmit/sequence work on the local copy.
|
||||
cfg := n.cfg
|
||||
if cfg.Mode == ModeOff || cfg.Mode == "" {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
eligible := shouldEmit(n.cfg, event, n.focused)
|
||||
eligible := shouldEmit(cfg, event, n.focused)
|
||||
var sinks []Sink
|
||||
if eligible && len(n.sinks) > 0 {
|
||||
sinks = append(sinks, n.sinks...)
|
||||
}
|
||||
if eligible && n.w != nil {
|
||||
if seq := sequence(n.cfg.Mode, message); seq != "" {
|
||||
if seq := sequence(cfg.Mode, message); seq != "" {
|
||||
_, _ = io.WriteString(n.w, seq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,44 @@ func TestSequence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure swaps the policy at runtime: after turning a silent notifier on,
|
||||
// the next Notify emits; after turning a noisy one off, it stays silent. Sinks
|
||||
// survive the swap (the TUI relies on this when /notify reconfigures mid-run).
|
||||
func TestConfigureAppliesImmediatelyAndKeepsSinks(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
n := New(&buf, Config{Mode: ModeOff})
|
||||
n.SetFocused(true)
|
||||
|
||||
n.Notify(Completion, "x")
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("off should be silent, got %q", buf.String())
|
||||
}
|
||||
|
||||
sink := &recordingSink{}
|
||||
n.AddSink(sink)
|
||||
n.Configure(Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
n.Notify(Completion, "x")
|
||||
if buf.String() != "\x07" {
|
||||
t.Fatalf("after Configure(bell) should bell, got %q", buf.String())
|
||||
}
|
||||
sink.mu.Lock()
|
||||
got := len(sink.events)
|
||||
sink.mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("sink should still receive events after Configure, got %d", got)
|
||||
}
|
||||
|
||||
n.Configure(Config{Mode: ModeOff})
|
||||
buf.Reset()
|
||||
n.Notify(Completion, "x")
|
||||
sink.mu.Lock()
|
||||
got = len(sink.events)
|
||||
sink.mu.Unlock()
|
||||
if buf.Len() != 0 || got != 1 {
|
||||
t.Fatalf("after Configure(off) should be fully silent, buf=%q events=%d", buf.String(), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessage(t *testing.T) {
|
||||
if got := sanitizeMessage("ok\x1b]0;evil\x07more\nx"); got != "ok]0;evilmorex" {
|
||||
t.Fatalf("sanitize=%q", got)
|
||||
@@ -109,6 +147,21 @@ func TestNotifyRaceSafe(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Configure mutates cfg under the lock while Notify reads it; this pair must
|
||||
// be race-clean (run under -race). Regression for the unsynchronized cfg read
|
||||
// Notify used to perform before acquiring the lock.
|
||||
func TestConfigureConcurrentWithNotify(t *testing.T) {
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); n.Configure(Config{Mode: ModeBoth, FocusMode: FocusAlways}) }()
|
||||
go func() { defer wg.Done(); n.Configure(Config{Mode: ModeOff}) }()
|
||||
go func() { defer wg.Done(); n.Notify(AwaitingInput, "x") }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestDefaultMessage(t *testing.T) {
|
||||
if DefaultMessage(Completion) != "Zero: ready" {
|
||||
t.Fatal("completion message")
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// callbackReadHeaderTimeout bounds how long an accepted connection may take to
|
||||
// send its request header. A single-use loopback callback server has no reason
|
||||
// to wait longer, and without a bound a client that stalls mid-header holds the
|
||||
// connection, and the server goroutine, open indefinitely. A var so the
|
||||
// package tests can shorten it.
|
||||
var callbackReadHeaderTimeout = 5 * time.Second
|
||||
|
||||
// callbackShutdownBudget is how long Close waits for in-flight requests before
|
||||
// it force-closes whatever is still open.
|
||||
const callbackShutdownBudget = time.Second
|
||||
|
||||
// CallbackServer is the lifecycle every loopback OAuth callback server needs,
|
||||
// in one place.
|
||||
//
|
||||
// It exists because the same server was written out three times (the shared
|
||||
// LoopbackListener, MCP's Login and OpenRouter's login) and a fix to one of
|
||||
// them did not reach the others. http.Server.Shutdown only closes idle
|
||||
// connections, so a client that has sent part of a request header is active,
|
||||
// survives the shutdown deadline, and keeps its goroutine and the Serve
|
||||
// goroutine alive past the end of the flow (#1028). #1051 closed that for
|
||||
// LoopbackListener; the other two kept the original construction.
|
||||
type CallbackServer struct {
|
||||
server *http.Server
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// StartCallbackServer serves handler on listener until Close.
|
||||
func StartCallbackServer(listener net.Listener, handler http.Handler) *CallbackServer {
|
||||
return startCallbackServer(listener, handler, nil)
|
||||
}
|
||||
|
||||
// startCallbackServer is StartCallbackServer with a ConnState hook, which the
|
||||
// package's own tests use to see a connection reach a given state.
|
||||
func startCallbackServer(listener net.Listener, handler http.Handler, connState func(net.Conn, http.ConnState)) *CallbackServer {
|
||||
s := &CallbackServer{
|
||||
server: &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: callbackReadHeaderTimeout,
|
||||
ConnState: connState,
|
||||
},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
_ = s.server.Serve(listener)
|
||||
close(s.done)
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
// Close shuts the server down within callbackShutdownBudget. A connection still
|
||||
// open when the budget runs out (a stalled request header, say) is force-closed
|
||||
// rather than leaked, and Close returns only after the Serve goroutine has
|
||||
// finished, so nothing this server started outlives the call. Idempotent.
|
||||
func (s *CallbackServer) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), callbackShutdownBudget)
|
||||
defer cancel()
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
_ = s.server.Close()
|
||||
}
|
||||
<-s.done
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startStalledCallbackClient starts a CallbackServer and connects a client that
|
||||
// sends part of a request header and then stops, which is the shape #1028 is
|
||||
// about. It returns once the server has ACCEPTED the connection, using the
|
||||
// ConnState hook rather than a sleep. StateNew, not StateActive: Go reports
|
||||
// StateActive only after a request read returns, and a stalled header read
|
||||
// returns only when the header timeout fires, so waiting for StateActive would
|
||||
// make the setup depend on the very bound the test is about.
|
||||
func startStalledCallbackClient(t *testing.T) (*CallbackServer, net.Conn) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted := make(chan struct{}, 1)
|
||||
server := startCallbackServer(listener, http.NotFoundHandler(), func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
select {
|
||||
case accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
server.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(conn, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", listener.Addr()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("SETUP INVALID: the server never accepted the stalled client")
|
||||
}
|
||||
return server, conn
|
||||
}
|
||||
|
||||
// connClosedWithin reports whether the server end of conn closes before the
|
||||
// deadline: a read must fail with something other than a timeout.
|
||||
func connClosedWithin(conn net.Conn, deadline time.Duration) bool {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(deadline))
|
||||
var one [1]byte
|
||||
_, err := conn.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return false
|
||||
}
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// THE HEADER READ IS BOUNDED EVEN BEFORE CLOSE. The force-close in Close ends a
|
||||
// stalled client when the flow ends; the header timeout ends it while the flow
|
||||
// is still waiting for its callback, which can be minutes. Close is not called
|
||||
// until after the assertion, so this can only pass on ReadHeaderTimeout.
|
||||
func TestCallbackServerBoundsAStalledHeaderWithoutClose(t *testing.T) {
|
||||
previous := callbackReadHeaderTimeout
|
||||
callbackReadHeaderTimeout = 200 * time.Millisecond
|
||||
defer func() { callbackReadHeaderTimeout = previous }()
|
||||
|
||||
server, conn := startStalledCallbackClient(t)
|
||||
defer server.Close()
|
||||
defer conn.Close()
|
||||
|
||||
if !connClosedWithin(conn, 3*time.Second) {
|
||||
t.Fatal("a client stalled mid-header was still connected with Close never called; the header read is unbounded")
|
||||
}
|
||||
}
|
||||
|
||||
// CLOSE RETURNS ONLY AFTER SERVE HAS. Its doc comment promises that nothing the
|
||||
// server started outlives the call. With no connections open, Shutdown closes
|
||||
// the listener and returns at once, so this is the case where Close could beat
|
||||
// the Serve goroutine back; the goroutine closing done is the last thing it
|
||||
// does, so done has to be closed by the time Close returns.
|
||||
func TestCallbackServerCloseWaitsForServe(t *testing.T) {
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := StartCallbackServer(listener, http.NotFoundHandler())
|
||||
server.Close()
|
||||
select {
|
||||
case <-server.done:
|
||||
default:
|
||||
t.Fatalf("attempt %d: Close returned while the Serve goroutine was still running", attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
|
||||
@@ -19,8 +18,7 @@ type LoopbackListener struct {
|
||||
listener net.Listener
|
||||
state string
|
||||
result chan callbackResult
|
||||
server *http.Server
|
||||
done chan struct{}
|
||||
server *CallbackServer
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
@@ -58,20 +56,8 @@ 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 = startCallbackServer(ln, http.HandlerFunc(l.handle), connState)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
@@ -122,14 +108,10 @@ 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.
|
||||
// and waits for the Serve goroutine to finish before returning. See
|
||||
// CallbackServer.Close.
|
||||
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.Close()
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization code,
|
||||
|
||||
@@ -45,6 +45,16 @@ type Token struct {
|
||||
// `chatgpt_account_id`) that the request path needs as headers. Treated as
|
||||
// sensitive like the access token: never logged, persisted 0600.
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
// TokenEndpoint, ClientID and ClientSecret record what a login learned that
|
||||
// config does not carry (a discovered token endpoint, a dynamically
|
||||
// registered client) so a later refresh can reuse it. ClientSecret is
|
||||
// sensitive like the tokens. ProtectedTokenEndpoint marks an endpoint
|
||||
// advertised by the server, which must be re-checked against the public
|
||||
// network policy before a refresh posts credentials to it.
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
ProtectedTokenEndpoint bool `json:"protected_token_endpoint,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
// Expired reports whether the token has an expiry that is at or before now.
|
||||
|
||||
@@ -375,6 +375,14 @@ type modelsResponseItem struct {
|
||||
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
|
||||
DefaultServiceTier string `json:"default_service_tier"`
|
||||
SupportedParameters []string `json:"supported_parameters"`
|
||||
Modalities struct {
|
||||
Input []string `json:"input"`
|
||||
Output []string `json:"output"`
|
||||
} `json:"modalities"`
|
||||
Architecture struct {
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
OutputModalities []string `json:"output_modalities"`
|
||||
} `json:"architecture"`
|
||||
}
|
||||
|
||||
type modelsResponse struct {
|
||||
@@ -428,6 +436,14 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
discoveryContainsFold(item.SupportedParameters, "include_reasoning")
|
||||
efforts := reasoningEfforts(item)
|
||||
serviceTiers := modelServiceTiers(item)
|
||||
inputModalities := cleanDiscoveryStrings(item.Modalities.Input)
|
||||
if len(inputModalities) == 0 {
|
||||
inputModalities = cleanDiscoveryStrings(item.Architecture.InputModalities)
|
||||
}
|
||||
outputModalities := cleanDiscoveryStrings(item.Modalities.Output)
|
||||
if len(outputModalities) == 0 {
|
||||
outputModalities = cleanDiscoveryStrings(item.Architecture.OutputModalities)
|
||||
}
|
||||
models = append(models, Model{
|
||||
ID: id,
|
||||
Description: description,
|
||||
@@ -438,6 +454,8 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
DefaultReasoningEffort: strings.TrimSpace(item.DefaultReasoning),
|
||||
ServiceTiers: serviceTiers,
|
||||
DefaultServiceTier: normalizeServiceTier(item.DefaultServiceTier),
|
||||
InputModalities: inputModalities,
|
||||
OutputModalities: outputModalities,
|
||||
Source: "live",
|
||||
})
|
||||
}
|
||||
@@ -574,6 +592,12 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
|
||||
if live.DefaultServiceTier != "" {
|
||||
catalog.DefaultServiceTier = live.DefaultServiceTier
|
||||
}
|
||||
if len(catalog.InputModalities) == 0 && len(live.InputModalities) > 0 {
|
||||
catalog.InputModalities = append([]string{}, live.InputModalities...)
|
||||
}
|
||||
if len(catalog.OutputModalities) == 0 && len(live.OutputModalities) > 0 {
|
||||
catalog.OutputModalities = append([]string{}, live.OutputModalities...)
|
||||
}
|
||||
catalog.Source = firstDiscoverySource(catalog.Source, "live")
|
||||
result = append(result, catalog)
|
||||
continue
|
||||
@@ -600,6 +624,20 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
|
||||
return result
|
||||
}
|
||||
|
||||
func cleanDiscoveryStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func discoveryContainsFold(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(strings.TrimSpace(value), want) {
|
||||
|
||||
@@ -109,6 +109,49 @@ func TestParseModelsResponseSupportsChatGPTCatalog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelsResponseCapturesModalities(t *testing.T) {
|
||||
models, err := parseModelsResponse([]byte(`{
|
||||
"data": [
|
||||
{
|
||||
"id": "openrouter/custom-multimodal",
|
||||
"architecture": {
|
||||
"input_modalities": ["text", "image"],
|
||||
"output_modalities": ["text"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "opengateway/flat-multimodal",
|
||||
"modalities": {
|
||||
"input": ["text", "image"],
|
||||
"output": ["text", "image"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "text-only",
|
||||
"architecture": {
|
||||
"input_modalities": ["text"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseModelsResponse: %v", err)
|
||||
}
|
||||
byID := map[string]Model{}
|
||||
for _, m := range models {
|
||||
byID[m.ID] = m
|
||||
}
|
||||
if got := strings.Join(byID["openrouter/custom-multimodal"].InputModalities, ","); got != "text,image" {
|
||||
t.Fatalf("openrouter input modalities = %q, want text,image", got)
|
||||
}
|
||||
if got := strings.Join(byID["opengateway/flat-multimodal"].InputModalities, ","); got != "text,image" {
|
||||
t.Fatalf("opengateway input modalities = %q, want text,image", got)
|
||||
}
|
||||
if got := strings.Join(byID["text-only"].InputModalities, ","); got != "text" {
|
||||
t.Fatalf("text-only input modalities = %q, want text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelsResponseNormalizesLegacyFastTier(t *testing.T) {
|
||||
models, err := parseModelsResponse([]byte(`{"data":[{"id":"gpt-test","additional_speed_tiers":["fast","priority"]}]}`))
|
||||
if err != nil {
|
||||
|
||||
@@ -79,7 +79,11 @@ func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error
|
||||
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The shared callback server bounds header reads, force-closes a client
|
||||
// still connected when shutdown runs out of time, and waits for Serve to
|
||||
// return. The bare http.Server this used to build did none of that, so a
|
||||
// client stalled mid-header outlived the login (#1028).
|
||||
server := oauth.StartCallbackServer(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -98,13 +102,8 @@ func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error
|
||||
case errCh <- errors.New("provideroauth: callback missing authorization code"):
|
||||
default:
|
||||
}
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer func() {
|
||||
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancelShutdown()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
authURL := base + openRouterAuthPath + "?" + url.Values{
|
||||
"callback_url": {callbackURL},
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// simulateBrowser returns an OpenBrowser func that authorizes by GETting the
|
||||
@@ -129,3 +132,62 @@ func TestOpenRouterLoginBrowserErrorPropagates(t *testing.T) {
|
||||
t.Fatal("a browser-open failure should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// A CLIENT STALLED MID-HEADER MUST NOT OUTLIVE THE LOGIN (#1028). Same defect
|
||||
// as MCP's Login: a bare http.Server ended by Shutdown alone, which leaves a
|
||||
// connection that has sent part of a request header open after the flow ends.
|
||||
// The stalled client is dialled before the real callback, so Serve has
|
||||
// accepted it by the time the login can complete; no sleep is involved.
|
||||
func TestOpenRouterLoginDoesNotLeaveAStalledCallbackClientOpen(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"key": "sk-or-mock-123"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var stalled net.Conn
|
||||
open := func(authURL string) error {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cb, err := url.Parse(u.Query().Get("callback_url"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stalled, err = net.Dial("tcp", cb.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(stalled, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", cb.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.Get(cb.String() + "?code=TESTCODE") //nolint:noctx // test loopback
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
|
||||
BaseURL: srv.URL, HTTPClient: srv.Client(), OpenBrowser: open,
|
||||
}); err != nil {
|
||||
t.Fatalf("OpenRouterLogin: %v", err)
|
||||
}
|
||||
if stalled == nil {
|
||||
t.Fatal("SETUP INVALID: the stalled client never connected, so this proves nothing")
|
||||
}
|
||||
defer stalled.Close()
|
||||
|
||||
_ = stalled.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var one [1]byte
|
||||
_, err := stalled.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
t.Fatal("the stalled callback client was still connected after OpenRouterLogin returned")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("the stalled callback client received data instead of being closed")
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ 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.
|
||||
@@ -108,6 +109,7 @@ type Provider struct {
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
streamIdleTimeout time.Duration
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an Anthropic provider.
|
||||
@@ -143,6 +145,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -163,12 +166,12 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, 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)
|
||||
@@ -185,6 +188,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
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 != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -53,6 +54,11 @@ 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
|
||||
@@ -74,6 +80,7 @@ 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{
|
||||
@@ -88,6 +95,7 @@ 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{
|
||||
@@ -345,6 +353,18 @@ 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
|
||||
|
||||
@@ -3,13 +3,17 @@ 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"
|
||||
)
|
||||
|
||||
@@ -794,3 +798,566 @@ 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
|
||||
}
|
||||
@@ -65,6 +65,7 @@ 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.
|
||||
@@ -85,6 +86,7 @@ type Provider struct {
|
||||
parseThinkTags bool
|
||||
setRequestExtra func(*http.Request)
|
||||
disablePromptCacheKey bool
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an OpenAI-compatible provider.
|
||||
@@ -140,6 +142,7 @@ func New(options Options) (*Provider, error) {
|
||||
parseThinkTags: options.ParseThinkTags,
|
||||
setRequestExtra: options.SetRequestExtra,
|
||||
disablePromptCacheKey: options.DisablePromptCacheKey,
|
||||
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -156,13 +159,13 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
|
||||
endpoint := provider.endpoint
|
||||
|
||||
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
|
||||
@@ -186,6 +189,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -52,6 +53,28 @@ 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
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
package redaction
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"regexp/syntax"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A CREDENTIAL IS STILL A CREDENTIAL WITH AN INVISIBLE BYTE IN THE MIDDLE.
|
||||
//
|
||||
// The shape matchers (openaiKeyPattern, textSecretPatterns) all describe a
|
||||
// CONTIGUOUS run of body characters, and none of their character classes admits
|
||||
// a control or format character. So a single NUL, ESC or zero-width space
|
||||
// dropped into the body of a key ends the match, and RedactString emits the two
|
||||
// fragments untouched. Whoever reads that output next, a terminal that eats the
|
||||
// escape, a log viewer, a JSON consumer that strips control bytes, or a person
|
||||
// copying the text, sees the original key back, because nothing about those
|
||||
// characters was ever displayed. Reported in #969 for NUL and ESC; every other
|
||||
// Cc and Cf character behaves the same way, which is why this is written against
|
||||
// the classes rather than against the two bytes named there.
|
||||
//
|
||||
// HOW: each shape is rewritten so that a run of separators may sit between any
|
||||
// two characters it consumes, and matched against the ORIGINAL text. Nothing is
|
||||
// removed before matching, so there are no offsets to map back, and the leading
|
||||
// \b of every shape is judged by the regexp engine against the real neighbour:
|
||||
// a key written after a NUL starts at a boundary, exactly as the unsplit key
|
||||
// does, and a key glued to the end of a word does not.
|
||||
//
|
||||
// An earlier version removed the separators first and matched the compacted
|
||||
// copy. That lost the difference between a separator inside a credential and one
|
||||
// next to it, and the fixes for that (check the boundary against the source
|
||||
// afterwards, map offsets back) left a hole: in "xAKIA<NUL>AKIAIOSF<SOH>ODNN7..."
|
||||
// the leftmost compacted match began at the decoy AKIA, was then rejected for
|
||||
// the "x" in front of it, and had already consumed the start of the real key, so
|
||||
// the real key was never considered. Resuming the scan one byte after each
|
||||
// rejection would close that, but it is quadratic for the greedy shapes on text
|
||||
// like "ask-ask-ask-..." with a single NUL in it. Keeping the boundary inside
|
||||
// the pattern lets RE2 do it in one linear pass.
|
||||
//
|
||||
// This pass runs AFTER the contiguous matchers, over their output, and never
|
||||
// across a replacement. A credential that is whole is claimed by its own strict
|
||||
// match, so two whole credentials with a separator between them stay two
|
||||
// replacements with the separator still between them. Two split credentials do
|
||||
// too: see spansFor.
|
||||
//
|
||||
// A match never starts or ends on a separator, so the separators on either side
|
||||
// of a credential stay where they were: "<NUL>AKIA..." becomes
|
||||
// "<NUL>[REDACTED]", as it does for the unsplit key.
|
||||
//
|
||||
// ONE KNOWN LIMIT, from running the contiguous matchers first. When the part of
|
||||
// a split key BEFORE its first separator is already a complete credential on
|
||||
// its own, the contiguous matcher claims that part and stops at the separator,
|
||||
// and what follows is judged by itself. That is the rest of the key, which is no
|
||||
// credential alone, so it stays visible: a fragment of the key, never the whole
|
||||
// of it, because the part in front is replaced. For the same reason a credential
|
||||
// glued straight onto that rest is left, where the unsplit key would have run on
|
||||
// and swallowed it. Carrying the contiguous match across the separator instead
|
||||
// would bring back what running first exists to prevent, since nothing tells the
|
||||
// rest of a key from prose written after a whole one: that prose would be eaten,
|
||||
// and a following JWT would lose its header.
|
||||
//
|
||||
// WHAT THIS DELIBERATELY DOES NOT TREAT AS A SEPARATOR: tab, newline and
|
||||
// carriage return. Those three are real text structure rather than invisible
|
||||
// filler, so a credential broken across a line boundary is still two visible
|
||||
// fragments here and is not covered. Treating them as gaps would also let a
|
||||
// match span a line break and collapse unrelated lines into one replacement. A
|
||||
// credential split that way is visible to whoever reads it; the ones handled
|
||||
// here are not. Invalid UTF-8 is out for the same reason: the engine reads an
|
||||
// invalid byte as U+FFFD, which is drawn.
|
||||
|
||||
// splitSecretSeparator reports a character that can sit inside a credential
|
||||
// without being seen. Cc covers the C0 and C1 controls (NUL, ESC, DEL, NEL, CSI)
|
||||
// and Cf the format characters (zero-width space and joiner, word joiner, BOM,
|
||||
// soft hyphen). Tab, newline and carriage return are excluded: see above.
|
||||
func splitSecretSeparator(r rune) bool {
|
||||
switch r {
|
||||
case '\t', '\n', '\r':
|
||||
return false
|
||||
}
|
||||
return unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r)
|
||||
}
|
||||
|
||||
// containsSplitSeparator is the cheap gate in front of the gap-tolerant pass, so
|
||||
// text with nothing invisible in it, which is nearly all text, costs one scan and
|
||||
// no allocation. ASCII is settled from the byte alone; only a byte that could
|
||||
// begin a multi-byte rune needs decoding, and the C1 and Cf blocks all start
|
||||
// with a lead byte at or above 0xC2.
|
||||
func containsSplitSeparator(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
b := value[i]
|
||||
if b < utf8.RuneSelf {
|
||||
if b < 0x20 || b == 0x7f {
|
||||
if b == '\t' || b == '\n' || b == '\r' {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
r, width := utf8.DecodeRuneInString(value[i:])
|
||||
if splitSecretSeparator(r) {
|
||||
return true
|
||||
}
|
||||
i += width - 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stripSplitSeparators returns value with every splitSecretSeparator removed.
|
||||
// Used only on a matched credential, to judge it by its visible characters.
|
||||
func stripSplitSeparators(value string) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(value))
|
||||
for offset := 0; offset < len(value); {
|
||||
r, width := utf8.DecodeRuneInString(value[offset:])
|
||||
if !splitSecretSeparator(r) {
|
||||
builder.WriteString(value[offset : offset+width])
|
||||
}
|
||||
offset += width
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// splitSeparatorClass is splitSecretSeparator as a regexp character class,
|
||||
// generated from the same Unicode tables and the same three exclusions so the
|
||||
// class and the predicate cannot disagree.
|
||||
var splitSeparatorClass = func() string {
|
||||
var class strings.Builder
|
||||
class.WriteByte('[')
|
||||
emit := func(lo, hi rune) {
|
||||
fmt.Fprintf(&class, `\x{%x}-\x{%x}`, lo, hi)
|
||||
}
|
||||
for _, table := range []*unicode.RangeTable{unicode.Cc, unicode.Cf} {
|
||||
visit := func(lo, hi, stride uint32) {
|
||||
for r := lo; r <= hi; r += stride {
|
||||
start := rune(r)
|
||||
if !splitSecretSeparator(start) {
|
||||
continue
|
||||
}
|
||||
end := start
|
||||
if stride == 1 {
|
||||
for next := rune(r) + 1; uint32(next) <= hi && splitSecretSeparator(next); next++ {
|
||||
end = next
|
||||
}
|
||||
r = uint32(end)
|
||||
}
|
||||
emit(start, end)
|
||||
}
|
||||
}
|
||||
for _, r := range table.R16 {
|
||||
visit(uint32(r.Lo), uint32(r.Hi), uint32(r.Stride))
|
||||
}
|
||||
for _, r := range table.R32 {
|
||||
visit(r.Lo, r.Hi, r.Stride)
|
||||
}
|
||||
}
|
||||
class.WriteByte(']')
|
||||
return class.String()
|
||||
}()
|
||||
|
||||
// splitOpenAIPattern and splitTextPatterns are the shape matchers with a run of
|
||||
// separators allowed between every two characters they consume, derived from
|
||||
// the originals rather than written out a second time so the two lists cannot
|
||||
// drift apart.
|
||||
var (
|
||||
splitOpenAIPattern = gapTolerant(openaiKeyPattern)
|
||||
splitTextPatterns = gapTolerantAll(textSecretPatterns)
|
||||
)
|
||||
|
||||
// splitShape is one credential shape as the split pass uses it.
|
||||
type splitShape struct {
|
||||
// free finds the shape anywhere, leftmost first. anchored matches it only
|
||||
// where the text begins, and whole only when it spans the entire text.
|
||||
free, anchored, whole *regexp.Regexp
|
||||
// prefix is the literal every match of the shape begins with.
|
||||
prefix string
|
||||
// accept is the test a match has to pass to be a credential, or nil when
|
||||
// every match is one.
|
||||
accept func(match string) bool
|
||||
}
|
||||
|
||||
var splitShapes = func() []splitShape {
|
||||
shapes := []splitShape{newSplitShape(openaiKeyPattern, splitOpenAIPattern, func(match string) bool {
|
||||
return openAIShapeIsSecret(stripSplitSeparators(match))
|
||||
})}
|
||||
for i, pattern := range textSecretPatterns {
|
||||
shapes = append(shapes, newSplitShape(pattern, splitTextPatterns[i], nil))
|
||||
}
|
||||
return shapes
|
||||
}()
|
||||
|
||||
func newSplitShape(original, free *regexp.Regexp, accept func(string) bool) splitShape {
|
||||
return splitShape{
|
||||
free: free,
|
||||
anchored: regexp.MustCompile(`\A(?:` + free.String() + `)`),
|
||||
whole: regexp.MustCompile(`\A(?:` + free.String() + `)\z`),
|
||||
prefix: leadingLiteral(original),
|
||||
accept: accept,
|
||||
}
|
||||
}
|
||||
|
||||
// leadingLiteral is the literal every match of pattern begins with, read past a
|
||||
// leading \b, or "" when the pattern does not open with one.
|
||||
func leadingLiteral(pattern *regexp.Regexp) string {
|
||||
tree, err := syntax.Parse(pattern.String(), syntax.Perl)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("redaction: parse %q: %v", pattern.String(), err))
|
||||
}
|
||||
nodes := []*syntax.Regexp{tree}
|
||||
if tree.Op == syntax.OpConcat {
|
||||
nodes = tree.Sub
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if node.Op == syntax.OpWordBoundary {
|
||||
continue
|
||||
}
|
||||
if node.Op == syntax.OpLiteral {
|
||||
return string(node.Rune)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maxRunOnCandidates bounds the anchored matches tried inside one match, so a
|
||||
// match full of lookalike starts still costs a fixed number of them.
|
||||
const maxRunOnCandidates = 8
|
||||
|
||||
// spansFor turns one match of the shape into the stretches to replace.
|
||||
//
|
||||
// A MATCH CAN RUN ON INTO THE NEXT CREDENTIAL OF ITS OWN SHAPE. Gaps are allowed
|
||||
// inside a match, and a separator between two credentials looks exactly like one
|
||||
// inside a credential, so a greedy body runs through it into whatever follows. A
|
||||
// credential of another shape is still found, because every shape is matched on
|
||||
// its own and the union is replaced. One of the SAME shape is not: matches of one
|
||||
// pattern never overlap, so the second credential cannot start inside the first
|
||||
// one's match. Most bodies swallow it whole, so nothing leaks, but a JWT stops at
|
||||
// the next token's first dot, and two split JWTs side by side left the second
|
||||
// one's payload and signature in the clear. And a match the filter rejects hides
|
||||
// whatever it ran into, so kebab-case prose in front of a split key took the key
|
||||
// down with it. Reported by @jatmn.
|
||||
//
|
||||
// So when a match runs through a run of separators into a position where the
|
||||
// same shape matches again and reaches at least as far, that credential is taken
|
||||
// on its own, and the first is cut at the separators if it is a complete match by
|
||||
// itself. The separators between the two then stay outside both replacements, as
|
||||
// they do between two whole credentials, and each piece is filtered on its own.
|
||||
// When the first is not complete alone, it keeps the whole of its match and the
|
||||
// union joins the two into one replacement.
|
||||
//
|
||||
// ONE STEP PER MATCH, NOT THE WHOLE CHAIN. The second credential's own match can
|
||||
// run on into a third, and following that from here would walk the rest of a
|
||||
// chain once for every match inside it, which is quadratic: a megabyte of split
|
||||
// JWTs ran for minutes. The scan's own next match starts inside the second
|
||||
// credential and takes the step after it, so the chain is still covered, and in
|
||||
// a chain of three or more the markers after the first pair can join into one.
|
||||
func (shape splitShape) spansFor(text string, start, end int) [][]int {
|
||||
next, gap, nextEnd := shape.runOn(text, start, end)
|
||||
if next < 0 {
|
||||
return shape.keep(nil, text, start, end)
|
||||
}
|
||||
var spans [][]int
|
||||
if shape.whole.MatchString(text[start:gap]) {
|
||||
spans = shape.keep(spans, text, start, gap)
|
||||
} else {
|
||||
spans = shape.keep(spans, text, start, end)
|
||||
}
|
||||
return shape.keep(spans, text, next, nextEnd)
|
||||
}
|
||||
|
||||
func (shape splitShape) keep(spans [][]int, text string, start, end int) [][]int {
|
||||
if shape.accept != nil && !shape.accept(text[start:end]) {
|
||||
return spans
|
||||
}
|
||||
return append(spans, []int{start, end})
|
||||
}
|
||||
|
||||
// runOn looks inside the match [start, end) for where it ran through a run of
|
||||
// separators into another credential of the same shape. It returns where that
|
||||
// credential begins, where the separator run in front of it begins, and where the
|
||||
// credential's own match ends, or next = -1 when there is none.
|
||||
//
|
||||
// Latest first, because a match reaches into the next credential only as far as
|
||||
// its own pattern allows. Only where the text after the separators opens with the
|
||||
// shape's literal prefix, which is where a match of the shape can begin; the
|
||||
// separator in front supplies the word boundary, so matching from there judges it
|
||||
// the same as the whole text would.
|
||||
func (shape splitShape) runOn(text string, start, end int) (next, gap, nextEnd int) {
|
||||
tried := 0
|
||||
for at := end; at > start && tried < maxRunOnCandidates; {
|
||||
r, size := utf8.DecodeLastRuneInString(text[start:at])
|
||||
if !splitSecretSeparator(r) {
|
||||
at -= size
|
||||
continue
|
||||
}
|
||||
candidate := at
|
||||
for at > start {
|
||||
r, size = utf8.DecodeLastRuneInString(text[start:at])
|
||||
if !splitSecretSeparator(r) {
|
||||
break
|
||||
}
|
||||
at -= size
|
||||
}
|
||||
if !hasGappedPrefix(text[candidate:], shape.prefix) {
|
||||
continue
|
||||
}
|
||||
tried++
|
||||
if match := shape.anchored.FindStringIndex(text[candidate:]); match != nil && candidate+match[1] >= end {
|
||||
return candidate, at, candidate + match[1]
|
||||
}
|
||||
}
|
||||
return -1, 0, 0
|
||||
}
|
||||
|
||||
// hasGappedPrefix reports whether text opens with prefix, allowing a run of
|
||||
// separators between its characters the way the shapes themselves do. A plain
|
||||
// prefix test missed a token split inside its first few characters, and the run
|
||||
// that swallowed it then went unnoticed.
|
||||
func hasGappedPrefix(text, prefix string) bool {
|
||||
at := 0
|
||||
for index, want := range prefix {
|
||||
if index > 0 {
|
||||
for at < len(text) {
|
||||
r, size := utf8.DecodeRuneInString(text[at:])
|
||||
if !splitSecretSeparator(r) {
|
||||
break
|
||||
}
|
||||
at += size
|
||||
}
|
||||
}
|
||||
if at >= len(text) {
|
||||
return false
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(text[at:])
|
||||
if r != want {
|
||||
return false
|
||||
}
|
||||
at += size
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gapTolerantAll(patterns []*regexp.Regexp) []*regexp.Regexp {
|
||||
tolerant := make([]*regexp.Regexp, 0, len(patterns))
|
||||
for _, pattern := range patterns {
|
||||
tolerant = append(tolerant, gapTolerant(pattern))
|
||||
}
|
||||
return tolerant
|
||||
}
|
||||
|
||||
// gapTolerant rewrites pattern so that a run of separators may appear before
|
||||
// every character it consumes except the first. Zero-width assertions, the
|
||||
// leading \b in particular, are left exactly where they were.
|
||||
func gapTolerant(pattern *regexp.Regexp) *regexp.Regexp {
|
||||
tree, err := syntax.Parse(pattern.String(), syntax.Perl)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("redaction: parse %q: %v", pattern.String(), err))
|
||||
}
|
||||
separator, err := syntax.Parse(splitSeparatorClass, syntax.Perl)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("redaction: parse separator class: %v", err))
|
||||
}
|
||||
consumed := false
|
||||
return regexp.MustCompile(withGaps(tree, separator, &consumed).String())
|
||||
}
|
||||
|
||||
// withGaps inserts the separator run before each consuming atom. consumed
|
||||
// tracks whether anything has been consumed yet on this path, because the first
|
||||
// character of a match must be a real one: a leading gap would let a match start
|
||||
// on a separator and swallow it.
|
||||
func withGaps(node, separator *syntax.Regexp, consumed *bool) *syntax.Regexp {
|
||||
switch node.Op {
|
||||
case syntax.OpLiteral:
|
||||
parts := make([]*syntax.Regexp, 0, len(node.Rune))
|
||||
for _, r := range node.Rune {
|
||||
atom := &syntax.Regexp{Op: syntax.OpLiteral, Rune: []rune{r}, Flags: node.Flags}
|
||||
parts = append(parts, gapBefore(atom, separator, consumed))
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return &syntax.Regexp{Op: syntax.OpConcat, Sub: parts}
|
||||
case syntax.OpCharClass, syntax.OpAnyChar, syntax.OpAnyCharNotNL:
|
||||
return gapBefore(node, separator, consumed)
|
||||
case syntax.OpConcat, syntax.OpCapture:
|
||||
for i := range node.Sub {
|
||||
node.Sub[i] = withGaps(node.Sub[i], separator, consumed)
|
||||
}
|
||||
return node
|
||||
case syntax.OpAlternate:
|
||||
start, after := *consumed, *consumed
|
||||
for i := range node.Sub {
|
||||
branch := start
|
||||
node.Sub[i] = withGaps(node.Sub[i], separator, &branch)
|
||||
after = after || branch
|
||||
}
|
||||
*consumed = after
|
||||
return node
|
||||
case syntax.OpStar, syntax.OpPlus, syntax.OpQuest, syntax.OpRepeat:
|
||||
// Every shape opens with a literal prefix, so a repetition is never the
|
||||
// first thing a match consumes. If one ever is, its first iteration would
|
||||
// need no gap and the rest would; fail loudly rather than guess.
|
||||
if !*consumed {
|
||||
panic("redaction: gap-tolerant rewrite reached a repetition before any literal")
|
||||
}
|
||||
node.Sub[0] = withGaps(node.Sub[0], separator, consumed)
|
||||
return node
|
||||
case syntax.OpWordBoundary, syntax.OpNoWordBoundary, syntax.OpBeginLine, syntax.OpEndLine,
|
||||
syntax.OpBeginText, syntax.OpEndText, syntax.OpEmptyMatch:
|
||||
return node
|
||||
default:
|
||||
panic(fmt.Sprintf("redaction: gap-tolerant rewrite does not handle %v", node.Op))
|
||||
}
|
||||
}
|
||||
|
||||
func gapBefore(atom, separator *syntax.Regexp, consumed *bool) *syntax.Regexp {
|
||||
if !*consumed {
|
||||
*consumed = true
|
||||
return atom
|
||||
}
|
||||
gap := &syntax.Regexp{Op: syntax.OpStar, Sub: []*syntax.Regexp{separator}}
|
||||
return &syntax.Regexp{Op: syntax.OpConcat, Sub: []*syntax.Regexp{gap, atom}}
|
||||
}
|
||||
|
||||
// openAIShapeIsSecret is the digit/kebab-case filter RedactString applies to an
|
||||
// openaiKeyPattern match, named so both callers state the same rule once.
|
||||
func openAIShapeIsSecret(match string) bool {
|
||||
if knownOpenAIKeyPrefix(match) || secretMatchHasDigit(match) {
|
||||
return true
|
||||
}
|
||||
return !strings.Contains(strings.TrimPrefix(match, "sk-"), "-")
|
||||
}
|
||||
|
||||
// redactControlSplitSecrets replaces credentials whose body is interrupted by
|
||||
// invisible characters. It is a no-op for text containing none of them, which is
|
||||
// nearly all text, so the contiguous path keeps its behavior exactly.
|
||||
//
|
||||
// It runs over the contiguous matchers' output and stops at every replacement
|
||||
// they left behind: those mark text already accounted for, and a match reaching
|
||||
// across one would be reaching out of one credential and into the next.
|
||||
//
|
||||
// It runs at most twice. The second pass exists for a credential glued straight
|
||||
// onto the end of another split one: it has no word boundary of its own until
|
||||
// the first is replaced, and the replacement then supplies one, the same way the
|
||||
// contiguous shapes cascade into each other. Two passes and no more keeps the
|
||||
// cost linear; a longer chain of glued split credentials is left as the
|
||||
// contiguous matchers leave a chain of glued whole ones.
|
||||
func redactControlSplitSecrets(value, replacement string) string {
|
||||
if !containsSplitSeparator(value) {
|
||||
return value
|
||||
}
|
||||
once := redactSplitRegions(value, replacement, true)
|
||||
if once == value {
|
||||
return value
|
||||
}
|
||||
// Ungated: the credential the second pass exists for is often contiguous
|
||||
// itself (a whole JWT glued onto a split AWS key), so its region can hold no
|
||||
// separator at all once the first pass has cut the text around it.
|
||||
return redactSplitRegions(once, replacement, false)
|
||||
}
|
||||
|
||||
func redactSplitRegions(value, replacement string, gated bool) string {
|
||||
if replacement == "" {
|
||||
return redactSplitRegion(value, replacement, 0, gated)
|
||||
}
|
||||
regions := strings.Split(value, replacement)
|
||||
if len(regions) == 1 {
|
||||
return redactSplitRegion(value, replacement, 0, gated)
|
||||
}
|
||||
previous := byte(0)
|
||||
for i, region := range regions {
|
||||
regions[i] = redactSplitRegion(region, replacement, previous, gated)
|
||||
previous = replacement[len(replacement)-1]
|
||||
}
|
||||
return strings.Join(regions, replacement)
|
||||
}
|
||||
|
||||
// redactSplitRegion runs every gap-tolerant shape over one stretch of text with
|
||||
// no replacement inside it and replaces the UNION of what they found.
|
||||
//
|
||||
// Independently, not one after another. An unbounded body with gaps allowed will
|
||||
// run on across a separator into whatever follows, and when that is the start of
|
||||
// another credential it takes that credential's prefix with it: in
|
||||
// "sk-ant-…<ESC>build<ESC>eyJ….eyJ….sig" the Anthropic shape stops only at the
|
||||
// JWT's first dot. Applied in sequence, the JWT shape then finds its header gone
|
||||
// and the payload and signature are left in the clear. Matched against the same
|
||||
// text, the JWT shape still finds the whole token, and the union covers both.
|
||||
//
|
||||
// previous is the byte immediately before the region in the original, or zero at
|
||||
// the start of the string. It is lent to the engine in front of the region so a
|
||||
// leading \b is judged against the real neighbour, and a match is never allowed
|
||||
// to begin on it.
|
||||
func redactSplitRegion(value, replacement string, previous byte, gated bool) string {
|
||||
if gated && !containsSplitSeparator(value) {
|
||||
return value
|
||||
}
|
||||
text, lead := value, 0
|
||||
if previous != 0 {
|
||||
text, lead = string([]byte{previous})+value, 1
|
||||
}
|
||||
var spans [][]int
|
||||
for _, shape := range splitShapes {
|
||||
for _, match := range shape.free.FindAllStringIndex(text, -1) {
|
||||
if match[0] >= lead {
|
||||
spans = append(spans, shape.spansFor(text, match[0], match[1])...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return spliceSpans(text, spans, replacement)[lead:]
|
||||
}
|
||||
|
||||
// spliceSpans replaces every named range of value with replacement, merging
|
||||
// ranges that overlap so one stretch of text is never replaced twice.
|
||||
func spliceSpans(value string, spans [][]int, replacement string) string {
|
||||
if len(spans) == 0 {
|
||||
return value
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool {
|
||||
if spans[i][0] != spans[j][0] {
|
||||
return spans[i][0] < spans[j][0]
|
||||
}
|
||||
return spans[i][1] > spans[j][1]
|
||||
})
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(value))
|
||||
written := 0
|
||||
for _, span := range spans {
|
||||
if span[0] < written {
|
||||
// Overlaps the stretch just replaced: extend it rather than emit a
|
||||
// second marker, which would claim two credentials where there was one
|
||||
// stretch of text.
|
||||
if span[1] > written {
|
||||
written = span[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
builder.WriteString(value[written:span[0]])
|
||||
builder.WriteString(replacement)
|
||||
written = span[1]
|
||||
}
|
||||
builder.WriteString(value[written:])
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
package redaction
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
awsKey = "AKIAIOSFODNN7EXAMPLE"
|
||||
anthropicKey = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaa0123456789ABCD"
|
||||
githubKey = "ghp_cccccccccccccccccccccccccccccccccccc"
|
||||
openaiKey = "sk-proj-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
// rejoin drops every character a reader would never see, which is what a
|
||||
// terminal, a log viewer or a copy-paste does to this output. If the secret is
|
||||
// back after that, the redaction did not happen.
|
||||
func rejoin(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if splitSecretSeparator(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
// AN INVISIBLE BYTE INSIDE A KEY MUST NOT END THE MATCH.
|
||||
//
|
||||
// Every shape matcher describes a contiguous body, so one NUL or ESC in the
|
||||
// middle of a key ended the match and both fragments were emitted. Dropping the
|
||||
// byte — which is what the next reader does, because it was never displayed —
|
||||
// put the original key back. Reported in #969 for NUL and ESC; this covers the
|
||||
// classes those two belong to, because every member behaves the same way.
|
||||
func TestRedactStringRedactsSecretsSplitByInvisibleCharacters(t *testing.T) {
|
||||
separators := map[string]string{
|
||||
"NUL": "\x00",
|
||||
"ESC": "\x1b",
|
||||
"BEL": "\x07",
|
||||
"backspace": "\x08",
|
||||
"vertical tab": "\x0b",
|
||||
"form feed": "\x0c",
|
||||
"DEL": "\x7f",
|
||||
"C1 NEL": "\u0085",
|
||||
"C1 CSI": "\u009b",
|
||||
"zero width space": "\u200b",
|
||||
"zero width joiner": "\u200d",
|
||||
"word joiner": "\u2060",
|
||||
"byte order mark": "\ufeff",
|
||||
"soft hyphen": "\u00ad",
|
||||
}
|
||||
// Every entry has to BE a separator, or its leg passes vacuously: inserting an
|
||||
// ordinary character into a key also stops the secret matching, and the rejoin
|
||||
// below would then find nothing. An escape mangled on the way into this file is
|
||||
// exactly how that happens.
|
||||
for name, separator := range separators {
|
||||
if runes := []rune(separator); len(runes) != 1 || !splitSecretSeparator(runes[0]) {
|
||||
t.Fatalf("SETUP INVALID: %s is %q, which is not a single invisible separator", name, separator)
|
||||
}
|
||||
}
|
||||
secrets := map[string]string{"aws": awsKey, "anthropic": anthropicKey, "github": githubKey, "openai": openaiKey}
|
||||
for secretName, secret := range secrets {
|
||||
// The premise: the unsplit form already redacts, so a failure below is
|
||||
// about the separator and not about the shape being unknown.
|
||||
if out := RedactString(secret, Options{}); strings.Contains(out, secret) {
|
||||
t.Fatalf("SETUP INVALID: the unsplit %s key is not redacted at all: %q", secretName, out)
|
||||
}
|
||||
for sepName, separator := range separators {
|
||||
for _, at := range []int{1, len(secret) / 2, len(secret) - 1} {
|
||||
split := secret[:at] + separator + secret[at:]
|
||||
out := RedactString(split, Options{})
|
||||
if got := rejoin(out); strings.Contains(got, secret) {
|
||||
t.Errorf("%s key split by %s at byte %d survives: %q rejoins to %q", secretName, sepName, at, out, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The replacement has to cover the ORIGINAL bytes, separators included. Leaving
|
||||
// the separator behind would emit `[REDACTED]\x00[REDACTED]` and tell a reader
|
||||
// there were two secrets, and leaving either fragment behind is the leak itself.
|
||||
func TestRedactStringCoversTheSeparatorsInsideASplitSecret(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"one separator", "key=" + awsKey[:8] + "\x00" + awsKey[8:], "key=[REDACTED]"},
|
||||
{"several separators", "key=" + strings.Join(strings.Split(awsKey, ""), "\x1b"), "key=[REDACTED]"},
|
||||
{"adjacent separators", "key=" + awsKey[:8] + "\x00\x1b\u200b" + awsKey[8:], "key=[REDACTED]"},
|
||||
{"two split secrets", awsKey[:8] + "\x00" + awsKey[8:] + " and " + githubKey[:8] + "\x00" + githubKey[8:], "[REDACTED] and [REDACTED]"},
|
||||
{"separator before the secret", "\x00" + awsKey, "\x00[REDACTED]"},
|
||||
{"separator after the secret", awsKey + "\x00", "[REDACTED]\x00"},
|
||||
{"text around it survives", "before " + awsKey[:4] + "\x00" + awsKey[4:] + " after", "before [REDACTED] after"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := RedactString(testCase.value, Options{}); got != testCase.want {
|
||||
t.Errorf("RedactString(...) = %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NOTHING ELSE MOVES. The compaction exists to find a match; it must never
|
||||
// reach text that has no secret in it, and text with no invisible character at
|
||||
// all must take exactly the path it took before.
|
||||
func TestRedactStringLeavesOrdinaryTextWithControlCharactersAlone(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"a plain sentence",
|
||||
"a sentence\x00with a NUL in it",
|
||||
"\x1b[31mcolored output\x1b[0m",
|
||||
"tab\tseparated\tcolumns",
|
||||
"line one\nline two\r\nline three",
|
||||
"zero\u200bwidth\u200bspaces",
|
||||
"", "\x00", "\x00\x00\x00",
|
||||
"not-a-secret-just-a-long-kebab-case-identifier-here",
|
||||
"sk-some-kebab-case-value-without-any-digits-at-all",
|
||||
} {
|
||||
if got := RedactString(value, Options{}); got != value {
|
||||
t.Errorf("RedactString(%q) = %q, want it unchanged", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The openai filter that keeps kebab-case identifiers out of the redactor has
|
||||
// to apply on the split path too, or working around the false positive would
|
||||
// become a matter of inserting a control byte.
|
||||
func TestSplitPathKeepsTheOpenAIKebabCaseFilter(t *testing.T) {
|
||||
kebab := "sk-some-kebab-case-value-without-digits"
|
||||
split := kebab[:6] + "\x00" + kebab[6:]
|
||||
if got := RedactString(split, Options{}); got != split {
|
||||
t.Errorf("a kebab-case identifier split by a NUL was redacted: %q", got)
|
||||
}
|
||||
// ... while a real key of the same family, split the same way, is not spared.
|
||||
realKey := openaiKey[:6] + "\x00" + openaiKey[6:]
|
||||
if got := RedactString(realKey, Options{}); strings.Contains(rejoin(got), openaiKey) {
|
||||
t.Errorf("a real openai key split by a NUL survived: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TAB, NEWLINE AND CARRIAGE RETURN ARE OUT OF SCOPE ON PURPOSE, and this says
|
||||
// so in a place that fails if someone changes it without meaning to. They are
|
||||
// real text structure: stripping them would let one match span a line break and
|
||||
// replace unrelated lines, and a credential broken across a line is visible to
|
||||
// whoever reads it rather than hidden from them.
|
||||
func TestLineStructureIsNotTreatedAsAnInvisibleSeparator(t *testing.T) {
|
||||
for name, separator := range map[string]string{"tab": "\t", "newline": "\n", "carriage return": "\r"} {
|
||||
if splitSecretSeparator([]rune(separator)[0]) {
|
||||
t.Errorf("%s is treated as an invisible separator; if that is now wanted, this test and the note in control_split.go both need to change", name)
|
||||
}
|
||||
split := awsKey[:8] + separator + awsKey[8:]
|
||||
if got := RedactString(split, Options{}); got != split {
|
||||
t.Errorf("%s split was redacted: %q. That may be an improvement, but it is a scope change this test is here to make deliberate", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed UTF-8 must not move the span mapping, which works in the source's
|
||||
// own byte offsets. A decoder that measured an invalid byte as the three bytes
|
||||
// of U+FFFD would shift every later span.
|
||||
// The exact output is asserted, not just the absence of the key. An invalid
|
||||
// byte measured as three would still cover the secret while moving the
|
||||
// replacement's edges onto the bytes around it, which reads as a pass if the
|
||||
// test only asks whether the secret is gone.
|
||||
func TestSplitRedactionHandlesMalformedUTF8(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"invalid byte before the secret", "\xff" + awsKey[:8] + "\x00" + awsKey[8:], "\xff[REDACTED]"},
|
||||
// An invalid byte is NOT one of the separators: it decodes to U+FFFD and
|
||||
// is drawn, so a key broken by one is visible to whoever reads it, the
|
||||
// same argument that keeps tab and newline out. It is kept in the
|
||||
// compacted text and so it still ends the match.
|
||||
{"invalid byte inside the split", awsKey[:8] + "\x00\xff" + awsKey[8:], awsKey[:8] + "\x00\xff" + awsKey[8:]},
|
||||
{"invalid byte after the secret", awsKey[:8] + "\x00" + awsKey[8:] + "\xfe", "[REDACTED]\xfe"},
|
||||
{"invalid bytes on both sides", "\xff\xfe" + awsKey[:8] + "\x00" + awsKey[8:] + "\xfd\xfc", "\xff\xfe[REDACTED]\xfd\xfc"},
|
||||
{"invalid byte then text then the secret", "\xffpre " + awsKey[:8] + "\x00" + awsKey[8:] + " post", "\xffpre [REDACTED] post"},
|
||||
{"only invalid bytes", "\xff\xfe\x00\xff", "\xff\xfe\x00\xff"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := RedactString(testCase.value, Options{})
|
||||
if got != testCase.want {
|
||||
t.Errorf("RedactString(%q) = %q, want %q", testCase.value, got, testCase.want)
|
||||
}
|
||||
if strings.Contains(rejoin(got), awsKey) {
|
||||
t.Errorf("the key survived: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The compaction is only a lens for finding the match. A string with no
|
||||
// separator at all must come back byte-identical, including its invalid bytes.
|
||||
func TestCompactionDoesNotRewriteTextItDoesNotRedact(t *testing.T) {
|
||||
for _, value := range []string{"plain", "with\xffinvalid", "tab\there", "\xef\xbb\xbfbom at the front"} {
|
||||
if got := redactControlSplitSecrets(value, RedactedSecret); got != value {
|
||||
t.Errorf("redactControlSplitSecrets(%q) = %q, want it unchanged", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The separators the tests below need by name, built from their code points so
|
||||
// no escape in this file can be mangled into the character it stands for.
|
||||
var (
|
||||
nulSeparator = string(rune(0x00))
|
||||
escSeparator = string(rune(0x1b))
|
||||
zwspSeparator = string(rune(0x200b))
|
||||
)
|
||||
|
||||
const (
|
||||
slackKey = "xoxb-EXAMPLE-NOT-A-REAL-TOKEN-AAAAAAAAAA"
|
||||
googleKey = "AIzaSyD-0123456789abcdefghijklmnopqrstuv"
|
||||
jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r0W1gFWFOEjXkPY"
|
||||
otherJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEwIn0.QWxpY2VCb2JDYXJvbERhdmVFdmVGcmFua0dyYWNlSA"
|
||||
)
|
||||
|
||||
// splitInTheMiddle puts one separator inside the body of a secret.
|
||||
func splitInTheMiddle(secret, separator string) string {
|
||||
half := len(secret) / 2
|
||||
return secret[:half] + separator + secret[half:]
|
||||
}
|
||||
|
||||
// A SEPARATOR IN FRONT OF A KEY IS A DELIMITER, ONE INSIDE IT IS FILLER.
|
||||
//
|
||||
// #969 asks for one thing: the split form is redacted the same as the unsplit
|
||||
// form. That is the whole assertion here, run over every kind of byte a key can
|
||||
// follow. It catches the two ways of getting it wrong from opposite sides. Drop
|
||||
// the separators and then ask the pattern for a word boundary, and a key written
|
||||
// after a NUL stops redacting the moment a second separator lands inside it,
|
||||
// even though the unsplit key after that same NUL redacts today. Ignore the
|
||||
// boundary instead, and a key glued to the end of a word starts redacting when
|
||||
// it is split, although the unsplit form never did.
|
||||
func TestSplitRedactionMatchesTheUnsplitVerdictAtEveryLeadingBoundary(t *testing.T) {
|
||||
leaders := map[string]string{
|
||||
"start of string": "",
|
||||
"space": "before ",
|
||||
"quote": `{"k":"`,
|
||||
"equals": "key=",
|
||||
"NUL": "prefix" + nulSeparator,
|
||||
"ESC": "prefix" + escSeparator,
|
||||
"zero width": "prefix" + zwspSeparator,
|
||||
"letter": "prefix",
|
||||
"digit": "7",
|
||||
"underscore": "field_",
|
||||
}
|
||||
secrets := map[string]string{"aws": awsKey, "github": githubKey, "openai": openaiKey, "slack": slackKey, "google": googleKey}
|
||||
for secretName, secret := range secrets {
|
||||
for leaderName, leader := range leaders {
|
||||
unsplit := leader + secret
|
||||
split := leader + splitInTheMiddle(secret, escSeparator)
|
||||
wantRedacted := !strings.Contains(RedactString(unsplit, Options{}), secret)
|
||||
out := RedactString(split, Options{})
|
||||
gotRedacted := !strings.Contains(rejoin(out), secret)
|
||||
if gotRedacted == wantRedacted {
|
||||
continue
|
||||
}
|
||||
if wantRedacted {
|
||||
t.Errorf("%s key after a %s leader: the unsplit form redacts and the split form does not: %q rejoins to %q",
|
||||
secretName, leaderName, out, rejoin(out))
|
||||
continue
|
||||
}
|
||||
t.Errorf("%s key after a %s leader: the unsplit form is left alone and the split form is redacted: %q",
|
||||
secretName, leaderName, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A MATCH MUST NOT REACH OUT OF ONE CREDENTIAL AND INTO THE NEXT.
|
||||
//
|
||||
// The JWT shapes end on a run of body characters with no trailing boundary, so
|
||||
// a matcher reading a copy with the separators removed can start in the key in
|
||||
// front and run through the header of the JWT behind it, replacing both with one
|
||||
// marker and eating the delimiter between them. The contiguous pass claims whole
|
||||
// credentials before this one runs, which is what keeps them apart.
|
||||
func TestSplitRedactionKeepsNeighbouringCredentialsApart(t *testing.T) {
|
||||
separators := map[string]string{"NUL": nulSeparator, "ESC": escSeparator, "zero width": zwspSeparator}
|
||||
leaders := map[string]string{
|
||||
"jwt": jwtToken,
|
||||
"github": githubKey,
|
||||
"anthropic": anthropicKey,
|
||||
"openai": openaiKey,
|
||||
"slack": slackKey,
|
||||
"google": googleKey,
|
||||
"aws": awsKey,
|
||||
}
|
||||
for leaderName, leader := range leaders {
|
||||
for sepName, separator := range separators {
|
||||
value := leader + separator + otherJWT
|
||||
want := RedactedSecret + separator + RedactedSecret
|
||||
if got := RedactString(value, Options{}); got != want {
|
||||
t.Errorf("a %s key %s a JWT: RedactString(...) = %q, want %q", leaderName, sepName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitThroughout puts a separator a quarter of the way into every dot-separated
|
||||
// segment of a token. Early, so the part in front is too short to be a
|
||||
// credential by itself and the strict pass cannot claim it; a split whose front
|
||||
// part is complete alone is the documented limit, not what these tests are about.
|
||||
func splitThroughout(secret, separator string) string {
|
||||
parts := strings.Split(secret, ".")
|
||||
for i, part := range parts {
|
||||
quarter := len(part) / 4
|
||||
parts[i] = part[:quarter] + separator + part[quarter:]
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// TWO SPLIT CREDENTIALS OF ONE SHAPE ARE TWO CREDENTIALS.
|
||||
//
|
||||
// Matches of one pattern never overlap, so when the first one's body runs on
|
||||
// through the separator between them, the second cannot start a match of its
|
||||
// own. Whole neighbours are safe because the strict pass claims them first;
|
||||
// these are both split. A JWT body stops at the next token's first dot and a
|
||||
// classic GitHub body at the next token's underscore, which left the rest of the
|
||||
// second credential in the clear, and the bodies that swallow the next key whole
|
||||
// joined both keys and the separator between them into one replacement.
|
||||
func TestSplitRedactionKeepsTwoSplitCredentialsOfOneShapeApart(t *testing.T) {
|
||||
separators := map[string]string{"NUL": nulSeparator, "ESC": escSeparator, "zero width": zwspSeparator}
|
||||
pairs := map[string][2]string{
|
||||
"jwt": {jwtToken, otherJWT},
|
||||
"github": {githubKey, "ghp_ffffffffffffffffffffffffffffffffffff"},
|
||||
"openai": {openaiKey, "sk-proj-dddddddddddddddddddddddddddddddd"},
|
||||
"anthropic": {anthropicKey, "sk-ant-api03-eeeeeeeeeeeeeeeeeeeeeeee9876543210WXYZ"},
|
||||
"slack": {slackKey, "xoxb-ANOTHER-NOT-REAL-TOKEN-BBBBBBBBBBBB"},
|
||||
}
|
||||
for name, pair := range pairs {
|
||||
for sepName, separator := range separators {
|
||||
value := splitThroughout(pair[0], escSeparator) + separator + splitThroughout(pair[1], escSeparator)
|
||||
want := RedactedSecret + separator + RedactedSecret
|
||||
if got := RedactString(value, Options{}); got != want {
|
||||
t.Errorf("two split %s credentials with %s between them: RedactString(...) = %q, want %q", name, sepName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The search for where the next credential begins is bounded, so it has to be
|
||||
// spent on positions where one can begin. Here the second token's header is split
|
||||
// at every character inside the part the first match swallowed, which puts dozens
|
||||
// of separator runs between the end of that match and the real boundary.
|
||||
func TestSplitRedactionFindsTheNextCredentialPastItsOwnSplits(t *testing.T) {
|
||||
parts := strings.Split(otherJWT, ".")
|
||||
header := strings.Join(strings.Split(parts[0], ""), escSeparator)
|
||||
second := header + "." + strings.Join(parts[1:], ".")
|
||||
value := splitThroughout(jwtToken, escSeparator) + nulSeparator + second
|
||||
want := RedactedSecret + nulSeparator + RedactedSecret
|
||||
if got := RedactString(value, Options{}); got != want {
|
||||
t.Errorf("RedactString(...) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// And a chain of them leaks nothing, however the markers fall.
|
||||
func TestSplitRedactionLeavesNothingOfAChainOfSplitJWTs(t *testing.T) {
|
||||
chain := splitThroughout(jwtToken, escSeparator) + nulSeparator +
|
||||
splitThroughout(otherJWT, escSeparator) + nulSeparator +
|
||||
splitThroughout(jwtToken, zwspSeparator)
|
||||
out := rejoin(RedactString(chain, Options{}))
|
||||
for _, segment := range append(strings.Split(jwtToken, "."), strings.Split(otherJWT, ".")...) {
|
||||
if strings.Contains(out, segment) {
|
||||
t.Errorf("a JWT segment survived a chain of three split tokens: %q in %q", segment, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A CHAIN OF SPLIT CREDENTIALS COSTS WHAT ITS LENGTH COSTS.
|
||||
//
|
||||
// The run-on search once followed a chain to its end from every match inside it,
|
||||
// which is quadratic in the chain: a megabyte of split JWTs back to back
|
||||
// allocated seventeen gigabytes and ran for ten minutes. Measured as allocation,
|
||||
// which that walk multiplies with the chain and a linear pass does not.
|
||||
func TestSplitRedactionStaysLinearOnAChainOfSplitJWTs(t *testing.T) {
|
||||
unit := splitThroughout(jwtToken, escSeparator) + nulSeparator
|
||||
chain := strings.Repeat(unit, (64<<10)/len(unit))
|
||||
signature := strings.Split(jwtToken, ".")[2]
|
||||
if strings.Contains(rejoin(RedactString(chain, Options{})), signature) {
|
||||
t.Fatal("SETUP INVALID: the chain is not redacted, so the run-on search never ran")
|
||||
}
|
||||
limit := int64(200 * len(chain))
|
||||
if used := redactAllocBytes(chain); used > limit {
|
||||
t.Errorf("redacting a %d byte chain of split JWTs allocated %d bytes, over the %d byte limit", len(chain), used, limit)
|
||||
}
|
||||
}
|
||||
|
||||
// A MATCH THE FILTER REJECTS MUST NOT HIDE THE KEY IT RAN INTO.
|
||||
//
|
||||
// The OpenAI shape is filtered, because kebab-case with no digit in it is prose
|
||||
// and not a key. A rejected match still spans the text it matched, and its body
|
||||
// runs on through a separator into whatever follows, so a split key after a
|
||||
// kebab-case name was rejected along with the name and printed in full.
|
||||
func TestSplitRedactionDoesNotLetARejectedNeighbourHideASplitKey(t *testing.T) {
|
||||
const prose = "sk-some-kebab-case-name-here-ok"
|
||||
const key = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh"
|
||||
if !strings.Contains(RedactString(prose, Options{}), prose) {
|
||||
t.Fatal("SETUP INVALID: the prose is redacted on its own, so it is not a rejected match")
|
||||
}
|
||||
if strings.Contains(RedactString(key, Options{}), key) {
|
||||
t.Fatal("SETUP INVALID: the key is not redacted even whole, so it is not a credential")
|
||||
}
|
||||
value := prose + nulSeparator + splitInTheMiddle(key, escSeparator)
|
||||
want := prose + nulSeparator + RedactedSecret
|
||||
if got := RedactString(value, Options{}); got != want {
|
||||
t.Errorf("RedactString(...) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ORDINARY TEXT AFTER A KEY IS NOT PART OF THE KEY. Removing the separator
|
||||
// between them joins the word behind it onto the end of the key, and an
|
||||
// unbounded shape then carries the replacement over text that was never secret.
|
||||
func TestSplitRedactionLeavesTextBehindAKeyAlone(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"word", openaiKey + nulSeparator + "ordinary prose here", RedactedSecret + nulSeparator + "ordinary prose here"},
|
||||
{"escape then word", openaiKey + escSeparator + "ordinary", RedactedSecret + escSeparator + "ordinary"},
|
||||
{"zero width then word", githubKey + zwspSeparator + "ordinary", RedactedSecret + zwspSeparator + "ordinary"},
|
||||
{"digits", awsKey + nulSeparator + "0123456789", RedactedSecret + nulSeparator + "0123456789"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := RedactString(testCase.value, Options{}); got != testCase.want {
|
||||
t.Errorf("RedactString(...) = %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// THE OFFSET TABLE IS SIZED BY THE MATCHES, NOT BY THE TEXT IT SEARCHED.
|
||||
//
|
||||
// RedactString is handed whole command output, verification stdout and
|
||||
// notification bodies, none of which is bounded before it runs, and an index
|
||||
// carrying a start and an end for every source byte costs sixteen bytes per byte
|
||||
// of input on a 64-bit build. Measured against the same text with no separator
|
||||
// in it, so the regex work and the output copy cancel out and what is left is
|
||||
// what the split path added.
|
||||
func TestSplitRedactionDoesNotIndexEverySourceByte(t *testing.T) {
|
||||
const line = "plain log output line with no secret in it "
|
||||
filler := strings.Repeat(line, (1<<20)/len(line))
|
||||
split := filler + " " + splitInTheMiddle(awsKey, escSeparator)
|
||||
plain := filler + " " + awsKey
|
||||
if got := RedactString(split, Options{}); !strings.HasSuffix(got, RedactedSecret) {
|
||||
t.Fatalf("SETUP INVALID: the split key in the large input is not redacted, so nothing indexes it: %q", got[len(got)-64:])
|
||||
}
|
||||
// Four times the input still fails an index of two ints per source byte,
|
||||
// which needs sixteen, and clears the copy this path actually makes.
|
||||
limit := int64(4 * len(split))
|
||||
overhead := redactAllocBytes(split) - redactAllocBytes(plain)
|
||||
if overhead > limit {
|
||||
t.Errorf("redacting %d bytes with a split key allocated %d bytes more than the same text without one, over the %d byte limit",
|
||||
len(split), overhead, limit)
|
||||
}
|
||||
}
|
||||
|
||||
// redactAllocBytes is the smallest number of bytes any of three RedactString
|
||||
// calls allocated. Smallest rather than mean because a garbage collection or an
|
||||
// unrelated goroutine can only ever add to the figure.
|
||||
func redactAllocBytes(value string) int64 {
|
||||
smallest := int64(-1)
|
||||
for i := 0; i < 3; i++ {
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
out := RedactString(value, Options{})
|
||||
runtime.ReadMemStats(&after)
|
||||
if len(out) == 0 {
|
||||
panic("RedactString returned nothing")
|
||||
}
|
||||
used := int64(after.TotalAlloc - before.TotalAlloc)
|
||||
if smallest < 0 || used < smallest {
|
||||
smallest = used
|
||||
}
|
||||
}
|
||||
return smallest
|
||||
}
|
||||
|
||||
// A REPLACEMENT ALREADY ON THE PAGE IS A BARRIER. The default marker is spelled
|
||||
// with brackets, which no credential body admits, so it stops a match by
|
||||
// itself. A caller is free to supply one made of ordinary word characters, and
|
||||
// then the text left by the contiguous pass would read as the middle of a key
|
||||
// and carry the replacement out over both delimiters and the text behind them.
|
||||
func TestSplitRedactionStopsAtACallerSuppliedReplacement(t *testing.T) {
|
||||
options := Options{Replacement: "REDACTED"}
|
||||
value := "sk-aaaaa" + nulSeparator + awsKey + nulSeparator + "bbbbbbbbbbbbbbb"
|
||||
want := "sk-aaaaa" + nulSeparator + "REDACTED" + nulSeparator + "bbbbbbbbbbbbbbb"
|
||||
if got := RedactString(value, options); got != want {
|
||||
t.Errorf("RedactString(...) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A REJECTED MATCH MUST NOT SHADOW THE REAL KEY BEHIND IT. Raised by CodeRabbit
|
||||
// on #1067. With the separators removed before matching, the leftmost AWS match
|
||||
// in "xAKIA<NUL>AKIAIOSF<SOH>ODNN7EXAMPLE" began at the decoy AKIA, was rejected
|
||||
// for the "x" in front of it, and had already consumed the start of the real key,
|
||||
// so the real key was never tried. "ask-" in front of a split OpenAI key does the
|
||||
// same through "sk-", which is the ordinary-prose version of it. Each input is
|
||||
// checked against its unsplit counterpart, which main already redacts.
|
||||
func TestSplitRedactionDoesNotLetARejectedMatchShadowTheRealKey(t *testing.T) {
|
||||
soh := string(rune(0x01))
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
split string
|
||||
unsplit string
|
||||
}{
|
||||
{"decoy AKIA before a split key", "xAKIA" + nulSeparator + "AKIAIOSFODNN7E" + soh + "XAMPLE", "xAKIA" + nulSeparator + awsKey},
|
||||
{"ask- before a split OpenAI key", "ask-" + nulSeparator + splitInTheMiddle(openaiKey, soh), "ask-" + nulSeparator + openaiKey},
|
||||
{"task- before a split OpenAI key", "task-" + escSeparator + splitInTheMiddle(openaiKey, zwspSeparator), "task-" + escSeparator + openaiKey},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
want := RedactString(testCase.unsplit, Options{})
|
||||
if strings.Contains(want, awsKey) || strings.Contains(want, openaiKey) {
|
||||
t.Fatalf("SETUP INVALID: main does not redact the unsplit form either: %q", want)
|
||||
}
|
||||
if got := RedactString(testCase.split, Options{}); got != want {
|
||||
t.Errorf("RedactString(split) = %q, want the unsplit form's %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AN UNBOUNDED BODY MUST NOT TAKE A NEIGHBOUR'S PREFIX WITH IT. With gaps allowed
|
||||
// between body characters, a split key's body runs on across a separator into
|
||||
// whatever follows. When that is a JWT, it stops only at the JWT's first dot, and
|
||||
// if the shapes were applied one after another the JWT shape would then find its
|
||||
// header gone and leave the payload and signature in the clear. The shapes are
|
||||
// matched against the same text and their union replaced, so it cannot.
|
||||
func TestSplitRedactionKeepsANeighbourWhosePrefixASplitBodyWouldSwallow(t *testing.T) {
|
||||
cut := len(jwtToken) / 2
|
||||
splitJWT := jwtToken[:cut] + nulSeparator + jwtToken[cut:]
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"split Anthropic key, a word, then a split JWT", splitInTheMiddle(anthropicKey, zwspSeparator) + escSeparator + "build" + escSeparator + splitJWT},
|
||||
{"split GitHub key, then a split JWT", splitInTheMiddle(githubKey, nulSeparator) + escSeparator + splitJWT},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := rejoin(RedactString(testCase.value, Options{}))
|
||||
for _, piece := range strings.Split(jwtToken, ".") {
|
||||
if strings.Contains(got, piece) {
|
||||
t.Errorf("part of the JWT survives: %q in %q", piece, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A CREDENTIAL GLUED ONTO A SPLIT ONE GETS ITS BOUNDARY FROM THE REPLACEMENT.
|
||||
// Contiguous shapes cascade: once an AWS key is replaced, a JWT written straight
|
||||
// after it has the "]" in front of it and matches. A split key is replaced by
|
||||
// the second pass, so the JWT behind it needs a pass of its own after that one,
|
||||
// and the JWT is often whole, which means its region may hold no separator at
|
||||
// all by then. The second case adds a whole key elsewhere so that the strict
|
||||
// pass has already cut the text into regions.
|
||||
func TestSplitRedactionCascadesIntoACredentialGluedOntoASplitOne(t *testing.T) {
|
||||
csi := string(rune(0x9b))
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"whole JWT glued onto a split AWS key", splitInTheMiddle(awsKey, escSeparator) + jwtToken},
|
||||
{"same, with a whole key elsewhere in the text", "user?q=" + "ASIAIOSFODNN7EXA" + csi + "MPLE" + jwtToken + "'" + awsKey + string(rune(0x7f)) + "ok"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := rejoin(RedactString(testCase.value, Options{}))
|
||||
for _, piece := range strings.Split(jwtToken, ".") {
|
||||
if strings.Contains(got, piece) {
|
||||
t.Errorf("part of the JWT survives: %q in %q", piece, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// THE CLASS AND THE PREDICATE ARE ONE FACT. splitSeparatorClass is generated
|
||||
// from the same tables as splitSecretSeparator; this walks every code point and
|
||||
// fails on the first one they disagree about.
|
||||
func TestSplitSeparatorClassAgreesWithThePredicate(t *testing.T) {
|
||||
class := regexp.MustCompile("^" + splitSeparatorClass + "$")
|
||||
for r := rune(0); r <= unicode.MaxRune; r++ {
|
||||
if r >= 0xd800 && r <= 0xdfff {
|
||||
continue // surrogates are not valid in a Go string
|
||||
}
|
||||
if got, want := class.MatchString(string(r)), splitSecretSeparator(r); got != want {
|
||||
t.Fatalf("U+%04X: class matches = %v, predicate says %v", r, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ON TEXT WITH NO SEPARATOR, EVERY GAP-TOLERANT SHAPE IS ITS ORIGINAL. The
|
||||
// rewrite only adds optional gaps, so a contiguous key has to produce exactly the
|
||||
// same match; anything else means the rewrite changed what a shape is.
|
||||
func TestGapTolerantShapesMatchContiguousKeysLikeTheOriginals(t *testing.T) {
|
||||
inputs := []string{
|
||||
"key=" + awsKey + " and " + githubKey,
|
||||
"x" + awsKey,
|
||||
anthropicKey + "." + openaiKey,
|
||||
jwtToken + " then " + slackKey + ", " + googleKey,
|
||||
"sk-this-is-kebab-case-prose-not-a-key",
|
||||
"github_pat_11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGH/glpat-abcdefghij0123456789",
|
||||
}
|
||||
originals := append([]*regexp.Regexp{openaiKeyPattern}, textSecretPatterns...)
|
||||
tolerant := append([]*regexp.Regexp{splitOpenAIPattern}, splitTextPatterns...)
|
||||
if len(originals) != len(tolerant) {
|
||||
t.Fatalf("SETUP INVALID: %d original shapes and %d gap-tolerant ones", len(originals), len(tolerant))
|
||||
}
|
||||
for i := range originals {
|
||||
for _, input := range inputs {
|
||||
want := fmt.Sprint(originals[i].FindAllStringIndex(input, -1))
|
||||
if got := fmt.Sprint(tolerant[i].FindAllStringIndex(input, -1)); got != want {
|
||||
t.Errorf("shape %s on %q: gap-tolerant matched %s, original %s", originals[i], input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,69 @@ var textSecretPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`),
|
||||
}
|
||||
|
||||
// anchoredSecretPatterns mirrors textSecretPatterns with the leading \b
|
||||
// replaced by \A, so each shape can be tested at an exact byte offset. A
|
||||
// credential that starts exactly where a redacted span ends (two AWS keys
|
||||
// glued end to end, e.g. AKIA...AKIA...) has no word boundary on its left,
|
||||
// so the plain pattern misses it; the anchored variant catches it.
|
||||
var anchoredSecretPatterns = func() []*regexp.Regexp {
|
||||
out := make([]*regexp.Regexp, len(textSecretPatterns))
|
||||
for i, p := range textSecretPatterns {
|
||||
src := p.String()
|
||||
if strings.HasPrefix(src, `\b`) {
|
||||
src = `\A(?:` + src[len(`\b`):] + `)`
|
||||
}
|
||||
out[i] = regexp.MustCompile(src)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// redactAdjacent redacts every match of pattern, plus any match of anchored
|
||||
// that begins exactly where a redacted span ends. This catches credentials
|
||||
// glued end to end (AKIA...AKIA...) without weakening the leading-boundary
|
||||
// rule elsewhere: a mid-word occurrence that does not abut a redacted span
|
||||
// still does not match. Chained runs (three or more glued credentials) are
|
||||
// followed to the end.
|
||||
func redactAdjacent(s string, pattern, anchored *regexp.Regexp, replacement string) string {
|
||||
spans := pattern.FindAllStringIndex(s, -1)
|
||||
// Bound the outer loop to the original match count. The inner loop
|
||||
// appends chained spans to the same slice; re-reading len(spans) would
|
||||
// re-chain every appended span, growing the span count (and the
|
||||
// FindStringIndex calls) exponentially on long glued runs.
|
||||
n := len(spans)
|
||||
for i := 0; i < n; i++ {
|
||||
end := spans[i][1]
|
||||
for end < len(s) {
|
||||
loc := anchored.FindStringIndex(s[end:])
|
||||
if loc == nil || loc[0] != 0 || loc[1] <= 0 {
|
||||
break
|
||||
}
|
||||
newEnd := end + loc[1]
|
||||
spans = append(spans, []int{end, newEnd})
|
||||
end = newEnd
|
||||
}
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
return s
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
|
||||
var out strings.Builder
|
||||
pos := 0
|
||||
for _, sp := range spans {
|
||||
if sp[0] < pos {
|
||||
continue // overlapping span, already covered
|
||||
}
|
||||
out.WriteString(s[pos:sp[0]])
|
||||
// s[sp[0]:sp[1]] is a match (anchored spans match at \b when taken
|
||||
// alone), so ReplaceAllString expands $ references exactly as the
|
||||
// plain loop below would.
|
||||
out.WriteString(pattern.ReplaceAllString(s[sp[0]:sp[1]], replacement))
|
||||
pos = sp[1]
|
||||
}
|
||||
out.WriteString(s[pos:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
var (
|
||||
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
|
||||
jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
|
||||
@@ -227,15 +290,19 @@ func RedactString(value string, options Options) string {
|
||||
// openai keys first so the filter can drop kebab-case false positives
|
||||
// before any other pattern rewrites nearby text.
|
||||
redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
if !knownOpenAIKeyPrefix(match) && !secretMatchHasDigit(match) &&
|
||||
strings.Contains(strings.TrimPrefix(match, "sk-"), "-") {
|
||||
if !openAIShapeIsSecret(match) {
|
||||
return match
|
||||
}
|
||||
return replacement
|
||||
})
|
||||
for _, pattern := range textSecretPatterns {
|
||||
redacted = pattern.ReplaceAllString(redacted, replacement)
|
||||
for i, pattern := range textSecretPatterns {
|
||||
redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement)
|
||||
}
|
||||
// Credentials broken up by invisible characters last, over what the
|
||||
// contiguous matchers left: a whole credential is already claimed by its own
|
||||
// strict match, so this pass cannot reach out of one and into the next. It is
|
||||
// a no-op for text with no such character, which is nearly all text.
|
||||
redacted = redactControlSplitSecrets(redacted, replacement)
|
||||
return redacted
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,32 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRedactStringRedactsAdjacentAWSKeys(t *testing.T) {
|
||||
// Two AWS keys glued end to end have no word boundary between them, so a
|
||||
// plain \b-anchored pattern only matches the first. Both must be redacted.
|
||||
// gitleaks:allow -- synthetic redaction fixtures below
|
||||
key1 := "AKIAIOSFODNN7EXAMPLE"
|
||||
key2 := "ASIAIOSFODNN7EXAMPLE"
|
||||
input := key1 + key2
|
||||
if got := RedactString(input, Options{}); got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("adjacent AWS keys not both redacted: got %q", got)
|
||||
}
|
||||
|
||||
// Three glued keys chain as well.
|
||||
input3 := key1 + key2 + key1
|
||||
if got := RedactString(input3, Options{}); got != RedactedSecret+RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("three adjacent AWS keys not all redacted: got %q", got)
|
||||
}
|
||||
|
||||
// A mid-word AKIA that does not abut a redacted span still must not match.
|
||||
if got := RedactString("prefixAKIAIOSFODNN7EXAMPLE", Options{}); got != "prefixAKIAIOSFODNN7EXAMPLE" {
|
||||
t.Fatalf("mid-word AKIA should not be redacted: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactStringCoversCommonSecretShapes(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
`{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`,
|
||||
@@ -141,3 +165,23 @@ func containsCircular(v any) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestRedactStringLongAdjacentKeyRunCompletesPromptly(t *testing.T) {
|
||||
// A long run of glued AWS keys must not blow up the span-chaining loop in
|
||||
// redactAdjacent: the outer loop is bounded to the original match count,
|
||||
// so appended spans are never re-chained. On the old code this input
|
||||
// grows the span list exponentially and never finishes.
|
||||
// The key is assembled at runtime so the literal never appears in source.
|
||||
// gitleaks:allow -- synthetic redaction fixture below
|
||||
key := "AKIA" + strings.Repeat("A", 16)
|
||||
const count = 64
|
||||
input := strings.Repeat(key, count)
|
||||
start := time.Now()
|
||||
got := RedactString(input, Options{})
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("RedactString took %v on %d glued keys", elapsed, count)
|
||||
}
|
||||
if want := strings.Repeat(RedactedSecret, count); got != want {
|
||||
t.Fatalf("expected %d redaction markers, got %d", count, strings.Count(got, RedactedSecret))
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,21 @@ func (backend Backend) EnforcementLevel(policy Policy) EnforcementLevel {
|
||||
return EnforcementDegraded
|
||||
}
|
||||
|
||||
// DegradedNotice is one line saying the plan's enforcement is degraded and why,
|
||||
// or "" at any other level. Only degraded: a disabled sandbox is the user's own
|
||||
// choice, and the unelevated Windows tier still enforces the write jail and
|
||||
// says what it lacks through `zero sandbox policy`.
|
||||
func (plan BackendPlan) DegradedNotice() string {
|
||||
if plan.EnforcementLevel != EnforcementDegraded {
|
||||
return ""
|
||||
}
|
||||
reason := strings.TrimRight(strings.TrimSpace(plan.DowngradeReason), ".")
|
||||
if reason == "" {
|
||||
reason = "no native sandbox is available"
|
||||
}
|
||||
return "Sandbox enforcement is degraded: " + reason + ". See `zero sandbox policy --effective`."
|
||||
}
|
||||
|
||||
func (backend Backend) DowngradeReason(policy Policy) string {
|
||||
if policy.Mode == "" {
|
||||
policy = DefaultPolicy()
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Only DEGRADED says anything. A disabled sandbox is the user's own choice, and
|
||||
// the unelevated Windows tier still enforces the write jail and reports what it
|
||||
// lacks through `zero sandbox policy`; a notice on every session for either
|
||||
// would stop meaning anything.
|
||||
func TestDegradedNoticeSpeaksOnlyForADegradedPlan(t *testing.T) {
|
||||
for _, level := range []EnforcementLevel{EnforcementNative, EnforcementUnelevated, EnforcementDisabled, ""} {
|
||||
plan := BackendPlan{EnforcementLevel: level, DowngradeReason: "some reason"}
|
||||
if notice := plan.DegradedNotice(); notice != "" {
|
||||
t.Errorf("level %q produced a notice: %q", level, notice)
|
||||
}
|
||||
}
|
||||
|
||||
plan := BackendPlan{EnforcementLevel: EnforcementDegraded, DowngradeReason: "Linux sandbox helper is not available"}
|
||||
want := "Sandbox enforcement is degraded: Linux sandbox helper is not available. See `zero sandbox policy --effective`."
|
||||
if got := plan.DegradedNotice(); got != want {
|
||||
t.Errorf("DegradedNotice() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A reason that already ends a sentence keeps one period, and a missing reason
|
||||
// still says what is wrong.
|
||||
func TestDegradedNoticeWordsItsReasonOnce(t *testing.T) {
|
||||
withPeriod := BackendPlan{EnforcementLevel: EnforcementDegraded, DowngradeReason: "Run `zero sandbox setup` first."}
|
||||
if got := withPeriod.DegradedNotice(); strings.Contains(got, "..") {
|
||||
t.Errorf("the reason's own period was doubled: %q", got)
|
||||
}
|
||||
empty := BackendPlan{EnforcementLevel: EnforcementDegraded}
|
||||
if got := empty.DegradedNotice(); !strings.Contains(got, "no native sandbox is available") {
|
||||
t.Errorf("a degraded plan with no reason gave no reason: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE SESSION'S NOTICE AND `zero sandbox policy` DESCRIBE ONE SANDBOX. The engine
|
||||
// builds its notice from its own backend and policy through BuildPlan, the
|
||||
// function the policy command prints from, rather than deciding "degraded" a
|
||||
// second way.
|
||||
func TestEngineDegradedNoticeComesFromThePolicyCommandsPlan(t *testing.T) {
|
||||
clearNestingMarkers(t)
|
||||
root := t.TempDir()
|
||||
unavailable := Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"}
|
||||
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: unavailable})
|
||||
want := unavailable.BuildPlan(root, DefaultPolicy()).DegradedNotice()
|
||||
if want == "" {
|
||||
t.Fatal("SETUP INVALID: an unavailable backend under the default policy is not degraded")
|
||||
}
|
||||
if got := engine.DegradedNotice(); got != want {
|
||||
t.Errorf("engine notice = %q, want the policy command's %q", got, want)
|
||||
}
|
||||
|
||||
disabled := DefaultPolicy()
|
||||
disabled.Mode = ModeDisabled
|
||||
off := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: disabled, Backend: unavailable})
|
||||
if got := off.DegradedNotice(); got != "" {
|
||||
t.Errorf("a sandbox the user turned off still produced a notice: %q", got)
|
||||
}
|
||||
|
||||
var none *Engine
|
||||
if got := none.DegradedNotice(); got != "" {
|
||||
t.Errorf("a nil engine produced a notice: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// clearNestingMarkers keeps a test from inheriting the markers when the suite
|
||||
// itself runs inside a Zero sandbox.
|
||||
func clearNestingMarkers(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(EnvSandboxed, "")
|
||||
t.Setenv(EnvSandboxBackend, "")
|
||||
}
|
||||
|
||||
// THE NESTING MARKERS SKIP WRAPPING, SO THEY HAVE TO BE SAID.
|
||||
//
|
||||
// With ZERO_SANDBOXED and ZERO_SANDBOX_BACKEND set, the runner passes every
|
||||
// command through unwrapped on the word of an outer sandbox nothing verifies
|
||||
// (#727). A native backend's plan knows nothing of that and reports full
|
||||
// enforcement, so the session used to run every command unwrapped in silence.
|
||||
func TestEngineDegradedNoticeSpeaksWhenTheNestingMarkersSkipWrapping(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
native := Backend{Name: BackendLinuxBwrap, Platform: "linux", Available: true, NativeIsolation: true, CommandWrapping: true, Executable: "/usr/bin/bwrap"}
|
||||
|
||||
clearNestingMarkers(t)
|
||||
if got := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: native}).DegradedNotice(); got != "" {
|
||||
t.Fatalf("SETUP INVALID: a native backend without the markers already gives a notice: %q", got)
|
||||
}
|
||||
|
||||
t.Setenv(EnvSandboxed, "1")
|
||||
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
|
||||
got := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: native}).DegradedNotice()
|
||||
if !strings.Contains(got, EnvSandboxed) || !strings.Contains(got, "cannot verify") {
|
||||
t.Errorf("with the nesting markers set the notice = %q, want one naming the markers and the unverified outer sandbox", got)
|
||||
}
|
||||
|
||||
disabled := DefaultPolicy()
|
||||
disabled.Mode = ModeDisabled
|
||||
if got := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: disabled, Backend: native}).DegradedNotice(); got != "" {
|
||||
t.Errorf("a sandbox the user turned off still produced a notice under the markers: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// NO GRANT REACHES A DEGRADED PLAN THE SESSION'S NOTICE MISSED.
|
||||
//
|
||||
// The notice is decided once, from the policy the session starts with, and
|
||||
// grants made during the session change the policy each command runs under.
|
||||
// They cannot open a gap: a grant only opens the network or adds allow and
|
||||
// deny paths, never the mode, and every mode but disabled builds a restricted
|
||||
// filesystem profile that needs the platform sandbox from the start. So a
|
||||
// degraded command plan means the notice already spoke, and a disabled sandbox
|
||||
// stays disabled whatever is granted. A deny path the backend cannot enforce is
|
||||
// refused at the command rather than run degraded. Raised by CodeRabbit.
|
||||
func TestNoGrantReachesADegradedPlanTheSessionNoticeMissed(t *testing.T) {
|
||||
clearNestingMarkers(t)
|
||||
cacheRoot, tempRoot := t.TempDir(), t.TempDir()
|
||||
original := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = original })
|
||||
t.Setenv("TMPDIR", tempRoot)
|
||||
t.Setenv("TMP", tempRoot)
|
||||
t.Setenv("TEMP", tempRoot)
|
||||
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
secret := filepath.Join(root, "secret.txt")
|
||||
enabled := true
|
||||
grants := []struct {
|
||||
name string
|
||||
profile RequestPermissionProfile
|
||||
}{
|
||||
{"network", RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}},
|
||||
{"write root", RequestPermissionProfile{FileSystem: &FileSystemPermissions{Write: []string{extra}}}},
|
||||
{"deny read", RequestPermissionProfile{FileSystem: &FileSystemPermissions{DenyRead: []string{secret}}}},
|
||||
}
|
||||
unavailable := Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"}
|
||||
degradedAndSaid, refused := 0, 0
|
||||
for _, mode := range []PolicyMode{ModeEnforce, ModeDisabled} {
|
||||
for _, network := range []NetworkMode{NetworkAllow, NetworkDeny} {
|
||||
for _, grant := range grants {
|
||||
for _, scope := range []PermissionGrantScope{PermissionGrantScopeSession, PermissionGrantScopeTurn} {
|
||||
name := fmt.Sprintf("%s/%s/%s/%s", mode, network, grant.name, scope)
|
||||
policy := DefaultPolicy()
|
||||
policy.Mode, policy.Network = mode, network
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: unavailable})
|
||||
notice := engine.DegradedNotice()
|
||||
undo, err := engine.GrantRequestPermissions(grant.profile, scope)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: SETUP INVALID: grant: %v", name, err)
|
||||
}
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root})
|
||||
undo()
|
||||
// The plan holds the runtime lease until it is cleaned up, and a lease
|
||||
// file still open fails the temp directory removal on Windows.
|
||||
plan.Cleanup()
|
||||
if err != nil {
|
||||
if grant.name != "deny read" || mode == ModeDisabled {
|
||||
t.Errorf("%s: refused a command it should have planned: %v", name, err)
|
||||
}
|
||||
refused++
|
||||
continue
|
||||
}
|
||||
if plan.EnforcementLevel == EnforcementDegraded {
|
||||
if notice == "" {
|
||||
t.Errorf("%s: the command plan is degraded but the session started with no notice", name)
|
||||
}
|
||||
degradedAndSaid++
|
||||
}
|
||||
if mode == ModeDisabled && plan.EnforcementLevel != EnforcementDisabled {
|
||||
t.Errorf("%s: a grant took a disabled sandbox to %q", name, plan.EnforcementLevel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not vacuous: the enforced modes did reach degraded plans, and the deny
|
||||
// grants were refused rather than degraded.
|
||||
if degradedAndSaid == 0 || refused == 0 {
|
||||
t.Fatalf("SETUP INVALID: %d degraded plans and %d refusals, so the invariant was never exercised", degradedAndSaid, refused)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,33 @@ func (engine *Engine) CanPersistGrants() bool {
|
||||
return engine != nil && engine.store != nil
|
||||
}
|
||||
|
||||
// DegradedNotice is the line a session shows when this engine's sandbox cannot
|
||||
// enforce what its policy asks for, or "" when it can or the sandbox is off.
|
||||
//
|
||||
// Built from the engine's own backend and policy through the same plan `zero
|
||||
// sandbox policy` prints, so the notice and the command that explains it cannot
|
||||
// describe two different sandboxes.
|
||||
//
|
||||
// EXCEPT WHERE THE RUNNER TAKES A DIFFERENT PATH. With the nesting markers set,
|
||||
// BuildCommandPlan passes every command through unwrapped, trusting an outer
|
||||
// sandbox to contain it, and the markers are ambient environment that nothing
|
||||
// authenticates (#727). The plan above does not model that, so a session started
|
||||
// with them ran every command unwrapped and said nothing. Reported by CodeRabbit.
|
||||
func (engine *Engine) DegradedNotice() string {
|
||||
if engine == nil || engine.policy.Mode == ModeDisabled {
|
||||
return ""
|
||||
}
|
||||
if IsAlreadySandboxed() {
|
||||
return nestedSandboxNotice
|
||||
}
|
||||
return engine.backend.BuildPlan(engine.workspaceRoot, engine.policy).DegradedNotice()
|
||||
}
|
||||
|
||||
// nestedSandboxNotice says why a session with the nesting markers set does not
|
||||
// wrap its commands, and that the outer sandbox it relies on is unverified.
|
||||
const nestedSandboxNotice = "Sandbox enforcement is degraded: " + EnvSandboxed + " and " + EnvSandboxBackend +
|
||||
" say this session already runs inside a sandbox, so it does not wrap commands again, and Zero cannot verify that outer sandbox."
|
||||
|
||||
func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) {
|
||||
if engine == nil || engine.store == nil {
|
||||
return "", nil
|
||||
|
||||
@@ -246,20 +246,62 @@ func RedactResult(result Result) Result {
|
||||
return redacted
|
||||
}
|
||||
|
||||
// redactMetadata redacts EVERY text field of the metadata, not the subset a
|
||||
// reader happens to think is user-facing. `zero search --json` prints this
|
||||
// straight to stdout, and the fields carrying the most free text are the ones
|
||||
// least likely to be on a hand-kept list: SpecUserComment and SpecRejectReason
|
||||
// are review text the user typed, SpecDraftReasoning is the model's own prose,
|
||||
// and Goal.Objective and Goal.StatusReason are whatever the goal was phrased
|
||||
// as. A field added to sessions.Metadata later is covered by
|
||||
// TestRedactMetadataCoversEveryTextField, which walks the struct rather than
|
||||
// repeating this list, so the two cannot drift apart silently.
|
||||
func redactMetadata(session sessions.Metadata, options redaction.Options) sessions.Metadata {
|
||||
session.SessionID = redaction.RedactString(session.SessionID, options)
|
||||
session.SessionKind = sessions.SessionKind(redaction.RedactString(string(session.SessionKind), options))
|
||||
session.Title = redaction.RedactString(session.Title, options)
|
||||
session.Cwd = redaction.RedactString(session.Cwd, options)
|
||||
session.ModelID = redaction.RedactString(session.ModelID, options)
|
||||
session.Provider = redaction.RedactString(session.Provider, options)
|
||||
session.Tag = redaction.RedactString(session.Tag, options)
|
||||
session.ParentSessionID = redaction.RedactString(session.ParentSessionID, options)
|
||||
session.RootSessionID = redaction.RedactString(session.RootSessionID, options)
|
||||
session.AgentName = redaction.RedactString(session.AgentName, options)
|
||||
session.TaskID = redaction.RedactString(session.TaskID, options)
|
||||
session.ForkedFromEventID = redaction.RedactString(session.ForkedFromEventID, options)
|
||||
session.SpawnedFromEventID = redaction.RedactString(session.SpawnedFromEventID, options)
|
||||
session.SpecID = redaction.RedactString(session.SpecID, options)
|
||||
session.SpecFilePath = redaction.RedactString(session.SpecFilePath, options)
|
||||
session.SpecStatus = sessions.SpecStatus(redaction.RedactString(string(session.SpecStatus), options))
|
||||
session.SpecDraftModelID = redaction.RedactString(session.SpecDraftModelID, options)
|
||||
session.SpecDraftReasoning = redaction.RedactString(session.SpecDraftReasoning, options)
|
||||
session.SpecUserComment = redaction.RedactString(session.SpecUserComment, options)
|
||||
session.SpecRejectReason = redaction.RedactString(session.SpecRejectReason, options)
|
||||
session.SpecSourceSessionID = redaction.RedactString(session.SpecSourceSessionID, options)
|
||||
session.SpecImplSessionID = redaction.RedactString(session.SpecImplSessionID, options)
|
||||
session.CreatedAt = redaction.RedactString(session.CreatedAt, options)
|
||||
session.UpdatedAt = redaction.RedactString(session.UpdatedAt, options)
|
||||
session.LastEventType = sessions.EventType(redaction.RedactString(string(session.LastEventType), options))
|
||||
session.Goal = redactGoal(session.Goal, options)
|
||||
return session
|
||||
}
|
||||
|
||||
// redactGoal returns a redacted COPY. Metadata is passed by value, but Goal is
|
||||
// a pointer inside it, so redacting through it would rewrite the goal the
|
||||
// caller still holds: a search would quietly edit the live session record it
|
||||
// was only supposed to report on.
|
||||
func redactGoal(goal *sessions.Goal, options redaction.Options) *sessions.Goal {
|
||||
if goal == nil {
|
||||
return nil
|
||||
}
|
||||
redacted := *goal
|
||||
redacted.Objective = redaction.RedactString(redacted.Objective, options)
|
||||
redacted.Status = sessions.GoalStatus(redaction.RedactString(string(redacted.Status), options))
|
||||
redacted.StatusReason = redaction.RedactString(redacted.StatusReason, options)
|
||||
redacted.CreatedAt = redaction.RedactString(redacted.CreatedAt, options)
|
||||
redacted.UpdatedAt = redaction.RedactString(redacted.UpdatedAt, options)
|
||||
return &redacted
|
||||
}
|
||||
|
||||
func redactEventSummary(event EventSummary, options redaction.Options) EventSummary {
|
||||
event.ID = redaction.RedactString(event.ID, options)
|
||||
event.Type = sessions.EventType(redaction.RedactString(string(event.Type), options))
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
@@ -107,3 +110,156 @@ func fixedSearchClock(value string) func() time.Time {
|
||||
}
|
||||
return func() time.Time { return parsed }
|
||||
}
|
||||
|
||||
// EVERY TEXT FIELD, NOT A HAND-KEPT SUBSET.
|
||||
//
|
||||
// `zero search --json` prints Hit.Session straight to stdout, so a field
|
||||
// redactMetadata forgets is a field that reaches the terminal with whatever was
|
||||
// in it. The list in redactMetadata is written out by hand; this walks the
|
||||
// struct instead, so a string field added to sessions.Metadata (or to the Goal
|
||||
// hanging off it) fails here on the day it lands rather than the day someone
|
||||
// notices it in output. It named twenty uncovered fields when it was written,
|
||||
// including the review text the user types and the model's own draft reasoning.
|
||||
func TestRedactMetadataCoversEveryTextField(t *testing.T) {
|
||||
const secret = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaa0123456789ABCD"
|
||||
|
||||
// The premise: the secret is one RedactString already recognizes, so a field
|
||||
// that still carries it was not passed through the redactor at all.
|
||||
if redaction.RedactString(secret, redaction.Options{}) == secret {
|
||||
t.Fatalf("SETUP INVALID: %q is not a shape RedactString redacts, so every leg below passes vacuously", secret)
|
||||
}
|
||||
|
||||
var session sessions.Metadata
|
||||
filled := fillMetadataText(t, reflect.ValueOf(&session).Elem(), secret, 0)
|
||||
|
||||
var leaked []string
|
||||
var checked int
|
||||
walkMetadataText(t, reflect.ValueOf(redactMetadata(session, redaction.Options{})), "", 0, func(name, value string) {
|
||||
checked++
|
||||
if strings.Contains(value, secret) {
|
||||
leaked = append(leaked, name)
|
||||
}
|
||||
})
|
||||
|
||||
// Filling and walking are separate traversals of the same shape, so a field
|
||||
// one of them understands and the other does not would otherwise show up as
|
||||
// a quiet pass rather than a failure.
|
||||
if checked != filled {
|
||||
t.Fatalf("SETUP INVALID: filled %d text fields and walked %d, so the two traversals disagree about the shape of Metadata", filled, checked)
|
||||
}
|
||||
if filled == 0 {
|
||||
t.Fatal("SETUP INVALID: no text fields were populated, so this proves nothing")
|
||||
}
|
||||
if len(leaked) > 0 {
|
||||
t.Errorf("redactMetadata left the secret in %d of %d text fields: %s",
|
||||
len(leaked), checked, strings.Join(leaked, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// fillMetadataText writes text into every text-bearing field reachable from
|
||||
// value, allocating nil pointers and one-element slices on the way, and returns
|
||||
// how many strings it set.
|
||||
//
|
||||
// Recursive on purpose. The first version of this walked only the direct string
|
||||
// fields of one struct and hand-allocated the Goal next to it, which left the
|
||||
// guard with the same blind spot it exists to close: a nested struct added to
|
||||
// sessions.Metadata later would arrive empty, the walk would find nothing in it
|
||||
// and the test would pass while the new field went unredacted. Raised by
|
||||
// CodeRabbit on the PR.
|
||||
//
|
||||
// It fails rather than skips on a kind it does not understand, because a field
|
||||
// this cannot populate is a field the guard is not guarding.
|
||||
func fillMetadataText(t *testing.T, value reflect.Value, text string, depth int) int {
|
||||
t.Helper()
|
||||
if depth > 6 {
|
||||
t.Fatalf("fillMetadataText recursed past depth %d, which suggests a cycle in the type", depth)
|
||||
}
|
||||
if !value.CanSet() {
|
||||
return 0
|
||||
}
|
||||
switch value.Kind() {
|
||||
case reflect.String:
|
||||
value.SetString(text)
|
||||
return 1
|
||||
case reflect.Pointer:
|
||||
if value.IsNil() {
|
||||
value.Set(reflect.New(value.Type().Elem()))
|
||||
}
|
||||
return fillMetadataText(t, value.Elem(), text, depth+1)
|
||||
case reflect.Struct:
|
||||
filled := 0
|
||||
for index := 0; index < value.NumField(); index++ {
|
||||
if !value.Type().Field(index).IsExported() {
|
||||
continue
|
||||
}
|
||||
filled += fillMetadataText(t, value.Field(index), text, depth+1)
|
||||
}
|
||||
return filled
|
||||
case reflect.Slice:
|
||||
value.Set(reflect.MakeSlice(value.Type(), 1, 1))
|
||||
return fillMetadataText(t, value.Index(0), text, depth+1)
|
||||
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||
reflect.Float32, reflect.Float64:
|
||||
return 0
|
||||
default:
|
||||
t.Fatalf("fillMetadataText does not know how to populate %s (kind %s); teach it and walkMetadataText together, or this guard stops covering that field",
|
||||
value.Type(), value.Kind())
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// walkMetadataText visits every text-bearing field reachable from value, in the
|
||||
// same shape fillMetadataText populates. The two are deliberately symmetric and
|
||||
// the caller compares their counts.
|
||||
func walkMetadataText(t *testing.T, value reflect.Value, prefix string, depth int, visit func(name, value string)) {
|
||||
t.Helper()
|
||||
if depth > 6 {
|
||||
t.Fatalf("walkMetadataText recursed past depth %d, which suggests a cycle in the type", depth)
|
||||
}
|
||||
switch value.Kind() {
|
||||
case reflect.String:
|
||||
visit(prefix, value.String())
|
||||
case reflect.Pointer:
|
||||
if !value.IsNil() {
|
||||
walkMetadataText(t, value.Elem(), prefix, depth+1, visit)
|
||||
}
|
||||
case reflect.Struct:
|
||||
for index := 0; index < value.NumField(); index++ {
|
||||
field := value.Type().Field(index)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
name := field.Name
|
||||
if prefix != "" {
|
||||
name = prefix + "." + name
|
||||
}
|
||||
walkMetadataText(t, value.Field(index), name, depth+1, visit)
|
||||
}
|
||||
case reflect.Slice:
|
||||
for index := 0; index < value.Len(); index++ {
|
||||
walkMetadataText(t, value.Index(index), fmt.Sprintf("%s[%d]", prefix, index), depth+1, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata is passed by value, but Goal is a pointer inside it. Redacting
|
||||
// through that pointer would edit the session the caller still holds, so a
|
||||
// search would quietly rewrite the live record it was asked to report on.
|
||||
func TestRedactMetadataDoesNotRewriteTheCallersGoal(t *testing.T) {
|
||||
const secret = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaa0123456789ABCD"
|
||||
goal := &sessions.Goal{Objective: "ship " + secret, StatusReason: "blocked on " + secret}
|
||||
session := sessions.Metadata{SessionID: "s", Goal: goal}
|
||||
|
||||
redacted := redactMetadata(session, redaction.Options{})
|
||||
|
||||
if goal.Objective != "ship "+secret || goal.StatusReason != "blocked on "+secret {
|
||||
t.Errorf("redactMetadata rewrote the caller's goal: %+v", *goal)
|
||||
}
|
||||
if redacted.Goal == goal {
|
||||
t.Error("redacted metadata still points at the caller's goal")
|
||||
}
|
||||
if strings.Contains(redacted.Goal.Objective, secret) || strings.Contains(redacted.Goal.StatusReason, secret) {
|
||||
t.Errorf("goal text was not redacted: %+v", *redacted.Goal)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
@@ -151,10 +152,20 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
}
|
||||
|
||||
func readExecContextEvents(store *Store, sessionID string) ([]Event, error) {
|
||||
// The session was picked from its metadata a moment ago. Make sure it is
|
||||
// still there, and held, before reading it to continue: see HoldToContinue.
|
||||
if err := store.HoldToContinue(sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contextEvents, err := store.ReadRehydratedEvents(sessionID)
|
||||
if err == nil {
|
||||
return contextEvents, nil
|
||||
}
|
||||
if errors.Is(err, ErrPruning) {
|
||||
// Not a rehydration failure: the raw read would continue the session
|
||||
// without holding it while prune may be removing it.
|
||||
return nil, err
|
||||
}
|
||||
rawEvents, rawErr := store.ReadEvents(sessionID)
|
||||
if rawErr != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// leaseFileName is the file whose lock says a process has the session open.
|
||||
const leaseFileName = "lease.lock"
|
||||
|
||||
func (store *Store) leasePath(sessionID string) string {
|
||||
return filepath.Join(store.sessionPath(sessionID), leaseFileName)
|
||||
}
|
||||
|
||||
// Hold marks sessionID as open in this process until the process exits.
|
||||
//
|
||||
// A SESSION HOLDS NO LOCK BETWEEN WRITES. session.lock is taken around each
|
||||
// write and released straight after, so nothing told `zero sessions prune` in
|
||||
// another terminal that a TUI, an exec run or an editor still had the session
|
||||
// open, and an idle open session looked exactly like an abandoned one. Hold takes
|
||||
// a shared lock on the session's lease file and keeps it for the life of the
|
||||
// process; prune takes the same lock exclusively and leaves alone any session it
|
||||
// cannot get. Every write holds its session this way (lockSession).
|
||||
//
|
||||
// Hold never waits and never fails its caller. A lease that cannot be taken,
|
||||
// because the session directory is gone or prune holds it at this moment, only
|
||||
// means prune cannot see this process, and that is enough for a write: it lands
|
||||
// under session.lock, where prune checks the session again, so either prune sees
|
||||
// the write and keeps the session or the write finds the session gone. A read
|
||||
// changes nothing prune checks, so the reads that continue a session, and the
|
||||
// creations that read a parent, use holdOrRefuse instead.
|
||||
func (store *Store) Hold(sessionID string) {
|
||||
store.hold(sessionID)
|
||||
}
|
||||
|
||||
// ErrPruning is matched, through errors.Is, by the error a process gets when it
|
||||
// tries to continue a session, or to create a session under it, while zero
|
||||
// sessions prune holds that session or once it has removed it.
|
||||
var ErrPruning = errors.New("locked by zero sessions prune")
|
||||
|
||||
// pruneRefusal is an error matching ErrPruning whose message says which of the
|
||||
// two it was.
|
||||
type pruneRefusal struct{ message string }
|
||||
|
||||
func (refusal pruneRefusal) Error() string { return refusal.message }
|
||||
|
||||
func (refusal pruneRefusal) Is(target error) bool { return target == ErrPruning }
|
||||
|
||||
func pruneBusy(sessionID string) error {
|
||||
return pruneRefusal{"zero session " + sessionID + " is locked by zero sessions prune; try again"}
|
||||
}
|
||||
|
||||
func pruneRemoved(sessionID string) error {
|
||||
return pruneRefusal{"zero session " + sessionID + " was removed while it was being opened"}
|
||||
}
|
||||
|
||||
// holdOrRefuse holds a session that is about to be read in order to continue it
|
||||
// (the rehydrated read behind the TUI's resume, exec --resume and --fork, and
|
||||
// ACP's session/load and session/resume) or to create a session under it (Fork,
|
||||
// CreateChild, Create with a parent). When prune holds it at this moment the
|
||||
// caller is refused rather than left to read it without the lease: prune could
|
||||
// then remove a session this process goes on to use, or one a new session is
|
||||
// created under, whose Lineage and Tree fail on a missing ancestor.
|
||||
func (store *Store) holdOrRefuse(sessionID string) error {
|
||||
if store.hold(sessionID) {
|
||||
return pruneBusy(sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// holdParent is holdOrRefuse for the session a new one is created under: Fork,
|
||||
// CreateChild, and Create with a ParentSessionID.
|
||||
//
|
||||
// HOLDING A LEASE FILE DOES NOT PROVE THE SESSION IS STILL THERE. Prune removes
|
||||
// the metadata first and unlinks lease.lock after it, so a process that takes
|
||||
// the lease just then creates a fresh lease.lock in a directory prune is
|
||||
// emptying, and locks it with nothing to contend with. So a parent whose
|
||||
// directory still exists without its metadata is refused once the lease is
|
||||
// held. A parent whose directory is gone altogether is left alone, as before: a
|
||||
// session may name a parent this store never had.
|
||||
func (store *Store) holdParent(parentSessionID string) error {
|
||||
if err := store.holdOrRefuse(parentSessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.beingRemoved(parentSessionID) {
|
||||
store.dropStrayLease(parentSessionID)
|
||||
return pruneRemoved(parentSessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HoldToContinue holds a session the caller has already picked, having read its
|
||||
// metadata, and is about to continue: the TUI's resume, exec --resume and
|
||||
// --fork, and ACP's session/load and session/resume. It refuses, with an error
|
||||
// matching ErrPruning, a session prune holds and one that is gone by the time it
|
||||
// is held, whether prune is part way through removing it or has finished.
|
||||
// Callers must not fall back to reading the session another way on that error.
|
||||
// ReadRehydratedEvents on its own still reads a session that does not exist as
|
||||
// an empty one, for callers that picked nothing.
|
||||
func (store *Store) HoldToContinue(sessionID string) error {
|
||||
if err := store.holdOrRefuse(sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(store.metadataPath(sessionID)); errors.Is(err, fs.ErrNotExist) {
|
||||
store.dropStrayLease(sessionID)
|
||||
return pruneRemoved(sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// beingRemoved reports a session directory that exists without its metadata:
|
||||
// one prune is part way through removing.
|
||||
func (store *Store) beingRemoved(sessionID string) bool {
|
||||
if _, err := os.Stat(store.metadataPath(sessionID)); !errors.Is(err, fs.ErrNotExist) {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(store.sessionPath(sessionID))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// dropStrayLease gives back a lease taken on a session that turned out to be
|
||||
// gone, and removes the lease file it may have created afresh in a directory
|
||||
// prune is emptying, so that prune can still remove the directory.
|
||||
func (store *Store) dropStrayLease(sessionID string) {
|
||||
store.Release(sessionID)
|
||||
if store.beingRemoved(sessionID) {
|
||||
_ = os.Remove(store.leasePath(sessionID))
|
||||
}
|
||||
}
|
||||
|
||||
// hold is Hold, reporting busy when the lease could not be taken because prune
|
||||
// holds it exclusively right now.
|
||||
func (store *Store) hold(sessionID string) (busy bool) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return false
|
||||
}
|
||||
store.leasesMu.Lock()
|
||||
defer store.leasesMu.Unlock()
|
||||
if _, held := store.leases[sessionID]; held {
|
||||
return false
|
||||
}
|
||||
// Creates the lease file, never the directory: a session that is gone stays
|
||||
// gone.
|
||||
file, err := openLeaseFile(store.leasePath(sessionID))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
locked, err := tryLockLease(file, false)
|
||||
if err != nil || !locked {
|
||||
_ = file.Close()
|
||||
return err == nil
|
||||
}
|
||||
if store.leases == nil {
|
||||
store.leases = map[string]*os.File{}
|
||||
}
|
||||
store.leases[sessionID] = file
|
||||
return false
|
||||
}
|
||||
|
||||
// Release gives up this Store's lease on sessionID. Nothing in Zero calls it
|
||||
// yet: a process holds every session it has touched until it exits, which
|
||||
// releases every lease with it, and tests use Release to stand in for that
|
||||
// exit. A long-lived process that switches sessions, like the TUI on /new, could
|
||||
// call it for the session it leaves.
|
||||
func (store *Store) Release(sessionID string) {
|
||||
store.leasesMu.Lock()
|
||||
defer store.leasesMu.Unlock()
|
||||
file, held := store.leases[sessionID]
|
||||
if !held {
|
||||
return
|
||||
}
|
||||
delete(store.leases, sessionID)
|
||||
unlockLease(file)
|
||||
_ = file.Close()
|
||||
}
|
||||
|
||||
// HoldExclusive takes sessionID's lease exclusively without waiting, which is how
|
||||
// prune keeps every other process out of a session while it checks or removes
|
||||
// it. It reports false, holding nothing, when any process holds the lease. While
|
||||
// it is held, Hold takes nothing and holdOrRefuse refuses. release lets it go,
|
||||
// and does nothing after the first call.
|
||||
func (store *Store) HoldExclusive(sessionID string) (release func(), locked bool, err error) {
|
||||
nothing := func() {}
|
||||
if !ValidSessionID(sessionID) {
|
||||
return nothing, false, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
file, err := openLeaseFile(store.leasePath(sessionID))
|
||||
if err != nil {
|
||||
return nothing, false, err
|
||||
}
|
||||
locked, err = tryLockLease(file, true)
|
||||
if err != nil || !locked {
|
||||
_ = file.Close()
|
||||
return nothing, false, err
|
||||
}
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
unlockLease(file)
|
||||
_ = file.Close()
|
||||
})
|
||||
}, true, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build !windows
|
||||
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// tryLockLease takes the lease lock on file without waiting: shared for a
|
||||
// process that has the session open, exclusive for prune. It reports false when
|
||||
// another holder's lock conflicts. flock locks belong to the open file
|
||||
// description, so a second descriptor in the same process conflicts like another
|
||||
// process would.
|
||||
func tryLockLease(file *os.File, exclusive bool) (bool, error) {
|
||||
how := unix.LOCK_SH
|
||||
if exclusive {
|
||||
how = unix.LOCK_EX
|
||||
}
|
||||
err := unix.Flock(int(file.Fd()), how|unix.LOCK_NB)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, unix.EWOULDBLOCK) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// openLeaseFile opens (creating it if needed) the lease file of a session whose
|
||||
// directory exists.
|
||||
func openLeaseFile(path string) (*os.File, error) {
|
||||
return os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
}
|
||||
|
||||
func unlockLease(file *os.File) {
|
||||
_ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build windows
|
||||
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// tryLockLease takes the lease lock on file without waiting: shared for a
|
||||
// process that has the session open, exclusive for prune. It reports false when
|
||||
// another holder's lock conflicts. LockFileEx locks belong to the handle, so a
|
||||
// second handle in the same process conflicts like another process would.
|
||||
func tryLockLease(file *os.File, exclusive bool) (bool, error) {
|
||||
flags := uint32(windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if exclusive {
|
||||
flags |= windows.LOCKFILE_EXCLUSIVE_LOCK
|
||||
}
|
||||
err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, new(windows.Overlapped))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// openLeaseFile opens (creating it if needed) the lease file of a session whose
|
||||
// directory exists.
|
||||
//
|
||||
// WITH FILE_SHARE_DELETE, unlike session.lock. A lease is held for the life of
|
||||
// the process, and a file held open without share-delete cannot be removed: the
|
||||
// session directory could then never be deleted while any process had it open,
|
||||
// by prune or by anything else, including a test's temp-directory cleanup. With
|
||||
// it, a delete removes the name at once (POSIX semantics) while the holder's
|
||||
// handle stays valid.
|
||||
func openLeaseFile(path string) (*os.File, error) {
|
||||
pathUTF16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
handle, err := windows.CreateFile(pathUTF16, windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil, windows.OPEN_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
|
||||
func unlockLease(file *os.File) {
|
||||
_ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, new(windows.Overlapped))
|
||||
}
|
||||
@@ -10,6 +10,9 @@ func (store *Store) CreateChild(parentSessionID string, input ChildInput) (Metad
|
||||
if !ValidSessionID(parentSessionID) {
|
||||
return Metadata{}, fmt.Errorf("invalid zero session id %q", parentSessionID)
|
||||
}
|
||||
if err := store.holdParent(parentSessionID); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
parent, err := store.Get(parentSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MinimumPruneAge is the shortest cutoff Prune accepts.
|
||||
//
|
||||
// A floor under the lease, not a substitute for it. An older Zero, or anything
|
||||
// else that writes a session without taking the lease, is invisible to prune,
|
||||
// and a day is long enough that a session such a writer is actively using has
|
||||
// been written since.
|
||||
const MinimumPruneAge = 24 * time.Hour
|
||||
|
||||
// PruneOptions chooses what Prune removes.
|
||||
type PruneOptions struct {
|
||||
// OlderThan makes a session a candidate when it was last updated at least
|
||||
// this long ago. It must be at least MinimumPruneAge.
|
||||
OlderThan time.Duration
|
||||
// DryRun decides everything a real run would and removes nothing. It takes
|
||||
// the same locks to decide, so a Zero opening a session at the moment it is
|
||||
// being checked is told to try again, as it would be during a real run.
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// PruneEntry is one session Prune removed (or in a dry run would remove), kept
|
||||
// although it was old enough, or failed to remove.
|
||||
type PruneEntry struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Kind SessionKind `json:"kind,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// PruneReport says what Prune did, or in a dry run what it would do. Sessions
|
||||
// newer than the cutoff are not listed.
|
||||
type PruneReport struct {
|
||||
Cutoff string `json:"cutoff"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
// Removed are the sessions removed, or in a dry run the ones a real run
|
||||
// would remove.
|
||||
Removed []PruneEntry `json:"removed"`
|
||||
// Kept are sessions Prune left for a reason other than being recent.
|
||||
Kept []PruneEntry `json:"kept"`
|
||||
// Failed are sessions whose removal went wrong, or in a dry run could not be
|
||||
// checked; Reason says how. A failure after the metadata was removed leaves
|
||||
// a directory List no longer shows.
|
||||
Failed []PruneEntry `json:"failed"`
|
||||
}
|
||||
|
||||
// The reasons Prune keeps a session that is old enough to remove.
|
||||
const (
|
||||
PruneKeptOpen = "open in another Zero process"
|
||||
PruneKeptParent = "parent of a session that is kept"
|
||||
PruneKeptUpdated = "updated while pruning"
|
||||
PruneKeptUndated = "its last update time could not be read"
|
||||
)
|
||||
|
||||
// pruneRemoveSeam runs after Prune holds a session's lease and write lock and
|
||||
// before it re-reads the metadata. Nil in production.
|
||||
var pruneRemoveSeam func(sessionID string)
|
||||
|
||||
// pruneRemoveDirSeam runs just before Prune removes a session's directory, and
|
||||
// says whether Prune still holds the lease. Nil in production.
|
||||
var pruneRemoveDirSeam func(sessionID string, leaseHeld bool)
|
||||
|
||||
// prunePlannedSeam runs once Prune has made its plan and before it removes
|
||||
// anything. Nil in production.
|
||||
var prunePlannedSeam func()
|
||||
|
||||
// Prune removes sessions last updated before the cutoff that OlderThan sets.
|
||||
//
|
||||
// Only on request: nothing in Zero calls it by itself (#971). It never removes:
|
||||
// - a session another process has open, which holds its lease (see Hold);
|
||||
// - a session with a descendant that is kept, including one created after the
|
||||
// plan was made, because Lineage and Tree fail on a missing ancestor;
|
||||
// - a session written between the plan and its removal;
|
||||
// - a session whose last update time cannot be read.
|
||||
//
|
||||
// Descendants are removed before their ancestors, so a descendant that turns
|
||||
// out to be open part way through still keeps every ancestor above it. Within a
|
||||
// session the metadata goes first, under both the lease and the write lock:
|
||||
// from then on List and Get do not show it, so a removal that fails part way
|
||||
// leaves nothing that looks like a session.
|
||||
func (store *Store) Prune(options PruneOptions) (PruneReport, error) {
|
||||
if options.OlderThan < MinimumPruneAge {
|
||||
return PruneReport{}, fmt.Errorf("prune cutoff %s is shorter than the minimum of %s", options.OlderThan, MinimumPruneAge)
|
||||
}
|
||||
cutoff := store.now().UTC().Add(-options.OlderThan)
|
||||
report := PruneReport{
|
||||
Cutoff: cutoff.Format(time.RFC3339),
|
||||
DryRun: options.DryRun,
|
||||
Removed: []PruneEntry{},
|
||||
Kept: []PruneEntry{},
|
||||
Failed: []PruneEntry{},
|
||||
}
|
||||
all, err := store.List()
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
byID := make(map[string]Metadata, len(all))
|
||||
for _, session := range all {
|
||||
byID[session.SessionID] = session
|
||||
}
|
||||
|
||||
candidates := map[string]bool{}
|
||||
for _, session := range all {
|
||||
updated, err := time.Parse(time.RFC3339, session.UpdatedAt)
|
||||
if err != nil {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, PruneKeptUndated))
|
||||
continue
|
||||
}
|
||||
if !updated.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
// Open elsewhere at planning time. Checked again under the lease when the
|
||||
// session is removed; this pass is what lets its ancestors be kept too.
|
||||
release, locked, err := store.HoldExclusive(session.SessionID)
|
||||
if err != nil {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, "its lease could not be checked: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
if !locked {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, PruneKeptOpen))
|
||||
continue
|
||||
}
|
||||
release()
|
||||
candidates[session.SessionID] = true
|
||||
}
|
||||
|
||||
// Every ancestor of a session that stays, stays.
|
||||
for _, session := range all {
|
||||
if candidates[session.SessionID] {
|
||||
continue
|
||||
}
|
||||
for _, ancestor := range pruneAncestors(byID, session.SessionID) {
|
||||
if candidates[ancestor] {
|
||||
delete(candidates, ancestor)
|
||||
report.Kept = append(report.Kept, store.pruneEntry(byID[ancestor], PruneKeptParent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
order := make([]Metadata, 0, len(candidates))
|
||||
for id := range candidates {
|
||||
order = append(order, byID[id])
|
||||
}
|
||||
depth := func(id string) int { return len(pruneAncestors(byID, id)) }
|
||||
sort.Slice(order, func(left, right int) bool {
|
||||
leftDepth, rightDepth := depth(order[left].SessionID), depth(order[right].SessionID)
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth > rightDepth
|
||||
}
|
||||
if order[left].UpdatedAt != order[right].UpdatedAt {
|
||||
return order[left].UpdatedAt < order[right].UpdatedAt
|
||||
}
|
||||
return order[left].SessionID < order[right].SessionID
|
||||
})
|
||||
if prunePlannedSeam != nil {
|
||||
prunePlannedSeam()
|
||||
}
|
||||
|
||||
keep := map[string]bool{}
|
||||
for _, session := range order {
|
||||
if keep[session.SessionID] {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, PruneKeptParent))
|
||||
continue
|
||||
}
|
||||
entry := store.pruneEntry(session, "")
|
||||
removed, keptReason, err := store.pruneSession(session.SessionID, cutoff, byID, options.DryRun)
|
||||
switch {
|
||||
case err != nil:
|
||||
entry.Reason = err.Error()
|
||||
report.Failed = append(report.Failed, entry)
|
||||
case keptReason != "":
|
||||
entry.Reason = keptReason
|
||||
report.Kept = append(report.Kept, entry)
|
||||
case removed:
|
||||
report.Removed = append(report.Removed, entry)
|
||||
}
|
||||
if !removed {
|
||||
for _, ancestor := range pruneAncestors(byID, session.SessionID) {
|
||||
keep[ancestor] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// pruneAncestors lists a session's ancestors, nearest first, stopping at a
|
||||
// missing one or a cycle.
|
||||
func pruneAncestors(byID map[string]Metadata, sessionID string) []string {
|
||||
var ancestors []string
|
||||
seen := map[string]bool{sessionID: true}
|
||||
current, ok := byID[sessionID]
|
||||
for ok && current.ParentSessionID != "" && !seen[current.ParentSessionID] {
|
||||
parentID := current.ParentSessionID
|
||||
seen[parentID] = true
|
||||
ancestors = append(ancestors, parentID)
|
||||
current, ok = byID[parentID]
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
func (store *Store) pruneEntry(session Metadata, reason string) PruneEntry {
|
||||
return PruneEntry{
|
||||
SessionID: session.SessionID,
|
||||
Title: session.Title,
|
||||
Kind: session.SessionKind,
|
||||
UpdatedAt: session.UpdatedAt,
|
||||
Bytes: store.sessionBytes(session.SessionID),
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionBytes is what a session occupies on disk, without following links.
|
||||
func (store *Store) sessionBytes(sessionID string) int64 {
|
||||
var total int64
|
||||
_ = filepath.WalkDir(store.sessionPath(sessionID), func(_ string, entry fs.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, err := entry.Info(); err == nil && info.Mode().IsRegular() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
// childCreatedAfterPlan reports whether a session the plan did not see names
|
||||
// sessionID as its parent. Only sessions missing from planned are read, so the
|
||||
// cost is one directory listing plus whatever was created since the plan.
|
||||
func (store *Store) childCreatedAfterPlan(sessionID string, planned map[string]Metadata) (bool, error) {
|
||||
entries, err := os.ReadDir(store.RootDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
id := entry.Name()
|
||||
if !entry.IsDir() || id == sessionID {
|
||||
continue
|
||||
}
|
||||
if _, seen := planned[id]; seen {
|
||||
continue
|
||||
}
|
||||
child, err := store.readMetadata(id)
|
||||
if err != nil {
|
||||
continue // not a session, or one without its metadata
|
||||
}
|
||||
if child.ParentSessionID == sessionID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// pruneSession removes one session that planning chose. It reports removed
|
||||
// once the metadata is gone, even when leftovers could not be deleted (err says
|
||||
// what), and a keptReason when the session turned out to be open, was written
|
||||
// since the plan, or has a child the plan did not know about. planned is every
|
||||
// session the plan saw. A dry run makes every one of those checks under the
|
||||
// same locks and stops before removing anything; removed then says a real run
|
||||
// would have gone on to remove the session.
|
||||
func (store *Store) pruneSession(sessionID string, cutoff time.Time, planned map[string]Metadata, dryRun bool) (removed bool, keptReason string, err error) {
|
||||
releaseLease, locked, err := store.HoldExclusive(sessionID)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("check the session's lease: %w", err)
|
||||
}
|
||||
if !locked {
|
||||
return false, PruneKeptOpen, nil
|
||||
}
|
||||
leaseHeld := true
|
||||
letLeaseGo := func() {
|
||||
leaseHeld = false
|
||||
releaseLease()
|
||||
}
|
||||
defer letLeaseGo()
|
||||
release, err := store.lockSessionWithoutLease(sessionID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
if pruneRemoveSeam != nil {
|
||||
pruneRemoveSeam(sessionID)
|
||||
}
|
||||
|
||||
session, err := store.readMetadata(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, "", fmt.Errorf("the session disappeared while pruning")
|
||||
}
|
||||
return false, "", err
|
||||
}
|
||||
updated, err := time.Parse(time.RFC3339, session.UpdatedAt)
|
||||
if err != nil {
|
||||
return false, PruneKeptUndated, nil
|
||||
}
|
||||
if !updated.Before(cutoff) {
|
||||
return false, PruneKeptUpdated, nil
|
||||
}
|
||||
// A session forked or given a child after the plan was made, by a process that
|
||||
// has exited since, is not in the plan, and nothing else would stop its parent
|
||||
// going. Every way of creating a session under a parent holds that parent
|
||||
// first (holdParent), so none can start while this lease is held exclusively,
|
||||
// and one that finished has its metadata on disk.
|
||||
child, err := store.childCreatedAfterPlan(sessionID, planned)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("look for sessions created under it: %w", err)
|
||||
}
|
||||
if child {
|
||||
return false, PruneKeptParent, nil
|
||||
}
|
||||
if dryRun {
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// THE METADATA FIRST. From here the session no longer exists to List or Get.
|
||||
if err := os.Remove(store.metadataPath(sessionID)); err != nil {
|
||||
return false, "", fmt.Errorf("remove the session metadata: %w", err)
|
||||
}
|
||||
dir := store.sessionPath(sessionID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return true, "", fmt.Errorf("list what is left of the session: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
// session.lock is held right here; it goes after the release below. The
|
||||
// lease file is held too, but it was opened for deletion (openLeaseFile).
|
||||
if entry.Name() == filepath.Base(store.lockPath(sessionID)) {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
|
||||
return true, "", fmt.Errorf("remove %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
release()
|
||||
released = true
|
||||
// A writer that takes session.lock in the gap only keeps an empty directory
|
||||
// alive, and its own write then fails: the metadata is already gone.
|
||||
if err := os.Remove(store.lockPath(sessionID)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return true, "", fmt.Errorf("remove the session lock: %w", err)
|
||||
}
|
||||
// Let go of the lease before the directory. lease.lock went with the rest, but
|
||||
// where a delete only takes effect once the last handle closes (Windows without
|
||||
// POSIX delete semantics: older builds, or FAT and exFAT volumes), this handle
|
||||
// would keep the directory from being empty.
|
||||
letLeaseGo()
|
||||
if pruneRemoveDirSeam != nil {
|
||||
pruneRemoveDirSeam(sessionID, leaseHeld)
|
||||
}
|
||||
if err := os.Remove(dir); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return true, "", fmt.Errorf("remove the session directory: %w", err)
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user