Commit Graph
559 Commits
Author SHA1 Message Date
anandh8x 5fb01dae77 docs: make validation guidance non-mutating 2026-07-18 21:24:07 +05:30
anandh8x f90fd8567e docs: refresh repository agent guidelines 2026-07-18 21:16:00 +05:30
uma-prasadandCursor 77960229b8 fix(doctor): detect missing native binary during runtime checks (#450)
* fix(doctor): report missing npm native binary in wrapper

Co-authored-by: Cursor <[email protected]>

* fix(doctor): preserve JSON output for missing native binary

* fix(doctor): preserve doctor CLI behavior in wrapper fallback

Co-authored-by: Cursor <[email protected]>

* style: gofmt npmwrapper doctor fallback tests

Co-authored-by: Cursor <[email protected]>

* fix(doctor): reuse shared Bun recovery helpers in wrapper fallback

Extract bunRecoveryParagraph and related helpers so the doctor missing-binary text path and the generic wrapper bail share the same copy. Add regression tests with ZERO_WRAPPER_SIMULATE_BUN to guard against future drift.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-07-18 22:49:19 +08:00
Vasanth T 60dc84e7a3 perf(openai): optimized turn session — background prewarm and prefix telemetry (PR8) (#723)
* trace(perf): add provider prewarm span and prefix-stability counters

* feat(openai): optimized turn session with prewarm and prefix telemetry

* feat(providers): gate optimized OpenAI turn sessions behind env flag

* feat(cli): wire optimized turn sessions into headless exec

* docs(readme): document the optimized OpenAI turn-session toggle

* fix(openai): include tool descriptions in prefix digest, harden gate and test bounds

* fix(openai): isolate prewarm tracing, skip non-reusable transports, fingerprint wire order and cache key

* test(openai): pin keep-alive transports in probe-expecting tests

* feat(providers): preserve resolved capabilities on switched-model fallback sessions
2026-07-18 14:24:50 +05:30
Vasanth T 30e2c3f7ff perf(providers): provider capabilities and default turn-session adapter (PR7) (#720)
* perf(providers): add provider capabilities and default turn-session adapter (PR7)

* fix(providers): project effective reasoning efforts into capabilities

* feat(agent): carry optimized turn sessions across mid-run model switches

* test(agent): cover swap-time session open failure
2026-07-18 09:33:07 +05:30
Anandan 18cce358a2 Add deterministic completion policy (#719)
Extract completion decisions from the agent loop into a feature-gated typed policy with complete, incomplete, and uncertain outcomes. Preserve bounded plan-stall nudges and allow at most one semantic acceptance check for self-correcting runs.

Tested: make build

Tested: make test

Tested: go fmt ./...

Tested: go vet ./...

Tested: make lint

Tested: govulncheck ./...

Note: repository-wide pinned golangci-lint reports 36 pre-existing unrelated findings; internal/agent/... reports 0 issues.
2026-07-18 00:29:58 +05:30
KRATOSandKRATOS e5670c427f perf(output): add token-aware semantic output budgeting (PR11) (#717)
* perf(output): add token-aware budget contract

* perf(output): add semantic retention policies

* perf(output): budget results after redaction

* perf(output): propagate tool truncation metadata

* perf(output): trace semantic budget decisions

* perf(output): classify core tool output

* docs(output): describe semantic output budgeting

* test(output): make spill fallback portable on windows

* fix(output): address semantic budgeting review findings

* test(output): make hook budgeting tests portable

* fix(output): retain oversized single-line file content

* fix(output): apply semantic budgets to captured output

* fix(output): preserve hook budgets and search paths

* fix(output): preserve alias and truncation metadata

* test(trace): validate output budget metadata

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-18 00:02:43 +05:30
KRATOSandKRATOS 31d45d5f14 perf(agent): concurrent read-only tool batches via capability gate (#715)
Upgrade the parallel tool planner from SideEffectRead probes to the PR5
CapabilitiesOf contract: EffectReadOnly + ThreadSafe + auto-allowed, with
resource-key conflict boundaries so same-path reads stay sequential.

Mark audited pure reads ThreadSafe (read_file, read_minified_file,
list_directory, glob, grep, skill, web_fetch). Glob/grep use scopedScan
directory keys (nil for workspace-wide "." so scans do not false-conflict).

Tests cover the capability gate, key conflicts, extendParallelRun windows,
and catalog ThreadSafe audit for concurrent-safe reads.

Co-authored-by: KRATOS <[email protected]>
2026-07-17 17:52:11 +05:30
Vasanth T 727ad4d321 perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness (#712)
* perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness

The baseline manifest's pass/fail contract had three holes that let a weak
or no-op agent read as a pass:

  - edit grep oracles were substring checks: a reworded line (e.g.
    fmt.Println("debug: starting") -> fmt.Println("starting up")) passed
    `! grep 'debug: starting'`.
  - refactor lived in the build tier with a non-positive `go build ./...`,
    so a no-op refactor passed.
  - the 24 read-only tasks (nav/longproc/longctx/parallel) had no oracle;
    a zero-exit only proved the turn ran.

This makes the tiers honest:

  - edit/refactor now carry a stamped oracle_test.go compiled by
    `go test ./...` — the Go compiler is the structural verifier, so a
    no-op refactor, a missing field, or a reworded-but-not-removed line
    fails to compile/run. The test is stamped into the fixture copy AFTER
    the agent run, so it can't interfere with the agent's own build or be
    pre-seen.
  - nav gains an answer-oracle: the harness captures the agent's final
    answer (.zero-answer.txt) and greps it for determinable facts. Count
    tasks (nav-04/05/08) use a structured `count: N` token so the oracle is
    tight rather than a loose prose-contains on "0".
  - refactor graduates from build to correctness (its oracle is now
    positive); nav graduates from latency-only to correctness. The build
    tier is empty (buildOnlyClasses: []).
  - longproc/longctx/parallel stay permanently latency-only — there is no
    deterministic oracle for "report whether the build succeeds" or
    "summarize a large file"; documented in MANIFEST.md.

Tier counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor 6),
build 0, latency 14. Total 48 unchanged.

TurnSchemaVersion 2 -> 3: the result shape is unchanged (only class-list
membership shifts) but correctnessPassRate now means something strictly
stronger, so the bump prevents a v2->v2 cross-version comparison from
misreading the jump as a model improvement.

Prerequisite fix: the mutating fixtures had no go.mod, so `go test ./...`
in the temp copy failed with "cannot find main module". Added go.mod per
fixture (edit/fix/refactor/nav) and moved copyFixture's temp dir one level
below the system temp root — Go ignores go.mod in direct children of the
temp dir (a hijack guard), which would have broken every compiler-backed
oracle. This also fixes the pre-existing fix/refactor oracles, which were
silently broken because the test suite used fake runners that never ran
go test.

Tests: stub-binary gating tests prove each oracle fails the wrong thing
(no-op refactor, missing field, wrong nav answer, reworded debug print) and
passes the right thing (real refactor, added field, correct answer); an
edit-01 test proves the scoped negative grep doesn't false-fail a correct
rename that leaves the doc comment. Aggregation, schema, and unit tests
cover the v3 tier model and the answer-capture helper.

* Address CodeRabbit review on PR #712: tighten oracles + isolation

- nav count oracles (nav-01/04/05/08): anchored line-exact
  `^count: N$` with the fixture's exact expected count, so a substring
  like "the count: 0" or a wrong count like "count: 3" fails instead
  of rubber-stamping. nav-01 added to the structured-count contract.
- stampOracleAndAnswer now errors when a task has an oracle or
  verification but no workspace fixture, so verification can't run in
  the caller's cwd with the oracle never compiled (false-pass closed).
- copyFixture uses a unique 0700 os.MkdirTemp parent under the temp
  root and nests the copy beneath it (grandchild of temp root), so the
  Go go.mod hijack guard is satisfied and cleanup removes the whole
  parent. Avoids a predictable shared world-readable dir.
- Rejection tests require a non-empty VerifyErr via a shared
  assertVerifyFailed helper, proving the failure came from
  verification rather than a harness error or a silent non-pass.
- Unify streamJSONExitCode + streamJSONFinalText behind one
  parseStreamJSON parser (behavior-preserving; scanner limits and
  malformed-event handling now live in one place).

* Address gnanam1990 review on PR #712: close count=0 gameability + stronger oracles

- nav count tasks (nav-04/05/08) had a count=0 ground truth, so an agent
  that never opened the workspace and always emitted "count: 0" passed
  three correctness tasks -- the exact pass-rate-misread-as-correctness
  failure this PR closes. nav-04 now has a real TestGreet (count 1) and
  nav-05 a real TODO (count 1); nav-01's file count moves to 5. nav-08
  stays at 0 (a real third-party import would need a network fetch and
  break the offline suite) but its oracle now requires the answer to name
  both stdlib imports (fmt from main.go, testing from main_test.go), so a
  blind count:0 or a main.go-only read fails. nav-08's prompt now asks the
  agent to enumerate and classify every import, so naming the stdlib
  imports is part of a correct answer, not a false-fail.
- refactor-05: stamp a behavioral oracle (TestGreetWrapped) so deleting
  both Wrapper and greetWrapped, or stubbing the caller to return "",
  fails to compile/run. The negative grep alone passed that wrong edit.
- refactor-04: replace the brittle exact-text shell grep with a stamped
  file-reading oracle using a spacing-tolerant regex, so a bare
  map[string]int decl under any spacing/:= variant can't dodge it, and
  the Stats type must be referenced in main.go.
- edit-05: also assert the "debug: starting" message text is absent, so
  a respell onto fmt.Printf / backticks / log.Println / println that
  still prints the message fails (the fmt.Println(" check missed those).
- v3 doc comments on ClassSummary / TurnBenchResult /
  FormatTurnBenchSummary now describe the v3 tiers (correctness =
  edit/fix/nav/refactor, build empty, latency = longproc/longctx/
  parallel) instead of the stale v2 model.
- stampOracleAndAnswer failure now mutates the existing outcome instead
  of returning a fresh one, so the already-parsed trace and trace issue
  survive a stamp error (attribution is preserved, pass/fail unchanged).
- Schema comment provenance: "PR #701" -> neutral "Phase 0 oracle
  hardening" (this PR is #712).
- New gating + satisfiable stub tests for refactor-04/05 and edit-05, and
  count-oracle zero-guess / inspection-proof tests for nav-04/05/08.

* Fix macOS CI: copyFixture isolation test was slash-sensitive

TestCopyFixtureIsolatesSourceFromMutation compared filepath.Dir(parent)
against os.TempDir() verbatim. On macOS $TMPDIR ends in a trailing slash
("/var/folders/.../T/") while filepath.Dir strips it, so the equality
false-failed even though the parent is genuinely a direct child of the
temp root. Linux (/tmp, no slash) and Windows (no slash in this Go
build) matched by accident.

The production copyFixture nesting is correct on all platforms (the
grandchild copy sits one level below the temp root, satisfying Go's
go.mod hijack guard); only the assertion was slash-sensitive. Normalize
both sides with filepath.Clean.

* Close nav-04/05 blind-count-one gameability (anandh8x review)

nav-04 and nav-05 had a count-only oracle (anchored '^count:1$'). Once the
ground truth became 1, an agent that never inspects the fixture and always
emits 'count: 1' passed both — the blind-count-one surface the prior
non-zero-ground-truth fix opened. Pair the count with a workspace-derived
named fact that proves inspection, mirroring nav-08's fmt+testing:

- nav-04 now also requires 'TestGreet' (the one test function in
  main_test.go) — a specific, non-generic name a blind agent can't produce
  without reading main.go to learn a greet function exists. Fully closes
  the blind-count-one surface.
- nav-05 now also requires 'main.go' (the file holding the one TODO).
  main.go is the conventional entrypoint and the most guessable filename, so
  this reduces rather than fully closes the surface — a blind 'count: 1, in
  main.go' can still pass. Accepted for Phase 0: the blind floor drops below
  100% (a bare 'count: 1' fails), so a model still moves the pass rate by
  inspecting. MANIFEST.md documents this residual honestly.

Two gating tests per task: RejectsBlindCountOne (bare 'count: 1' now fails)
and, for nav-05, RejectsSubstring (main.go present but unanchored count
still fails — parity with nav-04). Existing accept tests still pass (their
answers already name TestGreet / main.go).

Doc nits also fixed: MANIFEST.md #701 -> #712; refactor-05 removed from the
'no oracleTest' inventory (it now has TestGreetWrapped); the nav-01 named-
fact prose corrected to the three files the oracle actually anchors on;
test comment #701 -> #712.
2026-07-17 17:32:45 +05:30
github-actions[bot] e666395d47 chore(main): release 0.4.0 (#625)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.4.0
2026-07-17 13:25:31 +08:00
PierrunoYTandClaude Sonnet 5 9043baedcf fix(sandbox): scrub dynamic credential env vars (#682)
* fix(sandbox): scrub dynamic credential env vars

* fix(sandbox): tolerate env assignments in configured sensitive keys

A config value mistakenly given as a full assignment (e.g.
"COMPANY_LLM_SECRET=...") would never match the real env key during
scrubbing, silently re-exposing the credential to sandboxed commands.
normalizeSensitiveEnvKeys now keeps only the name part before '=' and
re-trims it, per PR review.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): scrub credentials on direct/degraded command plans

directCommandPlan (used when the native sandbox backend is unavailable,
disabled, or not required, including the EnforcementDegraded fallback)
left Env nil whenever spec.Env was nil, so exec.Cmd inherited the full
caller environment unscrubbed. Configured apiKeyEnv credentials and
dynamically named ZERO_OAUTH_<NAME>_CLIENT_SECRET variables leaked
through that path even though the wrapped sandbox plans already
scrubbed them.

Route direct plans through scrubSensitiveEnv the same way wrapped
plans do, snapshotting os.Environ() when spec.Env is nil so scrubbing
still applies. Adds a regression test covering the degraded-fallback
path with both a configured and a dynamically named secret.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-17 13:04:12 +08:00
Ashwinhegde19 4d8c31cf16 fix(config): enforce MCP trust boundary so project config cannot override user disable (#609)
A user-level MCP server disable is now sticky. The project config
(a cloned repo's ./.zero/config.json) cannot re-enable a server the
user disabled, and cannot disable a server the user explicitly enabled.

The previous merge just assigned base.Disabled = next.Disabled, so a repo
that set disabled:true could silently defeat a user's explicit mcp enable
<server>. The guard is now symmetric via a mergeMCPServer canReenable
flag: once a higher-trust scope has explicitly set the disabled flag, a
lower-trust layer may not override that decision in either direction. Only
the CLI override scope (canReenable=true) may lift a sticky user decision.

The prior disabledSet-before-check ordering bug is also fixed: the
higher-trust scope's prior disabledSet/Disabled is captured before folding
in next's flag, so a project layer can still disable a default-enabled
server (e.g. firecrawl) rather than being silently ignored.

Ports the fix onto main's refactored MCP merge (internal/config/mcp_merge.go
and mergeProjectMCPConfig), which splits project-scope merging from the
general mergeMCPConfig path.

Fixes #512
2026-07-17 13:03:32 +08:00
PierrunoYTandClaude Sonnet 5 824ecdbcf9 fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts (#606)
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts

On interrupt, Run returns with ctx already canceled. The deferred
dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose
context.WithTimeout derives an already-canceled child context, so the
hook process never actually launches (recorded as timed-out/error in
the audit log). Since most sessions end via interrupt, sessionEnd
hooks were effectively unreliable.

Detach the sessionEnd dispatch from the run context's cancellation
with context.WithoutCancel so the dispatcher's own timeout still
applies without being short-circuited by the run's cancellation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* test(agent): fall back to GOROOT for hook regression

* test(agent): justify GOROOT fallback

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-17 13:03:14 +08:00
6b9c2f0c08 feat(aimlapi): AI/ML API provider with guided onboarding (top-up + key issuance) (#655)
* feat(aimlapi): AI/ML API guided onboarding (top-up + key issuance)

Add the AI/ML API provider integration to the zero CLI: a guided TUI
onboarding sub-flow that takes an existing key or an email top-up and
writes the issued key into the provider profile, attributed to the
Gitlawb rebate partner.

- internal/aimlapi: OpenAI-compatible client, partner-checkout top-up,
  key validation, streaming, and endpoint config (Paths A/B/C).
- providercatalog / providermodelcatalog: register the aimlapi provider
  with default headers and models.
- tui: aimlapi onboarding screen wired into the provider wizard and
  onboarding flow.

Default endpoints point at production (auth/app/api/pay/aimlapi.com);
each is overridable via AIMLAPI_*_URL env vars.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): address review findings in onboarding flow

- topup: use errors.As for the 5xx retry check so a wrapped APIError
  still retries instead of aborting a paid checkout.
- onboarding: a Path-A top-up on an email with no account now lands on a
  single coherent "key saved, balance not topped up" screen instead of a
  green "ready" plus a red error.
- onboarding: an empty amount field is rejected instead of silently
  falling through to the $25 default.
- collapse the two identical top-up prompt constants into MsgTopUpPrompt
  and drop the dead amountPrompt() branch.
- reuse stdlib maps.Clone instead of a third bespoke header-copy helper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): retry transient poll errors and sharpen amount validation

* fix(aimlapi): retry transient poll errors and surface real amount errors

* docs(aimlapi): document exported client API; drop out-of-scope gitignore entry

Addresses the two CodeRabbit PR checks on #655:

- Add doc comments to the exported aimlapi client API (Client, APIError,
  the session/checkout/onboarding types and their methods) and
  ParseAmountUSD, to satisfy the docstring-coverage check.
- Remove the unrelated /zero.exe~ .gitignore entry that an earlier
  review-fix commit added; it is out of scope for the provider work, so
  the PR no longer touches .gitignore.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(aimlapi): render aimlapi row like other recommended providers

* fix(aimlapi): resolve onboarding review findings (Path A funding, base URL, request cancellation)

* fix(aimlapi): validate pasted API keys

* test(aimlapi): clarify pasted key validation

* test(aimlapi): cover key-balance low-balance transition

* fix(aimlapi): reject non-finite top-up amounts

* fix(cli): scope and canonicalize provider headers

* fix(tui): distinguish invalid verification codes

* feat(aimlapi): resume account-bound top-ups safely

* feat(tui): offer top-up for existing AIMLAPI keys

* feat(tui): reuse AIMLAPI env key on first run

* feat(tui): reuse configured AIMLAPI profiles

* test(aimlapi): trim redundant flow coverage

* fix(tui): preserve existing AIMLAPI profile metadata

* fix(tui): clone AIMLAPI headers in env fallback

* fix(aimlapi): address remaining review findings

* fix(aimlapi): harden onboarding lifecycle

* fix(aimlapi): preserve validated checkout context

* fix(aimlapi): harden checkout retry state

* fix(tui): clarify AIMLAPI configured choices

* fix(aimlapi): make checkout retries idempotent

* fix(aimlapi): validate checkout responses

* fix(providers): reconcile aimlapi catalog after rebase

---------

Co-authored-by: Lookoff123 <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-17 12:49:38 +08:00
KRATOSandKRATOS 8ef8576df7 perf(tools): add explicit effect metadata for safe concurrency (#705)
* perf(tools): add explicit tool effect metadata

Introduce fail-closed EffectClass and ToolCapabilities on every built-in
tool so a later concurrent read-only batch PR can decide safety from
explicit metadata rather than tool names. Plugin and MCP tools stay
EffectUnknown. No concurrent execution is enabled.

* fix(tools): address review feedback on effect metadata

Harden capability normalization and resource keys before PR6 consumes them:

- Force ThreadSafe=false for every non-ReadOnly effect at read time
- Reject duplicate builtin names in ValidateBuiltinCatalog
- Coerce numeric session ids and accept "session" for process keys
- Strip path/query before userinfo in endpoint host parsing
- Wire apply_patch keys from unified-diff paths + cwd
- Reclassify bash as WorkspaceWrite (one-shot shell mutator)
- Expand regression tests for keys, normalization, and gates

* fix(tools): keep multiFileResourceKeys used by apply_patch keys

Satisfies unused lint after switching apply_patch to applyPatchResourceKeys.

* fix(tools): resolve CodeRabbit residual capability/key issues

- Join apply_patch diff paths under cwd for conflict keys
- Validate constructor-declared capabilities before normalization
- Unify session/process ID key extraction helper

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-17 12:48:36 +08:00
Anandanandanandh8x 1c5c6e78a8 feat(perf): emit prompt-prefix hash fingerprint per turn (#704)
Compute a seven-field SHA-256 fingerprint of the cacheable prompt
prefix (base instructions, confirmation policy, project context, skills,
tools, tool schemas, complete) and emit one prefix_hash event per turn
through the trace recorder. The complete hash is the SHA-256 of the
canonical join of the other six, so any sub-component drift is
observable both individually and in aggregate.

The decomposition exists so a downstream observer can detect which
sub-component of the prefix broke cache stability across turns: a
stable complete_prefix hash means the cacheable prefix is byte-
identical, and the sub-hash that changed names the culprit.

No behavior change for untraced runs. For OpenAI, the existing
prompt_cache_key (session ID) is unchanged; the fingerprint is purely
additive observability. The Anthropic cache_control breakpoint work
already in main is not touched.

Co-authored-by: anandh8x <[email protected]>
2026-07-17 12:47:53 +08:00
Leonardo Faoro 7f669f4550 feat(tui): Ctrl+X leader chords and emacs menu navigation (#699)
* feat(tui): Ctrl+X leader chords and emacs menu navigation

Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ?
chord map), and Ctrl+P/N previous/next selection in modals and pickers.

* fix(tui): drop Ctrl+X e /edit leader chord

/edit replaces the composer and would discard an in-progress draft; leave
it unbound so leader shortcuts stay draft-preserving.

* fix(tui): consume idle Ctrl+N as a no-op

Reserve Ctrl+N for emacs menu navigation so it never falls through to
remapped global bindings when no selection surface is open.

* docs: drop manual CHANGELOG entries for TUI shortcuts
2026-07-17 12:44:00 +08:00
euxaristia 2b42cd567b fix(tui): stop the composer cursor blinking while typing or unfocused (#672)
* fix(tui): stop the composer cursor blinking while typing or unfocused

The composer's cursor blinked on a fixed timer unconditionally, including
mid-keystroke, which made typing feel janky since the cursor's job is to mark
the current position. Gate the existing blink tick on two states instead:
force solid while a keystroke landed within the last 500ms (reusing the
existing lastCharTime tracker), and hide it entirely while the terminal
window is unfocused (new terminalFocused field, driven by tea.FocusMsg/
BlurMsg). Idle and focused keeps the prior blink behavior unchanged.

Also decouples view.ReportFocus from the notifier (it was previously gated on
m.notifier != nil, which is effectively always true in production but left
cursor-focus behavior fragile if that ever changes) so focus reporting is
requested unconditionally.

* fix(tui): apply caret visibility synchronously and in the argument-hint line

- A keypress, focus, or blur now updates composerCursorVisible in its own
  message handler instead of waiting for the next 530ms composerBlinkMsg
  tick, which could render a typed character caret-less, leave a visible
  caret in an unfocused terminal, or keep it hidden after a refocus.
- commandArgumentHintComposerLine takes the caret visibility state instead
  of painting its cursor cell unconditionally, so the required-argument
  hint path follows the same focus and blink contract as the main
  composer renderer.
- New tests assert the state immediately after each message, with no blink
  tick in between, and cover the argument-hint render in both states.

* fix(tui): apply the caret state on composer paste

A paste is the same immediate-input transition as a keypress: routePaste now
refreshes lastCharTime and sets the caret visible for both the bracketed
tea.PasteMsg and right-click clipboardReadMsg paths, so a paste right after
the blink phase hid the caret renders solid immediately instead of waiting
for a tick that would toggle off a stale idle state.
2026-07-17 12:43:30 +08:00
euxaristia 665bd4b3d0 WIP: feat(tui): TUI theme presets (Dune and Neon) (#634)
* draft(tui): add theme interface and Claude/Codex presets proposal

Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md.

* fix(tui): implement Claude/Codex themes on the existing palette system

theme.go had replaced the tuiTheme/buildTheme/palette architecture every
renderer depends on with a standalone Theme interface using the wrong
lipgloss import, breaking the build and orphaning the existing theme
registry, /theme command, and picker.

Revert theme.go to the working implementation and add claude and codex
as real palette entries in theme_palettes.go, following the file's own
extension pattern. Both pass the WCAG AA contrast and gray-ramp
invariants asserted across the whole registry, and are picked up
automatically by --theme, ZERO_THEME, /theme, and the picker.

Rewrite docs/THEMES.md to describe the actual palette-registry
architecture instead of the removed interface.

* fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests

* style(tui): gofmt theme_select_test.go (drop trailing whitespace)

The ubuntu smoke run fails on gofmt -l; this clears the only unformatted
file in the draft so CI goes green.

* test(tui): cover the CLI/env resolution path for the dune and neon presets

TestNewThemePresetsWired only checked lookupTheme and the IsDark
grouping. Add coverage for the actual --theme/ZERO_THEME precedence
path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each
preset's own palette rather than a built-in fallback.

* fix(tui): raise Dune/Neon palette contrast and fix theme docs

Darken dune's accent, blue, and red tokens so the selected-row caret,
local-model dot, and diff deletion text clear WCAG AA against selBg and
delBg, including after ANSI-256 downsampling. Also correct THEMES.md's
description of bare /theme (it opens a live-preview picker, not a
list), and add the dune/neon names to the ZERO_THEME/--theme tables in
README.md and README_ZH.md.

* fix(tui): keep Dune and Neon readable after ANSI-256 downsampling

- Dune's accent becomes #724028: still AA on selBg and panel in truecolor
  (5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears
  AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to
  #875f00 at 3.81:1. Blue already survived quantization at 4.67:1.
- Neon's diff bands move to hexes that quantize to distinct green and red
  xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000,
  addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of
  collapsing to the same grays, while keeping every truecolor invariant.
- New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way
  a 256-color terminal does and asserts the Dune selected-row pairs stay AA
  and the Neon bands keep their green/red identity and mutual separation.
- docs/THEMES.md is now linked from the Documentation sections of both
  READMEs, and the Unreleased changelog inventory says twelve themes and
  names dune and neon.

* fix(tui): keep Neon diff content AA after quantization, fix error border

- faintest becomes #74c468: line numbers now quantize to #87d75f, which
  clears AA on the xterm-green addBg (#005f00) where the old #58af69
  quantized to 2.95:1, while staying dimmer than faint so the gray ramp
  stays monotonic.
- addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word
  band's #008700 (was 4.29:1). Both rendered diff-content pairs are now
  asserted in the ANSI-256 test instead of documented as omissions.
- cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and
  3.65:1 quantized, meeting the 3:1 non-text component threshold, with the
  border surface asserted in both renderings.
- docs/THEMES.md no longer claims the extended invariants run against the
  whole registry: it now says new palettes must be added to the
  per-palette contrast tests or given equivalent coverage.
2026-07-17 12:38:51 +08:00
euxaristia 9acb4113cc feat(cli): show ZERO wordmark on --version (#673)
* feat(cli): show ZERO wordmark on --version

The TUI's empty-state ZERO ascii art gets exposed via a new tui.Wordmark
helper and printed above the version line for `zero -v`/`--version`.
Coloring only applies when stdout is a real TTY and NO_COLOR isn't set,
so redirected or piped output (scripts parsing the version) stays plain.

* fix(release): match --version output by suffix, not exact string

The release smoke check compared zero --version output to an exact
"zero <version>" string, which broke once --version started printing
the wordmark above the version line. Check for a "version: <version>"
suffix instead so the smoke check doesn't couple to the wordmark art.

* fix(cli,tui): keep --version machine-readable off-TTY, drop wordmark color

- When stdout is not a terminal, --version prints exactly "zero <version>"
  again: pipes, redirects, command substitutions, and the release smoke
  contract all parse that single record, so the banner is a TTY-only
  affordance now.
- The TTY path keeps the banner but restores "zero <version>" as the
  version record instead of "version: <version>".
- Wordmark renders uncolored: only newModel resolves --theme/ZERO_THEME,
  so painting the global dark palette here was unreadable on light
  terminals. The terminal's default foreground works on any background.
- smokeVersion goes back to requiring the exact "zero <version>" output
  instead of the suffix match that papered over the banner.
2026-07-16 23:02:22 +08:00
d66a9dda69 feat(providers): add AI/ML API preset (rebased onto main) (#621)
* feat(providers): add AI/ML API preset

* fix(providers): relocate aimlapi preset and drop referral header

Addresses the two review asks on #402:

- Move the AI/ML API descriptor out of catalog slot #2 (it sat above the
  first-party OpenAI/Anthropic/Google entries) down next to openrouter in
  the aggregator cluster. Re-sync expectedCatalogIDs and the
  TransportOpenAICompat order-list to match.
- Drop the always-on X-AIMLAPI-Partner-ID / Integration-Repo /
  Integration-Version attribution headers. There's no partner arrangement
  behind the preset, so a non-overridable referral tag on every user's
  request isn't something to ship on a community preset. aimlapi now rides
  through plain CopyHeaders like every other provider. Removes
  HeadersForCatalog (it existed only for this special-case), reverts the
  factory and discovery call sites to CopyHeaders, and drops the header
  test. The factory/discovery tests now assert the endpoint, auth, and the
  user's own custom headers survive while no attribution header is sent.

* fix(providers): address CodeRabbit review on #621

- Document AIMLAPIID for docstring coverage.
- Apply providerio.CopyHeaders for Anthropic and Google provider paths,
  matching the OpenAI path introduced for aimlapi header isolation.

Co-authored-by: Cursor <[email protected]>

* fix(providers): gofmt factory.go to unblock CI smoke

Remove mixed tab/space indentation in the openai.New options block so
ubuntu-latest fmt-check passes.

---------

Co-authored-by: Dmitry Tumanov <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: KRATOS <[email protected]>
2026-07-16 22:57:35 +08:00
Leonardo Faoro 7d579996b4 feat(skills): discover shared ~/.agents/skills with multi-root skill loading (#696)
* feat(skills): discover shared ~/.agents/skills with multi-root skill loading

Add ~/.agents/skills as a read-only global discovery root after the
primary
Zero skills dir and before plugin roots. Unify runtime and CLI discovery
on
one multi-root path (DiscoveryRoots / LoadFromRoots / GlobalRoots) and
always
register the multi-root skill tool so bare runs and plugin runs share
the
same load surface.
Install/remove/lock still target only the primary Zero skills directory.
Fixes #610

* docs(skills): note lock stays on the primary write root

Document that install/remove/lock target only the primary Zero skills
directory, matching multi-root discovery write-root isolation.

* fix(skills): bubble primary root load errors from LoadFromRoots

Return non-missing failures from the first non-empty skills root so a
broken primary dir is not reported as an empty skill list. Keep fail-open
behavior for optional roots (~/.agents/skills, plugins).

* fix(skills): treat non-directory skills root as error on Windows

Windows maps ENOTDIR to ErrNotExist, so ReadDir on a regular file looked
like a missing skills dir. Reclassify existing non-directories and assert
the portable load behavior in tests.
2026-07-16 21:59:20 +08:00
Vasanth T abe24bcb10 Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline) (#700)
* Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline)

Introduce an opt-in `internal/trace` package that attributes a run's wall
time to named spans — prompt build, provider connect/queue, generation,
tool queue/execution, permission wait, verification, compaction,
persistence — plus counters for model requests, tool calls, retries,
reconnects, compactions, completion nudges, acceptance checks, model
switches, and input/cached/output tokens. The recorder is nil-safe (a nil
*Recorder is a no-op), so every stamp site can call it unguarded; an
untraced run is byte-identical to today.

The recorder threads into agent.Run via a new `Options.Trace` field and a
single `trace.WithContext` assignment, so the shared providerio seam
(connect around client.Do, queue around OAuth resolve, first token at the
first non-keepalive SSE payload) reaches it through `trace.FromContext`
without any provider-interface change. All instrumentation is opt-in: when
`Options.Trace` is nil the hot path is untouched.

Headless `zero exec` gains `--trace <path>` (and `ZERO_TRACE=<path>`),
writing an agenteval-contract-compatible NDJSON trace (`-` for stderr).
The trace is pure observation; enabling it does not change behavior.

A new per-turn benchmark harness extends `internal/perfbench` (reusing the
existing TaskSet/NumericStats/subprocess runner shapes): `RunTurnBench`
aggregates per-span median/P95 across iterations, ranks the top three
controllable latency sources by share of attributed time, rolls up per
task class, and totals tokens/counts. A checked-in 42-task manifest across
seven classes (nav, edit, fix, refactor, longproc, longctx, parallel) and
local offline fixtures back it, and `make baseline` runs it against the
built `zero`, writing `internal/perfbench/reports/baseline.json`. The
report is machine-specific and regenerated, not hand-edited.

The manifest is JSON rather than YAML to avoid adding a dependency;
per-task verification targets the specific test (`go test -run <name>`)
so fix tasks are independent. Each task is a fresh `zero exec` process, so
iterations are cold-start samples; a warm in-process path is left for
follow-up.

Verified: `go build ./...`, `go vet ./...`, `gofmt -l` clean on touched
files; `go test -race` green for internal/trace, internal/agent,
internal/perfbench, and cmd/zero-perf-bench.

* test(trace): make TestAttributionRatio clock-independent

The test recorded two synthetic 10ms spans with no real delay and asserted
the attribution ratio stayed <= 1.0. On a precise clock (Linux/macOS CI) the
wall is microseconds while attributed is 20ms, so attributed/wall blows past
1.0 and the test failed — even though the ratio legitimately exceeds 1.0
when spans overlap (parallel tool execution). On Windows the coarse timer
masked it, so the failure only surfaced in CI.

Assert the contract instead: attributed is the deterministic sum of span
durations, and the ratio equals attributed/wall (0 when wall is zero). Add a
zero-wall case for the divide-by-zero guard.

* Address review feedback on the per-turn tracer + benchmark harness

Benchmark runner (internal/perfbench):
- A nonzero agent exit code no longer counts as Passed. The runner set
  VerifyErr but then fell through to outcome.Passed = true, so a crashed
  run could be recorded as a pass. Return before the verification block
  when VerifyErr is already set.
- A task now passes only when every iteration passes. The previous
  "any iteration passes" rule let a flaky fail hide behind one good run.
- Each invocation copies its workspace fixture to a fresh temp dir and
  runs the agent + verifier there, so mutating tasks (edit/fix/refactor)
  can't dirty the checked-in fixtures or bleed into the next iteration.
- Trace load failures (missing/malformed trace file) are surfaced as
  result warnings via a new TraceIssue field instead of being swallowed.

Tracer (internal/trace):
- Finish freezes the recorder: Counter, addSpan, and the first-event
  stamps are dropped after Finish returns, so a snapshot captures exactly
  one turn and late stamps can't mutate it.
- WriteText propagates the first write error instead of silently
  swallowing sink failures.

Provider seam (internal/providers/providerio):
- FirstTokenAt is stamped only after a payload is accepted as real model
  output, not before handle() decides to drop it as an error payload.

Agent loop (internal/agent):
- The compaction counter increments only when the history actually
  shrinks, in both the proactive and reactive paths — paid no-ops no
  longer inflate the compaction total.
- Document on Options.Trace that a fresh Recorder is required per Run.

CLI (internal/cli/exec):
- Finish the trace at the agent.Run boundary and defer only the
  serialization, so the snapshot covers exactly the turn.
- Reject --trace / ZERO_TRACE for spec-draft runs with a clear error
  instead of silently accepting and writing nothing.

Manifest + fixtures:
- Strengthen the edit verifiers: rename and value-bump tasks now assert
  the old symbol is gone, and the getter task greps the method signature.
- Add the missing testdata/edit fixture (10 edit tasks referenced a
  directory that was never committed, so the whole edit class errored
  out at run time).
- The manifest test now stats each fixture so a missing dir fails at
  load time instead of at every run.
- longproc benchmark keeps Process live across the loop; README code
  fence gets a language tag and documents the per-invocation fixture copy.
- Add tests for the tracing wrapper (nil vs wired recorder, OnUsage
  forwarding, token aggregation) and the frozen-recorder contract.

* Address review feedback: exclusive-time attribution + wired spans + fix-suite compile

Rewrite the trace attribution model so concurrent/nested spans no longer
double-count. Spans record wall intervals; the recorder derives a parent for
each by interval containment and computes exclusive time (duration minus the
union of its children). AttributedDuration sums only top-level spans; Coverage
is the union of intervals over wall, capped at 1, the honest ">=95%" metric.
Top-latency ranking uses exclusive time so shares sum to ~1 instead of being
inflated by overlapping provider_connect/generation and nested
permission_wait/tool_execution.

Wire the spans that were named but never stamped: verification around the
self-correct verify pass, and StampFirstVisibleEvent on the first forwarded
text event. Drop the tool_queue/process_wait/persistence constants that had no
stamps, and rename the misnamed near-noise prompt_build span to tool_partition.
Trim RequiredEventKeys to events a healthy short run actually emits; move
conditional phases (tools, permission, compaction, verification) to
OptionalEventKeys so a short trace does not false-fail.

Make ReadNDJSON fail loudly on a corrupt file: require a type:trace header,
error on span/counter lines that appear before it, and error when a header
yields no spans or counters.

Fix the blocking fix-suite compile error: Label's %d format verb on a string
tripped go test's vet before any fix task could verify. Make it a runtime
wrong-prefix bug ("admin-" vs "user-") that still type-checks, so each fix
task is independent again.

Manifest: correct refactor-04's stats.go to main.go, reword fix-07 to the
wrong-prefix bug, add a latency-only description (read-only tasks carry no
verification oracle by design), and frame the report README on exclusive time
and coverage.

Tests updated for the occurrence/exclusive/coverage model; trace, perfbench,
and agent green under -race.

* Fix exclusive-time round-trip, identical-interval nesting, and empty-trace parse

Three correctness bugs in the attribution model found by an adversarial review
of the prior commit, all undermining the no-double-count guarantee:

1. exclusive_ms:0 was overwritten with the inclusive Duration on re-parse. A
   parent whose children tile its interval legitimately has exclusive time 0;
   WriteNDJSON emits exclusive_ms:0, but ReadNDJSON's "<= 0 means missing"
   fallback turned that back into the full duration, so every re-parsed trace
   re-introduced the double-counting the model exists to prevent (the benchmark
   harness, which ranks re-parsed traces, inherits it). Gate the fallback on
   key ABSENCE, so a written 0 is preserved.

2. deriveNesting formed a 2-cycle for two spans with identical [start, end]
   intervals: the symmetric containment check made each parent the other,
   dropping both from top-level and zeroing AttributedDuration. Break the tie
   deterministically — strict containment is an unambiguous parent; for
   identical intervals, only the lower-indexed span may parent the other, so
   one stays top-level.

3. ReadNDJSON returned a valid empty TurnTrace for empty/blank-only input, so a
   run that crashed before emitting a trace (or where --trace was not honored)
   masqueraded as a clean zero-attribution sample. Empty input now errors, so
   the harness records a TraceIssue.

Also: decode counters via json.Number so int64 values above 2^53 round-trip
exactly instead of losing precision through float64; and drop the harness's
own "exclusive <= 0 -> Duration" ranking fallback, which was the same
double-count on the re-parsed path. Added regression tests covering the
zero-exclusive round-trip, the identical-interval nesting, empty-input
rejection, and counter precision.

* perf(turn-bench): split pass/fail into oracle tiers so the pass rate can't be misread as correctness

The turn benchmark counted any exit-0 task as passed, including the 24
read-only tasks (nav/longproc/longctx/parallel) that carry no verification
oracle — so the JSON tasksPassed / "N/M passed" headline could read as a
correctness verdict it never was. Split pass/fail into three oracle tiers:

- correctness (edit grep + fix go test): the only pass rate that can move
  with model quality (tasksVerified / tasksPassed / correctnessPassRate)
- build-only (refactor go build): a non-positive oracle, reported as
  buildPassRate and excluded from correctnessPassRate; the manifest declares
  buildOnlyClasses: ["refactor"]
- latency-only (no oracle): counted in latencyOnlyTasks, never in any pass
  rate

NewTurnExecRunner no longer sets Passed=true for tasks with no
verificationCommand, and the summary headline names each tier explicitly so
an exit-0 read-only run cannot inflate a correctness number. Bumps
TurnSchemaVersion to 2. Class breakdown and tier contract documented in
internal/perfbench/MANIFEST.md and reports/README.md.

Also install the trace first-token/visible-event forwarding handlers in
agent.Run whenever options.Trace is set, not only when a UI OnText /
OnReasoning callback is set, so a headless traced run (zero exec --trace)
still captures FirstTokenAt. forwardedVisibleText stays tied to the user
callback so stall-retry semantics are unchanged. Adds a regression
assertion to the existing traced-run test.

* Address CodeRabbit review on 2bfb3406: omitempty + crash visibility

Three findings from CodeRabbit's review of the 3-tier pass-rate fix:

- BuildPassRate dropped its omitempty so an explicit 0.0 (all refactor
  tasks fail to compile) is no longer indistinguishable from 'no build
  tasks ran' in the JSON. Matches CorrectnessPassRate's always-serialize
  behavior, which is the whole point of the tier split: the report
  cannot be misread.
- A crashed sample (outcome.Err) now emits a Warning mirroring the
  TraceIssue path, so a run that died every iteration can't look like a
  normal benchmark with fewer measurements instead of a failure.
- cmd.Run()'s returned error is preserved on the no-run_end path: when
  stderr is empty and Run failed (binary missing, exec permission
  denied), the actual OS error is reported instead of collapsing to the
  generic 'missing terminal run_end event'.

go build/vet/gofmt clean; go test -race green for perfbench/agent/trace.

* Address CodeRabbit re-review: anti-averaging README + fixture-isolation test

Re-review at d0615249 asked for the pass-rate contract to be loud to
downstream consumers and for the fixture-isolation property to be asserted,
not just read from code.

- reports/README.md gains a prominent 'do not average the tier pass rates /
  do not report a single pass rate' warning. The schema offers no headline
  pass rate on purpose; a consumer must name the tier it is reporting. This
  is the durability fix for the misread the tier split exists to prevent.
- turn_bench_test.go gains TestCopyFixtureIsolatesSourceFromMutation: copies
  a fixture, mutates the copy, and asserts the source is byte-identical
  (and that a file added to the copy never appears in the source). Proves the
  'fixtures stay clean across runs' property without needing the binary.

Items the re-review also raised that were already satisfied: the
oracle-presence-over-declared-list precedence is documented on the
BuildOnlyClasses field (taskbench.go), and the PR body already says
'48-task manifest'. The Share->exclusiveShare rename is tracked in #701.

go build/vet/gofmt clean; go test -race green for perfbench.
2026-07-16 21:58:20 +08:00
Felix-Ayush 75a78e715b feat(cli): wire MCP serve WorkspaceRoot and --add-dir scope (#694)
* feat(cli): wire MCP serve WorkspaceRoot and --add-dir scope

Pass the resolved workspace into mcp.ServeOptions and honor --add-dir so
resources/list and scoped tools can expose extra roots beyond cwd.

* fix(cli): keep lexical serve --add-dir roots for path matching

Use symlink-resolved paths only for deduplication so Scope roots stay
aligned with the un-evaluated WorkspaceRoot MCP serve already passes.
2026-07-15 21:16:51 +08:00
PierrunoYT 8502449430 test(tui): isolate provider wizard credentials (#688) 2026-07-15 21:06:48 +08:00
euxaristia c0bcfdda41 chore(deps): bump go module dependencies (#669)
Updates charm.land bubbles/bubbletea/lipgloss v2, golang.org/x/sys,
golang.org/x/sync, and dlclark/regexp2/v2 to their latest patch releases.
2026-07-15 21:06:17 +08:00
euxaristia 1bb6b5745a fix(swarm): wait for job.Runs directly in scheduler skip test (#667)
The launcher recording a spawn and the job's Runs counter incrementing
are sequential but distinct steps in the scheduler goroutine
(fireIfIdle's Spawn call, then run's job.incRuns()). The test polled
only the launcher's recorded count, then asserted Runs immediately
with no wait, so a scheduler goroutine preempted between those two
steps could be observed with a stale Runs value under CI load. Wait
for Runs itself instead of assuming the launcher signal implies it.
2026-07-15 21:05:46 +08:00
euxaristia 6fc1220f6a fix(sandbox): scrub sensitive credentials from sandbox environment (#660)
* fix(windows): retry atomic file renames on Access is denied / Sharing violation

* test(swarm): add unit tests for rename retry and non-retryable errors

* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms

* fix(sandbox): scrub sensitive provider credentials from sandbox env

* refactor(sandbox): unify environment creation and reduce lines of code

* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend

* style(sandbox,swarm): remove stray blank lines flagged by gofmt

* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog

Address CodeRabbit review feedback:

- Add internal/fsutil.RenameWithRetry with an injectable rename override and
  move the four duplicated renameWithRetry/isWindowsSharingViolation copies
  (cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
  seam by passing its field through. Add fsutil tests for the Windows retry
  and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
  permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
  static list of non-catalog secrets, so new providers are scrubbed
  automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
  because they are pointers, not secrets.

* fix(sandbox): deny sandboxed reads of cloud credential stores by default

Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:

- Add credentialDenyReadPaths: default profile-level deny-read entries for
  ~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
  target file, wired into both permission profile builders so the seatbelt
  and Linux helper backends enforce them. Entries are stat-filtered and a
  store nested under a user AllowRead entry is dropped, keeping an explicit
  opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
  execution, and skip Windows so the profile deny list does not flip the
  runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
  since the deny-read rule on ~/.aws is the real protection and the SDKs
  fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
  test's exact deny count to containment.

* test(cli): pin HOME in sandbox policy golden test

The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.

* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations

Address CodeRabbit review feedback:

- credentialDenyReadPaths no longer returns early when the home lookup
  fails; home-based candidates become conditional and the
  GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
  matching the mcp lock handling; helper renamed to
  isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
  violation step in the retry sequence.

* fix(sandbox): tighten env-scrub test assertion and add missing secret keys

TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.

Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
2026-07-14 21:46:10 +08:00
euxaristia 80c39aa599 docs: add codebase minimization guideline to AGENTS.md (#661) 2026-07-14 15:15:45 +08:00
euxaristia 8bd9742fa9 fix(tools): classify silent wrapped Windows command failures as sandbox denials (#659)
* fix(tools): classify silent wrapped Windows command failures as sandbox denials

A Windows restricted-token sandbox failure is often completely silent:
when the token cannot open the target executable or its DLLs (or an
MSYS runtime dies during init), the command exits nonzero having
written zero bytes to stdout and stderr. The existing detection is
keyword-based, so these failures were never marked as sandbox denials,
the agent loop never offered the unsandboxed-retry prompt, and the
model was left staring at 'Command completed with no output' until it
gave up.

Treat a wrapped command on the Windows restricted-token or elevated
backend that exits nonzero with completely empty output as a likely
sandbox denial. A command that legitimately fails silently (findstr
with no match, for example) now costs one extra approval prompt; the
inverse misclassification silently strands the session, so the trade
goes to prompting.

* test(tools): cover the elevated Windows backend in the silent-denial test

The heuristic treats windows-elevated identically to
windows-restricted-token, but only the latter was exercised.
Parameterize the silent-failure test over both backends.
2026-07-14 15:13:05 +08:00
euxaristia 91ea6ded75 fix(keyring): pass generic password via stdin on macOS (#574)
* fix(keyring): pass generic password via stdin on macOS

Pass the generic password secret to security add-generic-password via stdin instead of passing it as a command-line argument. This prevents the secret from leaking to the local process list (visible via ps) and aligns the macOS keyring implementation with the Linux secret-tool implementation.

* fix(keyring): pipe secret twice for macOS security password+retype prompt

The macOS security add-generic-password command prompts for both a password and a retype confirmation when -w has no trailing value. The previous stdin approach only piped the secret once, causing a passwords don't match failure. Write the secret twice separated by newlines to satisfy both prompts.

* fix(keyring): reject multiline secrets on macOS instead of corrupting them

Addresses jatmn's review:
- security's -w password+retype prompt reads stdin line by line, so a
  secret containing its own newline can't be distinguished from the
  password/retype separator: the first line read back would silently
  truncate the stored value. Set now rejects a secret containing \r or
  \n on darwin before invoking security, rather than storing a
  corrupted value. Linux's secret-tool has no such restriction (reads
  the whole stdin payload), so it is unaffected.
- Updated the package doc comment, which still described the old
  argv-based (process-list-exposed) macOS path this PR replaced.

Added TestKeyringSetRejectsMultilineSecretOnDarwin and
TestKeyringSetAllowsMultilineSecretOnLinux.

* fix(keyring): drive security -i for macOS Set instead of the -w prompt

The trailing -w prompt uses getpass(3), which reads from /dev/tty whenever
the process has a controlling terminal, so the piped secret was ignored in
any interactive session, and getpass's small fixed buffer would truncate
long secrets (like the base64 OAuth blob) when the fallback did engage.

Interactive mode reads whole commands from stdin, keeping argv at just -i.
Arguments are quoted for SecurityTool's split_line parser (backslash and
double quote escaped inside double quotes). The parser is line-based with a
4096-byte buffer, so Set rejects newlines and oversized payloads up front
rather than corrupting the stored value.
2026-07-14 14:46:09 +08:00
Octopusandocto-patch fa3052a142 feat(providers): refresh MiniMax model coverage (#665)
Co-authored-by: octo-patch <[email protected]>
2026-07-13 22:33:55 +08:00
euxaristia 4c986d327b feat(tui): press up to edit queued messages (#656)
* feat(tui): press up to edit queued messages

Pressing up with a message queued pops it back into the composer for
editing, taking priority over history recall. While a message is queued
the empty composer shows a hint placeholder. A second prompt queued
during the same run now stacks under the first instead of replacing it.

* refactor(tui): remove openclaude references from queued_message comments
2026-07-13 22:18:55 +08:00
euxaristia a5d2e327c8 fix(sandbox): use WRITE_RESTRICTED token when no DenyRead paths are configured (#658)
Removing the WRITE_RESTRICTED flag in #612 made the restricted-SID check
apply to reads as well as writes. Default Windows DACLs grant
BUILTIN\Users rather than any SID in the token's restricted list
(random capability SIDs, logon SID, Everyone), so the sandboxed process
could no longer open any executable or non-KnownDlls DLL: every spawned
command failed with exit 1 and no output, including cmd builtins'
children (gh, git, where.exe). The existing
TestWindowsRestrictedTokenNestedPipeCapture smoke test reproduces the
regression.

Restore WRITE_RESTRICTED, but only when the permission profile has no
DenyRead paths. The flag makes the kernel skip restricted-SID deny ACEs
for reads, which is exactly the DenyRead bypass #612 fixed, so profiles
that configure DenyRead keep the fully restricted token and trade spawn
capability for read-deny enforcement. DenyRead is empty by default, so
the common case regains a working sandbox while #612's guarantee holds
for the profiles that rely on it.
2026-07-13 22:17:31 +08:00
euxaristia 5c4815a66e fix(sandbox): unblock git fetch/commit/add under the write-restricted sandbox (#654)
* fix(sandbox): stop blocking git fetch/commit/add by unblocking .git writes

The sandbox denied every write under .git for shell-executed commands,
so any git operation that touches its own metadata (fetch, commit, add,
pull, merge, stash) failed under the default sandbox. Narrow the block
to .git/hooks (auto-executing scripts) and .git/config (remote URLs,
credential.helper, core.hooksPath), the two subpaths that are actually
dangerous to write; the rest of .git stays writable to git subprocesses
since those writes go through git's own invariants.

The app-level auto-allow gate for direct file-tool writes (write_file,
edit_file, apply_patch) is unaffected and still treats all of .git as
protected, since hand-editing git's internals bypasses those invariants.

* test(cli): normalize Windows path separators in sandbox policy golden

The sandbox policy golden is shared across platforms, but readOnlySubpaths
carve-outs are built with filepath.Join and render as backslash paths on
Windows. normalizePortableJSONRootString only converted a bare backslash, so
the Windows smoke run diverged from the forward-slash golden. Replace all
backslashes with slashes during normalization so the shared golden passes on
every runner.
2026-07-13 21:56:10 +08:00
euxaristia 635c93af51 fix(agent): raise default and deep-mode turn budgets (#650)
* fix(agent): raise default and deep-mode turn budgets

The default per-run tool-turn budget of 50 was still too low for larger
multi-step tasks that span several files: agents hit the ceiling and
stopped with a "remaining work" summary instead of finishing. Raise the
default from 50 to 80.

The "deep" preset also carried MaxTurns: 50, identical to the default, so
its "larger turn budget" promise no longer held on the turn-budget axis.
Raise it to 160 so deep runs get a genuinely larger budget again.

Users can still override per-session with /turns or --max-turns (bounded
by MaxTurnsCeiling).

* test(cli): expect deep-mode MaxTurns=160 after budget raise
2026-07-13 21:54:48 +08:00
Ashwinhegde19 893b7b424c fix(config): surface unknown/typo'd config fields instead of silently dropping them (#645)
* fix(config): surface unknown/typo'd config fields instead of silently dropping them

json.Unmarshal ignores unknown JSON keys by default, so typos such as
"maxTurn" or "sandbox.network" were silently discarded: the user
believed a setting (e.g. a sandbox hardening option) was active while
Zero used its default. Worse, "zero doctor" reported
"config files parsed and validated successfully" even when fields were dropped.

Add a reflection-based unknown-field scanner
(internal/config/unknownfields.go) that walks the FileConfig JSON schema
and recurses into nested objects, arrays, and maps, reporting each
unknown key with its full JSON path and a near-match suggestion
("did you mean 'maxTurns'?"). The two legacy top-level aliases
(mcpServers, mcp_servers) are allowlisted so they are not flagged,
and detection only inspects json tags, so a config written by a newer
Zero with new fields still loads and merges normally — validate/doctor
just surface the unknown keys as issues rather than rejecting the file.

Wire the scanner into ValidateBytes/ValidateFile, which zero doctor
already uses, so doctor now reports the unknown fields instead of
"validation passed".

Add tests for unknown top-level keys, unknown nested keys, unknown
keys inside a provider entry, and that the legacy aliases are not
flagged.

Fixes #644

* fix(config): address review comments on unknown-field scanner

- Treat JSON keys case-insensitively (matching encoding/json), so a
  valid case variant such as "MaxTurns" is not falsely flagged as
  unknown. Canonical (correct-case) names are kept only for the
  "did you mean" hint.
- Allowlist ProviderProfile's legacy snake_case keys (providerKind,
  catalog_id, base_url, api_key, ...) that ProviderProfile.UnmarshalJSON
  accepts but which are not fields on the Go type, so existing
  configs using them are not false-positive'd in zero doctor.
- Make the near-match suggestion deterministic: sort candidates before
  picking so an edit-distance tie always resolves to the same lexical
  winner (map-range order was nondeterministic and could flake).
- Make the unknown-field test assertions order-independent (search by
  FieldPath instead of assuming issues[0]).

Add regression coverage for legacy provider keys and valid case
variants not being flagged.
2026-07-13 21:52:44 +08:00
euxaristia da41c3a75b fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix (#628)
* fix(lock): prevent POSIX lock file overwrite and leak on Windows/Unix

During lock reclamation, reclaiming a suspected stale lock renames the file aside and renames it back if not stale. On POSIX systems, the rename back silently overwrites any new lock file created in the gap. On Windows, it fails to rename back and leaks the stale file.

Introduce a cross-platform restoreLockFile helper:
- On POSIX: Use os.Link to link the file back (which atomic-fails if the target already exists), then remove the temporary link.
- On Windows: Use os.Rename (which safely fails if the target exists).
If restoring fails, delete the temporary file immediately to prevent leaks.

* fix(lockutil): fix Windows build by using golang.org/x/sys/windows

Addresses jatmn's follow-up review: the required Windows smoke check was
failing because syscall.MoveFileEx is not exported by the standard
library's syscall package on this platform, so internal/lockutil (and
everything importing it: cron, daemon, hooks, oauth) failed to compile
on Windows.

Switched to golang.org/x/sys/windows, which does export MoveFileEx with
the same signature, already a direct module dependency. Verified on a
real Windows machine that the no-flags call still fails with
ERROR_ALREADY_EXISTS when the destination exists, and that this
satisfies errors.Is(err, os.ErrExist), which cron/daemon/hooks/oauth's
reclaim paths all depend on to decide whether the sidelined lock was
genuinely superseded (safe to discard) versus some other, unrelated
failure (must be preserved).

TestRestoreLockFile already covered both the successful-restore and
fail-when-target-exists cases; it now passes on Windows.

* fix(lockutil): route lock file deletes through RemoveLockFile with Windows retry loop

* fix(lockutil): close restore split-brain and unify RemoveLockFile

RestoreLockFile could fail with errors other than os.ErrExist (hardlink-
incapable filesystems such as FAT or some FUSE/network mounts, ENOSPC,
Windows sharing violations). Callers only clean up on os.ErrExist, so any
other failure left the lock path missing with the live holder's lock
stranded in the sidelined file; the next O_EXCL create then succeeded and
two processes held the lock. RestoreLockFile now falls back to an O_EXCL
copy in that case, which preserves the no-overwrite guarantee, and a
successful restore with a failed sidelined-file cleanup reports nil
instead of a failed restore.

RemoveLockFile now has one cross-platform contract: removing an already
missing file reports nil on POSIX too, matching Windows, and daemon
release() drops its caller-side ErrNotExist filter.

Tests: TestRestoreByCopy and TestRemoveLockFile run on all platforms;
Windows-tagged tests pin the ERROR_ALREADY_EXISTS mapping to os.ErrExist
and exercise the RemoveLockFile retry loop to success and to bounded
exhaustion. The package doc comment documents the shared contract.

* refactor(swarm): extract reclaimStaleLock to mirror other lock callers

The stale-lock reclaim in acquireLock was inline, so its restore path
(a holder rotated a fresh lock in the gap between the stale check and
the rename) could not be regression-tested the way cron, daemon, hooks,
and oauth test theirs. Extract it into reclaimStaleLock with the same
shape as the other callers, behavior unchanged, and add the mirrored
regression test.

* test(hooks,oauth): add stale lock reclaim regression tests

Mirror the cron reclaim test in the two packages that lacked coverage
for reclaimStaleLock: a genuinely stale lock is reclaimed, a fresh lock
(reacquired in the gap) is restored intact rather than stolen, and a
missing lock reports no reclaim. daemon already had equivalent tests in
lock_test.go.

* fix(lock): fail closed and drop sidelined file when restore fails

A non-os.ErrExist error from RestoreLockFile means both the no-replace
restore and its O_EXCL copy fallback failed, so the lock path may be
missing while the sidelined holder is live. Callers previously swallowed
that error and their next O_EXCL create would succeed, breaking mutual
exclusion. reclaimStaleLock in cron, daemon, hooks, oauth, and swarm now
returns an error for that case and the acquire loops fail closed instead
of re-acquiring.

The sidelined .stale.* file is now removed on every restore failure, not
only os.ErrExist: once the restore has failed it has no protocol
function (release only consults the lock path), so keeping it only
leaked files.

Also replace the numeric errno literals in the Windows RemoveLockFile
retry loop with windows.ERROR_SHARING_VIOLATION and
windows.ERROR_ACCESS_DENIED.

* refactor(lockutil): extract shared ReclaimStaleLock helper

Consolidate the duplicated reclaimStaleLock functions from cron, hooks,
oauth, and swarm into lockutil.ReclaimStaleLock. Each caller now passes
an mtime-based isLive predicate to the shared helper, which owns the
rename-aside, restore-if-live, cleanup, ErrExist handling, and
fail-closed error propagation.

Daemon keeps its own reclaimStaleLock because it uses PID-based liveness
(different predicate shape and no staleAfter duration).

The per-package reclaim regression tests are removed since the shared
helper is thoroughly tested in lockutil/reclaim_test.go (dead lock
reclaimed, live lock restored, missing lock, fail-closed on restore
error, sidelined file dropped when new holder wins).

* fix(lockutil): propagate non-contention rename failures from ReclaimStaleLock

A rename-aside failure that is not a lost race previously mapped to the
benign lost-race result, so acquirers spun until their deadline and
reported a timeout instead of the real failure. Keep only a missing
source and the Windows sharing-violation/access-denied contention
errnos benign (neither Windows errno maps to os.ErrExist, and
ERROR_SHARING_VIOLATION does not map to os.ErrPermission either, so the
classification compares raw errnos) and return everything else so
callers fail closed.

Also convert daemon's private reclaimStaleLock to the shared helper so
it picks up the same contract, and reword the caller error messages
from "restore reclaimed" to "reclaim stale" since the error can now
come from the rename aside as well.

* fix(lockutil): publish restore-by-copy atomically and honor lost mailbox reclaims

restoreByCopy created the destination lock file empty before copying into
it, so a crash mid-copy could leave a live holder's lock permanently
unrepresented at the lock path. Stage the copy under a private name and
publish it with the platform's no-replace primitive instead.

swarm's mailbox lock discarded the reclaimed boolean and always retried
immediately, so a lost reclaim race (or Windows sharing-violation
contention) could hot-spin past the lock timeout instead of falling
through to the bounded wait like the other lock callers.

* fix(lockutil): shrink the reclaim/restore window with a fast replacing rename

Restoring a lock that turned out live went through the no-replace path
(plus its copy fallback on some failures) first, which is slow enough
that a fourth process's unrelated O_EXCL create could win lockPath while
it was absent for the whole call. RestoreLockFile's no-replace contract
would then report os.ErrExist and the caller silently dropped the
sidelined lock, orphaning the still-running original holder.

restoreLiveLock now tries a single replacing rename straight back first,
falling back to the no-replace path only if that fails. It shrinks the
window from a whole restore call down to roughly one syscall; it does
not close it, since the fast path can silently overwrite a competing
lock rather than detect it. A fully race-free reclaim would need an
OS-level advisory lock checked non-destructively instead of by moving
the file, which is a larger change than this.
2026-07-13 21:45:08 +08:00
euxaristia 5a5b7fd5b8 docs: add Go code quality & security checks and bump Go version to 1.26+ (#635)
* docs: add Go code quality & security checks and bump Go version to 1.26+

* docs: address CodeRabbit review comments and use pinned versions and Go 1.26.5+

* docs: remove trailing blank lines at end of AGENTS.md

* docs: add missing /v2/ segment to golangci-lint install path

The go install command pointed at the v1 module path
(golangci-lint/cmd/golangci-lint), which does not resolve for
v2.12.2. The go run invocation earlier in the same section already
had the correct v2 path.

* docs: trim AGENTS.md below 8 KiB guideline and clean up README cross-compile header

* docs: address CodeRabbit review feedback on extending guide
2026-07-12 22:37:30 +08:00
euxaristia 212734adf3 fix(securefile,credstore): call Sync on temp file before close and rename (#631)
When writing credentials or generating user secrets, files are written using a write-to-temp-then-rename pattern. However, neither package called Sync (fsync) on the temporary file before closing and renaming. If a crash or power failure occurred shortly after write, this could result in truncated or 0-byte key/credential files on disk.

Add tmp.Sync() calls to writeNewSecretFile and Store.write to guarantee durability.
2026-07-12 22:35:55 +08:00
euxaristia 2efe6d539e fix(plugins): resolve relative executable paths against plugin root (#627)
* fix(plugins): resolve relative executable paths against plugin root

When a plugin registers a tool or a hook command as a relative path (e.g. ./tools/helper.sh), running it in the caller's workspace CWD allows local execution hijacking by placing a malicious script at that path in the untrusted workspace. Update expandPluginRootPath to resolve relative executable paths containing path separators to absolute paths inside the plugin's install directory.

* fix(plugins): remove ineffectual permission initializer in toolSafety

The initial assignment to permission was always overwritten by one of
the switch arms below it, including a case that just reassigned the
same PermissionPrompt value the initializer already held. Flagged by
ineffassign in CI.
2026-07-12 22:34:34 +08:00
Kevin Codex 5e1405d0b7 feat(npm): ship the native binary as platform optionalDependencies (#626)
* feat(npm): ship the native binary as platform optionalDependencies

Replace the postinstall downloader with Codex-style platform packages:
suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the
binary, sandbox helpers, and the vendored agent-browser/tuistory tree,
referenced from the wrapper via npm: aliases in optionalDependencies.

The published wrapper has no lifecycle scripts and no dependencies, so
npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's
node>=24 engines pin, no allow-scripts warnings, no network outside the
npm registry. The old downloader remains as a checksum-verified first-run
fallback for --omit=optional installs and unsupported platforms.

scripts/npm/build-platform-packages.mjs assembles the payloads from the
release archives at publish time: it materializes npm's symlink .bin
shims into regular sh shims (npm pack silently drops symlinks) and prunes
agent-browser's other-platform binaries (~65 MB per package). The publish
job uploads platform versions first under --tag platform, asserts the
latest dist-tag survived (platform versions are semver prereleases of the
wrapper version), then publishes the wrapper.

See docs/NPM_PACKAGING.md for the architecture and publishing rules.

* fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs

- Ship the repo LICENSE in both the wrapper and platform payloads (npm only
  includes a license file physically present in the packed directory).
- Guard the platform-publish dist-tag lookup with || true so a transient
  npm view failure (or a bootstrap publish with no latest tag) cannot abort
  the job between the platform and wrapper publishes.
- Gate the wrapper's fallback downloader on the release matrix so platforms
  with no asset (windows-arm64 and unknown combos) skip the spawn instead of
  attempting it every run; keep deliberate retry-on-failure for supported
  platforms and say so honestly in the notice and docs.
- Correct Windows support wording everywhere: x64 only, ARM runs under
  emulation (README, README_ZH, INSTALL.md incl. the release-target list).
2026-07-11 23:25:41 +08:00
KRATOSandKRATOS 1af58828eb fix(openai): omit prompt_cache_key for openai-compatible providers (#636)
Interactive TUI always has a session ID, so every completion request
forwarded OpenAI's prompt_cache_key. Strict openai-compatible gateways
(e.g. NVIDIA NIM) reject unknown fields with a 400, while plain zero
exec usually has no session and omits the field.

Disable prompt_cache_key for ProviderKindOpenAICompatible; keep it for
official OpenAI. ZERO_DISABLE_PROMPT_CACHE_KEY remains a global kill
switch.

Fixes #624

Co-authored-by: KRATOS <[email protected]>
2026-07-10 21:43:49 +08:00
euxaristia dc06fe72ca fix(hooks): fail closed on launch failures for beforeTool hooks (#629)
When executing a security-critical beforeTool hook, if the hook command fails to launch (e.g. binary not found, missing execute permission), it previously failed open and let tool execution proceed.

Update classifyResult to return AuditBlocked, true when a launch error occurs on a beforeTool hook, enforcing a fail-closed policy. Observational afterTool hooks still fail open.
2026-07-10 16:42:32 +08:00
euxaristia e4bd703cfb fix(cron): prevent cron job Mutate from clobbering concurrent updates (#630)
Inside fireJob, the Mutate transaction callback previously returned the stale in-memory job copy (read at tick start). This clobbered any concurrent updates made to the job (such asExpr, Prompt, or Cwd edits) while the job was running.

Update the callback to apply schedule and status changes directly onto the fresh current job loaded from disk, and return current.
2026-07-10 16:41:27 +08:00
euxaristia aa73a76f1b fix(tui): resolve pending askUser callbacks to prevent runner hangs (#620)
When a run is completed or canceled (agentResponseMsg) while an askUser prompt is pending, or when a stale/superseded askUserRequestMsg is received, the answer callback is now invoked with nil. This unblocks the waiting agent loop goroutine, preventing goroutine leaks and runner hangs.
2026-07-10 10:12:16 +08:00
euxaristia 5b4f48d2dc fix(cli): prevent consuming positional arguments as flag values (#619)
When parsing CLI flags, positional prompt text placed immediately after flags requiring values (e.g. --auto, --notify, --max-turns) would be greedily consumed as their value by nextFlagValue. Refine nextFlagValue to validate choices/types (autonomy levels, notify modes, integers) to detect invalid values early and fail validation rather than eating positional arguments.
2026-07-10 10:11:12 +08:00
euxaristia f78b36c770 fix(tools): preserve SysProcAttr during PTY fallback (#618)
When falling back from a failed PTY start to a pipe execution, the command attributes are reset. Previously, resetExecCommandForPipeFallback set command.SysProcAttr = nil, which stripped custom settings like Windows CmdLine quoting. Update the fallback logic to restore the original SysProcAttr instance, and add TestStartExecProcessPTYFallbackPreservesSysProcAttr to cover.
2026-07-10 10:08:32 +08:00
euxaristia 2db00ee3d5 fix(windows): resolve absolute path for taskkill to prevent hijacking (#617)
Using unqualified taskkill / taskkill.exe commands on Windows can allow binary hijacking under certain conditions. Update all process termination callers on Windows to resolve the absolute path to taskkill.exe under System32 (referencing SystemRoot/windir environment variables, falling back to C:\Windows).
2026-07-10 10:08:11 +08:00
euxaristia 8ea53841a5 fix(daemon): handle os.ErrPermission as collision during O_EXCL lock creation (#616)
On Windows, a concurrent holder's os.Remove leaves the lock file in a 'delete pending' state, causing a racing O_EXCL create to fail with ERROR_ACCESS_DENIED (mapped to os.ErrPermission). Treat os.ErrPermission as lock contention / already-existing lock, matching the collision checks in cron, hooks, oauth, and securefile.
2026-07-10 10:07:50 +08:00