mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Fixes from the code review on the critical/high audit branch: - tools/apply_patch: parseHunkCounts now reads only the range section between the opening and closing "@@". A crafted section heading like "@@ -1,1 +1,1 @@ +1,999999" could otherwise overwrite the line count, wedge the parser in hunk mode, and swallow later file headers so they escaped validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass. - providerhealth: the SSRF-hardened dial now tries every validated address in order instead of pinning addrs[0], so a dual-stack/multi-record provider isn't failed by a single dead record. Extracted dialValidatedAddrs for coverage. - hooks/dispatch: a beforeTool hook killed by its deadline now fails CLOSED (vetoes the tool) instead of fail-open. A launch failure (missing binary) still fails open so a misconfigured hook can't wedge every tool call. - cron: Store.Get returns a distinct ErrJobNotFound sentinel; cron_run only treats that as "removed during run" and still records/persists on a transient read error instead of silently dropping the run. - providers/anthropic: closeOpen finalizes thinking buffers still open at stream end (SSE ended after thinking_delta/signature_delta but before content_block_stop), so they survive in the done event's ReasoningBlocks for replay. Finalized in index order. - providers/gemini: corrected the stale thinkingConfig comment (no IncludeThoughts field; thought summaries are suppressed by skipping part.Thought parts). Tests: regression coverage for each fix, plus the requested nitpicks — EOF-terminated MCP line, recipe-prompt propagation assertion, checked file.Close() in session store tests, and a shortened bash process-group timeout test via the bashWaitDelay hook. Note: declined the suggestion to replay a "signature" on redacted_thinking blocks — Anthropic's redacted_thinking carries only an opaque "data" field; the signature is for regular thinking blocks, and the stream never populates one for redacted blocks.
157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/cron"
|
|
)
|
|
|
|
func testCronStore(t *testing.T) *cron.Store {
|
|
t.Helper()
|
|
return cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }})
|
|
}
|
|
|
|
func TestCronAddListRemove(t *testing.T) {
|
|
store := testCronStore(t)
|
|
var out, errb bytes.Buffer
|
|
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
|
|
|
|
if code := runCronWith(store, now, []string{"add", "0 9 * * *", "--prompt", "daily"}, &out, &errb); code != 0 {
|
|
t.Fatalf("add exit=%d err=%s", code, errb.String())
|
|
}
|
|
jobs, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(jobs) != 1 || jobs[0].Expr != "0 9 * * *" || jobs[0].Prompt != "daily" {
|
|
t.Fatalf("job not stored: %+v", jobs)
|
|
}
|
|
if jobs[0].NextRunAt.IsZero() {
|
|
t.Fatal("NextRunAt should be set on add")
|
|
}
|
|
|
|
out.Reset()
|
|
if code := runCronWith(store, now, []string{"list"}, &out, &errb); code != 0 {
|
|
t.Fatalf("list exit=%d", code)
|
|
}
|
|
if !strings.Contains(out.String(), jobs[0].ID) || !strings.Contains(out.String(), "0 9 * * *") {
|
|
t.Fatalf("list output missing job:\n%s", out.String())
|
|
}
|
|
|
|
if code := runCronWith(store, now, []string{"pause", jobs[0].ID}, &out, &errb); code != 0 {
|
|
t.Fatalf("pause exit=%d", code)
|
|
}
|
|
j, err := store.Get(jobs[0].ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if j.Status != cron.StatusPaused {
|
|
t.Fatalf("pause did not persist, status=%q", j.Status)
|
|
}
|
|
|
|
if code := runCronWith(store, now, []string{"rm", jobs[0].ID}, &out, &errb); code != 0 {
|
|
t.Fatalf("rm exit=%d", code)
|
|
}
|
|
jobs, err = store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(jobs) != 0 {
|
|
t.Fatalf("expected removed, got %v", jobs)
|
|
}
|
|
}
|
|
|
|
func TestCronAddRejectsBadExpr(t *testing.T) {
|
|
store := testCronStore(t)
|
|
var out, errb bytes.Buffer
|
|
now := func() time.Time { return time.Now() }
|
|
if code := runCronWith(store, now, []string{"add", "99 * * * *", "--prompt", "x"}, &out, &errb); code == 0 {
|
|
t.Fatal("expected non-zero exit for invalid cron expr")
|
|
}
|
|
if !strings.Contains(errb.String(), "minute") {
|
|
t.Fatalf("error should name the bad field, got %q", errb.String())
|
|
}
|
|
}
|
|
|
|
func TestCronAddRecipe(t *testing.T) {
|
|
store := testCronStore(t)
|
|
var out, errb bytes.Buffer
|
|
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
|
|
if code := runCronWith(store, now, []string{"add", "--recipe", "git-recap"}, &out, &errb); code != 0 {
|
|
t.Fatalf("add --recipe exit=%d err=%s", code, errb.String())
|
|
}
|
|
jobs, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(jobs) != 1 || jobs[0].Expr != "*/30 * * * *" {
|
|
t.Fatalf("recipe job not stored: %+v", jobs)
|
|
}
|
|
}
|
|
|
|
func TestCronAddExplicitExprOverridesRecipe(t *testing.T) {
|
|
store := testCronStore(t)
|
|
var out, errb bytes.Buffer
|
|
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
|
|
// An explicit positional schedule must win over the recipe's default expr
|
|
// instead of being silently dropped.
|
|
if code := runCronWith(store, now, []string{"add", "0 9 * * *", "--recipe", "git-recap"}, &out, &errb); code != 0 {
|
|
t.Fatalf("add exit=%d err=%s", code, errb.String())
|
|
}
|
|
jobs, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(jobs) != 1 || jobs[0].Expr != "0 9 * * *" {
|
|
t.Fatalf("explicit expr should win over recipe, got: %+v", jobs)
|
|
}
|
|
// The recipe's prompt must actually propagate — assert the exact recipe text,
|
|
// not just non-empty (which a default prompt would also satisfy).
|
|
recipe, ok := cron.Recipe("git-recap")
|
|
if !ok {
|
|
t.Fatal("git-recap recipe missing")
|
|
}
|
|
if jobs[0].Prompt != recipe.Prompt {
|
|
t.Fatalf("recipe prompt not propagated: got %q want %q", jobs[0].Prompt, recipe.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestCronAddRejectsExtraArgs(t *testing.T) {
|
|
store := testCronStore(t)
|
|
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
|
|
var out, errb bytes.Buffer
|
|
if code := runCronWith(store, now, []string{"add", "0 9 * * *", "extra", "--prompt", "x"}, &out, &errb); code == 0 {
|
|
t.Fatal("expected non-zero exit for extra positional args")
|
|
}
|
|
jobs, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(jobs) != 0 {
|
|
t.Fatalf("no job should be stored on a usage error, got %v", jobs)
|
|
}
|
|
}
|
|
|
|
func TestCronResumeRejectsImpossibleSchedule(t *testing.T) {
|
|
store := testCronStore(t)
|
|
now := func() time.Time { return time.Date(2026, 6, 9, 8, 0, 0, 0, time.UTC) }
|
|
job, err := store.Add(cron.Job{Expr: "0 0 30 2 *", Prompt: "x", Status: cron.StatusPaused})
|
|
if err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
var out, errb bytes.Buffer
|
|
if code := runCronWith(store, now, []string{"resume", job.ID}, &out, &errb); code == 0 {
|
|
t.Fatal("resume of an impossible schedule must be rejected")
|
|
}
|
|
j, err := store.Get(job.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if j.Status != cron.StatusPaused {
|
|
t.Fatalf("job must remain paused after a rejected resume, got %q", j.Status)
|
|
}
|
|
}
|