mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* audit batch 1: redact nested secrets fully; count image tokens in compaction estimate
- secrets.Redact: replace longest matches first so a containing PRIVATE KEY
block is redacted before a nested shorter match corrupts its exact string
(audit medium internal/secrets/scanner.go:81) + regression test.
- agent compaction estimateTokens: add a flat per-image token cost so an
image-heavy context trends toward compaction instead of reading as ~0
(audit medium internal/agent/compaction.go:58) + test.
- ledger: re-verify against current main; mark scanner.go:35 and
redaction.go:362 already-fixed-on-main, record this batch's fixes.
* audit batch 2: worktrees defaultRunGit separates stdout/stderr
Capture git stdout and stderr into separate buffers instead of CombinedOutput,
so parsed rev-parse output is not polluted by git warnings and CommandResult.Stderr
carries the real error text (audit medium internal/worktrees/worktrees.go:247) + test.
* audit: re-verify all 87 pending findings against current main
Independent agent per file re-checked each open/partial finding against the
actual current code (the 06-11 ledger was verified on a since-diverged branch),
with an adversarial second pass on every already_fixed verdict. Authoritative
result: 78 genuinely pending (66 still_open + 12 partial); 9 rows were already
fixed on main and are reclassified. Full table in 2026-06-13-reverification.md.
* audit batch 3 (agent): cancellation identity, always-allow w/o sandbox, compaction usage, dedup
- loop.go: check ctx.Err() before collected.Error so a cancelled run returns
context.Canceled (errors.Is holds) instead of a plain stringified error.
- loop.go: 'Always allow' with no sandbox engine now allows THIS call instead of
denying it (persistence is skipped when there's nowhere to persist).
- loop.go: remove a duplicate schema["items"] assignment in propertyToRuntimeMap.
- compaction.go: forward OnUsage (token accounting) through the summarizer while
still omitting OnText, so compaction's token cost is counted in usage/budgets.
- tests for all three behavioral fixes (each fails pre-fix).
* audit batch 4 (cli): list-tools -o json, interrupted -o json terminal, surface session-record failures
- exec --list-tools -o json now emits a JSON tool listing instead of falling
through to the human-readable text (audit medium exec.go:182) + test.
- interrupted run with -o json now emits a terminal error+done on stdout instead
of only printing to stderr, so a JSON consumer sees a clean end (medium exec.go:500) + test.
- execSessionRecorder: surface a latched session-append failure to stderr once at
run end (deferred) instead of silently dropping it (medium exec_sessions.go:114/115) + test.
- left app.go trailing-arg ignore as-is: documented-intentional, TUI has no argv prompt.
* audit batch 5 (zerogit): status -z porcelain (renames + non-ASCII paths); document env-drop on plain Runner
- parseStatus: switch `git status` to `--porcelain -z` and parse the
NUL-delimited form (audit medium zerogit.go:212). Previously the default
--short output was split on newlines, so a rename `R old -> new` was kept as
one bogus path and a non-ASCII/whitespace filename arrived C-quoted+escaped
(e.g. `"caf\303\251.txt"`). The -z form never quotes paths and emits a
rename/copy as `XY <dest>\0<src>`, so we now record the destination verbatim
and consume the source field. + TestParseStatusZHandlesRenamesAndSpecialPaths.
- resolveRunners: document that the plain-Runner EnvRunner adapter intentionally
drops env (audit low zerogit.go:417). The branch is reached only when a caller
supplies a custom Runner without a RunGitEnv — in practice fake-Runner tests.
Production leaves both nil and gets defaultRunGit/defaultRunGitEnv, which honor
GIT_INDEX_FILE, so snapshot-diff isolation holds. Routing env calls to real git
instead would break the fake-Runner test pattern and fix nothing in production.
- update Inspect/Commit mock tests to the -z status format and the new command.
* audit batch 6 (config): reject negative maxTurns; drop dead contract-gap helpers
- FileConfig.UnmarshalJSON now rejects a negative maxTurns instead of letting the
`MaxTurns > 0` merge gates silently drop it and fall back to the default,
hiding the misconfiguration (audit medium resolver.go:136). 0 is left as the
"unset" sentinel (omitempty) and still falls back to the default; the CLI
--max-turns flag continues to reject 0 too since there it is distinguishable
from absent. + TestResolveRejectsNegativeMaxTurns / ...ZeroFallsBackToDefault.
- remove dead config/contracts.go (ContractGap, DefaultContractGaps,
FindContractGapsByMilestone, ContractOwnerRuntime) and its test — referenced
only by that test, never by production (audit low contracts.go:12).
- NOT changed: ResolveMCP intentionally does not run the provider command
(TestResolveMCPDoesNotRunProviderCommand guards this), so the "ZERO_PROVIDER_COMMAND
MCP servers dropped" row is by-design, not a defect.
* audit batch 7 (mcp): bound captured MCP server stderr
connectStdio attached a plain bytes.Buffer as cmd.Stderr that is only ever read
on initialize failure, yet it kept growing for the entire process lifetime as a
long-lived server logged to stderr (audit medium client.go:98). Replace it with
a concurrency-safe boundedBuffer capped at 64 KiB that keeps the head (enough for
init-error diagnostics) and discards the rest, reporting full write lengths so
os/exec never sees a short write. + TestBoundedBufferCapsRetainedBytes.
Left as-is (not defects worth the risk in the MCP client):
- client.go writer.write under client.mu: already designed to release the lock
before the unbounded response wait; the residual concern is a server that stops
draining stdin, an involved async rewrite for a theoretical deadlock.
- readLoop not answering server-initiated requests (ping): responding safely
needs re-locking around the shared writer; no configured server relies on it.
* audit batch 8 (tools/providers): read_file truncation marker; drop dead provider branch
- read_file: when a max_lines cut occurs, append an explicit "[truncated: N more
line(s)... set start_line=X to continue]" marker to the output. Previously only
the Result.Truncated flag was set, which is invisible in the rendered text, so
the model could not distinguish a cut read from a complete one (audit low
read_file.go:89). + TestReadFileToolMarksTruncation.
- providers/factory: simplify `if !explicitProvider || isImplicitOpenAI(...)` to
`if !explicitProvider` and delete the now-unused isImplicitOpenAI. The clause
was dead: explicitProvider==true implies ProviderKind or Provider is set, but
isImplicitOpenAI required both empty, so it could never contribute a case
(audit low factory.go:118). Behavior-preserving; providers tests still green.
* audit batch 9 (sessions): Tree builds from a single List() snapshot (no N+1 disk scans)
Store.Tree recursed via ListChildren per node, and each ListChildren ran a full
store.List() that re-read every metadata.json from disk — O(nodes * total-sessions)
reads for one tree (audit medium lineage.go:140). Snapshot all sessions once, index
children by parent in memory, and recurse over that. Child ordering is factored into
sortChildSessions so ListChildren and Tree stay identical; path-scoped cycle
detection (delete(seen,...)) is preserved. Existing tree/lineage tests still pass.
Left as-is: store.go timestamp() second precision (RFC3339) — switching to
RFC3339Nano would break lexical UpdatedAt ordering against existing second-precision
records ("...00.5Z" sorts before "...00Z"), so it is not a safe one-line change.
* audit batch 10 (specialist): serialize accounting dedup-then-append
recordSpecialistStop and appendSpecialistUsageRollup checked for an existing event
(specialistEventExists → ReadEvents) and then appended under SEPARATE store locks,
so two concurrent finishers — a foreground onExit racing a TaskOutput poll, or a
background reaper — could both pass the dedup check and write duplicate stop/usage
events (audit medium accounting.go:72). Guard each check-then-append pair with a
package-level accountingMu (Executor is used by value, so a struct field would not
be shared). + TestRecordSpecialistStopDedupesUnderConcurrency (passes under -race).
* audit batch 11 (zeroruntime/gemini): O(1) text accumulation; gemini cached-token counts
- zeroruntime CollectStreamWithOptions accumulated assistant text with
`collected.Text += event.Content`, reallocating the whole string on every chunk
(O(n^2) over a long streamed response). Accumulate in a strings.Builder on the
collector and materialize it once in flush, the single finalization point hit
before every return (audit low helpers.go:104). Existing collect tests still pass.
- gemini decoded usageMetadata without cachedContentTokenCount, so a cached-prompt
response reported zero cached input tokens while the other providers report them
(audit medium gemini/types.go:88). Decode the field and thread it through
streamState into NormalizeUsage as CachedInputTokens. + extended
TestStreamCompletionEmitsTextUsageAndReasoningTokens.
* audit batch 12 (specialist): parse child stream without a line-length cap; drop dead helper
- ParseStream used a bufio.Scanner capped at 1 MiB, so one large stream-json line
from a child (a big tool result or final answer) hit bufio.ErrTooLong and aborted
the entire specialist run (audit medium streamer.go:43). Switch to a bufio.Reader
with ReadString, which has no per-line limit; the child is our own trusted
subprocess, so its output length is the legitimate bound. Line numbering and
blank-line/EOF handling are preserved. + TestParseStreamHandlesLineLargerThan1MiB.
- remove dead formatTaskOutput (output_tool.go:244): zero callers and zero tests;
it only forwarded to formatTaskOutputSummary, which is the one actually used.
* audit batch 13 (background/specialist): kill background tasks as a process group
Background children were launched in the parent's process group and terminated by
signalling only the single PID, so any process the task forked was orphaned and
outlived a cancel; the signal also risked hitting a recycled PID after the child
was reaped (audit medium process_posix.go:38/50).
- ConfigureChildProcessGroup (Setpgid on POSIX, no-op on Windows where taskkill /T
already tree-kills) is applied at launch so the child leads its own group.
- terminateProcess now SIGTERM/SIGKILLs the negative PID (the whole group), with a
pid>1 guard so a bogus 0/1 can never expand to "our own group" or "everything",
and treats ESRCH as already-gone. Liveness polls the group. Targeting a group
(rarely recycled vs. a bare PID) also shrinks the reaped-then-recycled window.
- specialist launchBackgroundProcess opts every child into its own group; on a
SetPID failure it now kills the child + group, marks the task errored, and records
the stop (deduped) instead of leaking an untracked orphan.
- tests launch as group leaders; new TestTerminateProcessKillsForkedChildren proves
a forked grandchild dies with the group.
* audit batch 14 (sandbox): quote-aware shell segment splitting
splitShellSegments used a quote-unaware strings.Replacer, so a shell operator
inside quotes was treated as a separator: `git commit -m "use top | less"` split
into a `less` segment and `echo "a; vim b"` into a `vim` segment, both falsely
flagged as interactive (audit medium safe_command.go:138).
Replace it with a quote-aware scanner that mirrors the shell: single quotes make
everything literal; double quotes keep $(...)/`...` substitution active but treat
|, ;, && as literal; unquoted text splits on the same operator set as before
(;, |, ||, &&) plus substitution boundaries. Real unquoted operators still split,
so an interactive program behind a separator — or inside a live `"$(...)"`
substitution — is still caught (no new false negatives, the security-relevant
direction). + TestDetectInteractiveQuoteAwareSeparators covering both directions.
* audit batch 15 (cron): collapse DST fall-back repeated hour to one fire
On a fall-back day the clock repeats an hour (e.g. 01:30 EDT then 01:30 EST), so
Next("30 1 * * *", <01:30 EDT>) returned the same-day 01:30 EST — firing a daily
job twice (audit medium schedule.go:171, which previously guarded only the
spring-forward gap).
Next now skips a match whose local wall-clock minute equals `after`'s but whose
absolute time is later (the repeated-hour duplicate) and keeps searching. Normal
forward search always advances the wall-clock, so this only triggers on the
fall-back repeat; when the scheduler passes the last fire time as `after`, the
repeated hour collapses to a single fire. + TestNextDSTFallBackDoesNotRepeatHour
(spring-forward termination test still passes).
Note: Next is stateless, so it dedupes relative to `after`; a caller that passes
an arbitrary mid-hour "now" instead of the last fire time is out of scope here.
* audit batch 16 (mcp): skip SSE notifications before the response; larger SSE buffer
- decodeSSERPCMessage returned the first non-empty `message` event unconditionally,
so a server-initiated notification/request arriving before the response made the
caller see an id mismatch and fail (audit medium network_client.go:621). It now
skips any message carrying a method (notifications/requests have one; a response
does not) and returns the first real response; the caller's rpcIDMatches still
validates the id. + TestDecodeSSERPCMessageSkipsNotifications.
- raise the per-event SSE scanner cap from 1 MiB to a bounded 8 MiB so a large but
legitimate MCP message no longer hits bufio.ErrTooLong and fails the request
(audit medium network_client.go:637); still bounded against an unbounded server.
Out of scope (kept): full reconnect of a persistent SSE stream after a fatal read
error — a larger change than this remediation.
* audit batch 17 (sessions): fsync session metadata before the atomic rename
writeMetadata wrote the temp file with os.WriteFile (no sync) and renamed it into
place, so a crash right after the rename could expose a metadata file whose bytes
were never flushed — a torn or empty file that corrupts the whole session (audit
medium store.go:632). Write+fsync the temp via a new writeFileSync helper before
renaming, closing that window. + TestWriteFileSyncRoundTrips.
Deliberately scoped:
- Event-log appends (appendEventLocked) are NOT fsynced per append: that would add
a disk flush to every event (every tool call), and a hard-crash loss of the log's
unsynced tail is acceptable, whereas torn METADATA is not. Metadata is the
integrity-critical part and is what this syncs.
- Directory fsync is omitted for cross-platform simplicity (it errors on Windows);
the file fsync already prevents the torn-contents failure, the serious case.
Left for follow-up (in this bucket but not safe/clear wins here):
- hooks append sequence still re-reads the file per append; the in-memory cache is
cross-process-unsafe and the safe last-line read is fiddly for a low-volume log.
- Fork event re-append/de-dup is nuanced usage-accounting and needs its own change.
* audit batch 18 (agent): count tool-defs in compaction estimate; don't deny an always-allow on persist failure
- maybeCompact's estimate omitted the tool definitions, which ride on every
request, so the real input could exceed the model limit while the message-only
estimate stayed under threshold and compaction never fired (audit medium
compaction.go:58). partitionTools is now computed before maybeCompact (it does
not depend on the messages) and the exposed tools' estimated tokens are added to
both the threshold check and the post-compaction shrink check. + estimateToolDefTokens
and TestEstimateToolDefTokensCountsDefinitions.
- PermissionDecisionAlwaysAllow denied the call when persistPermissionGrant failed,
overriding what the user explicitly allowed (audit medium loop.go:468). Persisting
is now best-effort: a failure (or no sandbox engine) just means the grant is not
remembered and the user is re-prompted next time, not denied now. This also drops
the dead requestEvent.GrantMatched/Grant writes (audit low loop.go:473) — the
emitted event derives them from the sandbox decision via buildPermissionEvent.
Left intentional (documented, not defects): the reactive-retry path omits OnText to
avoid duplicating mid-stream text (loop.go:183); OnContext/MeasureContext is a
complete, tested context-breakdown scaffold awaiting a consumer (types.go:183).
* audit batch 19 (usage): price escalated usage from the event's own model
exec persists payload["model"] on --allow-escalation runs (the model can change
mid-run), but BuildReport priced every event from the session's Metadata.ModelID
and never read the payload, so usage from an escalated model was mis-priced at the
base model (audit medium report.go:17). usageEventPayload now carries Model and
BuildReport prefers it, falling back to the session model when absent (older
events stay byte-identical and behave as before). + TestBuildReportPricesFromEventModelWhenPresent.
* audit batch 20 (sessions): don't copy usage events into a fork (no double-count)
Fork re-appended every parent event, including provider_usage, into the child, so
a usage report aggregating the parent and the fork double-counted the parent's
usage (audit medium store.go:387). Skip EventUsage when copying — it already
counted against the parent and is not part of the conversation history a fork
replays — and record the actual copied count in the fork marker. + TestStoreForkSkipsUsageEvents.
* audit batch 21 (cli/skills): warn on duplicate skill names instead of silently dropping
skills.Load deduplicates same-named skills (first directory wins) and records the
dropped collisions via skills.Duplicates, but nothing surfaced them, so a shadowed
skill just vanished with no signal — and Duplicates had no production caller (audit
low skills.go:91). `skills list` now reports each collision to stderr (keeping
stdout/--json clean), using Duplicates. + TestRunSkillsListWarnsOnDuplicateNames.
Note: skills.Get is kept — it is a by-name accessor used throughout the package's
tests, not dead code.
* audit batch 22 (cli/cron): capture the run error from stream-json stdout
Cron jobs run exec with --output-format stream-json, so a failure is reported as an
`error` event on STDOUT, but the run record's Error was set only from stderr — so a
failed job often recorded an empty/uninformative error while the real message sat
unread in outBuf (audit low cron_run.go:150). On a non-zero exit, scan the stdout
stream-json for the error event's message and prefer it, falling back to stderr.
+ extractStreamJSONError and TestExtractStreamJSONError.
* audit batch 23 (sandbox): richer macOS seatbelt profile (signal + mach-lookup)
The generated sandbox-exec profile was default-deny with only process/sysctl/file
allowances, so two real gaps remained on macOS (sandbox quality report H1):
- A sandboxed script could not signal the children it spawned — `kill` returned
"Operation not permitted" under seatbelt, breaking common `cmd & ... kill`,
test-runner, and timeout patterns. Add `(allow signal (target self) (target pgrp))`,
which stays scoped to the command's own process group.
- XPC to system daemons was denied, so keychain (securityd/trustd), user/group
lookup (opendirectoryd), preferences (cfprefsd), network config
(SystemConfiguration), launch services, and the pasteboard all failed. Add a
curated `(allow mach-lookup ...)` allowlist (`sandboxMachServices`).
Neither touches the file-write or network rules, so the workspace boundary is
unchanged — verified on a real macOS host: `security`/`scutil`/`pbpaste`/self-kill
now succeed while writes to $HOME and /etc are still denied. iokit-open was tried
and dropped as unnecessary. + unit assertions (TestSandboxExecProfileGrantsSignalAndMachLookup)
and extended the darwin integration test with a self-kill case and an
outside-workspace deny check.
* audit batch 24 (sandbox/tools): remove dead OnSandboxDecision hook
RunOptions.OnSandboxDecision had no setter anywhere, yet registry.RunWithOptions
spawned a goroutine (with panic recovery) for it on every sandboxed tool call —
pure per-call overhead for an unreachable callback (sandbox report M1). The sandbox
decision is already surfaced to observers via the permission event
(buildPermissionEvent reads the decision), so this hook is redundant; delete the
field and its call site.
* audit batch 25 (sandbox/cli): exact-path grants — create and revoke by path
The engine already matched ScopeFile grants and DeriveScope already produced them
from path/file tool args, but the CLI could neither create one explicitly nor
revoke a single one (sandbox report L3 / prompt 7). Add:
- GrantStore.RevokePath(tool, path): revokes only the grant whose scope matches the
given file/dir, leaving tool-wide and other-path grants intact (path canonicalized
to absolute like stored scopes). + TestGrantStoreRevokePathRemovesOnlyMatchingScope.
- `zero sandbox grants allow <tool> --path <p>`: persists an exact-file (ScopeFile)
grant, path resolved to absolute so it matches lookups and revokes.
- `zero sandbox grants revoke <tool> --path <p>`: revokes just that grant (default
still revokes all grants for the tool). + TestRunSandboxGrantsCreateAndRevokeByPath.
The TUI permission-card "Allow always (exact path)" option is intentionally left for
the TUI feature branches (it overlaps #187/#188); the engine + CLI now cover the
full create/match/revoke round-trip for file-scoped grants.
* audit batch 26 (sandbox): Engine.Precheck — report violations before execution
Add Engine.Precheck(ctx, request) []Violation, which reports the sandbox
violations that would block a tool request before it runs (sandbox report / prompt 6).
It reuses Evaluate so policy is not duplicated: an allowed or merely-prompted
request yields no violations; a denied request yields its violation (via the shared
violationsFromDecision helper, with a ViolationPolicyDenied fallback for a deny that
carries no structured violation). A nil engine yields nothing.
+ TestEnginePrecheckReportsViolationsBeforeExecution.
Wiring Precheck into the batch-confirmation prompt is intentionally left to the TUI
branch: it touches prompt rendering, and short-circuiting the agent loop's deny path
would skip the before/after-tool hooks that currently fire around a policy-denied
call. The engine API is in place for that consumer.
* audit batch 27 (sandbox): scoped-egress domain prompt with timeout + caching
The scoped-egress proxy failed closed on any host outside the allowlist. Add an
opt-in DomainPrompt callback (prompt 8): an UNKNOWN host (neither allowed nor
explicitly denied) is authorized at request time via the callback instead of being
refused outright. An explicitly denied host is still refused without prompting; the
callback is bounded by PromptTimeout (default 60s, timeout/error => deny); decisions
are cached for the proxy's lifetime so a host is prompted at most once. With no
callback set the strict fail-closed behavior is unchanged. Wired through both the
CONNECT and HTTP handlers via authorizeTarget.
+ TestEgressProxyAuthorizeDomainPrompt / ...TimeoutDenies / ...NilPromptFailsClosed.
The user-facing prompt that supplies the callback is left to the permission UI (TUI)
branch; the egress mechanism (callback + timeout + cache) is in place and tested.
* audit batch 28 (sandbox): AST-based shell analysis (AnalyzeCommand)
Add AnalyzeCommand(script) AnalysisResult, a static shell analyzer built on the
mvdan.cc/sh/v3 parser (prompt 12). Walking the parsed command tree makes it a more
precise second opinion than the regex detector: a program name only counts when it
is an actual command, so `echo "vim ..."`, `printf 'open with vim'`, and
`git commit -m "rm -rf /"` are clean, while real commands are flagged —
interactive (editors/pagers + REPLs, with the same -e/script suppression as
nonInteractiveREPLFlags), destructive (rm -rf/-fr, dd, mkfs, shred, ...), and
network (curl/wget/ssh/nc/...). An unparseable script yields TooComplex so callers
can treat it as higher-risk. + TestAnalyzeCommand (16 cases) / ...EmptyIsClean.
Dependency: mvdan.cc/sh/v3 pinned to v3.11.0 — compatible with the existing go 1.24
directive (the latest v3.13.1 would have bumped the module to go 1.25). It is kept
as a standalone analyzer for callers to consult; feeding it into Engine.Evaluate is
deliberately not done here, since that would change allow/deny classification.
* audit batch 29 (sandbox): unify proxy-env generation (ProxyEnv)
Export ProxyEnv as the single source of truth for proxy-env injection (prompt 9
req 1), replacing the unexported scopedProxyEnv used by the sandboxed-shell path.
+ TestProxyEnv.
Investigation that bounds the rest of prompt 9 (documented in the ProxyEnv doc):
- web_fetch already honors HTTP_PROXY/HTTPS_PROXY — its transport clones
http.DefaultTransport, whose Proxy is http.ProxyFromEnvironment.
- MCP children already inherit the parent env (mergeProcessEnv appends os.Environ),
so they pick up any proxy vars the agent has.
So the only missing link to scope FetchUrl/MCP is a SESSION-level egress proxy whose
address reaches the agent process. That is deliberately NOT wired here: doing it
safely requires allowlisting the active LLM provider's own domain first, or the
agent's provider calls would route through the scoped proxy and be denied — a
correctness pitfall that also can't be verified end-to-end on this host. Flagged
rather than shipped blind.
* audit batch 30 (sandbox): Linux seccomp Unix-socket block — UNVERIFIED, needs Linux CI
Adds a pure-Go (no cgo, no vendored binary) seccomp BPF filter that denies
socket(AF_UNIX) with EPERM on x86-64/arm64, closing the Unix-socket gap bubblewrap's
filesystem/network isolation leaves open (prompt 11):
- internal/sandbox/seccomp.go: the classic-BPF program as a platform-neutral
[]sockFilter, built and UNIT-TESTED (structure + every jump offset in range — the
classic way a hand-written BPF filter goes silently wrong).
- seccomp_linux.go: ApplyUnixSocketBlock installs it via prctl(NO_NEW_PRIVS) +
prctl(SET_SECCOMP, MODE_FILTER). seccomp_other.go is a no-op everywhere else.
- cmd/zero-seccomp: a tiny linux-only exec wrapper that applies the filter then
execs the real command (the standard way to install seccomp in a child with
os/exec); degrades gracefully (warns + runs) if the kernel lacks seccomp.
⚠️ HONEST CAVEAT: I developed this on macOS and CANNOT run the actual AF_UNIX block
here. darwin build, `GOOS=linux` cross-compile, vet, and the structure test all pass,
but the unit test verifies the program's SHAPE, not that the kernel actually blocks
AF_UNIX — that MUST be verified on Linux CI (create a Unix socket under the wrapper
and assert EPERM). Safe to ship as-is: it is linux-only, no-op on every other
platform, and not wired into the bwrap command yet (opt-in prefixing the sandboxed
command with zero-seccomp is the remaining integration), so it cannot regress any
current path.
* audit batch 31 (sandbox): macOS denial log monitor (opt-in, default off)
Re-adds the macOS seatbelt-denial monitor as an opt-in feature (prompt 10). When
Policy.MonitorDenials is set, the sandbox-exec profile tags its default-deny with a
marker and bash tails `log stream` for that tag during the command, appending any
captured denials as a <sandbox_violations> block to the command's stderr so the
model can see what was blocked.
- DenialMonitor (darwin) tails/parses tagged denials with a capped, deduped ring
buffer and noise filtering; no-op stub on other platforms. parseSandboxDenyLine is
unit-tested. + profile-tag test + appendSandboxViolations test.
- Default OFF: with MonitorTag empty, StartDenialMonitor is a no-op and the command
path is byte-for-byte unchanged, so there is no regression or per-command overhead
unless enabled.
CAVEAT: on at least one macOS host (verified live) seatbelt denials are NOT delivered
to the `log stream`-queryable unified log, so the monitor captures nothing there. It
is the standard mechanism and works on macOS versions that do log denials; shipping
it default-off means it never affects whether a command runs.
* audit batch 32 (sandbox): wire seccomp Unix-socket block into bubblewrap (opt-in)
Completes prompt 11: the zero-seccomp helper (which installs the AF_UNIX seccomp
filter) is now actually invoked by the sandbox instead of only existing as a
standalone binary.
- bubblewrapCommandPlan, when Policy.BlockUnixSockets is set, ro-binds the
discovered zero-seccomp helper into the sandbox at its host path and prefixes the
command with it so the filter is installed before the real argv runs.
- findSeccompHelper discovers the helper next to the running executable first, then
on PATH; it returns "" when absent, so the sandbox degrades gracefully (runs
without the extra filter — bubblewrap's fs/net isolation still applies) rather
than failing the command. Exposed as a package var so the wiring is unit-tested
on any OS.
- Reachable from config: SandboxConfig.BlockUnixSockets and .MonitorDenials now map
onto the policy in applyConfiguredSandboxPolicy (opt-in only — config can turn a
hardening flag on, never off). Both default OFF.
- Tests: helper-prefix present when enabled / absent by default; config flags apply
independently and stay off when omitted.
Default OFF and Linux-only effect; the runtime seccomp enforcement still needs a
Linux host/CI to exercise end-to-end (the wiring and BPF program are unit-tested).
* audit batch 33 (sandbox): enforce scoped/deny network policy in web_fetch & web_search
Completes prompt 9: the built-in network tools now honor the SAME allow/deny/scoped
policy the sandboxed shell egress proxy enforces, instead of bypassing it. Before
this, web_fetch/web_search would reach any public host even when the policy was
NetworkDeny or NetworkScoped — the egress proxy only constrained shell commands.
- New shared gate Engine.NetworkHostAllowed(host) reuses the egress domain matcher
(domainAllowed/normalizeDomains/effectiveNetwork) as the single source of truth:
allow → all hosts; deny → none; scoped → only AllowedDomains minus DeniedDomains
(empty allowlist collapses to deny). Disabled policy / nil engine impose nothing.
- web_fetch is now sandbox-aware: the host is checked before the request AND on every
redirect, so a denied/unlisted host fails closed before any dial. Plumbed via a
networkPolicyGate so the no-sandbox path is byte-for-byte unchanged.
- web_search is sandbox-aware: the configured backend's endpoint host is checked
before the query leaves the machine; blocked under deny, and under scoped unless the
backend host is allowlisted.
- The registry already routes sandbox-aware tools through RunWithSandbox when an
engine is present, so this activates automatically wherever the sandbox is wired.
- Tests: engine gate matrix (deny/allow/scoped/denylist/empty/disabled/nil + host:port);
web_fetch & web_search block denied/unlisted hosts without dialing and allow listed
ones; endpoint-host parsing.
* audit batch 34 (sandbox): prove network-policy wiring end-to-end + document opt-in flags
Verification follow-up to batches 31-33; no behaviour change.
- Adds a registry-level test that exercises the FULL chain for the scoped-network
enforcement: registry.RunWithOptions -> engine.Evaluate (passes a scoped,
egress-capable sandbox-exec backend) -> routes web_fetch through RunWithSandbox ->
per-host allowlist blocks an unlisted host before any dial. This proves the
Tool-interface value actually satisfies sandboxAwareTool at the registry boundary,
not just in a direct method call.
- Documents the two opt-in hardening flags (sandbox.blockUnixSockets,
sandbox.monitorDenials) and the scoped web_fetch/web_search behaviour in the README
so the config keys are discoverable. Both remain off by default.
Confirmed wiring: agent loop forwards options.Sandbox to the registry; the registry
routes sandbox-aware tools (web_fetch, web_search) through RunWithSandbox when an
engine is present; MonitorTag is set only on the macOS sandbox-exec plan; the
seccomp helper prefix is added only on the bubblewrap plan when BlockUnixSockets is
set; both config flags map onto the policy via applyConfiguredSandboxPolicy.
* audit batch 35 (sessions): fix Windows CI failure in TestWriteFileSyncRoundTrips
The exact-permission assertion (perm == 0o600) added in batch 17 fails on
windows-latest because Windows does not honor Unix permission bits: Go reports
0o666 for a writable file regardless of the mode passed to OpenFile. This was the
sole cause of the failing Smoke (windows-latest) CI job; macOS and Ubuntu passed.
Guard the perm check with runtime.GOOS != "windows" so it still verifies the mode
on the platforms that enforce it, while the round-trip (write + read-back) is still
asserted on every platform.
* audit batch 36: address CodeRabbit review (5 findings)
- background/process_posix: after SIGKILL, poll until the process group is
actually gone (SIGKILL is async) and return an error if it survives past a
second deadline, instead of returning nil while descendants still race.
- sandbox/safe_command: only treat ')' as a substitution boundary when it closes
an active $(...). A literal ')' (e.g. echo "a) less") no longer splits into a
fake `less` segment and is no longer misflagged as interactive. Depth-tracked so
nested $(...) still isolate their inner command. Direct splitShellSegments test
(fails pre-fix: yields ["echo \"a", "less\""]).
- cron/schedule: the DST fall-back collapse now only applies when `after` sits
exactly on the minute boundary (its "last fire time" form). With sub-minute
precision (e.g. 01:30:30 EDT) the first 01:30 fire already passed, so the repeated
01:30 EST is the legitimate next fire and is returned — restoring strictly-after
semantics. New test fails pre-fix (skipped to next day).
- config/types: maxTurns error message now reads "must be >= 0" to match the check
(0 is accepted; only < 0 is rejected). Negative-maxTurns test asserts the message.
- docs/audit: header date/branch metadata now matches the body (2026-06-13,
fix/audit-batch) instead of the stale 2026-06-11/fix/audit-critical-high snapshot.
Verified: gofmt clean; go vet clean; host + linux + windows builds; affected suites
green. Each behavioural fix ships with a test confirmed to fail without the change.
* audit batch 37: address CodeRabbit re-review (12 findings)
Findings on the audit code from batches 21-36; each behavioural fix ships with a
regression test (several confirmed to fail without the change).
sandbox/analyzer (AnalyzeCommand):
- Unwrap launcher prefixes (sudo/env/nice/bash -c/...) and recurse into `sh -c`
payloads so `sudo rm -rf`, `env curl …`, `bash -c 'vim x'` classify on the real
command, not the launcher. Mirrors DetectInteractiveCommand.
- Honor rm's `--` end-of-options marker so `rm -- -rf` (delete a file named -rf) is
not flagged destructive.
sandbox/engine: NetworkHostAllowed now shares one backend-aware effective-mode
helper with Evaluate, so scoped collapses to deny when the backend can't enforce
scoped egress — the per-tool gate can no longer permit traffic the engine-level
decision would fail closed.
sandbox/egress: cache domain-prompt decisions by host:port, not host, so one
"allow" can't authorize other ports on the same host.
sandbox/safe_command: the segment splitter is now escape-aware (\| , \" ) so an
escaped operator/quote can't manufacture a fake interactive segment.
sandbox/log_monitor (darwin): Wait() the `log stream` child after draining stdout
so the cancelled process isn't leaked as a zombie.
tools/web_search: confine the search backend to same-host redirects (with a limit)
so a redirect chain can't egress to a host the scoped/deny policy never authorized.
cli/sandbox: an explicit empty --path (`--path=` / `--path ""`) now fails closed
instead of silently widening an allow to tool-wide or a revoke to all-grants.
cli/skills: warn on stderr when duplicate-skill detection fails instead of
swallowing the error.
specialist/exec: don't mask a failed orphan-kill — join the TerminateProcess error
with the SetPID error so a surviving process is visible.
background/process_posix: resolve the PGID and only group-signal when pid leads its
own group; otherwise signal the individual PID. Avoids both a false-success on a
non-leader ESRCH and the hazard of signalling an unrelated (possibly our own) group.
Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./...
green.
* audit batch 38: address CodeRabbit re-review round 2 (3 findings)
Follow-ups to batch 37, each with a regression test:
- safe_command (splitter): a ')' is now only a substitution boundary when it
closes an active, UNQUOTED $(...). A quote-state stack saved at each '$(' lets a
substitution run in a fresh quoting context and restores the outer quoting on
its matching ')'. Fixes `echo $(printf "a) less")` splitting into a fake `less`
segment, while still isolating the inner command of `echo "$(vim x)"`.
- egress: deduplicate in-flight domain prompts per host:port. Concurrent requests
for the same unknown target now wait on the first prompt and reuse its cached
decision instead of each firing a prompt (a prompt storm) and racing to write
the final decision. Race-tested with 25 concurrent authorizers → one prompt.
- analyzer + safe_command: wrapper option-value consumption is now wrapper-specific
(wrapperValueOptionsByProg) instead of a global option set. A valueless flag like
`sudo -n` no longer swallows the real payload, so `sudo -n rm -rf` /
`sudo -n curl …` are correctly classified destructive/network; `sudo -u root vim`
still consumes its value. Shared by the AST analyzer and the regex detector so
they can't diverge.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 39: address CodeRabbit re-review round 3 (12 findings)
All verified against current code; each behavioural fix has a regression test.
Security / correctness:
- tools/web_search: redirect policy now also refuses a scheme change, blocking a
same-host https→http downgrade that would send the query + bearer token over
plaintext.
- sandbox/safe_command: a ')' is only a substitution boundary when it closes an
active UNQUOTED $(...), and backticks now get the same quote-state save/restore
as $( — so `echo "`a | less`"` and `echo "$(true | less)"` no longer hide the
inner pager from interactive detection.
- sandbox/analyzer + safe_command: wrapper option-value consumption stays
wrapper-specific, and unwrapping no longer aborts on a dynamic ($x) wrapper arg
once a wrapper is active, so `env "$opts" curl` / `sudo "$x" rm -rf` are still
classified.
- sandbox/egress: in-flight prompts deduped per host:port (carried from batch 38);
the prompt callback is now cancellable (context cancelled on timeout) so a stuck
prompt can abort instead of leaking; signature threaded through.
- sandbox/runner: per-plan unique denial tag (pid+counter) so concurrent monitored
sandbox-exec runs can't ingest each other's denials; helper selection now
requires an executable regular file so a non-exec sibling degrades gracefully.
- mcp/network_client: aggregate SSE event-size cap (not just per-line) so a server
can't grow memory with many unterminated data: lines.
- specialist/accounting: a previously-recorded stop/usage with an empty runId is a
catch-all and now suppresses a later runId-bearing duplicate.
- sessions/lineage: Tree fetches the root via Get() first so a corrupt root
surfaces its real error instead of degrading to "not found".
Docs/tests:
- background/terminate: docstring now states the leader-only group-kill (PID-only
fallback for non-leaders); test child-liveness probe uses ESRCH specifically.
- cli/sandbox: grant usage strings include --path.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 40: address CodeRabbit re-review round 4 (8 fixed, 1 skipped)
Verified each against current code.
Fixed:
- sessions/lineage: PANIC FIX — store.Get returns (nil, nil) for a missing
session, so the batch-39 root prefetch could nil-deref. Guard root == nil and
return a not-found error. Regression test added (was a panic).
- agent/compaction: the reactive recover() now records lowWaterMark in the SAME
combined (messages + tool-defs) token domain maybeCompact uses, so the proactive
shrink-guard compares like with like. recover() takes tools; callers pass
request.Tools.
- mcp/network_client: SSE aggregate counter no longer counts a joining newline for
the first data line (only when prior content exists), avoiding premature rejects.
- sessions/store: fsync the parent directory after the metadata rename so the
rename is durable across a crash (no-op on Windows, which can't fsync a dir).
- sandbox/egress: parse the target port with strconv.Atoi instead of
net.LookupPort (no context-unaware resolver use).
- sandbox/runner: escape the per-plan denial tag through sandboxProfileString when
embedding it in the seatbelt profile.
- sandbox/seccomp_linux: guard against an empty filter program before taking
&kernelFilters[0] (defensive; the program is fixed non-empty today).
- cli/cron_run_test: assert last-error-wins with two error events.
- background/terminate, cli/sandbox usage, process_posix_test ESRCH (carried).
Skipped (with reason):
- zerogit worktree-rename (Y='R') test: git porcelain v1 -z only emits the
rename-source field when the INDEX column is R/C; the worktree column never
carries 'R' with a source field in v1, so the fixture would test output git
doesn't produce, and making parseStatus consume on code[1]=='R' would risk
mis-consuming the following entry for a plain ` R` status. Left as-is.
Verified: gofmt + go vet clean; host + linux + windows builds; go test ./... and
-race on sandbox/background all green.
* audit batch 41: address CodeRabbit re-review round 5 (4 findings)
All verified against current code; code fixes have regression tests.
- agent/compaction: summarizeWithFallback's reduce pass no longer swallows ALL
errors. A context-limit error still falls back to the joined partial summaries
(documented "extreme" case), but a non-context failure (auth/network/provider)
now surfaces unchanged, per the function's contract. Two tests (propagate vs.
joined-fallback); the propagate test fails without the fix.
- sandbox/safe_command + analyzer: wrapper value-consuming options now include the
long spellings (sudo --user root, env --unset NAME, timeout --signal …), so a
long flag's value is no longer mis-read as the program (interactive/destructive/
network detection bypass). A `--flag=value` token carries its own value and never
consumes the next token. Tests for space and = forms in both the AST analyzer and
the regex detector.
- docs/audit: the status-ledger summary table's open/partial cells (64/14) were the
lone outlier — the ledger's own narrative and 2026-06-13-reverification.md both
state 66/12. Corrected to 66/12 (66+12+97 = 175); derivation: body 69 open + 18
partial minus the 9 already-fixed-on-main (3 open + 6 partial) = 66/12 = 78
pending, fixed 88+9 = 97. Added a comment documenting the summary-vs-body
relationship. The reverification doc already states 66/12, so both now agree.
Verified: gofmt + go vet clean; host + linux + windows builds; full go test ./...
green.
* audit batch 42: address CodeRabbit re-review round 6 (1 fixed, 1 kept w/ proof)
CodeRabbit re-surfaced two "duplicate" refinements; CI was already green (its 6th
pass was a COMMENT with no new findings).
- cli/sandbox_test (TestRunSandboxGrantsRejectsEmptyPath): now seeds a tool-wide
grant and asserts grant-store immutability after EACH rejected empty-`--path`
call (reflect.DeepEqual before/after), so a mutation in one call can't be masked
by a later one — and a buggy "revoke all" is actually observable (it's a no-op on
an empty store). Stronger than the suggested patch.
- zerogit.parseStatus: KEPT consuming the rename source on the index column only
(code[0]), with an explanatory comment. Verified empirically that git porcelain
v1 -z reports a rename/copy (and emits the extra source field) only in the index
column: `git mv` → "R new\0old\0"; a worktree-only rename → " D old\0?? new\0"
(delete + untracked, never "R" in code[1]). Consuming on code[1]=='R'/'C' would
match no real git output and would only risk mis-consuming the next entry on
malformed input, so the suggestion is declined as incorrect for v1.
Verified: gofmt + go vet clean; host + linux + windows builds; affected suites green.
1931 lines
67 KiB
Go
1931 lines
67 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
func TestRunExecHelpDocumentsM1Flags(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"exec", "--help"},
|
|
{"exec", "--help", "--model", "m1"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run(args, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
for _, want := range []string{
|
|
"-f, --file",
|
|
"--image <path>",
|
|
"--mode <name>",
|
|
"-m, --model",
|
|
"--max-turns",
|
|
"--profile <profile>",
|
|
"-r, --reasoning-effort <effort>",
|
|
"-C, --cwd",
|
|
"-o, --output-format text|json",
|
|
"--prompt",
|
|
"--calling-session-id",
|
|
"--calling-tool-use-id",
|
|
"--tag <tag>",
|
|
"--depth <number>",
|
|
"--session-title",
|
|
"--init-session-id",
|
|
"--skip-permissions-unsafe",
|
|
} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("expected exec help to contain %q, got %q", want, stdout.String())
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsInvalidMaxTurnsBeforeRuntime(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
value string
|
|
want string
|
|
}{
|
|
{value: "nope", want: "invalid --max-turns"},
|
|
{value: "-1", want: "invalid --max-turns"},
|
|
{value: "0", want: "invalid --max-turns"},
|
|
} {
|
|
t.Run(tc.value, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--max-turns", tc.value, "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, tc.want) {
|
|
t.Fatalf("expected max-turns validation error containing %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("equals-empty", func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--max-turns=", "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--max-turns requires a value") {
|
|
t.Fatalf("expected empty max-turns validation error, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunExecMaxTurnsReachesConfigOverrides(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var gotMaxTurns int
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--max-turns", "7", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
gotMaxTurns = overrides.MaxTurns
|
|
return config.ResolvedConfig{}, errors.New("stop before provider")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
|
}
|
|
if gotMaxTurns != 7 {
|
|
t.Fatalf("overrides.MaxTurns = %d, want 7", gotMaxTurns)
|
|
}
|
|
}
|
|
|
|
func TestParseExecSpecialistMetadataFlags(t *testing.T) {
|
|
options, help, err := parseExecArgs([]string{
|
|
"--calling-session-id", "parent_session",
|
|
"--calling-tool-use-id=toolu_123",
|
|
"--tag", "specialist",
|
|
"--depth=2",
|
|
"--session-title", "Explorer child",
|
|
"--init-session-id", "child_session",
|
|
"--output-format", "debug",
|
|
"inspect the parser",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("parseExecArgs returned error: %v", err)
|
|
}
|
|
if help {
|
|
t.Fatal("help = true, want false")
|
|
}
|
|
if options.callingSessionID != "parent_session" ||
|
|
options.callingToolUseID != "toolu_123" ||
|
|
options.tag != "specialist" ||
|
|
options.depth != 2 ||
|
|
options.sessionTitle != "Explorer child" ||
|
|
options.initSessionID != "child_session" {
|
|
t.Fatalf("metadata flags did not parse correctly: %#v", options)
|
|
}
|
|
if options.outputFormat != execOutputStreamJSON {
|
|
t.Fatalf("outputFormat = %q, want stream-json debug alias", options.outputFormat)
|
|
}
|
|
if strings.Join(options.promptParts, " ") != "inspect the parser" {
|
|
t.Fatalf("promptParts = %#v", options.promptParts)
|
|
}
|
|
}
|
|
|
|
func TestParseExecSpecialistMetadataRejectsInvalidValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "negative depth", args: []string{"--depth=-1", "hello"}, want: "invalid --depth"},
|
|
{name: "non numeric depth", args: []string{"--depth", "many", "hello"}, want: "invalid --depth"},
|
|
{name: "empty tag", args: []string{"--tag=", "hello"}, want: "--tag requires a value"},
|
|
{name: "bad init session", args: []string{"--init-session-id", "../escape", "hello"}, want: "invalid --init-session-id"},
|
|
{name: "init with resume", args: []string{"--init-session-id", "child", "--resume", "parent", "hello"}, want: "Use --init-session-id only"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, _, err := parseExecArgs(tc.args)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("expected %q error, got %v", tc.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecRegistersTaskOnlyForUnsafeTopLevelRuns(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantTask bool
|
|
}{
|
|
{name: "default headless", args: []string{"exec", "--list-tools"}, wantTask: false},
|
|
{name: "unsafe headless", args: []string{"exec", "--auto", "high", "--list-tools"}, wantTask: true},
|
|
{name: "specialist child", args: []string{"exec", "--auto", "high", "--tag", "specialist", "--list-tools"}, wantTask: false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return t.TempDir(), nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
for _, toolName := range []string{"Task", "TaskOutput", "TaskStop", "GenerateSpecialist"} {
|
|
hasTool := strings.Contains(stdout.String(), " "+toolName+" ")
|
|
if hasTool != tc.wantTask {
|
|
t.Fatalf("%s visibility = %v, want %v; output:\n%s", toolName, hasTool, tc.wantTask, stdout.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecUsesInitSessionIDAndSessionTitle(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{
|
|
"exec",
|
|
"--init-session-id", "specialist_child",
|
|
"--session-title", "Explorer child",
|
|
"--tag", "specialist",
|
|
"--depth", "1",
|
|
"hello",
|
|
}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
|
session, err := store.Get("specialist_child")
|
|
if err != nil {
|
|
t.Fatalf("Get session returned error: %v", err)
|
|
}
|
|
if session == nil {
|
|
t.Fatal("expected initialized session specialist_child")
|
|
}
|
|
if session.Title != "Explorer child" || session.Tag != "specialist" || session.Depth != 1 {
|
|
t.Fatalf("session metadata = %#v, want title/tag/depth", session)
|
|
}
|
|
if session.Cwd != cwd {
|
|
t.Fatalf("session cwd = %q, want %q", session.Cwd, cwd)
|
|
}
|
|
}
|
|
|
|
func TestRunExecPersistsCallingSessionChildMetadata(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
|
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session", Title: "Parent", Cwd: "/repo", ModelID: "gpt-parent", Provider: "openai"})
|
|
if err != nil {
|
|
t.Fatalf("Create parent returned error: %v", err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{
|
|
"exec",
|
|
"--output-format", "stream-json",
|
|
"--init-session-id", "child_session",
|
|
"--session-title", "worker: Auth check",
|
|
"--tag", "specialist",
|
|
"--depth", "1",
|
|
"--calling-session-id", parent.SessionID,
|
|
"--calling-tool-use-id", "toolu_123",
|
|
"hello",
|
|
}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return t.TempDir(), nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
|
|
child, err := store.Get("child_session")
|
|
if err != nil {
|
|
t.Fatalf("Get child returned error: %v", err)
|
|
}
|
|
if child == nil {
|
|
t.Fatal("expected child session metadata")
|
|
}
|
|
if child.SessionKind != sessions.SessionKindChild ||
|
|
child.ParentSessionID != parent.SessionID ||
|
|
child.RootSessionID != parent.SessionID ||
|
|
child.AgentName != "worker" ||
|
|
child.TaskID != "child_session" ||
|
|
child.SpawnedFromEventID != "toolu_123" ||
|
|
child.Tag != "specialist" ||
|
|
child.Depth != 1 {
|
|
t.Fatalf("unexpected child metadata: %#v", child)
|
|
}
|
|
}
|
|
|
|
func TestRunExecModeSeedsModelAndTurnOverrides(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var gotModel string
|
|
var gotMaxTurns int
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--mode", "deep", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
gotModel = overrides.Provider.Model
|
|
gotMaxTurns = overrides.MaxTurns
|
|
return config.ResolvedConfig{}, errors.New("stop before provider")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
|
}
|
|
if gotModel != "claude-opus-4.1" {
|
|
t.Fatalf("overrides.Provider.Model = %q, want claude-opus-4.1", gotModel)
|
|
}
|
|
if gotMaxTurns != 50 {
|
|
t.Fatalf("overrides.MaxTurns = %d, want 50", gotMaxTurns)
|
|
}
|
|
}
|
|
|
|
func TestRunExecExplicitModelOverridesMode(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var gotModel string
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--mode", "deep", "--model", "gpt-4.1", "hello"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
gotModel = overrides.Provider.Model
|
|
return config.ResolvedConfig{}, errors.New("stop before provider")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
|
}
|
|
if gotModel != "gpt-4.1" {
|
|
t.Fatalf("explicit --model should override mode: got %q, want gpt-4.1", gotModel)
|
|
}
|
|
}
|
|
|
|
func TestRunExecModeRoutesModelThroughRegistry(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
var gotModel string
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
// "smart" maps to claude-sonnet-4.5; the mode's model must be routed through
|
|
// the registry (Resolve) so the canonical id reaches the overrides.
|
|
exitCode := runWithDeps([]string{"exec", "--mode", "smart", "hi"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
gotModel = overrides.Provider.Model
|
|
return config.ResolvedConfig{}, errors.New("stop before provider")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitProvider {
|
|
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
|
|
}
|
|
if gotModel != "claude-sonnet-4.5" {
|
|
t.Fatalf("expected mode smart to select claude-sonnet-4.5, got %q", gotModel)
|
|
}
|
|
}
|
|
|
|
func TestRunExecUnknownModeErrors(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--mode", "turbo", "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "unknown mode") {
|
|
t.Fatalf("expected unknown mode error, got %q", stderr.String())
|
|
}
|
|
for _, want := range []string{"smart", "deep", "fast", "large", "precise"} {
|
|
if !strings.Contains(stderr.String(), want) {
|
|
t.Fatalf("expected error to list valid mode %q, got %q", want, stderr.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyExecModeLeavesRawModelForSharedResolution(t *testing.T) {
|
|
// applyExecMode must NOT pre-resolve the mode's model: leaving the raw id/alias
|
|
// lets the shared --model resolution path resolve it AND surface any deprecation
|
|
// notice on stderr (the bug was that applyExecMode resolved it and discarded the
|
|
// notice). The raw alias must be the preset's exact Model value.
|
|
mode, ok := modelregistry.LookupMode("deep")
|
|
if !ok {
|
|
t.Fatal("expected built-in mode deep")
|
|
}
|
|
options := execOptions{mode: "deep"}
|
|
if err := applyExecMode(&options); err != nil {
|
|
t.Fatalf("applyExecMode returned error: %v", err)
|
|
}
|
|
if options.model != mode.Model {
|
|
t.Fatalf("options.model = %q, want raw mode model %q (resolution must be delegated)", options.model, mode.Model)
|
|
}
|
|
}
|
|
|
|
func TestRunExecModeModelSurfacesDeprecationNoticeViaSharedPath(t *testing.T) {
|
|
// A mode-supplied model must flow through the same resolution path as an
|
|
// explicit --model, so a deprecated id redirects AND prints a notice. No
|
|
// built-in mode references a deprecated model, so emulate one by setting the
|
|
// raw mode model directly through applyExecMode and threading it through the
|
|
// shared resolver, exactly as runExec does after the reorder.
|
|
options := execOptions{mode: "smart"}
|
|
if err := applyExecMode(&options); err != nil {
|
|
t.Fatalf("applyExecMode returned error: %v", err)
|
|
}
|
|
// Sanity: the shared resolver surfaces a notice + redirect for a deprecated id,
|
|
// which is the path the mode model now feeds into.
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
resolved, notice := resolveSelectedModel(registry, "gpt-4-turbo")
|
|
if resolved != "gpt-4.1" {
|
|
t.Fatalf("expected deprecated model to redirect to gpt-4.1, got %q", resolved)
|
|
}
|
|
if !strings.Contains(notice, "deprecated") {
|
|
t.Fatalf("expected shared resolver to surface a deprecation notice, got %q", notice)
|
|
}
|
|
}
|
|
|
|
func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
|
|
// applyExecMode now runs before tool-filter validation and the --list-tools
|
|
// branch, so a --mode preset is expanded for --list-tools. Combining a mode
|
|
// with --list-tools must still succeed and never resolve a provider.
|
|
cwd := t.TempDir()
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--list-tools", "--mode", "deep"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Tools visible to model") {
|
|
t.Fatalf("expected --list-tools --mode to list tools, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecModeToolFilterReflectedInListTools(t *testing.T) {
|
|
// The reorder also means a mode-injected tool filter would be reflected in
|
|
// --list-tools. No built-in mode ships a tool filter, so drive the equivalent
|
|
// surface through applyExecMode + formatExecToolList: a filter seeded onto the
|
|
// options before the listing must narrow the tools the model can see.
|
|
options := execOptions{enabledTools: []string{"read_file", "grep"}}
|
|
registry := newCoreRegistry(t.TempDir())
|
|
list := formatExecToolList(registry, options, agent.PermissionModeAuto)
|
|
for _, want := range []string{"read_file", "grep"} {
|
|
if !strings.Contains(list, want) {
|
|
t.Fatalf("expected tool list to contain %q, got %q", want, list)
|
|
}
|
|
}
|
|
if strings.Contains(list, "bash") {
|
|
t.Fatalf("expected mode-style tool filter to hide bash, got %q", list)
|
|
}
|
|
}
|
|
|
|
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{
|
|
"exec",
|
|
"--profile",
|
|
"fast",
|
|
"--reasoning-effort",
|
|
"low",
|
|
"hello",
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr)
|
|
}
|
|
if !strings.Contains(stdout, "hello") {
|
|
t.Fatalf("expected prompt output, got %q", stdout)
|
|
}
|
|
if stderr != "" {
|
|
t.Fatalf("expected empty stderr, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunExecJSONRunStartWriteFailureSkipsAgent(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
called := false
|
|
|
|
exitCode := runWithDeps([]string{"exec", "-o", "json", "hello"}, failingWriter{}, io.Discard, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return recordingExecProvider{called: &called}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitCrash {
|
|
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
|
|
}
|
|
if called {
|
|
t.Fatal("expected agent provider not to run after run_start write failure")
|
|
}
|
|
}
|
|
|
|
func TestRunExecUnsafeWarningWriteFailureSkipsAgent(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
called := false
|
|
|
|
exitCode := runWithDeps([]string{"exec", "--skip-permissions-unsafe", "hello"}, io.Discard, failingWriter{}, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return recordingExecProvider{called: &called}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitCrash {
|
|
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
|
|
}
|
|
if called {
|
|
t.Fatal("expected agent provider not to run after warning write failure")
|
|
}
|
|
}
|
|
|
|
func TestRunExecJSONProviderErrorWriteFailureReturnsCrash(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
|
|
exitCode := runWithDeps([]string{"exec", "-o", "json", "hello"}, failingWriter{}, io.Discard, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("provider config failed")
|
|
},
|
|
})
|
|
|
|
if exitCode != exitCrash {
|
|
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
|
|
}
|
|
}
|
|
|
|
func execResolvedConfig() config.ResolvedConfig {
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "echo",
|
|
Provider: config.ProviderProfile{
|
|
Name: "echo",
|
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
|
BaseURL: "http://127.0.0.1/v1",
|
|
Model: "echo-model",
|
|
},
|
|
}
|
|
}
|
|
|
|
type recordingExecProvider struct {
|
|
called *bool
|
|
}
|
|
|
|
func (provider recordingExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
*provider.called = true
|
|
return nil, errors.New("provider should not run")
|
|
}
|
|
|
|
func TestRunPromptFlagRoutesToExecRunner(t *testing.T) {
|
|
execExitCode, execStdout, execStderr := runExecWithEcho(t, []string{"exec", "hello zero"})
|
|
|
|
for _, args := range [][]string{
|
|
{"-p", "hello zero"},
|
|
{"--prompt", "hello zero"},
|
|
} {
|
|
t.Run(args[0], func(t *testing.T) {
|
|
exitCode, stdout, stderr := runExecWithEcho(t, args)
|
|
|
|
if exitCode != execExitCode {
|
|
t.Fatalf("expected exit code %d, got %d", execExitCode, exitCode)
|
|
}
|
|
if stdout != execStdout {
|
|
t.Fatalf("expected stdout %q, got %q", execStdout, stdout)
|
|
}
|
|
if stderr != execStderr {
|
|
t.Fatalf("expected stderr %q, got %q", execStderr, stderr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "prompt.txt"), []byte("file prompt\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "--file", "prompt.txt", "inline prompt"})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "prompt.txt"), []byte("file only prompt\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "-C", root, "-f", "prompt.txt"})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsInvalidCwdBeforeRuntime(t *testing.T) {
|
|
root := t.TempDir()
|
|
filePath := filepath.Join(root, "not-a-directory.txt")
|
|
if err := os.WriteFile(filePath, []byte("nope"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
cwd string
|
|
}{
|
|
{name: "missing", cwd: filepath.Join(root, "missing")},
|
|
{name: "file", cwd: filePath},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--cwd", tc.cwd, "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("expected exit code 2, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "cwd must be an existing directory") {
|
|
t.Fatalf("expected cwd validation error, got %q", got)
|
|
}
|
|
if strings.Contains(stdout.String()+stderr.String(), "Go agent runtime ready") {
|
|
t.Fatalf("expected validation before runtime, got stdout %q stderr %q", stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecRejectsInvalidOutputFormatBeforeRuntime(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "-o", "yaml", "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("expected exit code 2, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, `invalid output format "yaml"`) || !strings.Contains(got, "debug") {
|
|
t.Fatalf("expected output format validation error, got %q", got)
|
|
}
|
|
if strings.Contains(stdout.String()+stderr.String(), "Go agent runtime ready") {
|
|
t.Fatalf("expected validation before runtime, got stdout %q stderr %q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecUnsafeTextModeWarns(t *testing.T) {
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--skip-permissions-unsafe", "hello"})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
|
}
|
|
if !strings.Contains(stdout, "hello") {
|
|
t.Fatalf("expected prompt in stdout, got %q", stdout)
|
|
}
|
|
if got := stderr; !strings.Contains(got, "WARNING") || !strings.Contains(got, "--skip-permissions-unsafe") {
|
|
t.Fatalf("expected unsafe warning, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "-m", "m1-test", "-o", "json", "hello json"})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
|
}
|
|
if stderr != "" {
|
|
t.Fatalf("expected empty stderr, got %q", stderr)
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout)
|
|
eventTypes := jsonEventTypes(events)
|
|
for _, want := range []string{"run_start", "text", "final", "done"} {
|
|
if !slices.Contains(eventTypes, want) {
|
|
t.Fatalf("expected JSON event %q in %v; output %q", want, eventTypes, stdout)
|
|
}
|
|
}
|
|
if got := events[0]["type"]; got != "run_start" {
|
|
t.Fatalf("expected first event run_start, got %v", got)
|
|
}
|
|
if got := events[0]["model"]; got != "m1-test" {
|
|
t.Fatalf("expected run_start model m1-test, got %v", got)
|
|
}
|
|
if got := events[0]["cwd"]; got != root {
|
|
t.Fatalf("expected run_start cwd %q, got %v", root, got)
|
|
}
|
|
if got := events[0]["permission_mode"]; got != "auto" {
|
|
t.Fatalf("expected default permission_mode auto, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecResolvesCanonicalModelAlias(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// "openai:gpt-4.1" is a registry alias for the canonical gpt-4.1 id; the
|
|
// selection boundary should normalize it before the provider sees it.
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "-m", "openai:gpt-4.1", "-o", "json", "hi"})
|
|
|
|
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)
|
|
}
|
|
events := decodeJSONLines(t, stdout)
|
|
if got := events[0]["model"]; got != "gpt-4.1" {
|
|
t.Fatalf("expected alias to resolve to gpt-4.1, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecRedirectsDeprecatedModelWithNotice(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "-m", "gpt-4-turbo", "-o", "json", "hi"})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "deprecated") || !strings.Contains(stderr, "gpt-4.1") {
|
|
t.Fatalf("expected deprecation notice on stderr, got %q", stderr)
|
|
}
|
|
events := decodeJSONLines(t, stdout)
|
|
if got := events[0]["model"]; got != "gpt-4.1" {
|
|
t.Fatalf("expected deprecated model to redirect to gpt-4.1, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecReasoningEffortNoticeForNonReasoningModel(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "-m", "gpt-4.1", "-r", "high", "-o", "json", "hi"})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "does not support reasoning effort") {
|
|
t.Fatalf("expected non-reasoning effort notice on stderr, got %q", stderr)
|
|
}
|
|
if stdout == "" {
|
|
t.Fatal("expected run output on stdout")
|
|
}
|
|
}
|
|
|
|
func TestReasoningEffortNoticeCoercesUnsupportedEffort(t *testing.T) {
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
// claude-sonnet-4.5 supports low/medium/high with a medium default; xhigh is
|
|
// unsupported and should be coerced to the model default.
|
|
notice := reasoningEffortNotice(registry, "claude-sonnet-4.5", "xhigh")
|
|
if !strings.Contains(notice, "not supported") || !strings.Contains(notice, "medium") {
|
|
t.Fatalf("expected coercion notice to default medium, got %q", notice)
|
|
}
|
|
if got := reasoningEffortNotice(registry, "claude-sonnet-4.5", "high"); got != "" {
|
|
t.Fatalf("expected no notice for a supported effort, got %q", got)
|
|
}
|
|
if got := reasoningEffortNotice(registry, "gpt-4.1", "high"); !strings.Contains(got, "does not support") {
|
|
t.Fatalf("expected unsupported-model notice, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestForwardedReasoningEffortGating(t *testing.T) {
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
model string
|
|
requested string
|
|
want string
|
|
}{
|
|
{"empty request", "claude-sonnet-4.5", "", ""},
|
|
{"supported reasoning model", "claude-sonnet-4.5", "high", "high"},
|
|
{"unsupported effort coerced to default", "claude-sonnet-4.5", "xhigh", "medium"},
|
|
{"known non-reasoning model suppressed", "gpt-4.1", "high", ""},
|
|
{"unknown model forwards as-is", "custom-endpoint-model", "high", "high"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := forwardedReasoningEffort(registry, tc.model, tc.requested); got != tc.want {
|
|
t.Fatalf("forwardedReasoningEffort(%q, %q) = %q, want %q", tc.model, tc.requested, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--skip-permissions-unsafe", "-o", "json", "hello"})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
|
}
|
|
if stderr != "" {
|
|
t.Fatalf("expected empty stderr, got %q", stderr)
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout)
|
|
eventTypes := jsonEventTypes(events)
|
|
if !slices.Contains(eventTypes, "warning") {
|
|
t.Fatalf("expected JSON warning event in %v; output %q", eventTypes, stdout)
|
|
}
|
|
if got := events[0]["permission_mode"]; got != "unsafe" {
|
|
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
|
clearProviderEnv(t)
|
|
root := t.TempDir()
|
|
configDir := filepath.Join(root, ".zero")
|
|
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var gotAuth string
|
|
var gotMethod string
|
|
var gotPath string
|
|
var gotBody map[string]any
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
gotAuth = r.Header.Get("Authorization")
|
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
|
t.Fatalf("decode provider request: %v", err)
|
|
}
|
|
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"provider ok\"}}]}\n\n")
|
|
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
|
}))
|
|
defer server.Close()
|
|
|
|
writeConfig := `{
|
|
"activeProvider": "local",
|
|
"providers": [{
|
|
"name": "local",
|
|
"provider_kind": "openai-compatible",
|
|
"base_url": "` + server.URL + `",
|
|
"api_key": "sk-local",
|
|
"model": "local-model"
|
|
}]
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := Run([]string{"exec", "--cwd", root, "hello provider"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
|
}
|
|
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 gotAuth != "Bearer sk-local" {
|
|
t.Fatalf("Authorization = %q, want project config token", gotAuth)
|
|
}
|
|
if gotMethod != http.MethodPost {
|
|
t.Fatalf("method = %q, want %q", gotMethod, http.MethodPost)
|
|
}
|
|
if !strings.HasSuffix(gotPath, "/chat/completions") {
|
|
t.Fatalf("path = %q, want suffix /chat/completions", gotPath)
|
|
}
|
|
if gotBody["model"] != "local-model" {
|
|
t.Fatalf("provider model = %v, want local-model", gotBody["model"])
|
|
}
|
|
messages, ok := gotBody["messages"].([]any)
|
|
if !ok || len(messages) == 0 {
|
|
t.Fatalf("messages = %#v, want non-empty []any", gotBody["messages"])
|
|
}
|
|
lastMessage, ok := messages[len(messages)-1].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("last message = %#v, want map[string]any", messages[len(messages)-1])
|
|
}
|
|
if lastMessage["content"] != "hello provider" {
|
|
t.Fatalf("last provider message = %#v, want prompt", lastMessage)
|
|
}
|
|
}
|
|
|
|
func runExecWithEcho(t *testing.T, args []string) (int, string, string) {
|
|
t.Helper()
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "echo-model"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "echo",
|
|
Provider: config.ProviderProfile{
|
|
Name: "echo",
|
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
|
BaseURL: "http://127.0.0.1/v1",
|
|
Model: model,
|
|
},
|
|
MaxTurns: 3,
|
|
}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
return exitCode, stdout.String(), stderr.String()
|
|
}
|
|
|
|
func TestExecSessionRecorderWarnsOnRecordingFailure(t *testing.T) {
|
|
// A latched session-append failure must be surfaced once to stderr so a user
|
|
// is not misled into believing the session was persisted; a clean recorder
|
|
// stays silent.
|
|
var failed bytes.Buffer
|
|
(&execSessionRecorder{err: errors.New("disk full")}).warnIfRecordingFailed(&failed)
|
|
if !strings.Contains(failed.String(), "session not fully recorded") || !strings.Contains(failed.String(), "disk full") {
|
|
t.Fatalf("expected a recording-failure warning, got %q", failed.String())
|
|
}
|
|
|
|
var clean bytes.Buffer
|
|
(&execSessionRecorder{}).warnIfRecordingFailed(&clean)
|
|
if clean.Len() != 0 {
|
|
t.Fatalf("expected silence when recording succeeded, got %q", clean.String())
|
|
}
|
|
}
|
|
|
|
// canceledExecProvider fails its first call with context.Canceled, simulating a
|
|
// signal-interrupted run (agent.Run returns the error verbatim).
|
|
type canceledExecProvider struct{}
|
|
|
|
func (canceledExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
return nil, context.Canceled
|
|
}
|
|
|
|
type echoExecProvider struct{}
|
|
|
|
func (echoExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
prompt := ""
|
|
for index := len(request.Messages) - 1; index >= 0; index-- {
|
|
if request.Messages[index].Role == zeroruntime.MessageRoleUser {
|
|
prompt = request.Messages[index].Content
|
|
break
|
|
}
|
|
}
|
|
ch := make(chan zeroruntime.StreamEvent, 2)
|
|
select {
|
|
case <-ctx.Done():
|
|
close(ch)
|
|
return ch, ctx.Err()
|
|
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: prompt}:
|
|
}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func clearProviderEnv(t *testing.T) {
|
|
t.Helper()
|
|
|
|
for _, key := range []string{
|
|
"ZERO_PROVIDER_COMMAND",
|
|
"ZERO_PROVIDER",
|
|
"OPENAI_API_KEY",
|
|
"OPENAI_BASE_URL",
|
|
"OPENAI_MODEL",
|
|
} {
|
|
t.Setenv(key, "")
|
|
}
|
|
}
|
|
|
|
func decodeJSONLines(t *testing.T, output string) []map[string]any {
|
|
t.Helper()
|
|
|
|
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
if len(lines) == 0 || lines[0] == "" {
|
|
t.Fatalf("expected JSON lines, got %q", output)
|
|
}
|
|
|
|
events := make([]map[string]any, 0, len(lines))
|
|
for _, line := range lines {
|
|
var event map[string]any
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("expected JSON object line, got %q: %v", line, err)
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func jsonEventTypes(events []map[string]any) []string {
|
|
types := make([]string, 0, len(events))
|
|
for _, event := range events {
|
|
eventType, _ := event["type"].(string)
|
|
types = append(types, eventType)
|
|
}
|
|
return types
|
|
}
|
|
|
|
// runExecWithEffectiveModel runs exec with the echo provider but forces the
|
|
// resolved (effective) model regardless of any --model flag, so tests can
|
|
// exercise behavior that depends on the effective model rather than the
|
|
// override-supplied one.
|
|
func runExecWithEffectiveModel(t *testing.T, effectiveModel string, args []string) (int, string, string) {
|
|
t.Helper()
|
|
|
|
var stdout bytes.Buffer
|
|
var 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: effectiveModel,
|
|
},
|
|
MaxTurns: 3,
|
|
}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return echoExecProvider{}, nil
|
|
},
|
|
})
|
|
return exitCode, stdout.String(), stderr.String()
|
|
}
|
|
|
|
// TestRunExecReasoningEffortNoticeUsesEffectiveModel asserts that
|
|
// --reasoning-effort is validated against the EFFECTIVE (resolved) model even
|
|
// when --model is omitted, so the advisory notice still surfaces.
|
|
func TestRunExecReasoningEffortNoticeUsesEffectiveModel(t *testing.T) {
|
|
// gpt-4.1 is a registry-known non-reasoning model. With no --model the
|
|
// override model is empty, so the notice must be evaluated against the
|
|
// effective model resolved by resolveConfig.
|
|
exitCode, stdout, stderr := runExecWithEffectiveModel(t, "gpt-4.1", []string{
|
|
"exec", "-r", "high", "hi",
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "does not support reasoning effort") {
|
|
t.Fatalf("expected effort notice for effective model on stderr, got %q", stderr)
|
|
}
|
|
if stdout == "" {
|
|
t.Fatal("expected run output on stdout")
|
|
}
|
|
}
|
|
|
|
// TestRunExecAutoHighEmitsUnsafeWarning asserts that --auto high (which resolves
|
|
// to PermissionModeUnsafe) surfaces the same unsafe warning as
|
|
// --skip-permissions-unsafe.
|
|
func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
|
|
exitCode, stdout, stderr := runExecWithEcho(t, []string{
|
|
"exec", "--auto", "high", "-o", "json", "hello",
|
|
})
|
|
|
|
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)
|
|
}
|
|
|
|
events := decodeJSONLines(t, stdout)
|
|
if !slices.Contains(jsonEventTypes(events), "warning") {
|
|
t.Fatalf("expected JSON warning event for --auto high, got %v; output %q", jsonEventTypes(events), stdout)
|
|
}
|
|
if got := events[0]["permission_mode"]; got != "unsafe" {
|
|
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRunExecInvalidAutoValidatedWithSkipPermissions asserts that an invalid
|
|
// --auto value is still rejected even when --skip-permissions-unsafe is also
|
|
// passed (the unsafe path must not short-circuit --auto validation).
|
|
func TestRunExecInvalidAutoValidatedWithSkipPermissions(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--auto", "bogus", "--skip-permissions-unsafe", "hello"}, &stdout, &stderr)
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "Invalid autonomy level") {
|
|
t.Fatalf("expected autonomy validation error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestParseExecAllowEscalationFlag(t *testing.T) {
|
|
t.Run("absent defaults to false", func(t *testing.T) {
|
|
options, help, err := parseExecArgs([]string{"hello"})
|
|
if err != nil {
|
|
t.Fatalf("parseExecArgs returned error: %v", err)
|
|
}
|
|
if help {
|
|
t.Fatal("help = true, want false")
|
|
}
|
|
if options.allowEscalation {
|
|
t.Fatal("allowEscalation = true, want false by default")
|
|
}
|
|
})
|
|
|
|
t.Run("flag sets true", func(t *testing.T) {
|
|
options, _, err := parseExecArgs([]string{"--allow-escalation", "hello"})
|
|
if err != nil {
|
|
t.Fatalf("parseExecArgs returned error: %v", err)
|
|
}
|
|
if !options.allowEscalation {
|
|
t.Fatal("allowEscalation = false, want true")
|
|
}
|
|
if strings.Join(options.promptParts, " ") != "hello" {
|
|
t.Fatalf("promptParts = %#v, want [hello]", options.promptParts)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunExecHelpDocumentsAllowEscalation(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"exec", "--help"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
if !strings.Contains(stdout.String(), "--allow-escalation") {
|
|
t.Fatalf("expected exec help to document --allow-escalation, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecRegistersEscalateModelOnlyWithFlag(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
wantTool bool
|
|
}{
|
|
{name: "absent", args: []string{"exec", "--list-tools"}, wantTool: false},
|
|
{name: "present", args: []string{"exec", "--allow-escalation", "--list-tools"}, wantTool: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return t.TempDir(), nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
hasTool := strings.Contains(stdout.String(), " escalate_model ")
|
|
if hasTool != tc.wantTool {
|
|
t.Fatalf("escalate_model visibility = %v, want %v; output:\n%s", hasTool, tc.wantTool, stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// escalatingExecProvider drives an exec run through a mid-run switch without a
|
|
// live model. Each instance tracks how many turns IT served (turns) so a test
|
|
// can assert exactly which provider handled which turn across a switch.
|
|
//
|
|
// escalateOnce controls behavior: when true, the FIRST turn this instance serves
|
|
// emits an escalate_model tool call (later turns answer); when false, every turn
|
|
// emits a final text answer. Pointing escalateOnce at distinct instances lets a
|
|
// test prove the post-switch turn lands on the SECOND (escalated) provider.
|
|
type escalatingExecProvider struct {
|
|
// turns counts how many StreamCompletion calls THIS instance handled.
|
|
turns int
|
|
// escalateOnce makes this instance's first served turn request an escalation.
|
|
escalateOnce bool
|
|
}
|
|
|
|
func (provider *escalatingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
turn := provider.turns
|
|
provider.turns++
|
|
ch := make(chan zeroruntime.StreamEvent, 4)
|
|
if provider.escalateOnce && turn == 0 {
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
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 TestRunExecWiresModelSwitcherUnderFlag(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
var providerModels []string
|
|
// Each newProvider build returns a DISTINCT provider instance with its OWN
|
|
// turn counter, mirroring the agent-layer firstProvider/secondProvider split.
|
|
// The first build (escalation source) must handle exactly the escalation turn;
|
|
// the second build (escalation target) must handle exactly the post-switch
|
|
// answer turn. Sharing a single counter would hide a dropped `provider =
|
|
// newProvider` in loop.go — here it is mutation-proven by the per-instance
|
|
// turn assertions below.
|
|
var builtProviders []*escalatingExecProvider
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--allow-escalation", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "claude-haiku-4.5"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
cfg := execResolvedConfig()
|
|
// The escalation chain (haiku -> sonnet) is anthropic; declare the
|
|
// provider kind to match so provider-metadata resolution accepts it.
|
|
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
|
cfg.Provider.Model = model
|
|
cfg.MaxTurns = 3
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerModels = append(providerModels, profile.Model)
|
|
// First build escalates; every later build answers. Each instance owns
|
|
// its turn counter so we can assert exactly-one-turn per provider.
|
|
provider := &escalatingExecProvider{escalateOnce: len(builtProviders) == 0}
|
|
builtProviders = append(builtProviders, provider)
|
|
return provider, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
// Initial provider build + one rebuild for the escalation target.
|
|
if len(providerModels) != 2 {
|
|
t.Fatalf("newProvider called for models %#v, want exactly 2 builds (initial + escalated)", providerModels)
|
|
}
|
|
if providerModels[0] != "claude-haiku-4.5" {
|
|
t.Fatalf("initial provider model = %q, want claude-haiku-4.5", providerModels[0])
|
|
}
|
|
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
|
|
if !ok {
|
|
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
|
}
|
|
if providerModels[1] != target {
|
|
t.Fatalf("escalated provider model = %q, want %q", providerModels[1], target)
|
|
}
|
|
if len(builtProviders) != 2 {
|
|
t.Fatalf("built %d providers, want 2", len(builtProviders))
|
|
}
|
|
// The original (escalation-source) provider handled ONLY the escalation turn;
|
|
// the escalated (target) provider handled ONLY the post-switch answer turn.
|
|
// This FAILS if loop.go drops `provider = newProvider` (the second provider
|
|
// would then handle zero turns and the first would handle both).
|
|
if builtProviders[0].turns != 1 {
|
|
t.Fatalf("first (source) provider handled %d turns, want exactly 1 (the escalation turn)", builtProviders[0].turns)
|
|
}
|
|
if builtProviders[1].turns != 1 {
|
|
t.Fatalf("second (escalated) provider handled %d turns, want exactly 1 (the post-switch answer turn)", builtProviders[1].turns)
|
|
}
|
|
}
|
|
|
|
func mustUpgradeTarget(t *testing.T, id string) (string, bool) {
|
|
t.Helper()
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
t.Fatalf("DefaultRegistry: %v", err)
|
|
}
|
|
entry, ok := registry.UpgradeTarget(id)
|
|
return entry.ID, ok
|
|
}
|
|
|
|
func TestRunExecNoSwitcherWithoutFlag(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
var providerModels []string
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"exec", "--model", "claude-haiku-4.5", "escalate please"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "claude-haiku-4.5"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
cfg := execResolvedConfig()
|
|
// The escalation chain (haiku -> sonnet) is anthropic; declare the
|
|
// provider kind to match so provider-metadata resolution accepts it.
|
|
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
|
cfg.Provider.Model = model
|
|
cfg.MaxTurns = 3
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerModels = append(providerModels, profile.Model)
|
|
return &escalatingExecProvider{escalateOnce: true}, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
if len(providerModels) != 1 {
|
|
t.Fatalf("newProvider called for %#v, want exactly 1 build (no switcher wired)", providerModels)
|
|
}
|
|
if providerModels[0] != "claude-haiku-4.5" {
|
|
t.Fatalf("provider model = %q, want claude-haiku-4.5", providerModels[0])
|
|
}
|
|
}
|
|
|
|
func TestRunExecAttributesUsageToEscalatedModel(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
target, ok := mustUpgradeTarget(t, "claude-haiku-4.5")
|
|
if !ok {
|
|
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
|
}
|
|
|
|
// The escalation turn (turn 0, pre-switch) emits usage with distinct tokens so
|
|
// it can be told apart from the post-switch usage; the first build escalates,
|
|
// the second answers.
|
|
builds := 0
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{
|
|
"exec",
|
|
"--allow-escalation",
|
|
"--model", "claude-haiku-4.5",
|
|
"--init-session-id", "escalation_run",
|
|
"escalate please",
|
|
}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "claude-haiku-4.5"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
cfg := execResolvedConfig()
|
|
// The escalation chain (haiku -> sonnet) is anthropic; declare the
|
|
// provider kind to match so provider-metadata resolution accepts it.
|
|
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
|
cfg.Provider.Model = model
|
|
cfg.MaxTurns = 3
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
// First build = escalation source (escalate + usage 3/4 pre-switch);
|
|
// second build = escalation target (answer + usage 5/7 post-switch).
|
|
escalate := builds == 0
|
|
builds++
|
|
return &usageEmittingEscalatingProvider{escalate: escalate}, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
|
events, err := store.ReadEvents("escalation_run")
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
// Collect every usage model attribution IN ORDER. The pre-switch usage must
|
|
// attribute to the ORIGINAL model and the post-switch usage to the escalated
|
|
// target — proving the loop's ordering guarantee (usage is attributed to the
|
|
// model that produced it, not retroactively reassigned after the switch).
|
|
var usageModels []string
|
|
for _, event := range events {
|
|
if event.Type != sessions.EventUsage {
|
|
continue
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
|
t.Fatalf("unmarshal usage payload: %v", err)
|
|
}
|
|
model, _ := payload["model"].(string)
|
|
usageModels = append(usageModels, model)
|
|
}
|
|
if len(usageModels) != 2 {
|
|
t.Fatalf("recorded usage model attributions = %#v, want exactly 2 (pre- and post-switch)", usageModels)
|
|
}
|
|
if usageModels[0] != "claude-haiku-4.5" {
|
|
t.Fatalf("first (pre-switch) usage model = %q, want claude-haiku-4.5", usageModels[0])
|
|
}
|
|
if usageModels[1] != target {
|
|
t.Fatalf("second (post-switch) usage model = %q, want escalated target %q", usageModels[1], target)
|
|
}
|
|
}
|
|
|
|
func TestRunExecNilSwitchProviderKeepsOriginalAttribution(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
|
|
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
|
}
|
|
|
|
// The escalation rebuild returns (nil, nil): the loop must NOT swap the
|
|
// provider, and the CLI switcher must leave currentModel unchanged so usage
|
|
// after the declined switch stays attributed to the ORIGINAL model. Without
|
|
// the switchedProvider != nil guard, currentModel would advance to the target
|
|
// even though no switch happened, misattributing the post-turn usage.
|
|
builds := 0
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{
|
|
"exec",
|
|
"--allow-escalation",
|
|
"--model", "claude-haiku-4.5",
|
|
"--init-session-id", "nil_switch_run",
|
|
"escalate please",
|
|
}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "claude-haiku-4.5"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
cfg := execResolvedConfig()
|
|
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
|
cfg.Provider.Model = model
|
|
cfg.MaxTurns = 3
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
builds++
|
|
// First build = the original (haiku). The escalation rebuild returns
|
|
// (nil, nil), so the loop keeps the original provider for every turn.
|
|
if builds == 1 {
|
|
return &escalateThenAnswerProvider{}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
|
events, err := store.ReadEvents("nil_switch_run")
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
sawUsage := false
|
|
for _, event := range events {
|
|
if event.Type != sessions.EventUsage {
|
|
continue
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
|
t.Fatalf("unmarshal usage payload: %v", err)
|
|
}
|
|
sawUsage = true
|
|
if model, _ := payload["model"].(string); model != "claude-haiku-4.5" {
|
|
t.Fatalf("usage attributed to %q after a (nil,nil) switch, want original claude-haiku-4.5", model)
|
|
}
|
|
}
|
|
if !sawUsage {
|
|
t.Fatal("expected at least one usage event")
|
|
}
|
|
}
|
|
|
|
// escalateThenAnswerProvider escalates on its first turn and answers afterward,
|
|
// so a single instance can serve an entire run when no provider swap occurs
|
|
// (e.g. a (nil,nil) switcher). It emits usage on every turn for attribution.
|
|
type escalateThenAnswerProvider struct {
|
|
turns int
|
|
}
|
|
|
|
func (provider *escalateThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
turn := provider.turns
|
|
provider.turns++
|
|
ch := make(chan zeroruntime.StreamEvent, 6)
|
|
if turn == 0 {
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
// usageEmittingEscalatingProvider emits a usage event on every turn it serves so
|
|
// usage attribution can be traced across a mid-run switch. When escalate is set,
|
|
// its turn requests an escalation (and emits pre-switch usage tokens 3/4);
|
|
// otherwise it answers (post-switch usage tokens 5/7).
|
|
type usageEmittingEscalatingProvider struct {
|
|
escalate bool
|
|
}
|
|
|
|
func (provider *usageEmittingEscalatingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
ch := make(chan zeroruntime.StreamEvent, 6)
|
|
if provider.escalate {
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
|
// Pre-switch usage: still attributed to the ORIGINAL model.
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
|
// Post-switch usage: attributed to the escalated model.
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
// usageEmittingEchoProvider answers in a single turn and emits a usage event. It
|
|
// has no escalation behavior, so it exercises a plain (flag-OFF) run that still
|
|
// records usage.
|
|
type usageEmittingEchoProvider struct{}
|
|
|
|
func (usageEmittingEchoProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
ch := make(chan zeroruntime.StreamEvent, 3)
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
// TestRunExecUsageOmitsModelKeyWithoutEscalationFlag verifies the back-compat
|
|
// guarantee: a run WITHOUT --allow-escalation persists EventUsage payloads that
|
|
// carry NO "model" key (byte-identical to before the escalation feature), since
|
|
// the model can never change mid-run when escalation is off. The flag-ON,
|
|
// post-escalation case is covered by TestRunExecAttributesUsageToEscalatedModel.
|
|
func TestRunExecUsageOmitsModelKeyWithoutEscalationFlag(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{
|
|
"exec",
|
|
"--model", "claude-haiku-4.5",
|
|
"--init-session-id", "no_escalation_run",
|
|
"hello",
|
|
}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "claude-haiku-4.5"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
cfg := execResolvedConfig()
|
|
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
|
cfg.Provider.Model = model
|
|
cfg.MaxTurns = 3
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return usageEmittingEchoProvider{}, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
|
events, err := store.ReadEvents("no_escalation_run")
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
var sawUsage bool
|
|
for _, event := range events {
|
|
if event.Type != sessions.EventUsage {
|
|
continue
|
|
}
|
|
sawUsage = true
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
|
t.Fatalf("unmarshal usage payload: %v", err)
|
|
}
|
|
if _, ok := payload["model"]; ok {
|
|
t.Fatalf("flag-OFF usage payload must NOT carry a model key, got %#v", payload)
|
|
}
|
|
}
|
|
if !sawUsage {
|
|
t.Fatal("expected at least one usage event to be recorded")
|
|
}
|
|
}
|
|
|
|
// usageThenAnswerProvider serves a two-turn run on ONE instance: turn 0 requests
|
|
// an escalation and emits usage, turn 1 answers and emits usage. It is used by
|
|
// the switch-error test where no second provider is ever built, so a single
|
|
// instance must serve both turns on the original model.
|
|
type usageThenAnswerProvider struct {
|
|
turns int
|
|
}
|
|
|
|
func (provider *usageThenAnswerProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
turn := provider.turns
|
|
provider.turns++
|
|
ch := make(chan zeroruntime.StreamEvent, 6)
|
|
if turn == 0 {
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_escalate", ToolName: "escalate_model"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_escalate", ArgumentsFragment: "{}"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_escalate"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 3, OutputTokens: 4}}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 5, OutputTokens: 7}}
|
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
// TestRunExecSwitcherErrorKeepsOriginalModelAttribution verifies that when the
|
|
// ModelSwitcher fails on the rebuild (deps.newProvider errors for the escalation
|
|
// target), the run is NON-FATAL and stays on the original provider — and every
|
|
// usage event, including the one AFTER the failed switch, stays attributed to the
|
|
// ORIGINAL model (currentModel is never reassigned on a failed switch).
|
|
func TestRunExecSwitcherErrorKeepsOriginalModelAttribution(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
|
|
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
|
}
|
|
|
|
builds := 0
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{
|
|
"exec",
|
|
"--allow-escalation",
|
|
"--model", "claude-haiku-4.5",
|
|
"--init-session-id", "switch_error_run",
|
|
"escalate please",
|
|
}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "claude-haiku-4.5"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
cfg := execResolvedConfig()
|
|
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
|
cfg.Provider.Model = model
|
|
cfg.MaxTurns = 3
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
builds++
|
|
// The first build (original model) succeeds; the rebuild on escalation
|
|
// FAILS, so the switcher returns an error and the run continues on the
|
|
// original provider.
|
|
if builds >= 2 {
|
|
return nil, errors.New("provider rebuild failed")
|
|
}
|
|
return &usageThenAnswerProvider{}, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected non-fatal switch error, exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
if builds != 2 {
|
|
t.Fatalf("newProvider builds = %d, want 2 (initial + one failed rebuild attempt)", builds)
|
|
}
|
|
|
|
store := sessions.NewStore(sessions.StoreOptions{RootDir: filepath.Join(dataHome, "zero", "sessions")})
|
|
events, err := store.ReadEvents("switch_error_run")
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents returned error: %v", err)
|
|
}
|
|
var usageModels []string
|
|
for _, event := range events {
|
|
if event.Type != sessions.EventUsage {
|
|
continue
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
|
t.Fatalf("unmarshal usage payload: %v", err)
|
|
}
|
|
model, _ := payload["model"].(string)
|
|
usageModels = append(usageModels, model)
|
|
}
|
|
if len(usageModels) != 2 {
|
|
t.Fatalf("recorded usage attributions = %#v, want 2", usageModels)
|
|
}
|
|
// Both usages (pre- AND post-failed-switch) stay on the ORIGINAL model.
|
|
for i, model := range usageModels {
|
|
if model != "claude-haiku-4.5" {
|
|
t.Fatalf("usage[%d] model = %q, want claude-haiku-4.5 (currentModel must not change on a failed switch)", i, model)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunExecTopTierDeclineNoSwitch verifies an end-to-end flag-ON run where the
|
|
// agent calls escalate_model while ALREADY on a top-tier model (claude-opus-4.1):
|
|
// the tool returns the informational no-meta result, NO switch happens
|
|
// (newProvider is called exactly once), and the run succeeds.
|
|
func TestParseExecNotifyFlag(t *testing.T) {
|
|
opts, _, err := parseExecArgs([]string{"--notify", "both", "hello"})
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if opts.notifyMode != "both" || opts.noNotify {
|
|
t.Fatalf("got mode=%q noNotify=%v", opts.notifyMode, opts.noNotify)
|
|
}
|
|
}
|
|
|
|
func TestParseExecNoNotifyFlag(t *testing.T) {
|
|
opts, _, err := parseExecArgs([]string{"--no-notify", "hello"})
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if !opts.noNotify {
|
|
t.Fatal("expected noNotify=true")
|
|
}
|
|
}
|
|
|
|
func TestParseExecNotifyConflict(t *testing.T) {
|
|
if _, _, err := parseExecArgs([]string{"--notify", "bell", "--no-notify", "hi"}); err == nil {
|
|
t.Fatal("expected error for --notify with --no-notify")
|
|
}
|
|
}
|
|
|
|
func TestParseExecNotifyInvalidValue(t *testing.T) {
|
|
for _, arg := range [][]string{{"--notify", "buzz", "hi"}, {"--notify=loud", "hi"}} {
|
|
if _, _, err := parseExecArgs(arg); err == nil {
|
|
t.Fatalf("expected error for invalid notify value in %v", arg)
|
|
}
|
|
}
|
|
// Valid values still parse.
|
|
for _, mode := range []string{"off", "bell", "notify", "both"} {
|
|
if _, _, err := parseExecArgs([]string{"--notify", mode, "hi"}); err != nil {
|
|
t.Fatalf("valid --notify %s rejected: %v", mode, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecNotifyModeResolution(t *testing.T) {
|
|
resolved := config.ResolvedConfig{Notify: config.NotifyConfig{Mode: "bell"}}
|
|
if got := execNotifyMode(execOptions{}, resolved); got != "bell" {
|
|
t.Fatalf("config passthrough got %q", got)
|
|
}
|
|
if got := execNotifyMode(execOptions{notifyMode: "both"}, resolved); got != "both" {
|
|
t.Fatalf("flag override got %q", got)
|
|
}
|
|
if got := execNotifyMode(execOptions{noNotify: true}, resolved); got != "off" {
|
|
t.Fatalf("--no-notify got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
|
|
dataHome := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
cwd := t.TempDir()
|
|
|
|
// Sanity: claude-opus-4.1 must genuinely be top-tier (no upgrade target), or
|
|
// the scenario is meaningless.
|
|
if _, ok := mustUpgradeTarget(t, "claude-opus-4.1"); ok {
|
|
t.Skip("registry unexpectedly has an upgrade target for claude-opus-4.1")
|
|
}
|
|
|
|
var providerModels []string
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{
|
|
"exec",
|
|
"--allow-escalation",
|
|
"--model", "claude-opus-4.1",
|
|
"escalate please",
|
|
}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
model := "claude-opus-4.1"
|
|
if overrides.Provider.Model != "" {
|
|
model = overrides.Provider.Model
|
|
}
|
|
cfg := execResolvedConfig()
|
|
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
|
cfg.Provider.Model = model
|
|
cfg.MaxTurns = 3
|
|
return cfg, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerModels = append(providerModels, profile.Model)
|
|
return &escalatingExecProvider{escalateOnce: true}, nil
|
|
},
|
|
})
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
|
}
|
|
// The escalate_model call resolved to a no-target informational result, so the
|
|
// loop never requested a switch and newProvider was built exactly once.
|
|
if len(providerModels) != 1 {
|
|
t.Fatalf("newProvider built for %#v, want exactly 1 (top-tier decline performs no switch)", providerModels)
|
|
}
|
|
if providerModels[0] != "claude-opus-4.1" {
|
|
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
|
|
}
|
|
}
|