Compare commits

...
Author SHA1 Message Date
KRATOS f009491e0b fix(tui): address PR review for incomplete run replay
Use EventRunIncomplete instead of EventError for stalled plan runs so
resume replays a system notice rather than a hard error. Stamp
completedAt when markIncompleteRemaining leaves a terminal plan, tighten
incomplete plan assertions, and add hydration coverage.
2026-07-15 12:53:01 +05:30
KRATOS b97cf36bb4 fix(agent): prevent plan execution from stalling after update_plan
Apply the plan-aware completion gate to interactive runs while update_plan
still has pending items, count reasoning-only turns as non-productive during
an active plan, surface Incomplete results in the TUI, and mark the active
plan step failed instead of force-completing a stalled run.
2026-07-15 12:53:01 +05:30
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
euxaristia 8536cc87f7 fix(securefile): reclaim stale lock files to prevent permanent DOS (#615)
* fix(securefile): reclaim stale lock files to prevent permanent DOS

When createSecretFile fails to acquire the O_EXCL lock because it already exists, check if the file is stale (older than 10 seconds). If so, atomically reclaim it via a rename-aside check, preventing a permanent DOS after abrupt process crashes. Update the lock file to write and verify process-specific tokens, and add TestCreateSecretFileReclaimsStaleLock to cover the stale reclaim flow.

* style(securefile): gofmt const block alignment
2026-07-10 10:07:22 +08:00
euxaristia ddc4927aac fix(acp): make truncateHint rune-safe (#614)
truncateHint slices string hints using a raw byte offset (s[:max]), which can split multi-byte UTF-8 characters (like CJK characters, emojis, or accents) in half and produce invalid UTF-8 sequences. Update it to count and slice by runes to guarantee valid UTF-8, and add a unit test TestToolTitleTruncateHintRuneSafe to verify.
2026-07-10 10:05:56 +08:00
euxaristia 0184581ec2 fix(agent,tui): resolve git branch detection when starting Zero in subdirectories (#613)
Currently, both gitBranchForPrompt (agent) and gitBranch (tui) check for a .git entry directly inside the current working directory. When starting Zero in nested folders/subdirectories of a repository, the check fails and the branch segment is omitted.

Export findProjectGitRoot as FindProjectGitRoot in agent package, and use it in both functions to resolve the correct repository root before checking HEAD and resolving the branch name. Also resolve relative worktree gitdirs relative to the repository root.
2026-07-10 10:05:36 +08:00
euxaristia 3d96ac7e55 fix(sandbox): remove windowsWriteRestricted flag to fix DenyRead bypass (#612)
On Windows, creating restricted tokens with windowsWriteRestricted (0x08) instructs the kernel to skip checking the restricted SIDs list for read operations. This bypasses DenyRead path policies on NTFS. Remove the flag to enforce restricted SID constraints on both read and write operations, and add integration test assertions to verify that DenyRead paths are correctly blocked.
2026-07-10 10:05:04 +08:00
Gautam ManchandaniandGautam Manchandani fdddb05ba8 fix: harden MCP credential boundaries (#597)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-10 09:00:17 +08:00
euxaristia b6117af86d fix(exec): stop false INCOMPLETE downgrades on conversational final messages (#608)
* fix(agent): scope self-reported incompletion to sentences about the current objective

The detector matched inability stems anywhere in the final message, so a
conversational recap of a past exchange ("You asked if I could work
autonomously ... so I couldn't actually do it at the time") downgraded a
completed run to INCOMPLETE. Match per sentence instead, drop quoted spans,
and skip sentences that retell a past exchange. The real transcript is now a
regression test, and genuine admissions still fire, both present tense and
past tense about the current task.

* feat(exec): add --no-completion-gate for conversational callers

A chat frontend driving zero exec has an operator present, so a final
message that hands the turn back ("the sandbox blocks network egress,
approve it and I'll continue") is a complete conversational answer, not an
unfinished task. The headless completion gate still stamped such runs
INCOMPLETE (exit 4) on the honest inability phrasing, and no phrase
heuristic can fix that case because the admission is true. The new flag
maps to RequireCompletionSignal=false so conversational callers can opt
out. Default behavior is unchanged.

* fix(exec): address review findings on quote stripping and --use-spec combination

stripQuoted now removes only balanced quoted spans: an opening delimiter
that never closes is restored verbatim, so a stray quote cannot swallow
the rest of the message and hide a genuine admission from the detector.
Regression tests cover dangling straight, curly, and backtick delimiters.

--no-completion-gate combined with --use-spec is now rejected at parse
time, mirroring the --self-correct check: the spec-draft path never
consults the completion gate, so the flag would be silently ignored.
2026-07-10 08:59:33 +08:00
116 changed files with 5781 additions and 972 deletions

No files matched your search

+69 -11
View File
@@ -97,16 +97,21 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
# assets are live — the package's postinstall downloads its binary from the
# GitHub Release, so publishing before the assets exist would ship a package
# that fails to install.
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
name: Publish npm package
needs: package
@@ -156,10 +161,11 @@ jobs:
console.log(`npm ${version} supports trusted publishing.`);
'
# Probe the exact URLs postinstall will build (internal/release/release.go
# asset-name scheme) as an anonymous client. This also blocks publishing
# while the repo is still private — private release assets 404 without
# auth, exactly as they would for an installing user.
# Probe the exact URLs the wrapper's first-run downloader builds
# (internal/release/release.go asset-name scheme) as an anonymous client
# — install.sh/install.ps1 and `zero update` read them too. This also
# blocks publishing while the repo is still private: private release
# assets 404 without auth, exactly as they would for an installing user.
- name: Verify release assets are downloadable
shell: bash
run: |
@@ -173,12 +179,64 @@ jobs:
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
- name: Publish to npm
run: npm publish --access public
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: zero-*
path: dist/artifacts
merge-multiple: true
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
+11 -310
View File
@@ -1,326 +1,27 @@
---
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
description: Project guidelines for the open-source CLI coding agent (zero) repository.
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
alwaysApply: false
---
# Extending Zero
# Repository Conventions for Zero
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
This file outlines the project conventions and repository guidelines for coding agents when working on the `zero` repository. For the general guide on how to extend Zero (write specialist sub-agents, hooks, plugins, MCP, skills), see [docs/EXTENDING.md](docs/EXTENDING.md).
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
```markdown
# Project conventions for <your project>
## 1. Project Conventions
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
```
- Unify functions/methods where possible to prevent codebase inflation. Prefer a single cross-platform function with conditional checks over duplicating helpers per platform.
Tips:
## 2. Guidelines for Coding Agents
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
If you are an AI coding agent executing tasks in this repository, you **MUST** run all Go code quality and security checks before committing code or completing your task:
### Personal guidelines, across every project
1. **Format & Vet**: Run `go fmt ./...` and `go vet ./...`.
2. **Lint**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
3. **Security**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description: Reviews API changes for breaking-change risk and missing tests.
tools: read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes" \
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description: Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1. `make bench` — captures the wall-clock and RSS before and after.
2. `benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. During an agent run, Zero loads the default skills directory before plugin skill roots; earlier roots win name collisions silently. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
- User: `~/.config/zero/hooks.json`
- Project: `./.zero/hooks.json`
```json
{
"enabled": true,
"hooks": [
{
"id": "block-rm-rf",
"event": "beforeTool",
"matcher": "bash",
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
"enabled": true
},
{
"id": "log-session",
"event": "sessionStart",
"command": "/usr/local/bin/zero-hook-log.sh",
"enabled": true
}
]
}
```
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
```bash
#!/usr/bin/env bash
# /usr/local/bin/zero-hook-block-rmrf.sh
set -euo pipefail
payload="$(cat)"
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
echo "refusing rm -rf" >&2
exit 1
fi
```
Events the agent emits (in dispatch order):
| Event | Fires when | Matcher allowed? |
| --- | --- | --- |
| `beforeTool` | A tool is about to run | yes (tool name) |
| `afterTool` | A tool just returned | yes (tool name) |
| `sessionStart` | A session begins | no |
| `sessionEnd` | A session ends | no |
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
```json
{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--port", "7777"]
},
"github": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
}
}
}
}
```
Manage via CLI:
```bash
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id": "github-pr-review",
"name": "GitHub PR Review",
"description": "Adds review tools for GitHub PRs.",
"version": "1.0.0",
"tools": [
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
],
"hooks": [
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
],
"skills": [
{ "path": "./skills/review-checklist/SKILL.md" }
]
}
```
Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
## 7. Configuration locations
Three layers, applied in order (later layers override earlier ones):
| Layer | Path | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
- `.zero/config.json` — provider catalog, default model, allowed tools.
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
Each contributor adds only:
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
## 9. Reference
- [README](README.md) — install, quickstart, command reference.
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
If any check fails or cannot be run, do not ignore it. Prompt the user for instructions or setup assistance before proceeding.
+60 -31
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
@@ -49,34 +49,15 @@ npm install -g @gitlawb/zero
zero
```
The npm package installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
arm64.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
The npm package is a small wrapper whose platform build (Linux and macOS on
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
installs as an optional dependency straight from the npm registry — no install
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
no trust or approval steps. Installs that skip optional dependencies
(`--omit=optional`) still work: the wrapper fetches the binary from the
matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
### Install scripts
@@ -94,7 +75,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.25+.
Source builds require Go 1.26.5+.
```bash
git clone https://github.com/Gitlawb/zero.git
@@ -149,6 +130,8 @@ export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
@@ -157,6 +140,33 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
zero providers setup longcat --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
```bash
zero providers add minimax --set-active
zero providers add minimaxi-cn --set-active
```
To use the OpenAI-compatible endpoints instead, add a custom compatible profile
for the required region:
```bash
zero providers add custom-openai-compatible \
--name minimax-openai \
--model MiniMax-M3 \
--base-url https://api.minimax.io/v1 \
--api-key-env MINIMAX_API_KEY \
--set-active
zero providers add custom-openai-compatible \
--name minimax-cn-openai \
--model MiniMax-M3 \
--base-url https://api.minimaxi.com/v1 \
--api-key-env MINIMAXI_API_KEY \
--set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
`zero providers detect`.
@@ -339,7 +349,26 @@ go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
Cross-compile examples:
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
2. **Vetting**: Run `go vet ./...` (or `make vet`).
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
If you prefer to install these tools globally on your path, you can run:
```bash
# Install golangci-lint
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
# Install govulncheck
go install golang.org/x/vuln/cmd/[email protected]
```
### Cross-Compile Examples
```bash
go run ./cmd/zero-release build --goos linux --goarch amd64
+3 -21
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<a href="README.md">English</a> | <strong>中文</strong>
@@ -38,25 +38,7 @@ npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 项目安装
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
### 安装脚本
@@ -74,7 +56,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.25+。
源码构建需要 Go 1.26.5+。
```bash
git clone https://github.com/Gitlawb/zero.git
+76 -23
View File
@@ -2,6 +2,7 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -39,6 +40,11 @@ function resolveHelper(packageRoot, name) {
return null;
}
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
// installs vendor the helpers inside the platform package instead (a helpers/
// directory next to the binary, found by the Go side on its own), so this
// manifest is usually empty — it only matters for a downloaded-binary install
// whose wrapper still carries helper dependencies.
function localControlHelperManifest(packageRoot) {
const helpers = {};
for (const name of ['agent-browser', 'tuistory']) {
@@ -49,38 +55,85 @@ function localControlHelperManifest(packageRoot) {
return JSON.stringify({ version: 1, helpers });
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
} catch {
return null;
}
const candidate = join(dirname(manifestPath), zeroBinaryName());
return existsSync(candidate) ? candidate : null;
}
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
// Mirrors the release matrix (internal/release/release.go): the platforms a
// GitHub Release asset exists for. android maps to the linux asset; there is
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
function releaseAssetAvailable() {
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
process.platform
];
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
if (!platform || !arch) return false;
return !(platform === 'windows' && arch === 'arm64');
}
// Fallback for installs without a platform package (--omit=optional, package
// managers that skip optionalDependencies): fetch the binary next to the
// wrapper with the same checksum-verified downloader that used to run as
// postinstall. Deliberately NOT cached on failure — a transient network error
// self-heals on the next run — so this retries on every invocation until a
// binary is in place. Platforms with no release asset skip the attempt.
function downloadMissingBinary(packageRoot) {
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
console.error(
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\n' +
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
);
spawnSync(process.execPath, [downloadScript], {
stdio: ['ignore', 'inherit', 'inherit'],
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
const downloaded = join(packageRoot, zeroBinaryName());
if (existsSync(downloaded)) return downloaded;
downloadMissingBinary(packageRoot);
return existsSync(downloaded) ? downloaded : null;
}
const nativePath = resolveNativeBinary();
if (!nativePath) {
console.error(
'[zero] No native binary is available for this install.\n' +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.\n' +
'\n' +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\n` +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
);
process.exit(1);
}
const env = { ...process.env };
const localControlHelpers = localControlHelperManifest(packageRoot);
if (localControlHelpers) {
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
} else {
+327
View File
@@ -0,0 +1,327 @@
# Extending Zero
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](../README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
```markdown
# Project conventions for <your project>
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
```
Tips:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description: Reviews API changes for breaking-change risk and missing tests.
tools: read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes" \
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```text
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description: Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1. `make bench` — captures the wall-clock and RSS before and after.
2. `benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. During an agent run, Zero loads the default skills directory before plugin skill roots; earlier roots win name collisions silently. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
- User: `~/.config/zero/hooks.json`
- Project: `./.zero/hooks.json`
```json
{
"enabled": true,
"hooks": [
{
"id": "block-rm-rf",
"event": "beforeTool",
"matcher": "bash",
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
"enabled": true
},
{
"id": "log-session",
"event": "sessionStart",
"command": "/usr/local/bin/zero-hook-log.sh",
"enabled": true
}
]
}
```
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
```bash
#!/usr/bin/env bash
# /usr/local/bin/zero-hook-block-rmrf.sh
set -euo pipefail
payload="$(cat)"
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
echo "refusing rm -rf" >&2
exit 1
fi
```
Events the agent emits (in dispatch order):
| Event | Fires when | Matcher allowed? |
| --- | --- | --- |
| `beforeTool` | A tool is about to run | yes (tool name) |
| `afterTool` | A tool just returned | yes (tool name) |
| `sessionStart` | A session begins | no |
| `sessionEnd` | A session ends | no |
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
```json
{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--port", "7777"]
},
"github": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
}
}
}
}
```
Manage via CLI:
```bash
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
Project MCP config remains supported for shared team servers, but a project layer
that changes an existing server's target (`url`, `command`, or `args`) does not
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
or use a new server name. OAuth tokens are bound to the resolved server identity,
so changing a server target can require `zero mcp oauth login <server>` again.
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id": "github-pr-review",
"name": "GitHub PR Review",
"description": "Adds review tools for GitHub PRs.",
"version": "1.0.0",
"tools": [
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
],
"hooks": [
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
],
"skills": [
{ "path": "./skills/review-checklist/SKILL.md" }
]
}
```
Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
## 7. Configuration locations
Five configuration sources, in precedence order (later sources override earlier ones):
| Source | Path / Key | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
- `.zero/config.json` — provider catalog, default model, allowed tools.
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
- `.zero/hooks.json` — block `rm -rf` on `beforeTool`.
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
Each contributor adds only:
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
## 9. Reference
- [README](../README.md) — install, quickstart, command reference.
- [docs/SPECIALISTS.md](SPECIALISTS.md) — full specialist manifest spec.
- [docs/STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
- [docs/INSTALL.md](INSTALL.md) — install from source or release.
+23 -50
View File
@@ -4,10 +4,11 @@ Zero is distributed as:
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.25+
- source builds with Go 1.26.5+
The npm package and install scripts download a platform-specific release archive.
They require a published GitHub Release for the requested version.
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
## npm
@@ -16,59 +17,32 @@ npm install -g @gitlawb/zero
zero
```
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
`zero` command and downloads the matching release binary during `postinstall`.
The package supports Linux and macOS on x64 and arm64, and Windows on x64
(Windows on ARM runs the x64 build under emulation). It installs a small
`zero` wrapper plus, as an optional dependency, a platform payload with the
native binary and the bundled browser/terminal control helpers. There are no
install scripts and nothing is downloaded from outside the npm registry, so
the install is silent and works identically under npm, Bun, pnpm, and yarn —
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
package architecture.
Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
- network access to npm
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
If the install skipped optional dependencies (`npm install --omit=optional`,
or a package manager configured to do so), the wrapper falls back to
downloading the binary from the matching GitHub Release, with checksum
verification. The fetch is retried on each run until a binary is in place (a
transient network failure heals itself; an install directory the current user
cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
```
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
@@ -151,7 +125,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.25+.
Source builds require Go 1.26.5+.
### Sandbox Helpers For Source Builds
@@ -252,8 +226,7 @@ Supported targets:
- `linux-arm64`
- `macos-x64`
- `macos-arm64`
- `windows-x64`
- `windows-arm64`
- `windows-x64` (Windows on ARM runs this build under emulation)
Each archive must have a matching `.sha256` file. The install scripts download
both files, verify the checksum, and then copy the binary into the install
+162
View File
@@ -0,0 +1,162 @@
# npm packaging
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
`release-artifacts.yml`.
## Goals
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
install zero without prompts, trust ceremonies, or broken binaries.
- No network fetches outside the npm registry at install time. GitHub being
down or rate-limited must not break `npm install`.
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
of the box.
## Architecture
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
The alias suffixes use Node's `process.platform`/`process.arch` names so
`bin/zero.js` can derive its platform package directly.
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
them and skips the rest.
- A platform payload is assembled from the platform's release archive by
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
the platform's sandbox helpers, and the vendored `helpers/` tree (see
below). It has no `bin`, no scripts, and no dependencies — it is inert on
its own; the wrapper execs the binary out of it.
- The published wrapper has **no scripts and no dependencies** — the
assembly script strips both from the repo `package.json` and injects the
exact-version `optionalDependencies`. (The repo `package.json` keeps
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
the vendored helpers tree; they are never installed by consumers.)
- There is **no windows-arm64 build** (matches the release matrix); Windows on
ARM runs the x64 build under emulation via the first-run fallback.
### Binary resolution in `bin/zero.js`
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
3. If neither exists (`--omit=optional`, package managers that skip optional
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
verified against the release's own checksum file, no zip-slip), invoked by
the wrapper itself. Failures are deliberately not cached — the fetch
retries on every run until a binary is in place, so a transient network
error self-heals. Platforms with no release asset (anything outside the
matrix above, including windows-arm64) skip the attempt entirely rather
than probing the network each run.
4. If the download is impossible too, print build-from-source guidance.
There is deliberately **no `scripts.postinstall`** in any published
`package.json`. The downloader exists only as a first-run fallback.
### Vendored helpers (`helpers/`)
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
**vendored binaries/packages inside the platform payload**, not npm
dependencies of the wrapper:
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
every installer. As a vendored tree it produces none, because npm never
resolves it.
- `zero-release package` already stages the helpers tree into every release
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
`package.json` pins + lockfile on the native builder). The assembly script
reuses that staged tree, so npm installs and `install.sh` installs get
identical helpers.
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
wrapper involvement, no configuration.
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
other symlink, so the vendored tree survives publishing. (Verified
empirically; nested `node_modules` under `helpers/` IS packed — only the
package-root `node_modules` is always ignored.)
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
platform (7 total). The assembly script keeps only the payload's own
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
non-`latest` dist-tag would clobber `latest` and users would install a
platform payload as the CLI. The workflow publishes platform versions with
`--tag platform` and asserts `latest` survived before the wrapper publish,
then asserts `latest` equals the wrapper version after it. Both lookups
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
## Runbook: bumping the vendored helpers
The vendored helper versions are pinned by the repo `package.json`
`dependencies` + `package-lock.json`; the release build vendors exactly what
the lockfile resolves. No `^range` surprises ship: a release carries what its
lockfile pinned at build time.
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
ship with the next release.
- Security response: if a vendored helper publishes a security fix, the bump
is release-worthy on its own — cut a patch release of zero carrying only the
pin change.
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
vendored package directory (`helpers/node_modules/agent-browser/`), which
satisfies redistribution attribution.
## History / rationale
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
`agent-browser`/`tuistory` as regular dependencies. That produced three
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
silently under Bun's default script blocking, and coupled `npm install` to
GitHub Releases availability. The platform-package model removes the warnings
structurally — there is nothing left in the tree for a package manager to warn
about — rather than asking users to approve or suppress them.
+45 -10
View File
@@ -1,8 +1,8 @@
# npm Wrapper Smoke Checklist
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
wrapper.
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -21,17 +21,52 @@ go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
```
## End-To-End Assembly Smoke
Build a real archive for the host platform and assemble the npm payloads from
it (this is exactly what the publish job does):
```bash
go run ./cmd/zero-release package
node scripts/npm/build-platform-packages.mjs \
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
```
Then simulate an installed layout and run the wrapper through each resolution
path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@gitlawb/zero`), version,
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
archive's SHA-256, and extracts only the known binary basenames into place.
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
platform/arch is a non-fatal skip.
- The wrapper binary resolves through the package `bin` entry and
`node_modules/.bin` in a package-install smoke test.
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
matrix.
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
plan), verifies the downloaded archive's SHA-256, and extracts only the
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
(exit 0) and an unsupported platform/arch is a non-fatal skip.
- The wrapper prefers the platform package binary over a previously downloaded
one, and `agent-browser`/`tuistory` run from the platform package's
`helpers/node_modules/.bin` shims.
- The built binary exits 0 for `zero --version` or `zero --help`.
- `zero --version` reports `zero <package.json version>`.
- Release packaging still emits the expected archive and checksum names when
+3 -2
View File
@@ -3,6 +3,7 @@ package acp
import (
"encoding/json"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
@@ -70,8 +71,8 @@ func primaryArgHint(rawArgs string) string {
func truncateHint(s string) string {
s = strings.TrimSpace(s)
const max = 60
if len(s) > max {
return s[:max] + "…"
if utf8.RuneCountInString(s) > max {
return string([]rune(s)[:max]) + "…"
}
return s
}
+34
View File
@@ -1,7 +1,9 @@
package acp
import (
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
@@ -126,3 +128,35 @@ func TestPromptText(t *testing.T) {
t.Fatalf("promptText = %q", got)
}
}
func TestToolTitleTruncateHintRuneSafe(t *testing.T) {
// A 61-character string containing multi-byte UTF-8 runes (emojis / CJK characters).
// We want to verify that it is truncated without cutting any runes or producing invalid UTF-8.
longPath := "📁/项目/非常长的路径名称/测试/🚀/emoji-and-cjk-characters-which-are-very-long-and-exceed-sixty-characters"
// Create JSON args for read_file
rawArgs := `{"path":"` + longPath + `"}`
got := toolTitle("read_file", rawArgs)
expectedPrefix := "read_file "
if !strings.HasPrefix(got, expectedPrefix) {
t.Fatalf("expected title to start with %q, got %q", expectedPrefix, got)
}
hint := strings.TrimPrefix(got, expectedPrefix)
// Hint should end with the ellipsis character
if !strings.HasSuffix(hint, "…") {
t.Fatalf("expected truncated hint to end with ellipsis, got %q", hint)
}
// Check that we don't have invalid UTF-8 runes
if !utf8.ValidString(hint) {
t.Fatalf("truncated hint is not a valid UTF-8 string: %q", hint)
}
// The rune count of the hint (excluding ellipsis) should be exactly 60
runes := []rune(strings.TrimSuffix(hint, "…"))
if len(runes) != 60 {
t.Fatalf("expected exactly 60 runes before ellipsis, got %d (hint: %q)", len(runes), hint)
}
}
+20
View File
@@ -72,6 +72,26 @@ func TestSelfReportedIncompletionMatching(t *testing.T) {
{"I replaced the placeholder value with the computed key.", false},
{"The retry uses exponential backoff as a fallback.", false},
{"Without proper error handling the function would crash, so I added it.", false},
// real false positive (conversational recap): retelling a PAST exchange is
// not an admission about the current objective — the sentence carries its
// own narrative context ("You asked …", "at the time"):
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
{"Last session I was unable to build the project; that is what we should retry now.", false},
// quoted admissions (the model citing its own earlier message or a log
// line) must NOT be flagged:
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
// current-objective admissions must STILL be flagged, including past tense
// and multi-sentence finals whose other sentences are narrative:
{"I couldn't complete the refactor.", true},
{"I hit an error I could not resolve, so the migration is unfinished.", true},
{"You asked me to port the parser. I couldn't finish the second half.", true},
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
// spans are stripped), so an admission after a stray quote still fires:
{"The log ends with a stray \" and I couldn't finish the migration.", true},
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
}
for _, c := range cases {
got := selfReportedIncompletion(c.text) != ""
+140 -3
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"errors"
"strings"
"testing"
@@ -126,9 +127,107 @@ func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
}
}
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
// accepted as the final answer exactly as before — guaranteeing no behavior
// change for non-headless callers.
// Issue #666: the plan-aware gate applies even when RequireCompletionSignal is
// off (interactive/TUI). A model that creates a plan then stalls on a
// continuation-cue text turn must be re-prompted and eventually INCOMPLETE, not
// accepted as success while the plan panel stays on step one.
func TestPlanPendingGateAppliesWithoutRequireCompletionSignal(t *testing.T) {
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("in_progress", "pending", "pending"),
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
}}
result, err := Run(context.Background(), "set up a git server", provider, Options{
Registry: registry,
MaxTurns: 10,
// RequireCompletionSignal deliberately off — interactive/TUI default.
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true (plan stalled without headless gate), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
}
if len(provider.requests) != 1+maxContinueNudges+1 {
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge (%q) to be injected", continueNudgeMarker)
}
}
// A pending plan must nudge non-colon continuation announcements too — not only
// colon-terminated cues — so the first mid-step text turn cannot finalize while
// steps remain. A later clean final answer is accepted after the bounded nudges.
func TestPlanPendingNudgesNonColonContinuationText(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
midStep := "Let me inspect the configuration."
final := "Provider profiles are loaded from config and merged with defaults."
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("in_progress", "pending", "pending"),
textTurn(midStep),
textTurn(final), textTurn(final), textTurn(final), textTurn(final),
}}
result, err := Run(context.Background(), "inspect and configure", provider, Options{
Registry: registry,
MaxTurns: 10,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("expected success after nudge + final answer, got Incomplete (%q)", result.IncompleteReason)
}
if result.FinalAnswer != final {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, final)
}
if len(provider.requests) != 1+1+maxContinueNudges {
t.Fatalf("expected %d turns (plan + mid-step + %d nudged finals), got %d",
1+1+maxContinueNudges, maxContinueNudges, len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge after the mid-step text with pending plan")
}
}
// After bounded nudges, a confident final answer with stale plan bookkeeping must
// still succeed on the interactive/TUI path (no infinite loop, no false incomplete).
func TestPlanPendingAcceptsFinalAnswerAfterBoundedNudgesWithoutHeadlessGate(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
done := "All provider profiles are documented above."
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("in_progress", "pending"),
textTurn(done), textTurn(done), textTurn(done), textTurn(done),
}}
result, err := Run(context.Background(), "explain provider loading", provider, Options{
Registry: registry,
MaxTurns: 10,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("stale plan alone must not force Incomplete on TUI path; reason=%q", result.IncompleteReason)
}
if result.FinalAnswer != done {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, done)
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected at least one continue nudge before accepting completion")
}
}
// With the gate OFF and NO pending plan, a continuation-cue turn is still
// accepted as the final answer — no behavior change for short single-step tasks.
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
cue := "Let me check the config:"
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
@@ -177,6 +276,44 @@ func TestContinuationCueMatching(t *testing.T) {
}
}
// Cancellation during a plan-pending stall must return immediately without extra
// nudges or incomplete synthesis.
func TestRunCancellationDuringPlanPendingStopsImmediately(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
ctx, cancel := context.WithCancel(context.Background())
_, err := Run(ctx, "do work", cancelMidStreamProvider{cancel: cancel}, Options{
Registry: registry,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
// Repeated identical update_plan calls must still hit the max-turns ceiling rather
// than looping silently forever.
func TestRunRepeatedIdenticalUpdatePlanHitsMaxTurns(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
same := toolTurn("plan", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{same, same, same, textTurn("halted summary")}}
result, err := Run(context.Background(), "keep planning", provider, Options{
Registry: registry,
MaxTurns: 3,
})
if err != nil {
t.Fatal(err)
}
// MaxTurns=3 tool turns, then finalAnswerAfterMaxTurns issues one more request.
if len(provider.requests) != 4 {
t.Fatalf("expected 3 tool turns + 1 max-turns final-answer call, got %d", len(provider.requests))
}
if result.FinalAnswer == "" {
t.Fatal("expected a max-turns final answer")
}
}
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
// never reaches the no-tool-call gate) was reported as success. Under the headless
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
+109 -22
View File
@@ -183,35 +183,112 @@ var successNegationTails = []string{
"reproduce", "spot any", "locate any",
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
func selfReportedIncompletion(text string) string {
lower := strings.ToLower(text)
for _, phrase := range selfReportPhrases {
if strings.Contains(lower, phrase) {
return selfReportReason(phrase)
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
// reporting the outcome of the current objective, so an inability admission in
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
// conversational recap ("You asked if I could work autonomously … I was honest
// that my sandbox had no repo … so I couldn't actually do it at the time") was
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
// current turn's objective (summarize where we left off) was fully met. Every
// marker is second-person or explicitly past-referential, so a first-person
// admission about the current task ("I couldn't complete the refactor") is
// never masked.
var narrativeMarkers = []string{
"you asked", "you said", "you wanted", "you mentioned",
"we discussed", "we talked", "we covered",
"when we last", "last time", "last session", "previous session",
"previous conversation", "earlier session", "earlier conversation",
"at the time", "back then",
}
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
// backticks, so an admission the model merely QUOTES — its own earlier message,
// a log line, an error string — cannot fire the detector. Only BALANCED spans
// are removed: an opening delimiter that never closes is kept as literal text,
// so a stray quote cannot swallow the rest of the message (and with it a
// genuine admission the detector must see). Single quotes are left alone: they
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
// delimiters would swallow the text between two contractions.
func stripQuoted(s string) string {
var b strings.Builder
var span strings.Builder // pending text since the open delimiter, kept if it never closes
open := rune(0)
for _, r := range s {
switch {
case open != 0:
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
open = 0
span.Reset()
continue
}
span.WriteRune(r)
case r == '"' || r == '“' || r == '`':
open = r
span.WriteRune(r)
default:
b.WriteRune(r)
}
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(lower[start:], stem)
if rel < 0 {
break
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
return b.String()
}
// admissionSentences splits lowered text into sentence-ish fragments so the
// detector judges each claim in its own context. Newlines split too (markdown
// bullets are separate claims); the exact boundaries only need to keep an
// admission next to its own narrative/negation context, not be grammatical.
func admissionSentences(lower string) []string {
return strings.FieldsFunc(lower, func(r rune) bool {
return r == '.' || r == '!' || r == '?' || r == '\n'
})
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
// Matching is per sentence, after dropping quoted spans, and a sentence that
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
// must be the model's own report about the CURRENT objective, not general
// language that merely resembles one.
func selfReportedIncompletion(text string) string {
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
if containsAny(sentence, narrativeMarkers) {
continue
}
for _, phrase := range selfReportPhrases {
if strings.Contains(sentence, phrase) {
return selfReportReason(phrase)
}
abs := start + rel
tail := strings.TrimSpace(lower[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(sentence[start:], stem)
if rel < 0 {
break
}
abs := start + rel
tail := strings.TrimSpace(sentence[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
}
start = abs + len(stem)
}
}
return ""
}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func selfReportReason(marker string) string {
return `the final message admits the objective was not met ("` + marker + `")`
}
@@ -431,9 +508,19 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
hasVisibleText := strings.TrimSpace(collected.Text) != ""
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
if hasToolCalls || hasVisibleText || hasReasoning {
productive := hasToolCalls || hasVisibleText
switch {
case productive:
state.emptyTurns = 0
} else {
case hasReasoning && state.planItemsPending > 0:
// Reasoning alone does not prove progress while plan steps remain — weaker
// models (e.g. HY3 via OpenAI-compatible gateways) can stream long
// reasoning-only turns without tools or visible text, burning the turn
// budget while the plan panel stays on the first in_progress step.
state.emptyTurns++
case hasReasoning:
state.emptyTurns = 0
default:
state.emptyTurns++
}
if hasToolCalls && !hasVisibleText {
+109 -1
View File
@@ -30,6 +30,16 @@ func reasoningTurn(content string) []zeroruntime.StreamEvent {
}
}
// multiReasoningTurn streams several reasoning chunks inside ONE provider turn.
func multiReasoningTurn(chunks ...string) []zeroruntime.StreamEvent {
events := make([]zeroruntime.StreamEvent, 0, len(chunks)+1)
for _, chunk := range chunks {
events = append(events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: chunk})
}
events = append(events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
return events
}
// toolTurn produces a turn that calls a named tool with the given args JSON.
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -109,6 +119,95 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
}
}
func TestRunStopsReasoningOnlyTurnsWhilePlanPending(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
reasoningTurn("thinking 1"),
reasoningTurn("thinking 2"),
reasoningTurn("thinking 3"),
textTurn("should never reach here"),
}}
result, err := Run(context.Background(), "multi-step task", provider, Options{
Registry: registry,
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != maxEmptyTurns+1 {
t.Fatalf("expected %d turns (1 plan + %d reasoning-only stalls), got %d", maxEmptyTurns+1, maxEmptyTurns, len(provider.requests))
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true after reasoning-only stall with pending plan, got false")
}
if !strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("expected no-output stop answer, got %q", result.FinalAnswer)
}
}
func TestRunMultiChunkReasoningCountsAsOneNonProductiveTurnWhilePlanPending(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
multiReasoningTurn("chunk-1 ", "chunk-2 ", "chunk-3 ", "chunk-4 ", "chunk-5 "),
multiReasoningTurn("more-1 ", "more-2 ", "more-3 "),
multiReasoningTurn("final-1 ", "final-2 "),
textTurn("should never reach here"),
}}
result, err := Run(context.Background(), "multi-step task", provider, Options{
Registry: registry,
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != maxEmptyTurns+1 {
t.Fatalf("expected %d turns (1 plan + %d multi-chunk reasoning stalls), got %d",
maxEmptyTurns+1, maxEmptyTurns, len(provider.requests))
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true, got false")
}
}
func TestRunReasoningThenToolCallSucceedsWithPendingPlan(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
multiReasoningTurn("thinking ", "more thinking "),
toolTurn("read", "read_file", `{"path":"notes.txt"}`),
toolTurn("plan2", "update_plan", `{"plan":[{"content":"step 1","status":"completed"},{"content":"step 2","status":"completed"}]}`),
textTurn("Both steps are done."),
}}
result, err := Run(context.Background(), "multi-step task", provider, Options{
Registry: registry,
MaxTurns: 12,
Cwd: root,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("run should succeed after productive tool call, got Incomplete (%q)", result.IncompleteReason)
}
if result.FinalAnswer != "Both steps are done." {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
}
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -293,7 +392,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
toolTurn("call-1", "update_plan", `{"plan":[{"content":"step one"}]}`),
toolTurn("call-1", "update_plan", `{"plan":[{"content":"step one","status":"completed"}]}`),
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`),
textTurn("done"),
},
@@ -329,6 +428,12 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
for i := 0; i < staleToolCallThreshold+2; i++ {
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
}
// The plan-aware completion gate re-prompts up to maxContinueNudges when the
// model stops with text while plan items remain; supply those turns before
// the final answer.
for i := 0; i < maxContinueNudges; i++ {
turns = append(turns, textTurn("done."))
}
turns = append(turns, textTurn("done"))
provider := &mockProvider{turns: turns}
@@ -363,6 +468,9 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
for i := 0; i < staleToolCallThreshold*2; i++ {
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
}
for i := 0; i < maxContinueNudges; i++ {
turns = append(turns, textTurn("done."))
}
turns = append(turns, textTurn("done"))
provider := &mockProvider{turns: turns}
+11 -5
View File
@@ -416,6 +416,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
result.FinalAnswer = noOutputStopAnswer(result.Turns)
if guards.pendingPlanItems() {
result.Incomplete = true
result.IncompleteReason = "stopped after non-productive turns while plan items remained"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -429,10 +433,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
continue
}
// Completion gate (headless): a turn with text but no tool call is the
// model's final answer ONLY when the work is actually done. Default off
// (RequireCompletionSignal), so interactive runs stay byte-identical.
if options.RequireCompletionSignal {
// Completion gate: a turn with text but no tool call is the model's
// final answer ONLY when the work is actually done. Headless exec opts in
// via RequireCompletionSignal; interactive runs also apply the gate while
// update_plan still has pending/in_progress items so a plan cannot stall
// silently after the first step is marked active.
planPending := guards.pendingPlanItems()
if options.RequireCompletionSignal || planPending {
// (1) Self-report downgrade (strongest, unambiguous): the model's own
// final message admits it guessed / could not meet the objective. Checked
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
@@ -455,7 +462,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// false-fail a completed run with stale bookkeeping) — fall through to the
// acceptance check / success.
cue := endsWithContinuationCue(collected.Text)
planPending := guards.pendingPlanItems()
if cue || planPending {
if continueNudges < maxContinueNudges {
continueNudges++
+18
View File
@@ -2947,6 +2947,24 @@ func TestGitBranchForPromptResolvesRelativeWorktreeGitdir(t *testing.T) {
}
}
func TestGitBranchForPromptInSubdirectory(t *testing.T) {
root := t.TempDir()
gitdir := filepath.Join(root, ".git")
if err := os.MkdirAll(gitdir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gitdir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
subdir := filepath.Join(root, "sub", "dir")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
if got := gitBranchForPrompt(subdir); got != "main" {
t.Fatalf("gitBranchForPrompt in subdirectory = %q, want main", got)
}
}
func TestBuildSystemPromptInjectsWorkspaceContext(t *testing.T) {
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Always run `make lint` before committing."), 0o644); err != nil {
+12 -8
View File
@@ -295,7 +295,7 @@ func workspaceContext(cwd string) string {
}
b.WriteString("</environment>")
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
b.WriteString(projectGuidelines(cwd, FindProjectGitRoot(cwd)))
if repoMap := repoMapContext(cwd); repoMap != "" {
b.WriteString("\n\n## Repo map\n\n" + repoMap)
}
@@ -542,17 +542,17 @@ func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
return cur, true
}
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
// FindProjectGitRoot returns the nearest ancestor of cwd that contains a
// .git entry (file or directory). Returns "" when no git root is found, so
// the caller can fall back to cwd-only lookup.
func findProjectGitRoot(cwd string) string {
func FindProjectGitRoot(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
cur := cwd
for {
if hasGitMetadata(cur) {
if HasGitMetadata(cur) {
return cur
}
parent := filepath.Dir(cur)
@@ -563,7 +563,7 @@ func findProjectGitRoot(cwd string) string {
}
}
func hasGitMetadata(dir string) bool {
func HasGitMetadata(dir string) bool {
gitPath := filepath.Join(dir, ".git")
info, err := os.Stat(gitPath)
if err != nil {
@@ -619,7 +619,11 @@ func repoMapContext(cwd string) string {
// cwd, handling both a regular checkout (.git dir) and a worktree (.git file).
// Returns "" on any problem — the prompt simply omits the branch segment.
func gitBranchForPrompt(cwd string) string {
gitPath := filepath.Join(cwd, ".git")
gitRoot := FindProjectGitRoot(cwd)
if gitRoot == "" {
return ""
}
gitPath := filepath.Join(gitRoot, ".git")
info, err := os.Stat(gitPath)
if err != nil {
return ""
@@ -635,10 +639,10 @@ func gitBranchForPrompt(cwd string) string {
return ""
}
// In worktree mode the gitdir is often RELATIVE (e.g.
// "gitdir: ../.git/worktrees/<name>") — resolve it against cwd, not the
// "gitdir: ../.git/worktrees/<name>") — resolve it against the worktree root (gitRoot), not the
// process working directory, or HEAD lookup fails and we drop the branch.
if !filepath.IsAbs(dir) {
dir = filepath.Join(cwd, dir)
dir = filepath.Join(gitRoot, dir)
}
headPath = filepath.Join(dir, "HEAD")
}
+2 -2
View File
@@ -424,7 +424,7 @@ func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
if err := os.MkdirAll(leaf, 0o755); err != nil {
t.Fatal(err)
}
if got := findProjectGitRoot(leaf); got != "" {
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
if got := FindProjectGitRoot(leaf); got != "" {
t.Fatalf("FindProjectGitRoot = %q, want empty for invalid .git directory", got)
}
}
+7 -7
View File
@@ -296,13 +296,13 @@ type Options struct {
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
// work clearly remains — pending update_plan items, or a message that ends on a
// continuation cue ("…Let me check the config:"). The loop then nudges the
// model to continue instead, bounded by maxContinueNudges (and still by
// MaxTurns and the run deadline); if the model keeps stalling, the run
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
// final answer — except while update_plan still has pending/in_progress items,
// when the plan-aware completion gate always applies (interactive and headless).
// With RequireCompletionSignal, a no-tool-call turn is also gated on
// continuation cues ("…Let me check the config:"). The loop nudges the model to
// continue instead, bounded by maxContinueNudges (and still by MaxTurns and the
// run deadline); if the model keeps stalling, the run finalizes as INCOMPLETE
// (Result.Incomplete) rather than success.
RequireCompletionSignal bool
runPermissions *permissionRunState
+14 -1
View File
@@ -5,6 +5,7 @@ package background
import (
"os"
"os/exec"
"path/filepath"
"strconv"
)
@@ -14,7 +15,8 @@ import (
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
func terminateProcess(pid int) error {
if err := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
taskkill := taskkillPath()
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
@@ -23,3 +25,14 @@ func terminateProcess(pid int) error {
}
return process.Kill()
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
+2
View File
@@ -1347,6 +1347,8 @@ Flags:
--notify <off|bell|notify|both>
Override notification mode for this run
--no-notify Disable notifications for this run
--no-completion-gate Accept a no-tool-call reply as final without the INCOMPLETE
downgrade (for conversational callers with an operator present)
`)
return err
}
+5 -4
View File
@@ -211,11 +211,12 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
return job, nil
}
if current.Status == cron.StatusPaused {
// Honor an external pause that landed while the job ran.
job.Status = cron.StatusPaused
current.FireCount = job.FireCount
current.NextRunAt = job.NextRunAt
if current.Status != cron.StatusPaused {
current.Status = job.Status
}
return job, nil
return current, nil
})
if errors.Is(err, cron.ErrJobNotFound) {
// Genuinely removed mid-run: don't recreate it (no run record, no persist).
+14 -2
View File
@@ -110,6 +110,16 @@ type execOptions struct {
notifyMode string
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
noNotify bool
// noCompletionGate turns off the headless completion gate
// (agent.Options.RequireCompletionSignal) for this run. It exists for
// CONVERSATIONAL exec callers — a chat frontend driving exec with an operator
// present to reply (e.g. zeroclaw chat). There, a final message that hands
// the turn back ("the sandbox blocks network egress, approve it and I'll
// continue") is a COMPLETE answer, not an unfinished task, so the gate's
// self-report admission check and INCOMPLETE downgrade (exit 4) would only
// mislabel honest blocker reports. Default off: plain `zero exec` keeps the
// gate, preserving CI/cron semantics.
noCompletionGate bool
// addDirs holds directories passed via --add-dir that should be allowed as
// additional write roots for this run. Unioned with
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
@@ -556,8 +566,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
// if the model keeps stalling. The interactive TUI leaves this off.
RequireCompletionSignal: true,
// if the model keeps stalling. The interactive TUI leaves this off, and
// --no-completion-gate lets conversational exec callers (a chat frontend
// with an operator present) opt out the same way.
RequireCompletionSignal: !options.noCompletionGate,
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
+50
View File
@@ -30,6 +30,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
options.selfCorrect = true
case arg == "--no-notify":
options.noNotify = true
case arg == "--no-completion-gate":
options.noCompletionGate = true
case arg == "--notify":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -402,6 +404,11 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
// rather than pretend it took effect.
return options, false, execUsageError{"--self-correct cannot be combined with --use-spec."}
}
if options.useSpec && options.noCompletionGate {
// Same reasoning as --self-correct above: the spec-draft path never consults
// the completion gate, so the flag would be silently ignored.
return options, false, execUsageError{"--no-completion-gate cannot be combined with --use-spec."}
}
if !options.useSpec && options.specModel != "" {
return options, false, execUsageError{"--spec-model requires --use-spec."}
}
@@ -461,6 +468,49 @@ func nextFlagValue(args []string, index int, flag string) (string, int, error) {
if next == "" || flagValueLooksLikeOption(next) {
return "", index, execUsageError{fmt.Sprintf("%s requires a value", flag)}
}
// Validate specific flag values to prevent consuming positional prompt text
switch flag {
case "--auto":
switch strings.ToLower(next) {
case "low", "medium", "high", "member":
default:
return "", index, execUsageError{fmt.Sprintf("Invalid autonomy level %q. Expected low, medium, or high.", next)}
}
case "--notify":
switch strings.ToLower(next) {
case "off", "bell", "notify", "both":
default:
return "", index, execUsageError{fmt.Sprintf("invalid --notify %q. Expected off, bell, notify, or both.", next)}
}
case "-o", "--output":
switch strings.ToLower(next) {
case "text", "json", "stream-json", "debug":
default:
return "", index, execUsageError{fmt.Sprintf("invalid output format %q. Expected text, json, stream-json, or debug.", next)}
}
case "-i", "--input":
switch strings.ToLower(next) {
case "text", "stream-json":
default:
return "", index, execUsageError{fmt.Sprintf("Invalid input format %q. Expected text or stream-json.", next)}
}
case "--reasoning-effort", "--spec-reasoning-effort":
switch strings.ToLower(next) {
case "low", "medium", "high":
default:
return "", index, execUsageError{fmt.Sprintf("invalid %s %q. Expected low, medium, or high.", flag, next)}
}
case "--depth":
if _, err := strconv.Atoi(next); err != nil {
return "", index, execUsageError{fmt.Sprintf("invalid --depth %q. Expected a non-negative integer.", next)}
}
case "--max-turns":
if _, err := strconv.Atoi(next); err != nil {
return "", index, execUsageError{fmt.Sprintf("invalid --max-turns %q. Expected a positive integer.", next)}
}
}
return next, index + 1, nil
}
@@ -0,0 +1,34 @@
package cli
import "testing"
// --no-completion-gate exists for conversational exec callers (a chat frontend
// with an operator present): an honest blocker report that hands the turn back
// must not be downgraded to INCOMPLETE there. The flag maps to
// agent.Options.RequireCompletionSignal = false; default keeps the gate on.
func TestParseExecArgsNoCompletionGate(t *testing.T) {
options, _, err := parseExecArgs([]string{"--prompt", "hi", "--no-completion-gate"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if !options.noCompletionGate {
t.Fatal("--no-completion-gate must set noCompletionGate")
}
options, _, err = parseExecArgs([]string{"--prompt", "hi"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if options.noCompletionGate {
t.Fatal("noCompletionGate must default to false (gate on)")
}
}
// The spec-draft path never consults the completion gate, so the combination
// must be rejected up front instead of silently ignoring the flag (mirrors the
// existing --self-correct + --use-spec check).
func TestParseExecArgsNoCompletionGateRejectsUseSpec(t *testing.T) {
if _, _, err := parseExecArgs([]string{"--prompt", "hi", "--use-spec", "--no-completion-gate"}); err == nil {
t.Fatal("--no-completion-gate with --use-spec must error")
}
}
+17
View File
@@ -21,3 +21,20 @@ func TestParseExecArgsInlinePromptKeepsLeadingDash(t *testing.T) {
t.Fatalf("inline prompt = %q, want %q", got, "-foo bar")
}
}
func TestParseExecArgsPreventsPositionalFromBeingConsumedAsFlagValue(t *testing.T) {
_, _, err := parseExecArgs([]string{"--auto", "do a build"})
if err == nil || !strings.Contains(err.Error(), "Invalid autonomy level") {
t.Fatalf("expected error that --auto has invalid autonomy level, got: %v", err)
}
_, _, err = parseExecArgs([]string{"--notify", "do a build"})
if err == nil || !strings.Contains(err.Error(), "invalid --notify") {
t.Fatalf("expected error that --notify is invalid, got: %v", err)
}
_, _, err = parseExecArgs([]string{"--max-turns", "do a build"})
if err == nil || !strings.Contains(err.Error(), "invalid --max-turns") {
t.Fatalf("expected error that --max-turns is invalid, got: %v", err)
}
}
+2 -2
View File
@@ -345,8 +345,8 @@ func TestRunExecModeSeedsModelAndTurnOverrides(t *testing.T) {
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)
if gotMaxTurns != 160 {
t.Fatalf("overrides.MaxTurns = %d, want 160 (deep mode)", gotMaxTurns)
}
}
+2 -2
View File
@@ -86,7 +86,7 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := store.Save(serverName, token); err != nil {
if err := store.SaveForServer(server, token); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -116,7 +116,7 @@ func runMCPOAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps a
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
removed, err := store.Delete(serverName)
removed, err := store.DeleteForServerName(serverName)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
+34 -17
View File
@@ -88,6 +88,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
if err := store.Save("remote", mcp.StoredToken{AccessToken: "a", RefreshToken: "r"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
if err := store.SaveForServer(mcp.Server{Name: "remote", Identity: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, mcp.StoredToken{AccessToken: "identity", RefreshToken: "ir"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
deps := appDeps{newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil }}
var stdout, stderr bytes.Buffer
@@ -101,6 +104,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
if _, ok, _ := store.Load("remote"); ok {
t.Fatal("token still present after logout")
}
if statuses, err := store.Status(); err != nil || len(statuses) != 0 {
t.Fatalf("status after logout = %#v err=%v, want no tokens", statuses, err)
}
}
func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
@@ -122,23 +128,24 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
}
cwd := t.TempDir()
mcpConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: tokenServer.URL,
Scopes: []string{"read"},
},
},
}}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: tokenServer.URL,
Scopes: []string{"read"},
},
},
}}, nil
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return mcpConfig, nil
},
now: time.Now,
}
@@ -173,9 +180,13 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
}
token, ok, err := store.Load("remote")
servers, err := mcp.NormalizeConfig(mcpConfig)
if err != nil || len(servers) != 1 {
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
}
token, ok, err := store.LoadForServer(servers[0])
if err != nil || !ok {
t.Fatalf("Load() ok=%v err=%v", ok, err)
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
}
if token.AccessToken != "access-final" || token.RefreshToken != "refresh-final" {
t.Fatalf("stored token = %#v", token)
@@ -483,7 +494,13 @@ func TestRunMCPOAuthLoginTrustedProjectServerStoresToken(t *testing.T) {
if gotExclude {
t.Fatal("trusted workspace must resolve MCP config with excludeProject=false")
}
token, ok, err := store.Load("remote")
servers, err := mcp.NormalizeConfig(config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": projectServer,
}})
if err != nil || len(servers) != 1 {
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
}
token, ok, err := store.LoadForServer(servers[0])
if err != nil || !ok {
t.Fatalf("token must be stored on trusted login; ok=%v err=%v", ok, err)
}
+14 -2
View File
@@ -483,6 +483,14 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Point HOME at an empty directory so the default credential-store
// deny-read entries (which depend on what exists in the real home, e.g.
// ~/.aws on the macOS CI image) cannot leak host paths into the golden
// comparison.
emptyHome := t.TempDir()
t.Setenv("HOME", emptyHome)
t.Setenv("USERPROFILE", emptyHome)
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
@@ -616,8 +624,12 @@ func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
func normalizePortableJSONRootString(value *any) {
switch current := (*value).(type) {
case string:
if current == `\` {
*value = "/"
// Normalize OS-specific path separators so the shared golden file
// matches on both Unix (slash) and Windows (backslash) runners.
// readOnlySubpaths carve-outs are built with filepath.Join, which
// renders as backslash paths on Windows.
if current != "" {
*value = strings.ReplaceAll(current, `\`, "/")
}
case []any:
for index := range current {
@@ -40,8 +40,11 @@
"writeRoots": [
{
"root": "$WORKSPACE",
"readOnlySubpaths": [
"$WORKSPACE/.git/hooks",
"$WORKSPACE/.git/config"
],
"protectedMetadataNames": [
".git",
".zero",
".agents"
]
+168
View File
@@ -0,0 +1,168 @@
package config
import (
"fmt"
"slices"
"strings"
)
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
}
}
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
if len(src.Servers) == 0 {
return nil
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
base := dst.Servers[name]
candidate := mergeMCPServer(base, server)
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
}
candidate.ProjectConfigured = true
dst.Servers[name] = candidate
}
return nil
}
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
if strings.TrimSpace(next.Type) != "" {
base.Type = next.Type
}
if strings.TrimSpace(next.Command) != "" {
base.Command = next.Command
}
if next.Args != nil {
base.Args = append([]string{}, next.Args...)
}
if next.Env != nil {
base.Env = copyMCPStringMap(next.Env)
}
if strings.TrimSpace(next.URL) != "" {
base.URL = next.URL
}
if next.Headers != nil {
base.Headers = copyMCPStringMap(next.Headers)
}
if strings.TrimSpace(next.Auth) != "" {
base.Auth = next.Auth
}
if next.OAuth != nil {
base.OAuth = next.OAuth
}
if next.disabledSet || next.Disabled {
base.Disabled = next.Disabled
}
if next.configured {
base.configured = true
}
if next.ProjectConfigured {
base.ProjectConfigured = true
}
return base
}
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
return true
}
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
return true
}
if strings.TrimSpace(next.Command) != "" && strings.TrimSpace(next.Command) != strings.TrimSpace(base.Command) {
return true
}
if next.Args != nil && !slices.Equal(trimMCPArgs(next.Args), trimMCPArgs(base.Args)) {
return true
}
return false
}
func mcpServerTransportKind(server MCPServerConfig) string {
if typ := strings.TrimSpace(strings.ToLower(server.Type)); typ != "" {
return typ
}
if strings.TrimSpace(server.URL) != "" {
return "http"
}
return "stdio"
}
func hasInheritedMCPCredentialMaterial(project MCPServerConfig, candidate MCPServerConfig) bool {
if project.Headers == nil && hasMCPStringMapMaterial(candidate.Headers) {
return true
}
if project.Env == nil && hasMCPStringMapMaterial(candidate.Env) {
return true
}
if project.OAuth == nil && hasMCPOAuthMaterial(candidate.OAuth) {
return true
}
return false
}
func hasMCPStringMapMaterial(values map[string]string) bool {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
return false
}
func hasMCPOAuthMaterial(oauth *MCPOAuthConfig) bool {
if oauth == nil {
return false
}
if strings.TrimSpace(oauth.ClientID) != "" ||
strings.TrimSpace(oauth.ClientSecret) != "" ||
strings.TrimSpace(oauth.AuthorizationEndpoint) != "" ||
strings.TrimSpace(oauth.TokenEndpoint) != "" ||
strings.TrimSpace(oauth.RegistrationEndpoint) != "" ||
strings.TrimSpace(oauth.IssuerURL) != "" {
return true
}
for _, scope := range oauth.Scopes {
if strings.TrimSpace(scope) != "" {
return true
}
}
return false
}
func trimMCPArgs(values []string) []string {
if values == nil {
return nil
}
trimmed := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
trimmed = append(trimmed, value)
}
}
return trimmed
}
func copyMCPStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
copied[key] = value
}
return copied
}
+201
View File
@@ -0,0 +1,201 @@
package config
import (
"strings"
"testing"
)
func TestResolveRejectsProjectMCPURLOverrideWithInheritedHeaders(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {"url": "https://attacker.example/mcp"}
}
}
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want inherited MCP credential rejection")
}
if strings.Contains(err.Error(), "user-secret") {
t.Fatalf("error leaked header secret: %q", err.Error())
}
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
}
}
func TestResolveMCPRejectsProjectURLOverrideWithInheritedHeaders(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcpServers": {
"docs": {"url": "https://attacker.example/mcp"}
}
}`)
_, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
if err == nil {
t.Fatal("ResolveMCP() error = nil, want inherited MCP credential rejection")
}
if strings.Contains(err.Error(), "user-secret") {
t.Fatalf("error leaked header secret: %q", err.Error())
}
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
}
}
func TestResolveRejectsProjectMCPStdioOverrideWithInheritedEnv(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--user"],
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {"args": ["--project"]}
}
}
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want inherited MCP env rejection")
}
if strings.Contains(err.Error(), "user-secret") {
t.Fatalf("error leaked env secret: %q", err.Error())
}
}
func TestResolveAllowsProjectMCPTargetOverrideWhenCredentialsCleared(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"web": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
},
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--user"],
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"web": {"url": "https://project.example/mcp", "headers": {}},
"docs": {"args": ["--project"], "env": {}}
}
}
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
web := resolved.MCP.Servers["web"]
if web.URL != "https://project.example/mcp" || len(web.Headers) != 0 || !web.ProjectConfigured {
t.Fatalf("web = %#v, want project URL, cleared headers, project marker", web)
}
docs := resolved.MCP.Servers["docs"]
if got := strings.Join(docs.Args, " "); got != "--project" {
t.Fatalf("docs.Args = %q, want --project", got)
}
if len(docs.Env) != 0 || !docs.ProjectConfigured {
t.Fatalf("docs = %#v, want cleared env and project marker", docs)
}
}
func TestResolveAllowsTrustedMCPOverridesToInheritCredentials(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "http",
"url": "https://trusted.example/mcp",
"headers": {"Authorization": "Bearer user-secret"}
}
}
}
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
Env: map[string]string{},
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{
"docs": {URL: "https://cli.example/mcp"},
}}},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
docs := resolved.MCP.Servers["docs"]
if docs.URL != "https://cli.example/mcp" || docs.Headers["Authorization"] != "Bearer user-secret" || docs.ProjectConfigured {
t.Fatalf("docs = %#v, want trusted override to inherit headers without project marker", docs)
}
}
func TestResolveMCPProjectArgsTargetCompareUsesNormalizedArgs(t *testing.T) {
userPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--port", "7777"],
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
}
}
}
}`)
projectPath := writeConfig(t, `{
"mcp": {
"servers": {
"docs": {"args": [" --port ", "7777", ""]}
}
}
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if !resolved.MCP.Servers["docs"].ProjectConfigured {
t.Fatalf("docs = %#v, want project marker", resolved.MCP.Servers["docs"])
}
}
+15 -1
View File
@@ -3,7 +3,9 @@
package config
import (
"os"
"os/exec"
"path/filepath"
"strconv"
)
@@ -13,6 +15,18 @@ func terminateCommandProcess(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
taskkill := taskkillPath()
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
_ = cmd.Process.Kill()
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
+21 -71
View File
@@ -42,8 +42,10 @@ func (e *setupFixableError) Unwrap() []error { return []error{e.err, e.sentinel}
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
// too low for real multi-step agentic work (agents ran out mid-task before reaching
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
const defaultMaxTurns = 50
// later steps); 50 was still too low for larger tasks spanning several files (agents
// hit the ceiling and stopped with a "remaining work" summary instead of finishing).
// Raise per-session with /turns.
const defaultMaxTurns = 80
// MaxTurnsCeiling caps the per-run tool-turn budget so a stray env value or typo
// can't set an absurd ceiling. Shared between applyEnv (read site) and the /turns
@@ -163,22 +165,25 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
// can override any field or disable a default by writing over it.
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
if path == "" {
continue
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) stdio MCP servers. Fail-closed:
// only a trusted workspace clears ExcludeProject. Defaults and user config still load.
if options.ExcludeProject && path == options.ProjectConfigPath {
continue
}
fileConfig, err := loadConfigFile(path)
if options.UserConfigPath != "" {
fileConfig, err := loadConfigFile(options.UserConfigPath)
if err != nil {
return MCPConfig{}, err
}
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
}
// Drop the project layer when the workspace is untrusted, so a cloned repo's
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
// only a trusted workspace clears ExcludeProject. Defaults and user config still load.
if options.ProjectConfigPath != "" && !options.ExcludeProject {
fileConfig, err := loadConfigFile(options.ProjectConfigPath)
if err != nil {
return MCPConfig{}, err
}
if err := mergeProjectMCPConfig(&cfg.MCP, fileConfig.MCP); err != nil {
return MCPConfig{}, err
}
}
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
return cfg.MCP, nil
}
@@ -261,7 +266,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
}
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP)
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
return err
}
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
@@ -834,63 +841,6 @@ func validateSTTConfig(cfg STTConfig) error {
return nil
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
}
}
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
if strings.TrimSpace(next.Type) != "" {
base.Type = next.Type
}
if strings.TrimSpace(next.Command) != "" {
base.Command = next.Command
}
if next.Args != nil {
base.Args = append([]string{}, next.Args...)
}
if next.Env != nil {
base.Env = copyMCPStringMap(next.Env)
}
if strings.TrimSpace(next.URL) != "" {
base.URL = next.URL
}
if next.Headers != nil {
base.Headers = copyMCPStringMap(next.Headers)
}
if strings.TrimSpace(next.Auth) != "" {
base.Auth = next.Auth
}
if next.OAuth != nil {
base.OAuth = next.OAuth
}
if next.disabledSet || next.Disabled {
base.Disabled = next.Disabled
}
if next.configured {
base.configured = true
}
return base
}
func copyMCPStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
copied[key] = value
}
return copied
}
func hasProviderFields(profile ProviderProfile) bool {
return HasProviderProfile(profile)
}
+13 -10
View File
@@ -440,16 +440,19 @@ type MCPConfig struct {
}
type MCPServerConfig struct {
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled bool `json:"disabled,omitempty"`
disabledSet bool
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
Disabled bool `json:"disabled,omitempty"`
// ProjectConfigured marks servers touched by project config. It is runtime
// metadata, not persisted config.
ProjectConfigured bool `json:"-"`
disabledSet bool
// configured is true when the user's config JSON declared an object for
// this server at all (i.e. UnmarshalJSON ran for it), regardless of which
// fields it set or what values they hold. A built-in default seeded by
+235
View File
@@ -0,0 +1,235 @@
package config
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
)
// unknownFieldIssues scans data for JSON object keys that do not correspond to
// any known config field, recursing into nested objects, arrays, and maps so
// that typos such as "maxTurn" or "sandbox.network" are surfaced with
// their full path instead of being silently dropped by json.Unmarshal.
//
// The two legacy top-level aliases (mcpServers, mcp_servers) are explicitly
// allowed because FileConfig.UnmarshalJSON still reads them; nothing else is
// grandfathered, so a typo in a current or future field name is reported.
// Detection only inspects the documented JSON schema (struct json tags), so a
// config written by a newer Zero that carries genuinely new fields is still
// loaded and merged normally — only validate/doctor call this, and they
// surface the unknown keys as issues rather than rejecting the file.
func unknownFieldIssues(data []byte) []Issue {
var issues []Issue
collectUnknownFields(reflect.TypeOf(FileConfig{}), data, "", []string{"mcpServers", "mcp_servers"}, &issues)
return issues
}
func collectUnknownFields(t reflect.Type, raw json.RawMessage, path string, allow []string, issues *[]Issue) {
t = derefType(t)
switch t.Kind() {
case reflect.Struct:
var present map[string]json.RawMessage
if err := json.Unmarshal(raw, &present); err != nil {
return
}
known := knownJSONFields(t)
allowSet := toSet(allow)
for key, val := range present {
// JSON key matching is case-insensitive, so compare against a
// lower-cased key. A differently-cased but valid key (e.g.
// "MaxTurns") is still parsed by json and must not be flagged.
low := strings.ToLower(key)
if allowSet[low] {
continue
}
if kf, ok := known[low]; ok {
// Alias keys (legacy snake_case forms accepted by a
// custom UnmarshalJSON) have no Go type to recurse into;
// they are leaves, so skip descent rather than panic.
if kf.typ != nil {
collectUnknownFields(kf.typ, val, joinPath(path, key), nil, issues)
}
} else {
*issues = append(*issues, Issue{
FieldPath: joinPath(path, key),
Message: unknownFieldMessage(joinPath(path, key), key, canonicalKeys(known)),
})
}
}
case reflect.Slice, reflect.Array:
var elems []json.RawMessage
if err := json.Unmarshal(raw, &elems); err != nil {
return
}
for i, el := range elems {
collectUnknownFields(t.Elem(), el, fmt.Sprintf("%s[%d]", path, i), nil, issues)
}
case reflect.Map:
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return
}
for k, v := range m {
collectUnknownFields(t.Elem(), v, joinPath(path, k), nil, issues)
}
}
}
// legacyJSONAliases lists JSON keys a struct's custom UnmarshalJSON
// accepts in addition to its declared (camelCase) json tags. Such keys
// are local fields inside the UnmarshalJSON raw struct, not on the Go
// type, so the reflection scan would otherwise flag valid legacy configs
// (e.g. base_url, api_key) as unknown. Keep this in sync with each
// UnmarshalJSON's raw struct.
var legacyJSONAliases = map[string][]string{
"config.ProviderProfile": {
"providerKind", "catalog_id", "base_url", "api_key", "api_key_env",
"api_key_stored", "api_format", "auth_header", "auth_scheme",
"auth_header_value", "custom_headers", "model_id", "parse_think_tags",
},
}
// knownJSONFields returns the JSON field names of a struct (and their Go
// types, for recursion) by reading each field's json tag. JSON key
// matching is case-insensitive, so keys are stored lower-cased and
// compared lower-cased; canonical preserves the documented casing for
// the "did you mean" hint. Fields without a json tag (unexported
// helpers such as *Set markers) are omitted: they never appear in
// serialized config and must not be treated as valid keys.
func knownJSONFields(t reflect.Type) map[string]knownField {
t = derefType(t)
out := make(map[string]knownField, t.NumField())
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
tag := f.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name := strings.Split(tag, ",")[0]
if name == "" {
continue
}
low := strings.ToLower(name)
out[low] = knownField{canonical: name, typ: f.Type}
}
for _, alias := range legacyJSONAliases[t.String()] {
// No Go type to recurse into: legacy aliases are leaf fields.
// canonical preserves the documented casing for the hint.
out[strings.ToLower(alias)] = knownField{canonical: alias}
}
return out
}
// knownField is one entry of the known-key set: canonical is the
// documented (correct-case) JSON name, typ is the Go field type used
// for recursion (nil for leaf/alias keys).
type knownField struct {
canonical string
typ reflect.Type
}
func derefType(t reflect.Type) reflect.Type {
for t != nil && t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t
}
func joinPath(path, key string) string {
if path == "" {
return key
}
return path + "." + key
}
func toSet(items []string) map[string]bool {
s := make(map[string]bool, len(items))
for _, it := range items {
// JSON key matching is case-insensitive.
s[strings.ToLower(it)] = true
}
return s
}
func canonicalKeys(m map[string]knownField) []string {
out := make([]string, 0, len(m))
for _, kf := range m {
out = append(out, kf.canonical)
}
return out
}
func unknownFieldMessage(fullPath, key string, candidates []string) string {
if near := nearestKey(key, candidates); near != "" {
return fmt.Sprintf("unknown config field %q; did you mean %q?", fullPath, suggestPath(fullPath, near))
}
return fmt.Sprintf("unknown config field %q", fullPath)
}
// suggestPath rewrites only the leaf of fullPath with the suggested key so the
// hint reads "sandbox.network" rather than just "network".
func suggestPath(fullPath, suggestedLeaf string) string {
if i := strings.LastIndex(fullPath, "."); i >= 0 {
return fullPath[:i+1] + suggestedLeaf
}
return suggestedLeaf
}
func nearestKey(key string, candidates []string) string {
// Sort so map-range nondeterminism cannot change which candidate
// wins a distance tie: the lexically-first winner is stable.
sort.Strings(candidates)
best := ""
bestDist := -1
for _, c := range candidates {
d := editDistance(key, c)
if bestDist == -1 || d < bestDist {
bestDist = d
best = c
}
}
if bestDist >= 0 && bestDist <= 2 && bestDist < len(key) {
return best
}
return ""
}
func editDistance(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
cur := make([]int, lb+1)
cur[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[lb]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}
+2
View File
@@ -31,6 +31,7 @@ func ValidateFile(path string) (FileConfig, []Issue) {
}
issues := validateSemantics(cfg)
issues = append(issues, unknownFieldIssues(data)...)
return cfg, issues
}
@@ -46,6 +47,7 @@ func ValidateBytes(data []byte) (FileConfig, []Issue) {
return FileConfig{}, []Issue{{Message: fmt.Errorf("invalid config JSON: %w", err).Error()}}
}
issues := validateSemantics(cfg)
issues = append(issues, unknownFieldIssues(data)...)
return cfg, issues
}
+137
View File
@@ -122,3 +122,140 @@ func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
t.Fatalf("expected no issues for valid config, got %#v", issues)
}
}
func TestValidateFileFlagsUnknownTopLevelKey(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"maxTurn": 1
}`)
_, issues := ValidateFile(path)
if len(issues) == 0 {
t.Fatalf("expected unknown-field issue for top-level typo, got none")
}
var got Issue
found := false
for _, issue := range issues {
if issue.FieldPath == "maxTurn" {
got, found = issue, true
}
}
if !found {
t.Fatalf("expected unknown-field issue at maxTurn, got %#v", issues)
}
if !strings.Contains(got.Message, `did you mean "maxTurns"`) {
t.Fatalf("expected near-match suggestion, got %q", got.Message)
}
}
func TestValidateFileFlagsUnknownNestedKey(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"sandbox": {
"network": "allow",
"blockUnixSocket": true
}
}`)
_, issues := ValidateFile(path)
var got Issue
found := false
for _, issue := range issues {
if issue.FieldPath == "sandbox.blockUnixSocket" {
got, found = issue, true
}
}
if !found {
t.Fatalf("expected nested unknown-field issue at sandbox.blockUnixSocket, got %#v", issues)
}
if !strings.Contains(got.Message, `did you mean "sandbox.blockUnixSockets"`) {
t.Fatalf("expected near-match suggestion, got %q", got.Message)
}
}
func TestValidateFileFlagsUnknownInProvider(t *testing.T) {
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1", "endpoint": "https://example.test/v1"}
]
}`)
_, issues := ValidateFile(path)
var found bool
for _, issue := range issues {
if issue.FieldPath == "providers[0].endpoint" {
found = true
}
}
if !found {
t.Fatalf("expected unknown-field issue for providers[0].endpoint, got %#v", issues)
}
}
func TestValidateFileAllowsLegacyAliases(t *testing.T) {
// The legacy mcpServers / mcp_servers aliases are still read by
// FileConfig.UnmarshalJSON and must not be reported as unknown.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"mcpServers": {
"docs": {"type": "stdio", "command": "docs-mcp"}
}
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.FieldPath, "mcpServers") {
t.Fatalf("legacy alias mcpServers reported as unknown: %#v", issues)
}
}
}
func TestValidateFileAllowsCaseVariantKey(t *testing.T) {
// encoding/json matches object keys case-insensitively, so "MaxTurns"
// parses into MaxTurns. The scanner compares lower-cased keys,
// so a valid case variant must not be flagged as unknown.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai", "model": "gpt-4.1"}
],
"MaxTurns": 1
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "unknown config field") {
t.Fatalf("valid case-variant key flagged as unknown: %#v", issues)
}
}
}
func TestValidateFileAllowsLegacyProviderKeys(t *testing.T) {
// ProviderProfile.UnmarshalJSON accepts snake_case legacy keys
// (base_url, api_key, providerKind, ...). These are valid and
// must not be flagged as unknown — only the canonical camelCase
// tags are visible to the reflection scan, so they are allowlisted.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "providerKind": "openai", "base_url": "https://example.test/v1", "api_key": "sk-x", "model": "gpt-4.1"}
]
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "unknown config field") {
t.Fatalf("legacy provider key reported as unknown: %#v", issues)
}
}
}
+4 -4
View File
@@ -28,7 +28,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
Model: "gpt-4.1",
},
Registry: registry,
ContextWindow: 10_000,
ContextWindow: 20_000,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
@@ -46,8 +46,8 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
if report.ProviderName != "openai" || report.ModelID != "gpt-4.1" || report.APIModel != "gpt-4.1" {
t.Fatalf("provider metadata = %#v", report)
}
if report.ContextWindow != 10_000 {
t.Fatalf("ContextWindow = %d, want 10000", report.ContextWindow)
if report.ContextWindow != 20_000 {
t.Fatalf("ContextWindow = %d, want 20000", report.ContextWindow)
}
if report.ProjectGuidelineFile != "AGENTS.md" {
t.Fatalf("ProjectGuidelineFile = %q, want AGENTS.md", report.ProjectGuidelineFile)
@@ -59,7 +59,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
t.Fatalf("UsedTokens = %d, want > 0", report.UsedTokens)
}
if report.FreeTokens <= 0 {
t.Fatalf("FreeTokens = %d, want > 0", report.FreeTokens)
t.Fatalf("FreeTokens = %d, UsedTokens = %d, ContextWindow = %d, want > 0", report.FreeTokens, report.UsedTokens, report.ContextWindow)
}
if report.FreeTokens+report.UsedTokens != report.ContextWindow {
t.Fatalf("free + used = %d, want %d", report.FreeTokens+report.UsedTokens, report.ContextWindow)
+4
View File
@@ -251,6 +251,10 @@ func (s *Store) write(data map[string]string) error {
_ = tmp.Close()
return fmt.Errorf("credstore: write: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("credstore: sync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("credstore: write: %w", err)
}
+17 -29
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning. The lock is held only for a single metadata
@@ -45,11 +47,11 @@ func (s *Store) lockJob(id string) (func(), error) {
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: write job lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("cron: close job lock: %w", cerr)
}
var released bool
@@ -58,10 +60,8 @@ func (s *Store) lockJob(id string) (func(), error) {
return
}
released = true
// Only remove the file if it still carries OUR token, so a lock
// reclaimed as stale by another process is not deleted out from under it.
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
@@ -79,7 +79,18 @@ func (s *Store) lockJob(id string) (func(), error) {
// wins) and restores it if it turns out fresh, so a live lock is never deleted
// out from under its holder.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > cronLockStaleAfter {
if reclaimStaleLock(lockPath, token, cronLockStaleAfter) {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= cronLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("cron: reclaim stale job lock: %w", rerr)
}
if cleared {
continue // cleared a genuinely stale lock; retry the O_EXCL create now
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
@@ -91,26 +102,3 @@ func (s *Store) lockJob(id string) (func(), error) {
time.Sleep(cronLockRetryDelay)
}
}
// reclaimStaleLock atomically reclaims a stale lock file. It renames the lock to a
// per-acquirer unique name (only one racer can rename a given file, so two racers
// can never both reclaim the same lock), then verifies the moved file is still
// stale; if it became fresh (another holder reacquired in the gap between the
// stale check and the rename) it is restored rather than stolen. Returns true only
// when a genuinely stale lock was removed, so the caller knows it is safe to retry
// the exclusive create immediately; on a lost race it returns false and the caller
// falls through to its bounded wait.
func reclaimStaleLock(lockPath, token string, staleAfter time.Duration) bool {
reclaimed := fmt.Sprintf("%s.stale.%s", lockPath, token)
if err := os.Rename(lockPath, reclaimed); err != nil {
return false // another racer already moved/removed it, or it vanished
}
if info, err := os.Stat(reclaimed); err == nil && time.Since(info.ModTime()) <= staleAfter {
// Not actually stale anymore — a holder reacquired it in the gap. Put it
// back instead of stealing a live lock, and let the caller wait.
_ = os.Rename(reclaimed, lockPath)
return false
}
_ = os.Remove(reclaimed)
return true
}
-46
View File
@@ -1,46 +0,0 @@
package cron
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestReclaimStaleLock(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, "job.lock")
// A genuinely stale lock (old mtime) is reclaimed and removed.
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-2 * cronLockStaleAfter)
if err := os.Chtimes(lockPath, old, old); err != nil {
t.Fatal(err)
}
if !reclaimStaleLock(lockPath, "tok-a", cronLockStaleAfter) {
t.Fatal("a stale lock should be reclaimed")
}
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
t.Fatalf("reclaimed stale lock should be gone, stat err=%v", err)
}
// A FRESH lock (someone reacquired in the gap) must be RESTORED intact, not
// stolen — this is the H3 mutual-exclusion protection.
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
if reclaimStaleLock(lockPath, "tok-b", cronLockStaleAfter) {
t.Fatal("a fresh lock must not be reclaimed")
}
if data, err := os.ReadFile(lockPath); err != nil || string(data) != "live-holder" {
t.Fatalf("fresh lock must be left intact, got %q err %v", data, err)
}
// A missing lock reports no reclaim (nothing to steal).
_ = os.Remove(lockPath)
if reclaimStaleLock(lockPath, "tok-c", cronLockStaleAfter) {
t.Fatal("a missing lock should not report a reclaim")
}
}
+7 -1
View File
@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
)
const (
@@ -144,7 +146,11 @@ func (s *Store) writeJob(job Job) error {
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(dir, "metadata.json"))
if err := fsutil.RenameWithRetry(tmp, filepath.Join(dir, "metadata.json"), nil); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (s *Store) Get(id string) (Job, error) {
+29 -29
View File
@@ -8,6 +8,8 @@ import (
"strconv"
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
@@ -43,16 +45,16 @@ func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
// the single-instance guarantee — so on write failure, remove it and fail.
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
_ = f.Close()
_ = os.Remove(path)
_ = lockutil.RemoveLockFile(path)
return nil, werr
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(path)
_ = lockutil.RemoveLockFile(path)
return nil, cerr
}
return &fileLock{path: path}, nil
}
if !errors.Is(err, fs.ErrExist) {
if !errors.Is(err, fs.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, err
}
pid, perr := readPidFile(path)
@@ -65,7 +67,13 @@ func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
// freshly-created lock — leaving both "holding" the single-instance lock.
// reclaimStaleLock renames the file aside so only one racer wins the rename,
// and restores it if a live holder reacquired in the gap (D6).
reclaimStaleLock(path, isAlive)
if _, rerr := reclaimStaleLock(path, isAlive); rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break the single-instance guarantee).
// Fail closed instead of spinning to the deadline.
return nil, fmt.Errorf("daemon: reclaim stale lock: %w", rerr)
}
}
return nil, ErrAlreadyRunning
}
@@ -73,37 +81,29 @@ func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
// daemonLockSeq makes each reclaim attempt's sidelined filename unique per process.
var daemonLockSeq atomic.Uint64
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded PID is
// dead. A blind Remove lets two racers both delete-and-recreate the lock and wind up
// BOTH holding it; instead this renames the lock file aside (only one racer can win
// the rename of a given inode), re-reads the moved file's PID, and removes it only if
// that PID is still dead. If a new holder reacquired the lock in the gap between the
// stale check and the rename, the moved file carries that LIVE pid, so it is renamed
// back rather than stolen. Returns true only when a genuinely stale lock was removed.
func reclaimStaleLock(path string, isAlive func(pid int) bool) bool {
reclaimed := fmt.Sprintf("%s.stale.%d-%d", path, os.Getpid(), daemonLockSeq.Add(1))
if err := os.Rename(path, reclaimed); err != nil {
return false // another racer already moved/removed it, or it vanished
}
if pid, err := readPidFile(reclaimed); err == nil && pid > 0 && isAlive(pid) {
// A holder reacquired the lock in the gap — its live PID is now in the moved
// file. Restore it instead of stealing a live lock; let the caller refuse.
_ = os.Rename(reclaimed, path)
return false
}
_ = os.Remove(reclaimed)
return true
// reclaimStaleLock atomically reclaims a single-instance lock whose recorded
// PID is dead, via lockutil.ReclaimStaleLock: the lock file is renamed aside
// (only one racer can win the rename) and stolen only if the moved file's PID
// is still dead; a holder that reacquired the lock in the gap between the
// stale check and the rename carries a LIVE pid and is restored rather than
// stolen. Returns true only when a genuinely stale lock was removed. A
// non-nil error means the caller must fail closed instead of re-acquiring
// (see lockutil.ReclaimStaleLock).
func reclaimStaleLock(path string, isAlive func(pid int) bool) (bool, error) {
suffix := fmt.Sprintf("%d-%d", os.Getpid(), daemonLockSeq.Add(1))
return lockutil.ReclaimStaleLock(path, suffix, func(reclaimedPath string) bool {
pid, err := readPidFile(reclaimedPath)
return err == nil && pid > 0 && isAlive(pid)
})
}
// release removes the lock file. Safe to call once.
// release removes the lock file. Safe to call once. An already-missing lock
// file is not an error (lockutil.RemoveLockFile swallows it on every platform).
func (l *fileLock) release() error {
if l == nil || l.path == "" {
return nil
}
if err := os.Remove(l.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
return lockutil.RemoveLockFile(l.path)
}
// readPidFile reads and parses the PID recorded in a lock file.
+4 -4
View File
@@ -71,8 +71,8 @@ func TestReclaimStaleLockRemovesDeadHolder(t *testing.T) {
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
if !reclaimStaleLock(path, func(int) bool { return false }) {
t.Fatal("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed")
if ok, err := reclaimStaleLock(path, func(int) bool { return false }); err != nil || !ok {
t.Fatalf("reclaimStaleLock must report a genuinely stale (dead-PID) lock reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("a reclaimed stale lock must be removed, stat = %v", err)
@@ -87,8 +87,8 @@ func TestReclaimStaleLockRestoresLiveHolder(t *testing.T) {
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write lock: %v", err)
}
if reclaimStaleLock(path, func(int) bool { return true }) {
t.Fatal("reclaimStaleLock must NOT report a live-PID lock reclaimed")
if ok, err := reclaimStaleLock(path, func(int) bool { return true }); err != nil || ok {
t.Fatalf("reclaimStaleLock must NOT report a live-PID lock reclaimed (ok=%v err=%v)", ok, err)
}
data, err := os.ReadFile(path)
if err != nil {
+45
View File
@@ -0,0 +1,45 @@
// Package fsutil provides small filesystem helpers shared across packages.
package fsutil
import (
"errors"
"os"
"runtime"
"syscall"
"time"
)
// RenameWithRetry renames src to dst, retrying briefly on Windows when the
// destination is transiently locked (antivirus scanners, search indexers, or
// a concurrent reader holding the file open). rename overrides os.Rename so
// tests can exercise the retry path; pass nil to use os.Rename.
func RenameWithRetry(src, dst string, rename func(src, dst string) error) error {
if rename == nil {
rename = os.Rename
}
var err error
for i := 0; i < 10; i++ {
err = rename(src, dst)
if err == nil {
return nil
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
}
break
}
return err
}
func isWindowsSharingOrLockViolation(err error) bool {
var errno syscall.Errno
if errors.As(err, &errno) {
const ERROR_SHARING_VIOLATION syscall.Errno = 32
const ERROR_LOCK_VIOLATION syscall.Errno = 33
return errno == ERROR_SHARING_VIOLATION || errno == ERROR_LOCK_VIOLATION
}
return false
}
+47
View File
@@ -0,0 +1,47 @@
package fsutil
import (
"errors"
"runtime"
"syscall"
"testing"
)
func TestRenameWithRetryRetriesOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("RenameWithRetry only retries on Windows")
}
var attempts int
err := RenameWithRetry("src", "dst", func(src, dst string) error {
attempts++
switch attempts {
case 1:
return syscall.Errno(32) // ERROR_SHARING_VIOLATION
case 2:
return syscall.Errno(33) // ERROR_LOCK_VIOLATION
default:
return nil
}
})
if err != nil {
t.Fatalf("expected success after retries, got: %v", err)
}
if attempts != 3 {
t.Errorf("expected 3 attempts, got %d", attempts)
}
}
func TestRenameWithRetryNonRetryableError(t *testing.T) {
sentinel := errors.New("disk on fire")
var attempts int
err := RenameWithRetry("src", "dst", func(src, dst string) error {
attempts++
return sentinel
})
if !errors.Is(err, sentinel) {
t.Fatalf("expected sentinel error, got: %v", err)
}
if attempts != 1 {
t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts)
}
}
+4 -2
View File
@@ -176,8 +176,7 @@ func (dispatcher *Dispatcher) runWithTimeout(ctx context.Context, hook Definitio
func classifyResult(event Event, result commandResult) (AuditStatus, bool) {
// A hook that started but was killed by its deadline (or a cancellation) never
// returned a verdict. For a blocking event we must fail CLOSED and veto: a
// hung beforeTool policy hook cannot be treated as approval. (A launch failure
// below still fails OPEN, so a misconfigured hook doesn't wedge every tool.)
// hung beforeTool policy hook cannot be treated as approval.
if result.TimedOut {
if blocksOn(event) {
return AuditBlocked, true
@@ -185,6 +184,9 @@ func classifyResult(event Event, result commandResult) (AuditStatus, bool) {
return AuditError, false
}
if result.Err != nil {
if blocksOn(event) {
return AuditBlocked, true
}
return AuditError, false
}
if result.ExitCode != 0 {
+8 -4
View File
@@ -186,14 +186,18 @@ func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
}
}
func TestExecCommandRunnerReportsLaunchFailureWithoutBlocking(t *testing.T) {
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
if result.Err == nil {
t.Fatal("expected launch error for a missing binary")
}
// A launch failure is an error, never a block, even for beforeTool.
if status, blocked := classifyResult(EventBeforeTool, result); blocked || status != AuditError {
t.Fatalf("classify = (%q, %v), want (error, false) for a launch failure", status, blocked)
// A launch failure for beforeTool fails closed (vetoes/blocks).
if status, blocked := classifyResult(EventBeforeTool, result); !blocked || status != AuditBlocked {
t.Fatalf("beforeTool classify = (%q, %v), want (blocked, true) for a launch failure", status, blocked)
}
// An observational afterTool hook still fails open (does not block).
if status, blocked := classifyResult(EventAfterTool, result); blocked || status != AuditError {
t.Fatalf("afterTool classify = (%q, %v), want (error, false) for a launch failure", status, blocked)
}
}
+18 -26
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Cross-process lock tuning for the audit log. The lock is held only across a
@@ -28,8 +30,7 @@ var auditLockSeq atomic.Uint64
// than auditLockStaleAfter) is reclaimed. Wall-clock time is used deliberately so
// lock timing never depends on the store's injectable clock and the stale check
// compares against real file mtimes. This mirrors internal/cron/lock.go and
// internal/oauth/lock.go (a shared filelock helper would DRY all three — left as a
// follow-up to avoid churning those already-green packages here).
// internal/oauth/lock.go. Uses internal/lockutil for lock restore and reclaim.
func (store *AuditStore) lockAudit() (func(), error) {
lockPath := store.auditPath + ".lock"
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
@@ -44,11 +45,11 @@ func (store *AuditStore) lockAudit() (func(), error) {
// releaser could never delete it — stranding the lock. Fail closed.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: write audit lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("hooks: close audit lock: %w", cerr)
}
var released bool
@@ -57,10 +58,8 @@ func (store *AuditStore) lockAudit() (func(), error) {
return
}
released = true
// Only remove if the file still carries OUR token, so a lock
// reclaimed as stale by another process is not deleted under it.
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
@@ -76,7 +75,18 @@ func (store *AuditStore) lockAudit() (func(), error) {
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > auditLockStaleAfter {
if reclaimStaleLock(lockPath, token, auditLockStaleAfter) {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= auditLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("hooks: reclaim stale audit lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
@@ -88,21 +98,3 @@ func (store *AuditStore) lockAudit() (func(), error) {
time.Sleep(auditLockRetryDelay)
}
}
// reclaimStaleLock atomically reclaims a stale lock file: it renames the lock to a
// per-acquirer unique name (only one racer can rename a given file), then verifies
// the moved file is still stale; if a holder reacquired it in the gap it is
// restored rather than stolen. Returns true only when a genuinely stale lock was
// removed. Mirrors internal/cron/lock.go and internal/oauth/lock.go.
func reclaimStaleLock(lockPath, token string, staleAfter time.Duration) bool {
reclaimed := fmt.Sprintf("%s.stale.%s", lockPath, token)
if err := os.Rename(lockPath, reclaimed); err != nil {
return false
}
if info, err := os.Stat(reclaimed); err == nil && time.Since(info.ModTime()) <= staleAfter {
_ = os.Rename(reclaimed, lockPath)
return false
}
_ = os.Remove(reclaimed)
return true
}
+41 -6
View File
@@ -3,11 +3,13 @@
// macOS and `secret-tool` (libsecret) on Linux. It stores a single secret string
// per (service, account). Windows and other platforms report unsupported.
//
// It shells out to the OS tools rather than taking a third-party dependency. On
// macOS the secret is passed to `security` as an argument, so it is briefly
// visible to other processes via the process list; on Linux the secret is passed
// over stdin and is not exposed in the argument vector. Callers that need to keep
// a secret out of the process list on macOS should prefer the file backend.
// It shells out to the OS tools rather than taking a third-party dependency.
// On both macOS and Linux the secret is passed over stdin, never the argument
// vector, so it is not exposed via the process list. On macOS the write goes
// through `security -i` (interactive mode), whose command parser is line-based
// with a fixed 4096-byte line buffer, so Set rejects secrets containing
// newlines or exceeding that budget rather than silently corrupting them;
// Linux's secret-tool has no such restriction.
package keyring
import (
@@ -63,8 +65,28 @@ func (k *Keyring) Set(service, account, secret string) error {
}
switch k.goos {
case "darwin":
// The secret must stay out of the argument vector (visible to every
// process via `ps`), but a trailing -w prompt is not a substitute:
// security prompts with getpass(3), which reads from /dev/tty whenever
// the process has a controlling terminal (ignoring a piped stdin) and
// truncates at getpass's small fixed buffer otherwise. Instead drive
// `security -i`: interactive mode reads whole commands from stdin, so
// argv is just ["-i"] and the secret rides inside the command line.
// That parser is line-based with a fixed 4096-byte buffer, so reject
// newlines and oversized payloads up front; an overlong line would be
// split and executed as two garbage commands, silently corrupting the
// stored value.
for _, s := range []string{service, account, secret} {
if strings.ContainsAny(s, "\r\n") {
return wrap("set", errors.New("service, account, and secret must not contain newlines on macOS (security -i is line-based)"))
}
}
// -U updates the item if it already exists rather than failing.
_, err := k.exec(nil, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", secret)
line := "add-generic-password -U -s " + securityQuote(service) + " -a " + securityQuote(account) + " -w " + securityQuote(secret) + "\n"
if len(line) > securityMaxLine {
return wrap("set", fmt.Errorf("secret too large for the macOS security tool's %d-byte command line; use the file backend instead", securityMaxLine))
}
_, err := k.exec([]byte(line), "security", "-i")
return wrap("set", err)
case "linux":
// secret-tool reads the secret from stdin, keeping it out of the argv.
@@ -140,6 +162,19 @@ func (k *Keyring) Delete(service, account string) (bool, error) {
}
}
// securityMaxLine is the most `security -i` can read as one command: its line
// buffer is MAX_LINE_LEN (4096) bytes in Apple's SecurityTool, one of which the
// terminating NUL consumes.
const securityMaxLine = 4095
// securityQuote wraps s for one argument of a `security -i` command line. The
// tool's parser (split_line in Apple's SecurityTool) treats a backslash inside
// double quotes as escaping the next character and the matching quote as the
// argument terminator, so those two characters are all that needs escaping.
func securityQuote(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
func (k *Keyring) exec(stdin []byte, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
+164 -3
View File
@@ -50,6 +50,52 @@ func attrValue(args []string, attr string) string {
func key(service, account string) string { return service + "\x00" + account }
// splitSecurityLine mirrors split_line in Apple's SecurityTool so the fake
// parses `security -i` command lines the way the real tool does: whitespace
// separates arguments, double or single quotes group one, and a backslash
// escapes the next character both inside and outside quotes.
func splitSecurityLine(line string) []string {
var args []string
var cur strings.Builder
inArg, escaped := false, false
quote := byte(0)
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case escaped:
cur.WriteByte(c)
escaped = false
case c == '\\':
escaped = true
inArg = true
case quote != 0:
if c != quote {
cur.WriteByte(c)
continue
}
args = append(args, cur.String())
cur.Reset()
inArg, quote = false, 0
case c == '"' || c == '\'':
quote = c
inArg = true
case c == ' ' || c == '\t':
if inArg {
args = append(args, cur.String())
cur.Reset()
inArg = false
}
default:
cur.WriteByte(c)
inArg = true
}
}
if inArg {
args = append(args, cur.String())
}
return args
}
func (f *fakeKeyring) run(_ context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
f.lastStdin = string(stdin)
f.lastArgs = append([]string{name}, args...)
@@ -58,10 +104,19 @@ func (f *fakeKeyring) run(_ context.Context, name string, stdin []byte, args ...
}
switch f.goos {
case "darwin":
svc, acct := flagValue(args, "-s"), flagValue(args, "-a")
switch args[0] {
cmdArgs := args
if args[0] == "-i" {
// Interactive mode: the command arrives as one line on stdin.
line, _, _ := strings.Cut(string(stdin), "\n")
cmdArgs = splitSecurityLine(line)
if len(cmdArgs) == 0 {
return nil, fakeExit{1}
}
}
svc, acct := flagValue(cmdArgs, "-s"), flagValue(cmdArgs, "-a")
switch cmdArgs[0] {
case "add-generic-password":
f.data[key(svc, acct)] = flagValue(args, "-w")
f.data[key(svc, acct)] = flagValue(cmdArgs, "-w")
return nil, nil
case "find-generic-password":
if v, ok := f.data[key(svc, acct)]; ok {
@@ -129,6 +184,112 @@ func TestKeyringRoundTripDarwin(t *testing.T) {
}
}
func TestKeyringRoundTripDarwinUsesStdin(t *testing.T) {
f := newFake("darwin")
k := f.keyring()
if err := k.Set("zero", "tokens", "blob-CCC"); err != nil {
t.Fatalf("Set: %v", err)
}
// The secret must travel via stdin, never the argument vector: the write
// goes through `security -i`, whose whole command line (secret included)
// arrives on stdin while argv carries only the -i flag.
wantStdin := "add-generic-password -U -s \"zero\" -a \"tokens\" -w \"blob-CCC\"\n"
if f.lastStdin != wantStdin {
t.Fatalf("secret not sent via stdin correctly: stdin=%q, want=%q", f.lastStdin, wantStdin)
}
wantArgs := []string{"security", "-i"}
if len(f.lastArgs) != len(wantArgs) || f.lastArgs[0] != wantArgs[0] || f.lastArgs[1] != wantArgs[1] {
t.Fatalf("argv = %v, want %v", f.lastArgs, wantArgs)
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok || got != "blob-CCC" {
t.Fatalf("Get = %q ok=%v err=%v", got, ok, err)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
}
// TestKeyringSetRejectsMultilineSecretOnDarwin guards against silently
// truncating a secret containing a newline: `security -i` reads one command
// per line, so an embedded newline would split the write into two garbage
// commands. Set must reject this before ever invoking security, not store a
// corrupted value.
func TestKeyringSetRejectsMultilineSecretOnDarwin(t *testing.T) {
for _, secret := range []string{"line1\nline2", "line1\r\nline2", "trailing\n"} {
f := newFake("darwin")
k := f.keyring()
if err := k.Set("zero", "tokens", secret); err == nil {
t.Fatalf("Set(%q) = nil error, want rejection of the embedded newline", secret)
}
if f.lastArgs != nil {
t.Fatalf("Set(%q) should be rejected before invoking security, got args=%v", secret, f.lastArgs)
}
}
}
// TestKeyringDarwinQuotesSpecialCharacters proves the quoting survives the
// real tool's parser: the fake tokenizes stdin with a faithful mirror of
// SecurityTool's split_line, so a secret full of quotes, backslashes, and
// spaces must round-trip unchanged.
func TestKeyringDarwinQuotesSpecialCharacters(t *testing.T) {
for _, secret := range []string{
`spa ced`,
`quo"te`,
`back\slash`,
`sin'gle`,
`mi"x'ed \" \\ end\`,
` leading and trailing `,
} {
f := newFake("darwin")
k := f.keyring()
if err := k.Set("zero", "tokens", secret); err != nil {
t.Fatalf("Set(%q): %v", secret, err)
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok || got != secret {
t.Fatalf("Get after Set(%q) = %q ok=%v err=%v", secret, got, ok, err)
}
}
}
// TestKeyringSetRejectsOversizedSecretOnDarwin guards the `security -i` line
// budget: MAX_LINE_LEN is 4096 bytes, and an overlong line would be split and
// executed as two garbage commands. A payload comfortably under the budget
// must succeed; one over it must be rejected before invoking security.
func TestKeyringSetRejectsOversizedSecretOnDarwin(t *testing.T) {
f := newFake("darwin")
k := f.keyring()
if err := k.Set("zero", "tokens", strings.Repeat("a", 4000)); err != nil {
t.Fatalf("Set(4000 bytes): %v", err)
}
f = newFake("darwin")
k = f.keyring()
if err := k.Set("zero", "tokens", strings.Repeat("a", 5000)); err == nil {
t.Fatal("Set(5000 bytes) = nil error, want rejection of the oversized line")
}
if f.lastArgs != nil {
t.Fatalf("oversized Set should be rejected before invoking security, got args=%v", f.lastArgs)
}
}
// TestKeyringSetAllowsMultilineSecretOnLinux confirms the newline restriction
// is macOS-specific: secret-tool reads the whole stdin payload as the secret,
// with no line-based prompt to corrupt an embedded newline.
func TestKeyringSetAllowsMultilineSecretOnLinux(t *testing.T) {
f := newFake("linux")
k := f.keyring()
secret := "line1\nline2"
if err := k.Set("zero", "tokens", secret); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok || got != secret {
t.Fatalf("Get = %q ok=%v err=%v, want %q", got, ok, err, secret)
}
}
func TestKeyringRoundTripLinuxUsesStdin(t *testing.T) {
f := newFake("linux")
k := f.keyring()
+64
View File
@@ -0,0 +1,64 @@
// Package lockutil provides the platform-specific file primitives behind the
// O_EXCL lock files in cron, daemon, hooks, oauth, and swarm: a no-overwrite
// restore for locks that were sidelined during a stale-reclaim attempt, and a
// lock file remover with one cross-platform contract (missing files are a
// no-op; Windows retries transient sharing violations).
package lockutil
import (
"io"
"os"
)
// restoreByCopy restores reclaimed to path without overwriting an existing
// path, as a fallback for when the platform's primary no-replace primitive
// (hard link on POSIX, MoveFileEx on Windows) fails for a reason other than
// the destination existing. It stages a full copy under a private name next
// to reclaimed and publishes it to path with publish (the same no-replace
// primitive the caller's platform uses for the primary restore), so path
// never appears in a partially-copied state: a crash between staging and
// publish leaves path exactly as it was (missing or, if a competing holder
// created it, untouched), never a truncated file that a PID/content-based
// liveness check could mistake for dead. Leaving path missing here would let
// the next O_EXCL create succeed while the sidelined holder is still live,
// breaking mutual exclusion. publish keeps the no-overwrite guarantee: a new
// holder that appeared in the meantime wins and this returns os.ErrExist. The
// copy resets the lock's mtime to now, which only makes the restored lock
// look fresher; that is safe, since it is being handed back to a live holder.
func restoreByCopy(reclaimed, path string, publish func(from, to string) error) error {
staged := reclaimed + ".copy"
src, err := os.Open(reclaimed)
if err != nil {
return err
}
dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
_ = src.Close()
return err
}
_, err = io.Copy(dst, src)
// Close the source before removing anything below: Go opens files without
// FILE_SHARE_DELETE on Windows, so deleting reclaimed or staged while src
// is open would fail with a sharing violation.
_ = src.Close()
if err != nil {
_ = dst.Close()
_ = os.Remove(staged)
return err
}
if err := dst.Close(); err != nil {
_ = os.Remove(staged)
return err
}
if err := publish(staged, path); err != nil {
_ = os.Remove(staged)
return err
}
// publish may leave staged behind (a hard-link publish keeps both names),
// so remove it explicitly; a move-based publish already made this a no-op.
_ = os.Remove(staged)
// The lock is back at path, so the restore has succeeded; failing to clean
// up the sidelined name must not be reported as a failed restore.
_ = RemoveLockFile(reclaimed)
return nil
}
+46
View File
@@ -0,0 +1,46 @@
//go:build !windows
package lockutil
import (
"errors"
"io/fs"
"os"
)
// RestoreLockFile restores a sidelined lock file on non-Windows platforms. It
// uses os.Link so a competing lock created at path in the meantime wins: the
// link fails with os.ErrExist instead of overwriting it. If the link fails for
// any other reason (hardlink-incapable filesystems such as FAT or some
// FUSE/network mounts, ENOSPC, EPERM), it falls back to an O_EXCL copy rather
// than leaving path missing and the live holder's lock stranded in reclaimed.
// Once the lock is back at path the restore has succeeded, so a failed cleanup
// of the sidelined name is not reported as an error; the leftover file is
// invisible to the lock protocol.
func RestoreLockFile(reclaimed, path string) error {
err := os.Link(reclaimed, path)
if err == nil {
_ = RemoveLockFile(reclaimed)
return nil
}
if errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, os.Link)
}
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means it was lost to a racer rather than a hard failure. POSIX
// rename has no contention errno (a lost race surfaces only as ENOENT, which
// ReclaimStaleLock already treats as benign), so nothing extra is benign here.
func isReclaimContended(error) bool { return false }
// RemoveLockFile removes a lock file on non-Windows platforms. Removing an
// already-missing file reports nil, matching the Windows implementation, so
// callers see one cross-platform contract.
func RemoveLockFile(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestRestoreLockFile(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Successful restore when target does not exist
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := RestoreLockFile(reclaimed, path); err != nil {
t.Fatalf("RestoreLockFile failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !os.IsNotExist(err) {
t.Fatalf("expected sidelined lock to be cleaned up/removed: %v", err)
}
// 2. Fail when target already exists (prevent overwrite)
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := RestoreLockFile(reclaimed, path)
if err == nil {
t.Fatal("expected RestoreLockFile to fail when target exists")
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist, got: %v", err)
}
// The sidelined lock must still exist on failure, and the competing lock
// must be untouched.
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
}
func TestRestoreByCopy(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, "lock")
reclaimed := filepath.Join(tempDir, "lock.stale.token")
// 1. Restores content and cleans up the sidelined file when the target is
// free (the fallback taken when the platform's no-replace primitive fails
// for a reason other than the destination existing).
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); err != nil {
t.Fatalf("restoreByCopy failed: %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "token" {
t.Fatalf("restored lock content = %q, err %v; want %q intact", data, err, "token")
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected sidelined lock to be cleaned up after copy: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up: %v", err)
}
// 2. Keeps the no-overwrite guarantee: a competing lock at the target wins
// and the sidelined lock survives for the caller's ErrExist cleanup.
if err := os.WriteFile(reclaimed, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreByCopy(reclaimed, path, os.Link); !errors.Is(err, os.ErrExist) {
t.Fatalf("expected os.ErrExist when target exists, got: %v", err)
}
if data, _ := os.ReadFile(path); string(data) != "competing" {
t.Fatalf("competing lock was clobbered: %q", data)
}
if _, err := os.Stat(reclaimed); err != nil {
t.Fatalf("expected sidelined lock to still exist: %v", err)
}
if _, err := os.Stat(reclaimed + ".copy"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected staged copy to be cleaned up after a failed publish: %v", err)
}
}
func TestRemoveLockFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile failed: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
// Removing an already-missing lock file is a no-op on every platform.
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile on a missing file = %v, want nil", err)
}
}
+80
View File
@@ -0,0 +1,80 @@
//go:build windows
package lockutil
import (
"errors"
"os"
"syscall"
"time"
"golang.org/x/sys/windows"
)
// RestoreLockFile restores a sidelined lock file on Windows. It moves the file
// with a no-replace MoveFileEx so a competing lock created at path in the
// meantime wins: the move fails with os.ErrExist instead of overwriting it. If
// the move fails for any other reason (ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED under concurrent access to the file), it falls back to
// an O_EXCL copy rather than leaving path missing and the live holder's lock
// stranded in reclaimed.
func RestoreLockFile(reclaimed, path string) error {
err := moveFileNoReplace(reclaimed, path)
if err == nil || errors.Is(err, os.ErrExist) {
return err
}
return restoreByCopy(reclaimed, path, moveFileNoReplace)
}
// moveFileNoReplace renames from to to, failing if to already exists. It calls
// MoveFileExW directly via golang.org/x/sys/windows (the standard library's
// syscall package does not export MoveFileEx on this platform) with no flags,
// so an existing destination fails with ERROR_ALREADY_EXISTS, which satisfies
// errors.Is(err, os.ErrExist) (pinned by
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist), instead of overwriting it
// the way os.Rename does on Windows (it passes MOVEFILE_REPLACE_EXISTING to
// match POSIX rename semantics cross-platform).
func moveFileNoReplace(from, to string) error {
fromPtr, err := windows.UTF16PtrFromString(from)
if err != nil {
return err
}
toPtr, err := windows.UTF16PtrFromString(to)
if err != nil {
return err
}
return windows.MoveFileEx(fromPtr, toPtr, 0)
}
// isReclaimContended reports whether a failed rename-aside of a suspected
// stale lock means the file was concurrently open or pending deletion (which
// surfaces as ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED on Windows)
// rather than a hard failure, so ReclaimStaleLock reports a lost race instead
// of an error. Neither errno satisfies errors.Is(err, os.ErrExist), and
// ERROR_SHARING_VIOLATION does not satisfy errors.Is(err, os.ErrPermission)
// either, so the classification compares raw errnos like RemoveLockFile below.
func isReclaimContended(err error) bool {
var errno syscall.Errno
return errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED)
}
// RemoveLockFile removes a lock file on Windows, retrying on the sharing
// violation or access denied errors that are common under heavy concurrent
// contention. Removing an already-missing file reports nil, matching the
// non-Windows implementation, so callers see one cross-platform contract.
func RemoveLockFile(path string) error {
var err error
for i := 0; i < 15; i++ {
err = os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
var errno syscall.Errno
if errors.As(err, &errno) && (errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_ACCESS_DENIED) {
time.Sleep(5 * time.Millisecond)
continue
}
return err
}
return err
}
@@ -0,0 +1,83 @@
//go:build windows
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
// TestMoveFileNoReplaceMapsAlreadyExistsToErrExist pins the errno coupling
// every caller keys its cleanup on: with no flags, MoveFileEx fails with
// ERROR_ALREADY_EXISTS when the destination exists, and that errno must
// satisfy errors.Is(err, os.ErrExist).
func TestMoveFileNoReplaceMapsAlreadyExistsToErrExist(t *testing.T) {
dir := t.TempDir()
from := filepath.Join(dir, "from")
to := filepath.Join(dir, "to")
if err := os.WriteFile(from, []byte("sidelined"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(to, []byte("competing"), 0o600); err != nil {
t.Fatal(err)
}
err := moveFileNoReplace(from, to)
if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
t.Fatalf("MoveFileEx with an existing destination = %v, want ERROR_ALREADY_EXISTS", err)
}
if !errors.Is(err, os.ErrExist) {
t.Fatalf("ERROR_ALREADY_EXISTS must satisfy errors.Is(err, os.ErrExist), got: %v", err)
}
if data, _ := os.ReadFile(to); string(data) != "competing" {
t.Fatalf("destination was overwritten: %q", data)
}
}
// TestRemoveLockFileRetriesSharingViolation holds the file open briefly (Go
// opens files without FILE_SHARE_DELETE on Windows, so a concurrent DeleteFile
// fails with ERROR_SHARING_VIOLATION while the handle is open); RemoveLockFile
// must ride its retry loop (15 x 5ms) past the window and succeed once the
// handle closes.
func TestRemoveLockFileRetriesSharingViolation(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(10 * time.Millisecond)
_ = f.Close()
}()
if err := RemoveLockFile(path); err != nil {
t.Fatalf("RemoveLockFile should succeed once the contending handle closes: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected lock file to be removed: %v", err)
}
}
// TestRemoveLockFileSharingViolationExhausts verifies the retry loop is
// bounded: with the contending handle held for the whole call, RemoveLockFile
// surfaces the sharing violation instead of retrying forever.
func TestRemoveLockFileSharingViolationExhausts(t *testing.T) {
path := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(path, []byte("token"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := RemoveLockFile(path); !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
t.Fatalf("RemoveLockFile with a held handle = %v, want ERROR_SHARING_VIOLATION", err)
}
}
+82
View File
@@ -0,0 +1,82 @@
package lockutil
import (
"errors"
"os"
)
// restoreLockFile is swappable so tests can force the fail-closed path of
// ReclaimStaleLock, which requires both the fast restore and its no-replace
// fallback (with its own copy fallback) to fail; that cannot be provoked
// portably on a healthy filesystem.
var restoreLockFile = restoreLiveLock
// restoreLiveLock puts a lock that turned out to be live back at path after
// ReclaimStaleLock moved it aside to inspect it. It first tries a fast,
// replacing rename straight from reclaimed to path: a single syscall, which
// keeps the window during which path does not exist (and so is open to a
// fourth process's unrelated O_EXCL create landing on it) as short as
// possible. RestoreLockFile's no-replace restore (with its slower copy
// fallback on some failures) is a correctness-preserving fallback for when
// the fast path itself fails (e.g. a cross-device sidelined name): it is a
// much longer version of the identical race, but still detects rather than
// silently clobbers a competing lock, which the fast path's replacing rename
// cannot do. Neither path makes the race impossible, only unlikely; see
// ReclaimStaleLock's doc comment.
func restoreLiveLock(reclaimed, path string) error {
if err := os.Rename(reclaimed, path); err == nil {
return nil
}
return RestoreLockFile(reclaimed, path)
}
// ReclaimStaleLock atomically reclaims a suspected-stale lock file. It renames
// lockPath aside to "<lockPath>.stale.<suffix>" (only one racer can win the
// rename of a given file, so two racers can never both reclaim the same lock),
// then consults isLive on the moved file; if the lock turns out to be live (a
// holder reacquired it in the gap between the caller's stale check and the
// rename) it is restored rather than stolen. The suffix must be unique per
// acquirer attempt. Returns true only when a genuinely stale lock was removed,
// so the caller knows it is safe to retry its exclusive create immediately; on
// a lost race it returns false. A non-nil error means either the rename aside
// failed for a reason that is not contention (so retrying cannot help and the
// caller should fail fast instead of spinning to its deadline), or a live
// holder's lock could not be restored (both restoreLiveLock's fast path and
// its no-replace fallback failed), so lockPath may be missing; callers must
// fail closed instead of re-acquiring. The sidelined file is removed on every
// restore failure: once the restore has failed it has no protocol function
// (release only consults the lock path), so keeping it would only leak files.
//
// The live-restore path has an inherent, unclosed race: between the rename
// aside above and restoreLiveLock putting the lock back, lockPath does not
// exist, so an unrelated caller's O_EXCL create can legitimately succeed
// there. restoreLiveLock's fast path then silently overwrites that new
// claimant's lock file, which does not corrupt release (it is
// ownership-aware, so the new claimant's later release safely no-ops against
// content it no longer owns) but does mean the new claimant can still run
// its critical section concurrently with the original live holder. Making
// this race actually impossible would need an OS-level advisory lock (flock
// / LockFileEx) held for a holder's whole critical section, checked
// non-destructively instead of by moving the file; restoreLiveLock only
// shrinks the window to roughly one syscall, it does not close it.
func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) {
reclaimed := lockPath + ".stale." + suffix
if err := os.Rename(lockPath, reclaimed); err != nil {
if errors.Is(err, os.ErrNotExist) || isReclaimContended(err) {
return false, nil // another racer already moved/removed it, or it vanished
}
return false, err
}
if isLive(reclaimed) {
// Put the live lock back instead of stealing it, and let the caller wait.
if rerr := restoreLockFile(reclaimed, lockPath); rerr != nil {
_ = RemoveLockFile(reclaimed)
if !errors.Is(rerr, os.ErrExist) {
return false, rerr
}
}
return false, nil
}
_ = RemoveLockFile(reclaimed)
return true, nil
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside: a failure that is not a lost race must reach the caller.
// Mapping it to the benign lost-race result would leave acquirers spinning
// until their deadline and reporting a timeout instead of the real cause. A
// directory squatting on the sidelined name makes os.Rename fail with EISDIR,
// which matches neither os.ErrNotExist nor a contention errno.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(lockPath+".stale.tok", 0o755); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package lockutil
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestReclaimStaleLock(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
dead := func(string) bool { return false }
live := func(string) bool { return true }
// A lock the predicate reports dead is reclaimed and removed.
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-a", dead); err != nil || !ok {
t.Fatalf("a dead lock should be reclaimed (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("reclaimed dead lock should be gone, stat err=%v", err)
}
// A LIVE lock (a holder reacquired in the gap) must be RESTORED intact.
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, "tok-b", live); err != nil || ok {
t.Fatalf("a live lock must not be reclaimed (ok=%v err=%v)", ok, err)
}
if data, err := os.ReadFile(lockPath); err != nil || string(data) != "live-holder" {
t.Fatalf("live lock must be left intact, got %q err %v", data, err)
}
// A missing lock reports no reclaim (nothing to steal).
_ = os.Remove(lockPath)
if ok, err := ReclaimStaleLock(lockPath, "tok-c", live); err != nil || ok {
t.Fatalf("a missing lock should not report a reclaim (ok=%v err=%v)", ok, err)
}
}
// restoreLiveLock's fast path is a single replacing rename, chosen to
// minimize (not eliminate) the window during which lockPath is absent and
// open to an unrelated O_EXCL create; see ReclaimStaleLock's doc comment for
// why that residual race exists. This test pins the resulting behavior: the
// fast path overwrites a competing file at path rather than detecting it,
// unlike RestoreLockFile's own no-replace contract.
func TestRestoreLiveLockFastPathOverwritesCompetingFile(t *testing.T) {
dir := t.TempDir()
reclaimed := filepath.Join(dir, "lock.stale.tok")
path := filepath.Join(dir, "lock")
if err := os.WriteFile(reclaimed, []byte("original-holder"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("new-claimant"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreLiveLock(reclaimed, path); err != nil {
t.Fatalf("restoreLiveLock failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "original-holder" {
t.Fatalf("path = %q, err %v; want the original holder's content restored", data, err)
}
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected reclaimed to be consumed by the rename: %v", err)
}
}
func TestReclaimStaleLockFailsClosedOnRestoreError(t *testing.T) {
// When both the no-replace restore and its copy fallback fail (only
// provokable via the seam; a healthy filesystem cannot produce it), the
// caller must receive an error so it fails closed instead of re-acquiring a
// missing lock path, and the sidelined file must not leak.
restoreLockFile = func(reclaimed, path string) error { return errors.New("restore failed") }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err == nil || ok {
t.Fatalf("a failed restore must surface an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("a failed restore must not leak sidelined files: %v", matches)
}
}
func TestReclaimStaleLockDropsSidelinedWhenNewHolderWins(t *testing.T) {
// An os.ErrExist restore failure means a new holder recreated the lock
// path; that is not an error for the caller, and the sidelined file is
// dropped rather than leaked.
restoreLockFile = func(reclaimed, path string) error { return os.ErrExist }
defer func() { restoreLockFile = restoreLiveLock }()
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("live-holder"), 0o600); err != nil {
t.Fatal(err)
}
ok, err := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true })
if err != nil || ok {
t.Fatalf("losing to a new holder is not an error (ok=%v err=%v)", ok, err)
}
if matches, _ := filepath.Glob(lockPath + ".stale.*"); len(matches) != 0 {
t.Fatalf("the sidelined file must be dropped when a new holder wins: %v", matches)
}
}
+50
View File
@@ -0,0 +1,50 @@
//go:build windows
package lockutil
import (
"os"
"path/filepath"
"testing"
)
// TestReclaimStaleLockTreatsSharingViolationAsLostRace pins the errno
// classification the Windows acquire loops depend on: a rename aside that
// fails because the lock file is concurrently open (Go opens files without
// FILE_SHARE_DELETE, so the rename fails with ERROR_SHARING_VIOLATION or
// ERROR_ACCESS_DENIED) is contention, not a hard failure, and must report a
// benign lost race so the caller keeps waiting.
func TestReclaimStaleLockTreatsSharingViolationAsLostRace(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("held"), 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(lockPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if ok, rerr := ReclaimStaleLock(lockPath, "tok", func(string) bool { return true }); rerr != nil || ok {
t.Fatalf("a sharing-violation rename must report a benign lost race (ok=%v err=%v)", ok, rerr)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place: %v", err)
}
}
// TestReclaimStaleLockPropagatesRenameFailure pins the fail-fast contract of
// the rename aside on Windows: a failure that is not contention must reach
// the caller instead of masquerading as a lost race. A quote is an invalid
// filename character, so the sidelined name fails with ERROR_INVALID_NAME.
func TestReclaimStaleLockPropagatesRenameFailure(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "lock")
if err := os.WriteFile(lockPath, []byte("stale-holder"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := ReclaimStaleLock(lockPath, `bad"name`, func(string) bool { return false }); err == nil || ok {
t.Fatalf("a hard rename failure must surface an error (ok=%v err=%v)", ok, err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("the lock file must be left in place on a failed rename: %v", err)
}
}
+4
View File
@@ -31,6 +31,9 @@ type Server struct {
Auth string
OAuth *OAuthConfig
Identity string
// ProjectConfigured is true when project config touched this server. Runtime
// credential lookup uses it to avoid reusing legacy user tokens by name.
ProjectConfigured bool
// UnconfiguredDefault is true when this server is one of Zero's built-in
// defaults (e.g. keyless Firecrawl) that the user never touched in their
// config — no credentials, no overrides. Callers use it to avoid warning
@@ -88,6 +91,7 @@ func normalizeServer(name string, raw config.MCPServerConfig) (Server, error) {
Headers: copyStringMap(raw.Headers),
Auth: auth,
OAuth: normalizeOAuthConfig(raw.OAuth),
ProjectConfigured: raw.ProjectConfigured,
UnconfiguredDefault: config.IsUnconfiguredDefault(name, raw),
}
+3 -3
View File
@@ -847,7 +847,7 @@ func (source *storeTokenSource) config() OAuthConfig {
}
func (source *storeTokenSource) AccessToken(ctx context.Context) (string, error) {
token, ok, err := source.store.Load(source.server.Name)
token, ok, err := source.store.LoadForServer(source.server)
if err != nil {
return "", err
}
@@ -858,7 +858,7 @@ func (source *storeTokenSource) AccessToken(ctx context.Context) (string, error)
}
func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
token, ok, err := source.store.Load(source.server.Name)
token, ok, err := source.store.LoadForServer(source.server)
if err != nil {
return "", err
}
@@ -869,7 +869,7 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
if err := source.store.Save(source.server.Name, refreshed); err != nil {
if err := source.store.SaveForServer(source.server, refreshed); err != nil {
return "", err
}
return refreshed.AccessToken, nil
+132 -3
View File
@@ -15,6 +15,9 @@ import (
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
// retained so migration can recognize a file it understands.
const tokenStoreSchemaVersion = 1
const mcpServerIdentityLength = 32
const maxOAuthTokenKeySegmentLength = 128
const maxMCPServerNameForIdentityToken = maxOAuthTokenKeySegmentLength - 1 - mcpServerIdentityLength
// StoredToken holds the credentials issued by an OAuth 2.0 authorization server
// for a single MCP server. The token fields are sensitive: they are tagged so
@@ -32,6 +35,7 @@ type StoredToken struct {
// omits the access and refresh token material so it can be printed by the CLI.
type TokenStatus struct {
ServerName string `json:"serverName"`
ServerIdentity string `json:"serverIdentity,omitempty"`
HasToken bool `json:"hasToken"`
HasRefreshToken bool `json:"hasRefreshToken"`
TokenType string `json:"tokenType,omitempty"`
@@ -148,6 +152,17 @@ func (store *TokenStore) Save(serverName string, token StoredToken) error {
return store.store.Save(key, storedToOAuth(token))
}
// SaveForServer persists a token under the server's target identity. Identity
// binding prevents a project config with the same server name from reusing a
// token issued for a different MCP endpoint.
func (store *TokenStore) SaveForServer(server Server, token StoredToken) error {
key, err := mcpIdentityKey(server)
if err != nil {
return err
}
return store.store.Save(key, storedToOAuth(token))
}
// Load returns the stored token for a server. The second return value is false
// when no token has been stored for the server.
func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
@@ -162,6 +177,30 @@ func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
return tokenToStored(token), true, nil
}
// LoadForServer returns the token matching the server's target identity. Legacy
// name-only tokens are migrated only for servers untouched by project config.
func (store *TokenStore) LoadForServer(server Server) (StoredToken, bool, error) {
key, err := mcpIdentityKey(server)
if err != nil {
return StoredToken{}, false, err
}
token, ok, err := store.store.Load(key)
if err != nil || ok {
return tokenToStored(token), ok, err
}
if server.ProjectConfigured {
return StoredToken{}, false, nil
}
legacy, ok, err := store.Load(server.Name)
if err != nil || !ok {
return StoredToken{}, ok, err
}
if err := store.store.Save(key, storedToOAuth(legacy)); err != nil {
return StoredToken{}, false, err
}
return legacy, true, nil
}
// Delete removes the stored token for a server. It reports whether an entry was
// present before deletion.
func (store *TokenStore) Delete(serverName string) (bool, error) {
@@ -172,6 +211,36 @@ func (store *TokenStore) Delete(serverName string) (bool, error) {
return store.store.Delete(key)
}
// DeleteForServerName removes both legacy name-only and identity-bound tokens
// for a server name.
func (store *TokenStore) DeleteForServerName(serverName string) (bool, error) {
name := strings.TrimSpace(serverName)
if err := ValidateServerName(name); err != nil {
return false, err
}
removed, err := store.Delete(name)
if err != nil {
return false, err
}
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
if err != nil {
return removed, err
}
for _, status := range statuses {
segment := strings.TrimPrefix(status.Key, oauth.KeyPrefixMCP)
parsedName, _, ok := splitMCPIdentityKeySegment(segment)
if !ok || parsedName != name {
continue
}
deleted, err := store.store.Delete(status.Key)
if err != nil {
return removed, err
}
removed = removed || deleted
}
return removed, nil
}
// Status returns a redaction-safe summary of every stored MCP token, sorted by
// server name. It never includes the token material.
func (store *TokenStore) Status() ([]TokenStatus, error) {
@@ -181,8 +250,10 @@ func (store *TokenStore) Status() ([]TokenStatus, error) {
}
out := make([]TokenStatus, 0, len(statuses))
for _, s := range statuses {
serverName, serverIdentity, _ := splitMCPIdentityKeySegment(strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP))
out = append(out, TokenStatus{
ServerName: strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP),
ServerName: serverName,
ServerIdentity: serverIdentity,
HasToken: s.HasToken,
HasRefreshToken: s.HasRefreshToken,
TokenType: s.TokenType,
@@ -250,16 +321,69 @@ func (store *TokenStore) migrateLegacy(legacyPath string) error {
// mcpKey builds and validates the unified store key for an MCP server token.
func mcpKey(serverName string) (string, error) {
if err := ValidateServerName(serverName); err != nil {
name := strings.TrimSpace(serverName)
if err := ValidateServerName(name); err != nil {
return "", err
}
key := oauth.KeyPrefixMCP + strings.TrimSpace(serverName)
if hasMCPIdentitySuffix(name) {
return "", fmt.Errorf("MCP OAuth server name %q cannot end with .<32 hex chars> because it collides with identity-bound token storage", name)
}
key := oauth.KeyPrefixMCP + name
if err := oauth.ValidateKey(key); err != nil {
return "", err
}
return key, nil
}
func mcpIdentityKey(server Server) (string, error) {
name := strings.TrimSpace(server.Name)
if err := ValidateServerName(name); err != nil {
return "", err
}
if hasMCPIdentitySuffix(name) {
return "", fmt.Errorf("MCP OAuth server name %q cannot end with .<32 hex chars> because it collides with identity-bound token storage", name)
}
identity := strings.TrimSpace(server.Identity)
if len(name) > maxMCPServerNameForIdentityToken {
return "", fmt.Errorf("MCP OAuth server name %q is too long for identity-bound token storage", name)
}
if len(identity) != mcpServerIdentityLength || !isMCPServerIdentity(identity) {
return "", fmt.Errorf("MCP OAuth server %s has invalid identity %q", name, identity)
}
key := oauth.KeyPrefixMCP + name + "." + identity
if err := oauth.ValidateKey(key); err != nil {
return "", err
}
return key, nil
}
func splitMCPIdentityKeySegment(segment string) (string, string, bool) {
index := strings.LastIndex(segment, ".")
if index <= 0 || index == len(segment)-1 {
return segment, "", false
}
identity := segment[index+1:]
if len(identity) != mcpServerIdentityLength || !isMCPServerIdentity(identity) {
return segment, "", false
}
return segment[:index], identity, true
}
func hasMCPIdentitySuffix(value string) bool {
_, _, ok := splitMCPIdentityKeySegment(value)
return ok
}
func isMCPServerIdentity(value string) bool {
for _, ch := range value {
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
continue
}
return false
}
return value != ""
}
// storedToOAuth converts an MCP StoredToken to the shared oauth.Token (the
// inverse of tokenToStored). MCP never sets the oauth Account field.
func storedToOAuth(s StoredToken) oauth.Token {
@@ -284,6 +408,11 @@ func FormatTokenStatuses(statuses []TokenStatus) string {
builder.WriteByte('\n')
}
builder.WriteString(status.ServerName)
if status.ServerIdentity != "" {
builder.WriteString(" [")
builder.WriteString(status.ServerIdentity)
builder.WriteString("]")
}
builder.WriteString(": ")
if !status.HasToken {
builder.WriteString("no token")
+198
View File
@@ -1,9 +1,11 @@
package mcp
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
@@ -43,6 +45,191 @@ func TestTokenStoreRoundTrip(t *testing.T) {
}
}
func TestTokenStoreIdentityBoundRoundTrip(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
otherTarget := testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false)
if err := store.SaveForServer(server, StoredToken{AccessToken: "identity-token"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
loaded, ok, err := store.LoadForServer(server)
if err != nil || !ok {
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
}
if loaded.AccessToken != "identity-token" {
t.Fatalf("access token = %q", loaded.AccessToken)
}
if _, ok, err := store.LoadForServer(otherTarget); err != nil || ok {
t.Fatalf("LoadForServer(other target) ok=%v err=%v, want no token", ok, err)
}
}
func TestStoreTokenSourceRequiresMatchingIdentity(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
otherTarget := testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false)
if err := store.SaveForServer(server, StoredToken{AccessToken: "access-a"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
source := &storeTokenSource{server: server, store: store}
token, err := source.AccessToken(context.Background())
if err != nil {
t.Fatalf("AccessToken() error = %v", err)
}
if token != "access-a" {
t.Fatalf("token = %q, want access-a", token)
}
source.server = otherTarget
if _, err := source.AccessToken(context.Background()); err == nil {
t.Fatal("AccessToken() error = nil for mismatched identity")
}
}
func TestTokenStoreProjectConfiguredDoesNotLoadLegacyToken(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
if err := store.Save("demo", StoredToken{AccessToken: "legacy-token"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)
_, ok, err := store.LoadForServer(server)
if err != nil {
t.Fatalf("LoadForServer() error = %v", err)
}
if ok {
t.Fatal("LoadForServer() ok = true for project-configured legacy token")
}
}
func TestTokenStoreMigratesLegacyForUserOnlyServer(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
if err := store.Save("demo", StoredToken{AccessToken: "legacy-token"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
loaded, ok, err := store.LoadForServer(server)
if err != nil || !ok {
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
}
if loaded.AccessToken != "legacy-token" {
t.Fatalf("access token = %q", loaded.AccessToken)
}
if _, err := store.Delete("demo"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
loaded, ok, err = store.LoadForServer(server)
if err != nil || !ok || loaded.AccessToken != "legacy-token" {
t.Fatalf("identity-bound migrated token missing: loaded=%#v ok=%v err=%v", loaded, ok, err)
}
}
func TestTokenStoreRejectsLongIdentityBoundServerName(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
name := strings.Repeat("a", maxMCPServerNameForIdentityToken+1)
err = store.SaveForServer(testOAuthServer(name, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false), StoredToken{AccessToken: "x"})
if err == nil {
t.Fatal("SaveForServer() error = nil for long server name")
}
if !strings.Contains(err.Error(), `MCP OAuth server name "`+name+`" is too long for identity-bound token storage`) {
t.Fatalf("error = %q, want long-name message", err.Error())
}
}
func TestTokenStoreRejectsAmbiguousIdentitySuffixServerName(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
name := "demo.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
if err := store.SaveForServer(testOAuthServer(name, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false), StoredToken{AccessToken: "x"}); err == nil {
t.Fatal("SaveForServer() error = nil for ambiguous server name")
} else if !strings.Contains(err.Error(), `MCP OAuth server name "`+name+`" cannot end with .<32 hex chars>`) {
t.Fatalf("SaveForServer() error = %q, want ambiguous-name rejection", err.Error())
}
if err := store.Save(name, StoredToken{AccessToken: "legacy"}); err == nil {
t.Fatal("Save() error = nil for ambiguous legacy server name")
}
}
func TestTokenStoreDeleteForServerNameRemovesLegacyAndIdentityTokens(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
if err := store.Save("demo", StoredToken{AccessToken: "legacy"}); err != nil {
t.Fatal(err)
}
if err := store.SaveForServer(testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false), StoredToken{AccessToken: "a"}); err != nil {
t.Fatal(err)
}
if err := store.SaveForServer(testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false), StoredToken{AccessToken: "b"}); err != nil {
t.Fatal(err)
}
if err := store.SaveForServer(testOAuthServer("other", "cccccccccccccccccccccccccccccccc", false), StoredToken{AccessToken: "c"}); err != nil {
t.Fatal(err)
}
removed, err := store.DeleteForServerName("demo")
if err != nil {
t.Fatalf("DeleteForServerName() error = %v", err)
}
if !removed {
t.Fatal("DeleteForServerName() removed = false")
}
statuses, err := store.Status()
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if len(statuses) != 1 || statuses[0].ServerName != "other" {
t.Fatalf("statuses after delete = %#v, want only other", statuses)
}
}
func TestTokenStoreStatusParsesIdentityAndRedacts(t *testing.T) {
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
identity := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
if err := store.SaveForServer(testOAuthServer("demo", identity, false), StoredToken{AccessToken: "secret-access", RefreshToken: "secret-refresh"}); err != nil {
t.Fatalf("SaveForServer() error = %v", err)
}
statuses, err := store.Status()
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if len(statuses) != 1 || statuses[0].ServerName != "demo" || statuses[0].ServerIdentity != identity {
t.Fatalf("statuses = %#v, want parsed identity", statuses)
}
output := FormatTokenStatuses(statuses)
if !strings.Contains(output, "demo ["+identity+"]") {
t.Fatalf("status output = %q, want identity label", output)
}
if contains(output, "secret-access") || contains(output, "secret-refresh") {
t.Fatalf("status output leaked token: %s", output)
}
}
func TestTokenStoreFileIs0600(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX file permissions are not enforced on Windows")
@@ -268,6 +455,17 @@ func TestResolveTokenStorePathUsesXDG(t *testing.T) {
}
}
func testOAuthServer(name string, identity string, projectConfigured bool) Server {
return Server{
Name: name,
Type: ServerTypeHTTP,
URL: "https://example.com/mcp",
Auth: ServerAuthOAuth,
Identity: identity,
ProjectConfigured: projectConfigured,
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (haystack == needle || indexOf(haystack, needle) >= 0)
}
+1 -1
View File
@@ -39,7 +39,7 @@ var defaultModes = []Mode{
Description: "Hardest tasks: deep reasoning with a larger turn budget.",
Model: "claude-opus-4.1",
Effort: ReasoningEffortHigh,
MaxTurns: 50,
MaxTurns: 160,
},
{
Name: "fast",
+1 -1
View File
@@ -58,7 +58,7 @@ func VisionCapableByName(modelID string) bool {
strings.Contains(id, "qwen-vl"), strings.Contains(id, "qwenvl"):
return true // Qwen VL series are multimodal
case strings.Contains(id, "minimax-m3"):
return false // MiniMax M3 is text-only (the user confirmed this)
return true // MiniMax M3 accepts image input
case strings.Contains(id, "llava"), strings.Contains(id, "pixtral"),
strings.Contains(id, "internvl"), strings.Contains(id, "minicpm-v"),
strings.Contains(id, "moondream"), strings.Contains(id, "bakllava"),
+2 -1
View File
@@ -8,6 +8,7 @@ func TestVisionCapableByName(t *testing.T) {
"grok-4.3", "grok-4-fast", "grok-2-vision",
"gpt-4o", "gpt-4.1-mini", "gpt-4-turbo", "gpt-5", "o3-mini", "o1",
"claude-sonnet-4.5", "claude-3-haiku",
"MiniMax-M3",
"llava:13b", "qwen2.5-vl-7b", "llama3.2-vision", "pixtral-12b", "moondream",
}
textOnly := []string{
@@ -38,7 +39,7 @@ func TestSupportsVisionFallbackForUnknownModels(t *testing.T) {
t.Error("gpt-4o should be vision (catalog)")
}
// Unknown-to-catalog but real vision models now pass via the name fallback.
for _, m := range []string{"gemini-3-flash-preview", "grok-4.3"} {
for _, m := range []string{"gemini-3-flash-preview", "grok-4.3", "MiniMax-M3"} {
if !SupportsVision(reg, m) {
t.Errorf("%q should be vision via name fallback", m)
}
+260
View File
@@ -0,0 +1,260 @@
package npmwrapper
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// TestBuildPlatformPackagesAssemblesPublishPayloads runs the release-time
// assembly script against a fixture linux-x64 archive shaped like the output
// of `zero-release package` and checks the npm publish payloads it emits:
// the platform payload (suffixed version, os/cpu gate, pack-safe helper
// shims, pruned agent-browser binaries) and the wrapper (no scripts, no
// dependencies, full optionalDependencies alias matrix).
func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses POSIX symlinks and shell scripts")
}
node := requireNode(t)
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@gitlawb/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
outDir := filepath.Join(root, "out")
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer runCancel()
command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
var platformPkg struct {
Name string `json:"name"`
Version string `json:"version"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Scripts map[string]string
}
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
if platformPkg.Name != "@gitlawb/zero" {
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
}
if want := version + "-linux-x64"; platformPkg.Version != want {
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
}
if len(platformPkg.OS) != 1 || platformPkg.OS[0] != "linux" || len(platformPkg.CPU) != 1 || platformPkg.CPU[0] != "x64" {
t.Fatalf("platform package os/cpu = %v/%v, want [linux]/[x64]", platformPkg.OS, platformPkg.CPU)
}
if len(platformPkg.Scripts) != 0 {
t.Fatalf("platform package has lifecycle scripts: %v", platformPkg.Scripts)
}
info, err := os.Stat(filepath.Join(platformDir, "zero"))
if err != nil {
t.Fatalf("platform package binary missing: %v", err)
}
if info.Mode()&0o111 == 0 {
t.Fatalf("platform package binary is not executable: %v", info.Mode())
}
for _, excluded := range []string{"README.md", "VERSION", "bin"} {
if _, err := os.Stat(filepath.Join(platformDir, excluded)); !os.IsNotExist(err) {
t.Fatalf("platform package still contains wrapper-owned %q", excluded)
}
}
// npm only ships a license file it finds in the packed directory, so the
// assembly must place the repo LICENSE into every payload.
if _, err := os.Stat(filepath.Join(platformDir, "LICENSE")); err != nil {
t.Fatalf("platform package missing LICENSE: %v", err)
}
// npm pack drops symlinks, so the shim must now be a regular executable file.
shimPath := filepath.Join(platformDir, "helpers", "node_modules", ".bin", "agent-browser")
shimInfo, err := os.Lstat(shimPath)
if err != nil {
t.Fatalf("agent-browser shim missing: %v", err)
}
if shimInfo.Mode()&os.ModeSymlink != 0 {
t.Fatalf("agent-browser shim is still a symlink; npm pack would drop it")
}
if shimInfo.Mode()&0o111 == 0 {
t.Fatalf("agent-browser shim is not executable: %v", shimInfo.Mode())
}
shim, err := os.ReadFile(shimPath)
if err != nil {
t.Fatalf("ReadFile shim: %v", err)
}
if !strings.Contains(string(shim), "../agent-browser/bin/agent-browser.js") {
t.Fatalf("shim does not exec the vendored helper: %q", string(shim))
}
if _, err := os.Stat(filepath.Join(platformDir, "helpers", "node_modules", ".bin", "tuistory")); err != nil {
t.Fatalf("tuistory shim missing: %v", err)
}
// Pruning: only the linux-x64 (glibc + musl) agent-browser binaries survive.
prunedBinDir := filepath.Join(platformDir, "helpers", "node_modules", "agent-browser", "bin")
entries, err := os.ReadDir(prunedBinDir)
if err != nil {
t.Fatalf("ReadDir pruned bin: %v", err)
}
got := []string{}
for _, entry := range entries {
got = append(got, entry.Name())
}
want := map[string]bool{
"agent-browser.js": true,
"agent-browser-linux-x64": true,
"agent-browser-linux-musl-x64": true,
}
if len(got) != len(want) {
t.Fatalf("pruned agent-browser bin = %v, want exactly %v", got, want)
}
for _, name := range got {
if !want[name] {
t.Fatalf("pruning left unexpected %q (full set: %v)", name, got)
}
}
wrapperDir := filepath.Join(outDir, "wrapper")
var wrapperPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Scripts map[string]string `json:"scripts"`
Dependencies map[string]string `json:"dependencies"`
Optional map[string]string `json:"optionalDependencies"`
}
unmarshalJSONFile(t, filepath.Join(wrapperDir, "package.json"), &wrapperPkg)
if wrapperPkg.Version != version {
t.Fatalf("wrapper version = %q, want %q", wrapperPkg.Version, version)
}
if len(wrapperPkg.Scripts) != 0 {
t.Fatalf("published wrapper has lifecycle scripts: %v", wrapperPkg.Scripts)
}
if len(wrapperPkg.Dependencies) != 0 {
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
}
wantAliases := map[string]string{
"@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64",
"@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64",
"@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64",
"@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64",
"@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64",
}
if len(wrapperPkg.Optional) != len(wantAliases) {
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
}
for alias, spec := range wantAliases {
if wrapperPkg.Optional[alias] != spec {
t.Fatalf("wrapper optionalDependencies[%q] = %q, want %q", alias, wrapperPkg.Optional[alias], spec)
}
}
for _, file := range []string{"bin/zero.js", "scripts/postinstall.mjs", "README.md", "LICENSE"} {
if _, err := os.Stat(filepath.Join(wrapperDir, filepath.FromSlash(file))); err != nil {
t.Fatalf("wrapper payload missing %s: %v", file, err)
}
}
}
func unmarshalJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("Unmarshal %s: %v", path, err)
}
}
func writeFixtureFile(t *testing.T, path string, content string, mode os.FileMode) {
t.Helper()
mustMkdirAllFixture(t, filepath.Dir(path))
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
if mode&0o111 != 0 {
if err := os.Chmod(path, mode); err != nil {
t.Fatalf("Chmod %s: %v", path, err)
}
}
}
func mustMkdirAllFixture(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", path, err)
}
}
func requireCommand(t *testing.T, name string) string {
t.Helper()
path, err := exec.LookPath(name)
if err != nil {
t.Skipf("%s is not available", name)
}
return path
}
+142 -10
View File
@@ -41,16 +41,13 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
if pkg.Module != "bin/zero.js" {
t.Fatalf("module = %q, want bin/zero.js", pkg.Module)
}
// Only a postinstall hook (which downloads the prebuilt binary) is allowed.
// Repository build scripts (build/prepare/prepack/…) must not ship in the
// published package — the tarball has no Go source to build from.
if pkg.Scripts["postinstall"] != "node scripts/postinstall.mjs" {
t.Fatalf("scripts.postinstall = %q, want node scripts/postinstall.mjs", pkg.Scripts["postinstall"])
}
// The published package must carry NO lifecycle scripts at all: the native
// binary arrives as a platform optionalDependency, with scripts/postinstall.mjs
// kept only as a first-run fallback the wrapper invokes itself. Any script
// here would resurrect the npm/Bun/pnpm install-script warnings the platform
//-package model exists to eliminate (see docs/NPM_PACKAGING.md).
for name := range pkg.Scripts {
if name != "postinstall" {
t.Fatalf("package.json scripts contains %q; only a postinstall hook is allowed (no repository build scripts)", name)
}
t.Fatalf("package.json scripts contains %q; the published package must have no lifecycle scripts", name)
}
if pkg.License == "" {
t.Fatalf("package.json license is empty; set it (ties to the pending LICENSE file) so npm publish is not unlicensed")
@@ -256,11 +253,146 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
if !ok || exitErr.ExitCode() != 1 {
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
}
if !strings.Contains(string(output), "No native binary found next to the npm wrapper") {
if !strings.Contains(string(output), "No native binary is available for this install") {
t.Fatalf("missing-native output = %q", string(output))
}
}
func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
// A stale downloaded binary next to the wrapper must lose to the platform
// package: the platform version is pinned to the wrapper release, the
// downloaded copy is whatever a previous fallback fetched.
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
platformDir := filepath.Join(root, "node_modules", "@gitlawb", "zero-"+nodePlatformName()+"-"+nodeArchName())
if err := os.MkdirAll(platformDir, 0o755); err != nil {
t.Fatalf("MkdirAll platform package: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
t.Fatalf("WriteFile platform package.json: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "zero"), []byte("#!/usr/bin/env sh\nprintf 'platform-zero'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile platform binary: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching platform binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "platform-zero --version" {
t.Fatalf("wrapper output = %q, want platform-zero --version", got)
}
}
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching downloaded binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "downloaded-zero" {
t.Fatalf("wrapper output = %q, want downloaded-zero", got)
}
}
func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) {
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
// Give the fixture the real downloader so the wrapper's first-run path
// executes it; ZERO_SKIP_DOWNLOAD keeps the test offline, so the download
// "succeeds" without producing a binary and the wrapper must exit 1 with
// guidance.
scriptsDir := filepath.Join(root, "scripts")
if err := os.MkdirAll(scriptsDir, 0o755); err != nil {
t.Fatalf("MkdirAll scripts: %v", err)
}
postinstall, err := os.ReadFile(filepath.Join(repoRoot(t), "scripts", "postinstall.mjs"))
if err != nil {
t.Fatalf("ReadFile postinstall: %v", err)
}
if err := os.WriteFile(filepath.Join(scriptsDir, "postinstall.mjs"), postinstall, 0o644); err != nil {
t.Fatalf("WriteFile postinstall fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil {
t.Fatalf("WriteFile package fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
command.Env = append(command.Env, "ZERO_SKIP_DOWNLOAD=1")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out on first-run download path: %v; output: %s", ctx.Err(), output)
}
exitErr, ok := err.(*exec.ExitError)
if !ok || exitErr.ExitCode() != 1 {
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
}
text := string(output)
if !strings.Contains(text, "fetching the native binary from the GitHub Release") {
t.Fatalf("output missing fallback download notice: %q", text)
}
if !strings.Contains(text, "skipping native binary download") {
t.Fatalf("output shows the downloader did not run: %q", text)
}
if !strings.Contains(text, "No native binary is available for this install") {
t.Fatalf("output missing guidance: %q", text)
}
}
func nodePlatformName() string {
switch runtime.GOOS {
case "darwin":
return "darwin"
case "windows":
return "win32"
default:
return runtime.GOOS
}
}
func nodeArchName() string {
switch runtime.GOARCH {
case "amd64":
return "x64"
default:
return runtime.GOARCH
}
}
func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
+17 -22
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/lockutil"
)
const (
@@ -38,11 +40,11 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
// other processes. Fail closed: remove the file and surface the error.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
}
var released bool
@@ -52,7 +54,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
@@ -69,7 +71,18 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
if reclaimStaleLock(lockPath, token, fileLockStaleAfter) {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
@@ -81,21 +94,3 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
time.Sleep(10 * time.Millisecond)
}
}
// reclaimStaleLock atomically reclaims a stale lock file: it renames the lock to a
// per-acquirer unique name (only one racer can rename a given file), then verifies
// the moved file is still stale; if a holder reacquired it in the gap it is
// restored rather than stolen. Returns true only when a genuinely stale lock was
// removed. Mirrors internal/cron/lock.go and internal/hooks/lock.go.
func reclaimStaleLock(lockPath, token string, staleAfter time.Duration) bool {
reclaimed := fmt.Sprintf("%s.stale.%s", lockPath, token)
if err := os.Rename(lockPath, reclaimed); err != nil {
return false
}
if info, err := os.Stat(reclaimed); err == nil && time.Since(info.ModTime()) <= staleAfter {
_ = os.Rename(reclaimed, lockPath)
return false
}
_ = os.Remove(reclaimed)
return true
}
+4 -3
View File
@@ -460,6 +460,9 @@ func expandPluginRoot(value string, pluginDir string) string {
// e.g. URLs or flag values).
func expandPluginRootPath(value string, pluginDir string) string {
if !strings.Contains(value, pluginRootPlaceholder) {
if !filepath.IsAbs(value) && !isWindowsAbs(value) && !isRootedPath(value) && (strings.Contains(value, "/") || strings.Contains(value, "\\")) {
return filepath.Join(pluginDir, value)
}
return value
}
return filepath.FromSlash(strings.ReplaceAll(value, pluginRootPlaceholder, pluginDir))
@@ -617,14 +620,12 @@ func (tool pluginTool) meta() map[string]string {
// (parsePermission clamps allow→prompt otherwise), so honoring it does not
// silently auto-approve a mutating plugin tool.
func toolSafety(plugin LoadedPlugin, ext ToolExtension) tools.Safety {
permission := tools.PermissionPrompt
var permission tools.Permission
switch ext.Permission {
case PermissionAllow:
permission = tools.PermissionAllow
case PermissionDeny:
permission = tools.PermissionDeny
case PermissionPrompt:
permission = tools.PermissionPrompt
default:
permission = tools.PermissionPrompt
}
+21
View File
@@ -536,3 +536,24 @@ func containsSubstring(values []string, want string) bool {
}
return false
}
func TestExpandPluginRootPathRelative(t *testing.T) {
pluginDir := "/etc/zero/plugin"
cases := []struct {
input string
want string
}{
{"./bin/tool.sh", filepath.Join(pluginDir, "./bin/tool.sh")},
{"../outside/tool", filepath.Join(pluginDir, "../outside/tool")},
{"tool.sh", "tool.sh"}, // bare binary name
{"/usr/bin/tool", "/usr/bin/tool"}, // absolute Unix
{`C:\bin\tool`, `C:\bin\tool`}, // absolute Windows
{"${AGENT_PLUGIN_ROOT}/bin/tool.sh", filepath.FromSlash(pluginDir + "/bin/tool.sh")},
}
for _, tc := range cases {
got := expandPluginRootPath(tc.input, pluginDir)
if got != tc.want {
t.Errorf("expandPluginRootPath(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
+36 -2
View File
@@ -21,10 +21,33 @@ type Model struct {
Source string
}
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
// per call, so the shared backing slice cannot be mutated by callers.
// Flat cost fields stay unset because MiniMax-M3 pricing is tiered and this
// catalog model shape cannot represent those tiers without losing information.
var minimaxCuratedModels = []Model{
{ID: "MiniMax-M3", Description: "catalog default"},
{
ID: "MiniMax-M3",
Description: "catalog default",
ContextWindow: 1_000_000,
ToolCall: true,
Reasoning: true,
InputModalities: []string{"text", "image", "video"},
OutputModalities: []string{"text"},
Source: minimaxModelSource,
},
{
ID: "MiniMax-M2.7",
Description: "agentic coding model",
ContextWindow: 204_800,
ToolCall: true,
Reasoning: true,
InputModalities: []string{"text"},
OutputModalities: []string{"text"},
Source: minimaxModelSource,
},
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
}
@@ -222,10 +245,21 @@ func dedupeModels(defaultModel string, models []Model) []Model {
if model.Description == "" {
model.Description = "catalog model"
}
model.InputModalities = append([]string{}, model.InputModalities...)
model.OutputModalities = append([]string{}, model.OutputModalities...)
model.Tags = append([]string{}, model.Tags...)
seen[model.ID] = true
result = append(result, model)
}
add(Model{ID: defaultModel, Description: "catalog default"})
defaultEntry := Model{ID: defaultModel, Description: "catalog default"}
for _, model := range models {
if strings.TrimSpace(model.ID) == strings.TrimSpace(defaultModel) {
defaultEntry = model
defaultEntry.Description = "catalog default"
break
}
}
add(defaultEntry)
for _, model := range models {
add(model)
}
+37 -6
View File
@@ -1,6 +1,7 @@
package providermodelcatalog
import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/providercatalog"
@@ -39,7 +40,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M2.1"},
want: []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
@@ -89,18 +90,19 @@ func TestModelsAreProviderScoped(t *testing.T) {
}
func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
descriptor, ok := providercatalog.Get("groq")
descriptor, ok := providercatalog.Get("minimax")
if !ok {
t.Fatal("provider groq missing from catalog")
t.Fatal("provider minimax missing from catalog")
}
first := Models(descriptor)
if len(first) == 0 {
t.Fatal("expected groq models")
if len(first) == 0 || len(first[0].InputModalities) == 0 {
t.Fatal("expected MiniMax models with input modalities")
}
first[0].ID = "mutated"
first[0].InputModalities[0] = "mutated"
second := Models(descriptor)
if second[0].ID == "mutated" {
if second[0].ID == "mutated" || second[0].InputModalities[0] == "mutated" {
t.Fatal("Models returned aliased mutable catalog state")
}
}
@@ -113,6 +115,35 @@ func modelIDs(models []Model) []string {
return ids
}
func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
descriptor, ok := providercatalog.Get("minimax")
if !ok {
t.Fatal("provider minimax missing from catalog")
}
models := Models(descriptor)
if len(models) < 2 || models[0].ID != "MiniMax-M3" || models[1].ID != "MiniMax-M2.7" {
t.Fatalf("MiniMax current models = %#v, want MiniMax-M3 followed by MiniMax-M2.7", modelIDs(models))
}
m3 := models[0]
if m3.ContextWindow != 1_000_000 || !m3.ToolCall || !m3.Reasoning {
t.Fatalf("MiniMax-M3 capabilities = %#v, want 1M context with tools and reasoning", m3)
}
if !reflect.DeepEqual(m3.InputModalities, []string{"text", "image", "video"}) ||
!reflect.DeepEqual(m3.OutputModalities, []string{"text"}) {
t.Fatalf("MiniMax-M3 modalities = input:%#v output:%#v", m3.InputModalities, m3.OutputModalities)
}
m27 := models[1]
if m27.ContextWindow != 204_800 || !m27.ToolCall || !m27.Reasoning {
t.Fatalf("MiniMax-M2.7 capabilities = %#v, want 204.8K context with tools and reasoning", m27)
}
if !reflect.DeepEqual(m27.InputModalities, []string{"text"}) ||
!reflect.DeepEqual(m27.OutputModalities, []string{"text"}) {
t.Fatalf("MiniMax-M2.7 modalities = input:%#v output:%#v", m27.InputModalities, m27.OutputModalities)
}
}
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
// international provider; a divergence here is almost always a mistake.
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
+19 -12
View File
@@ -55,19 +55,25 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
switch resolved.providerKind {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
// prompt_cache_key is an OpenAI-only chat-completions field. Strict
// openai-compatible gateways (NVIDIA NIM, etc.) reject it with a 400
// instead of ignoring unknown parameters — so omit it for every
// openai-compatible profile. Official OpenAI keeps the field so
// multi-turn sessions still route to a cached-prefix replica.
return openai.New(openai.Options{
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
DisablePromptCacheKey: resolved.providerKind == config.ProviderKindOpenAICompatible,
})
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return anthropic.New(anthropic.Options{
@@ -121,6 +127,7 @@ func modelMayEmitThinkTags(model string) bool {
"glm-z1",
"kimi-k2-thinking",
"magistral",
"minimax-m2",
"minimax-m3",
"nemotron",
"qwen3",
+131
View File
@@ -56,6 +56,81 @@ func TestNewCreatesOpenAIProviderWithFactoryOptions(t *testing.T) {
}
}
func TestNewUsesMiniMaxCompatibleEndpoints(t *testing.T) {
tests := []struct {
name string
catalogID string
providerKind config.ProviderKind
baseURL string
responseBody string
wantURL string
}{
{
name: "global Anthropic",
catalogID: "minimax",
providerKind: config.ProviderKindAnthropicCompat,
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
wantURL: "https://api.minimax.io/anthropic/v1/messages",
},
{
name: "China Anthropic",
catalogID: "minimaxi-cn",
providerKind: config.ProviderKindAnthropicCompat,
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
wantURL: "https://api.minimaxi.com/anthropic/v1/messages",
},
{
name: "global OpenAI",
catalogID: "custom-openai-compatible",
providerKind: config.ProviderKindOpenAICompatible,
baseURL: "https://api.minimax.io/v1",
responseBody: "data: [DONE]\n\n",
wantURL: "https://api.minimax.io/v1/chat/completions",
},
{
name: "China OpenAI",
catalogID: "custom-openai-compatible",
providerKind: config.ProviderKindOpenAICompatible,
baseURL: "https://api.minimaxi.com/v1",
responseBody: "data: [DONE]\n\n",
wantURL: "https://api.minimaxi.com/v1/chat/completions",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
transport := &captureTransport{responseBody: test.responseBody}
provider, err := New(config.ProviderProfile{
Name: test.name,
CatalogID: test.catalogID,
ProviderKind: test.providerKind,
BaseURL: test.baseURL,
APIKey: "sk-minimax",
Model: "MiniMax-M3",
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
if got := transport.request.URL.String(); got != test.wantURL {
t.Fatalf("request URL = %q, want %q", got, test.wantURL)
}
})
}
}
func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
@@ -159,11 +234,67 @@ func TestNewSupportsOpenAIProviderKind(t *testing.T) {
}
}
// TestPromptCacheKeyOnlyOnOfficialOpenAI locks in #624: session-backed TUI
// turns always carry a PromptCacheKey, but openai-compatible gateways (NVIDIA
// NIM, strict local proxies) reject the OpenAI-only prompt_cache_key field.
// The factory must omit it for openai-compatible profiles while still
// forwarding it for official OpenAI so multi-turn cache routing stays intact.
func TestPromptCacheKeyOnlyOnOfficialOpenAI(t *testing.T) {
requestWithSession := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "sess_tui_123",
}
for _, tc := range []struct {
name string
kind config.ProviderKind
wantCacheKey bool
}{
{name: "openai", kind: config.ProviderKindOpenAI, wantCacheKey: true},
{name: "openai-compatible", kind: config.ProviderKindOpenAICompatible, wantCacheKey: false},
} {
t.Run(tc.name, func(t *testing.T) {
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "test",
ProviderKind: tc.kind,
BaseURL: "https://provider.example/v1",
APIKey: "sk-test",
Model: "test-model",
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), requestWithSession)
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
var body map[string]any
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
_, hasKey := body["prompt_cache_key"]
if hasKey != tc.wantCacheKey {
t.Fatalf("prompt_cache_key present = %v, want %v; body = %#v", hasKey, tc.wantCacheKey, body)
}
if tc.wantCacheKey && body["prompt_cache_key"] != "sess_tui_123" {
t.Fatalf("prompt_cache_key = %#v, want sess_tui_123", body["prompt_cache_key"])
}
})
}
}
func TestParseThinkTagsForProfileUsesConservativeDefaultsAndOverride(t *testing.T) {
openAICompatible := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "qwen3-coder:480b"}
if !parseThinkTagsForProfile(config.ProviderProfile{}, openAICompatible) {
t.Fatal("qwen3 OpenAI-compatible model should parse inline think tags by default")
}
minimaxM27 := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "MiniMax-M2.7"}
if !parseThinkTagsForProfile(config.ProviderProfile{}, minimaxM27) {
t.Fatal("MiniMax-M2.7 OpenAI-compatible model should parse inline think tags by default")
}
generic := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "factory-model"}
if parseThinkTagsForProfile(config.ProviderProfile{}, generic) {
+45 -34
View File
@@ -58,25 +58,33 @@ type Options struct {
// "originator" value. It is also called on the 401-refresh retry, so any
// per-request state must be re-derivable from the live request.
SetRequestExtra func(*http.Request)
// DisablePromptCacheKey omits OpenAI's prompt_cache_key even when the
// caller supplies a session identity. Used for openai-compatible gateways
// (NVIDIA NIM, strict local proxies, …) that validate and reject unknown
// request fields instead of ignoring them. Official OpenAI keeps the field
// enabled; the ZERO_DISABLE_PROMPT_CACHE_KEY env kill switch still applies
// on top for any endpoint.
DisablePromptCacheKey bool
}
// Provider streams completions from an OpenAI-compatible chat completions API.
type Provider struct {
apiKey string
baseURL string
endpoint string
model string
authHeader string
authScheme string
authHeaderValue string
customHeaders map[string]string
oauthResolver providerio.TokenResolver
maxTokens int
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
parseThinkTags bool
setRequestExtra func(*http.Request)
apiKey string
baseURL string
endpoint string
model string
authHeader string
authScheme string
authHeaderValue string
customHeaders map[string]string
oauthResolver providerio.TokenResolver
maxTokens int
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
parseThinkTags bool
setRequestExtra func(*http.Request)
disablePromptCacheKey bool
}
// New creates an OpenAI-compatible provider.
@@ -116,21 +124,22 @@ func New(options Options) (*Provider, error) {
}
return &Provider{
apiKey: options.APIKey,
baseURL: baseURL,
endpoint: endpoint,
model: model,
authHeader: strings.TrimSpace(options.AuthHeader),
authScheme: strings.TrimSpace(options.AuthScheme),
authHeaderValue: strings.TrimSpace(options.AuthHeaderValue),
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
oauthResolver: options.OAuthResolver,
maxTokens: maxTokens,
httpClient: httpClient,
userAgent: options.UserAgent,
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
parseThinkTags: options.ParseThinkTags,
setRequestExtra: options.SetRequestExtra,
apiKey: options.APIKey,
baseURL: baseURL,
endpoint: endpoint,
model: model,
authHeader: strings.TrimSpace(options.AuthHeader),
authScheme: strings.TrimSpace(options.AuthScheme),
authHeaderValue: strings.TrimSpace(options.AuthHeaderValue),
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
oauthResolver: options.OAuthResolver,
maxTokens: maxTokens,
httpClient: httpClient,
userAgent: options.UserAgent,
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
parseThinkTags: options.ParseThinkTags,
setRequestExtra: options.SetRequestExtra,
disablePromptCacheKey: options.DisablePromptCacheKey,
}, nil
}
@@ -458,10 +467,12 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
mapped.ReasoningEffort = effort
}
// prompt_cache_key is a documented OpenAI parameter; compatible servers
// ignore unknown fields, but a strict endpoint that rejects it can be
// accommodated with ZERO_DISABLE_PROMPT_CACHE_KEY=1.
if key := strings.TrimSpace(request.PromptCacheKey); key != "" && !promptCacheKeyDisabled() {
// prompt_cache_key is a documented OpenAI parameter for server-side prefix
// cache routing. Official OpenAI accepts it; many openai-compatible
// gateways (NVIDIA NIM, strict local proxies) reject unknown fields with a
// 400. Those providers are constructed with DisablePromptCacheKey, and any
// endpoint can still force-omit via ZERO_DISABLE_PROMPT_CACHE_KEY=1.
if key := strings.TrimSpace(request.PromptCacheKey); key != "" && !provider.disablePromptCacheKey && !promptCacheKeyDisabled() {
mapped.PromptCacheKey = key
}
if len(request.Tools) > 0 {
+23 -2
View File
@@ -1300,8 +1300,9 @@ func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
// TestOpenAIRequestPromptCacheKey locks in prompt_cache_key forwarding: a
// session-carrying request serializes the key so the backend can route to a
// replica holding the cached prefix, a keyless request omits the field
// entirely (strict servers see byte-identical requests to before), and
// ZERO_DISABLE_PROMPT_CACHE_KEY suppresses it for endpoints that reject it.
// entirely (strict servers see byte-identical requests to before),
// DisablePromptCacheKey (used for openai-compatible gateways) suppresses it,
// and ZERO_DISABLE_PROMPT_CACHE_KEY is the env kill switch for any endpoint.
func TestOpenAIRequestPromptCacheKey(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
@@ -1332,6 +1333,26 @@ func TestOpenAIRequestPromptCacheKey(t *testing.T) {
t.Fatalf("keyless request must omit prompt_cache_key: %s", data)
}
// openai-compatible providers are constructed with DisablePromptCacheKey so
// strict gateways (NVIDIA NIM, …) never see the OpenAI-only field.
compat, err := New(Options{Model: "gpt-test", DisablePromptCacheKey: true})
if err != nil {
t.Fatalf("New(DisablePromptCacheKey) returned error: %v", err)
}
req = compat.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
PromptCacheKey: "sess_123",
})
if req.PromptCacheKey != "" {
t.Fatalf("DisablePromptCacheKey ignored; PromptCacheKey = %q", req.PromptCacheKey)
}
if data, err = json.Marshal(req); err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(data), "prompt_cache_key") {
t.Fatalf("compatible provider must omit prompt_cache_key: %s", data)
}
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", "1")
req = provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
+3 -1
View File
@@ -11,7 +11,9 @@ type chatCompletionRequest struct {
// PromptCacheKey asks the backend to route the request to a replica that
// already holds this conversation's prefix in its prompt cache (the OpenAI
// `prompt_cache_key` parameter). Omitted when the caller carries no session
// identity or when ZERO_DISABLE_PROMPT_CACHE_KEY is set.
// identity, when the provider was constructed with DisablePromptCacheKey
// (openai-compatible gateways that reject unknown fields), or when
// ZERO_DISABLE_PROMPT_CACHE_KEY is set.
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
}
+58 -3
View File
@@ -41,11 +41,21 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
if !stringSliceContains(profile.FileSystem.ReadRoots, profileRootPath()) {
t.Fatalf("read roots = %#v, want full read root %q", profile.FileSystem.ReadRoots, profileRootPath())
}
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".git") || !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".zero") {
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".zero") || !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".agents") {
t.Fatalf("protected metadata names = %#v, want workspace metadata protected", profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
}
if len(profile.FileSystem.DenyRead) != 1 || len(profile.FileSystem.DenyWrite) != 1 {
t.Fatalf("deny paths = %#v / %#v, want one each", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite)
resolvedRoot := profile.FileSystem.WriteRoots[0].Root
wantGitCarveouts := []string{filepath.Join(resolvedRoot, ".git", "hooks"), filepath.Join(resolvedRoot, ".git", "config")}
for _, want := range wantGitCarveouts {
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, want) {
t.Fatalf("read-only subpaths = %#v, want git metadata carveout %q", profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, want)
}
}
// DenyRead may also carry default credential-store entries when the host
// has them, so assert containment rather than an exact count. Compare the
// normalized (symlink-resolved) form the profile stores.
if !stringSliceContains(profile.FileSystem.DenyRead, normalizeProfilePaths([]string{denyRead})[0]) || len(profile.FileSystem.DenyWrite) != 1 {
t.Fatalf("deny paths = %#v / %#v, want configured entries present", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite)
}
if profile.Network.Mode != NetworkDeny {
t.Fatalf("network profile = %#v, want deny", profile.Network)
@@ -363,3 +373,48 @@ func mkdirAll(paths ...string) error {
}
return nil
}
func TestCredentialDenyReadPathsIn(t *testing.T) {
home := t.TempDir()
awsDir := filepath.Join(home, ".aws")
gcloudDir := filepath.Join(home, ".config", "gcloud")
if err := mkdirAll(awsDir, gcloudDir); err != nil {
t.Fatal(err)
}
keyFile := filepath.Join(home, "sa-key.json")
if err := os.WriteFile(keyFile, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
paths := credentialDenyReadPathsIn(home, keyFile, nil)
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
if !stringSliceContains(paths, want) {
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
}
}
// A path the host does not have is dropped, not emitted blind.
if stringSliceContains(paths, filepath.Join(home, ".azure")) {
t.Errorf("credential deny paths = %#v, must not include the absent ~/.azure", paths)
}
// An explicit AllowRead entry covering a store is an opt-out.
optedOut := credentialDenyReadPathsIn(home, keyFile, []string{awsDir})
if stringSliceContains(optedOut, normalizeProfilePaths([]string{awsDir})[0]) {
t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop ~/.aws", optedOut)
}
if !stringSliceContains(optedOut, normalizeProfilePaths([]string{keyFile})[0]) {
t.Errorf("credential deny paths = %#v, want unrelated entries kept after opt-out", optedOut)
}
if got := credentialDenyReadPathsIn(" ", "", nil); len(got) != 0 {
t.Errorf("credential deny paths for blank home = %#v, want none", got)
}
// The GOOGLE_APPLICATION_CREDENTIALS target stays protected even when no
// home directory is resolvable.
homeless := credentialDenyReadPathsIn("", keyFile, nil)
if !stringSliceContains(homeless, normalizeProfilePaths([]string{keyFile})[0]) {
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
}
}
+90 -2
View File
@@ -3,6 +3,7 @@ package sandbox
import (
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -39,8 +40,34 @@ type NetworkPolicy struct {
Mode NetworkMode `json:"mode"`
}
// protectedMetadataNames marks control-plane directories where the app-level
// auto-allow gate (see relativePathTouchesProtectedMetadata in engine.go)
// always requires a prompt for direct file-tool writes (write_file, edit_file,
// apply_patch): hand-editing git's objects/refs/index or Zero's own state
// bypasses git's and Zero's own consistency checks, regardless of subpath.
var protectedMetadataNames = []string{".git", ".zero", ".agents"}
// sandboxFullyProtectedMetadataNames are the metadata directories the OS-level
// sandbox write-denies in full for shell-executed commands. .git is
// deliberately excluded here: git subprocesses (fetch, commit, add, merge,
// pull, stash, ...) need to write objects, refs, the index, and FETCH_HEAD,
// and those writes go through git's own invariants, unlike a raw file-tool
// write. Only .git/hooks (auto-executing scripts) and .git/config (remote
// URLs, credential.helper, core.hooksPath) stay write-denied, via
// gitMetadataWriteCarveouts below.
var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"}
// gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied
// under the OS-level sandbox even though the rest of .git is writable to git
// subprocesses. Nonexistent paths are harmless no-ops in every backend's
// enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry).
func gitMetadataWriteCarveouts(root string) []string {
return []string{
filepath.Join(root, ".git", "hooks"),
filepath.Join(root, ".git", "config"),
}
}
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
}
@@ -65,7 +92,8 @@ func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco
for _, root := range roots {
writeRoots = append(writeRoots, WritableRoot{
Root: root,
ProtectedMetadataNames: append([]string{}, protectedMetadataNames...),
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
ProtectedMetadataNames: append([]string{}, sandboxFullyProtectedMetadataNames...),
})
}
return PermissionProfile{
@@ -73,7 +101,7 @@ func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco
Kind: FileSystemRestricted,
ReadRoots: readRoots,
WriteRoots: writeRoots,
DenyRead: normalizeProfilePaths(policy.DenyRead),
DenyRead: dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)),
DenyWrite: normalizeProfilePaths(policy.DenyWrite),
IncludePlatformRoots: true,
AllowTemp: true,
@@ -119,6 +147,66 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
return dedupeStrings(readRoots)
}
// credentialDenyReadPaths returns default deny-read entries for well-known
// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file
// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read
// cloud secrets under the read-all workspace posture. Two deliberate limits:
//
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
// runner onto the capability-SID/ACL deny path and away from the
// WRITE_RESTRICTED token, which the unelevated tier depends on. Revisit
// once the Windows deny-read model is settled.
// - A candidate nested under a user-configured AllowRead entry is dropped,
// so `allowRead: ["~/.aws"]` remains an explicit opt-out.
//
// These are profile-level rules only; they are intentionally NOT merged into
// Policy.DenyRead, whose emptiness gates escalated (unsandboxed) execution and
// must keep reflecting user configuration alone.
func credentialDenyReadPaths(policy Policy) []string {
if runtime.GOOS == "windows" {
return nil
}
// A failed home lookup only drops the home-based candidates; the
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
home, _ := os.UserHomeDir()
return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead)
}
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
// separated so tests can exercise it against a synthetic home directory.
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
var candidates []string
if home = strings.TrimSpace(home); home != "" {
candidates = append(candidates,
filepath.Join(home, ".aws"),
filepath.Join(home, ".config", "gcloud"),
filepath.Join(home, ".azure"),
)
}
if target := strings.TrimSpace(googleCredentials); target != "" {
candidates = append(candidates, target)
}
allowRoots := normalizeProfilePaths(allowRead)
out := make([]string, 0, len(candidates))
for _, path := range normalizeProfilePaths(candidates) {
// Only stores that actually exist on this host need a deny rule.
if _, err := os.Stat(path); err != nil {
continue
}
reincluded := false
for _, allow := range allowRoots {
if pathWithinRoot(allow, path) {
reincluded = true
break
}
}
if !reincluded {
out = append(out, path)
}
}
return out
}
// userGitConfigReadPaths returns the user's global git config FILES so a
// sandboxed git can read identity and config (user.name/email, aliases) instead
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
+1
View File
@@ -61,6 +61,7 @@ func TestSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
},
DefaultPolicy(),
BackendMacOSSeatbelt,
"",
)
for _, want := range []string{
+70 -15
View File
@@ -10,6 +10,8 @@ import (
"runtime"
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/providercatalog"
)
var errNativeSandboxUnavailable = errors.New("native sandbox backend is unavailable")
@@ -205,7 +207,7 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap)
env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap, "")
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
@@ -316,7 +318,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
if envBackend == "" {
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend)
env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend, "")
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
@@ -351,7 +353,7 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy)
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
}
}
fs.DenyRead = normalizeProfilePaths(policy.DenyRead)
fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...))
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
return PermissionProfile{
FileSystem: fs,
@@ -370,11 +372,11 @@ func existingBubblewrapMounts() []string {
return mounts
}
func sandboxEnvironment(policy Policy, backend BackendName, _ string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend)
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
}
func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName) []string {
func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string {
env := cloneStrings(specEnv)
if specEnv == nil {
// Preserve the caller environment for sandboxed commands. The sandbox
@@ -382,6 +384,7 @@ func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend Backe
// command env values still replace inherited values below.
env = os.Environ()
}
env = scrubSensitiveEnv(env)
pathValue := envListValue(env, "PATH", defaultPath())
if runtime.GOOS == "darwin" {
// Preserve standard user tool locations so a bare `python3`/`node`
@@ -396,8 +399,15 @@ func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend Backe
"ZERO_SANDBOX_NETWORK=" + string(policy.Network),
EnvSandboxed + "=1",
}
if runtime.GOOS == "windows" {
overrides = append(overrides, "COMSPEC="+envListValue(env, "COMSPEC", "cmd.exe"))
if workspaceRoot != "" {
overrides = append(overrides, "HOME="+workspaceRoot)
}
if backend == BackendWindowsRestrictedToken || backend == BackendWindowsElevated {
overrides = append(overrides,
"COMSPEC="+envListValue(env, "COMSPEC", "cmd.exe"),
"SystemRoot="+envListValue(env, "SystemRoot", `C:\Windows`),
"WINDIR="+envListValue(env, "WINDIR", `C:\Windows`),
)
}
return upsertEnvList(env, overrides...)
}
@@ -409,13 +419,6 @@ func cloneStrings(values []string) []string {
return append([]string{}, values...)
}
func firstEnv(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envListValue(env []string, key string, fallback string) string {
for _, entry := range env {
existingKey, value, ok := strings.Cut(entry, "=")
@@ -963,3 +966,55 @@ func regexpQuoteMeta(value string) string {
)
return replacer.Replace(value)
}
func scrubSensitiveEnv(env []string) []string {
// Secrets not covered by the provider catalog: cloud/VCS credentials and
// providers Zero talks to through generic OpenAI-compatible endpoints.
sensitiveKeys := []string{
"COHERE_API_KEY",
"PERPLEXITY_API_KEY",
"ANYSCALE_API_KEY",
"AZURE_OPENAI_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GITLAB_TOKEN",
"GH_TOKEN",
"ZERO_WEBSEARCH_API_KEY",
"ZERO_DAEMON_REMOTE_TOKEN",
}
for _, descriptor := range providercatalog.All() {
for _, key := range descriptor.AuthEnvVars {
// AWS_PROFILE is a profile name, not a secret, and scrubbing it
// protects nothing: the SDKs fall back to the default profile in
// ~/.aws/credentials, which credentialDenyReadPaths blocks at the
// filesystem level instead. GOOGLE_APPLICATION_CREDENTIALS IS
// scrubbed: it points at a service-account key file at an
// arbitrary path, so hiding the pointer complements the deny-read
// rule on the file itself.
if key == "AWS_PROFILE" {
continue
}
sensitiveKeys = append(sensitiveKeys, key)
}
}
out := make([]string, 0, len(env))
for _, kv := range env {
key, _, ok := strings.Cut(kv, "=")
if !ok {
out = append(out, kv)
continue
}
drop := false
for _, sensitive := range sensitiveKeys {
if strings.EqualFold(key, sensitive) {
drop = true
break
}
}
if !drop {
out = append(out, kv)
}
}
return out
}
+66
View File
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
@@ -461,6 +462,35 @@ func TestSeatbeltProfileProtectsMetadataAndDenyOrdering(t *testing.T) {
}
}
// TestSeatbeltProfileAllowsGitWritesExceptHooksAndConfig locks in the fix for
// git subprocesses (fetch, commit, add, ...) failing under the sandbox: the
// default profile must stop write-denying the whole .git tree and only carve
// out .git/hooks and .git/config, which stay dangerous (auto-executing
// scripts, remote/credential-helper rewrites) regardless of what wrote them.
func TestSeatbeltProfileAllowsGitWritesExceptHooksAndConfig(t *testing.T) {
workspace := t.TempDir()
profile := DefaultPermissionProfile(workspace)
sbpl := seatbeltProfileFromPermissionProfile(profile, Policy{Mode: ModeEnforce}, "")
resolvedWorkspace := normalizeProfilePath(workspace)
gitRegex := `(deny file-write* (regex #"^` + regexpQuoteMeta(resolvedWorkspace) + `/\.git(/.*)?$"))`
if strings.Contains(sbpl, gitRegex) {
t.Fatalf("seatbelt profile must not blanket-deny the whole .git tree:\n%s", sbpl)
}
hooksPath := sandboxProfileString(filepath.Join(resolvedWorkspace, ".git", "hooks"))
configPath := sandboxProfileString(filepath.Join(resolvedWorkspace, ".git", "config"))
for _, want := range []string{
`(deny file-write* (literal "` + hooksPath + `"))`,
`(deny file-write* (subpath "` + hooksPath + `"))`,
`(deny file-write* (literal "` + configPath + `"))`,
`(deny file-write* (subpath "` + configPath + `"))`,
} {
if !strings.Contains(sbpl, want) {
t.Fatalf("seatbelt profile missing %q:\n%s", want, sbpl)
}
}
}
func TestSandboxExecProfileTagsDenialsWhenMonitoring(t *testing.T) {
off := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "")
if strings.Contains(off, "with message") {
@@ -608,3 +638,39 @@ func TestLinuxHelperPlanPreservesRealExtraRootCwd(t *testing.T) {
}
assertArgsContainSequence(t, bwrapArgs, "--chdir", resolvedExtra)
}
func TestScrubSensitiveEnv(t *testing.T) {
inputEnv := []string{
"PATH=/usr/bin",
"OPENAI_API_KEY=sk-proj-12345",
"ANTHROPIC_API_KEY=sk-ant-12345",
"GEMINI_API_KEY=AIzaSy12345",
"DEEPSEEK_API_KEY=ds-12345",
"GITHUB_TOKEN=ghp_12345",
"AWS_ACCESS_KEY_ID=AKIA12345",
"AWS_SECRET_ACCESS_KEY=secret12345",
"GOOGLE_API_KEY=AIzaSy67890",
"XAI_API_KEY=xai-12345",
"HUGGINGFACE_API_KEY=hf_12345",
"GOOGLE_APPLICATION_CREDENTIALS=/home/user/sa-key.json",
"AWS_PROFILE=staging",
"SAFE_VAR=hello",
}
scrubbed := scrubSensitiveEnv(inputEnv)
expected := map[string]string{
"PATH": "/usr/bin",
"SAFE_VAR": "hello",
"AWS_PROFILE": "staging",
}
actual := make(map[string]string, len(scrubbed))
for _, entry := range scrubbed {
key, value, _ := strings.Cut(entry, "=")
if _, dup := actual[key]; dup {
t.Errorf("duplicate key %q in scrubbed env: %v", key, scrubbed)
}
actual[key] = value
}
if !reflect.DeepEqual(actual, expected) {
t.Errorf("scrubSensitiveEnv() = %v, want %v", actual, expected)
}
}
@@ -145,12 +145,22 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
privateDir := filepath.Join(root, "private")
if err := os.MkdirAll(privateDir, 0o700); err != nil {
t.Fatalf("MkdirAll private: %v", err)
}
secretFile := filepath.Join(privateDir, "secret.txt")
if err := os.WriteFile(secretFile, []byte("super-secret"), 0o600); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
DenyRead: []string{privateDir},
IncludePlatformRoots: true,
AllowTemp: true,
},
@@ -179,6 +189,11 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
t.Fatalf("expected the unelevated setup marker to be recorded: %v", err)
}
// DenyRead check: reading from the privateDir must be blocked (exit code 1)
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", "type " + secretFile,
}, 1)
outsideMarker := filepath.Join(outside, "unelevated-write-denied.txt")
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", "echo leaked>" + outsideMarker,
@@ -101,6 +101,70 @@ func TestSeatbeltEnforcesExtraWriteRoots(t *testing.T) {
})
}
// TestSeatbeltAllowsGitMetadataWritesExceptHooksAndConfig proves under a real
// sandbox-exec run that git subprocesses can write their own metadata (the
// fix for git fetch/commit/add failing under the sandbox), while .git/hooks
// and .git/config - the two subpaths that can turn a write into code
// execution or a credential/remote hijack - stay kernel-denied.
func TestSeatbeltAllowsGitMetadataWritesExceptHooksAndConfig(t *testing.T) {
if _, err := exec.LookPath("sandbox-exec"); err != nil {
t.Skipf("sandbox-exec unavailable: %v", err)
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendMacOSSeatbelt {
t.Skipf("host sandbox backend is not sandbox-exec: %s", backend.Message)
}
workspace := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, ".git", "hooks"), 0o755); err != nil {
t.Fatalf("MkdirAll .git/hooks: %v", err)
}
if err := os.WriteFile(filepath.Join(workspace, ".git", "config"), []byte("[core]\n"), 0o644); err != nil {
t.Fatalf("WriteFile .git/config: %v", err)
}
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: backend,
})
resolvedWorkspace := resolvedTestPath(t, workspace)
t.Run("WriteToGitMetadataSucceeds", func(t *testing.T) {
target := filepath.Join(resolvedWorkspace, ".git", "FETCH_HEAD")
output, runErr := runSeatbeltShellWrite(t, engine, target, "fetch-head-ok")
if runErr != nil {
t.Fatalf("git-managed write under .git failed: %v\noutput: %s", runErr, output)
}
assertSeatbeltFileContent(t, target, "fetch-head-ok")
})
t.Run("WriteToGitHooksIsDenied", func(t *testing.T) {
target := filepath.Join(resolvedWorkspace, ".git", "hooks", "pre-commit")
output, runErr := runSeatbeltShellWrite(t, engine, target, "backdoor")
if runErr == nil {
t.Fatalf("write to .git/hooks succeeded, want seatbelt denial\noutput: %s", output)
}
if _, statErr := os.Lstat(target); !os.IsNotExist(statErr) {
t.Fatalf("Lstat(%s) = %v, want not-exist", target, statErr)
}
})
t.Run("WriteToGitConfigIsDenied", func(t *testing.T) {
target := filepath.Join(resolvedWorkspace, ".git", "config")
output, runErr := runSeatbeltShellWrite(t, engine, target, "[credential]\n\thelper = evil\n")
if runErr == nil {
t.Fatalf("write to .git/config succeeded, want seatbelt denial\noutput: %s", output)
}
content, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read back .git/config: %v", err)
}
if string(content) != "[core]\n" {
t.Fatalf(".git/config content = %q, want unchanged", content)
}
})
}
// runSeatbeltShellWrite launches /bin/sh through the engine's sandbox-exec
// wrapping and asks it to write content to target. It returns the combined
// output and the run error so callers can assert success or kernel denial.
@@ -69,7 +69,13 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
// this has no in-token fix; preflight blocking and output hints live in
// internal/tools/shell_runtime.go.
tokenSIDs := windowsRuntimeTokenSIDs(capabilitySIDs, offlineSID, config.PermissionProfile.Network.Mode)
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs)
// A WRITE_RESTRICTED token keeps reads unrestricted so sandboxed commands
// can actually launch executables; it is only unsafe when DenyRead paths
// are configured, because the kernel skips restricted-SID deny ACEs for
// reads under that flag (#612). Profiles with DenyRead keep the fully
// restricted token, trading spawn capability for read-deny enforcement.
writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0
token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
+1 -22
View File
@@ -326,7 +326,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
if err != nil {
return CommandPlan{}, err
}
childEnv := windowsSandboxChildEnv(spec.Env, policy, execRequest.WorkspaceRoot)
childEnv := sandboxEnvironmentForCommand(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot)
// The unelevated enforcement tier maps to the runner's unelevated level: same
// restricted token, but the runner applies the workspace ACLs itself instead
// of requiring the elevated setup marker.
@@ -367,27 +367,6 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
}, execRequest), nil
}
func windowsSandboxChildEnv(specEnv []string, policy Policy, workspaceRoot string) []string {
var env []string
if specEnv != nil {
env = cloneStrings(specEnv)
} else {
env = append(env, os.Environ()...)
}
env = upsertEnvList(env,
"HOME="+workspaceRoot,
"PATH="+firstEnv("PATH", defaultPath()),
"TERM="+firstEnv("TERM", "dumb"),
EnvSandboxBackend+"="+string(BackendWindowsRestrictedToken),
"ZERO_SANDBOX_NETWORK="+string(policy.Network),
EnvSandboxed+"=1",
"COMSPEC="+firstEnv("COMSPEC", "cmd.exe"),
"SystemRoot="+firstEnv("SystemRoot", `C:\Windows`),
"WINDIR="+firstEnv("WINDIR", `C:\Windows`),
)
return env
}
func upsertEnvList(env []string, values ...string) []string {
out := cloneStrings(env)
for _, value := range values {
+22 -4
View File
@@ -48,7 +48,7 @@ func (sid windowsLocalSID) close() {
}
}
func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string) (windows.Token, error) {
func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string, writeRestricted bool) (windows.Token, error) {
if len(capabilitySIDStrings) == 0 {
return 0, errors.New("windows restricted token requires at least one capability SID")
}
@@ -80,10 +80,10 @@ func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string
return 0, fmt.Errorf("open process token: %w", err)
}
defer base.Close()
return createWindowsRestrictedTokenFromBase(base, capabilitySIDs)
return createWindowsRestrictedTokenFromBase(base, capabilitySIDs, writeRestricted)
}
func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID) (windows.Token, error) {
func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []windowsLocalSID, writeRestricted bool) (windows.Token, error) {
logonSID, err := copyWindowsLogonSID(base)
if err != nil {
return 0, err
@@ -102,10 +102,28 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
windows.SIDAndAttributes{Sid: worldSID},
)
// WRITE_RESTRICTED scopes the restricted-SID check to write-type accesses:
// reads use only the normal token identity, so the sandboxed process can
// open executables, DLLs, and per-user config the user can read, while
// writes still require a restricted-SID match (the workspace write-jail).
// Without it the restricted-SID check applies to READS too, and because
// default Windows DACLs grant BUILTIN\Users rather than any SID in the
// restricted list, the token cannot open ANY executable outside the
// ACL-granted write roots — every spawned command dies silently.
//
// The flag also makes the kernel skip restricted-SID deny ACEs for reads,
// which is the DenyRead bypass fixed in #612 — so it is only safe when the
// profile has no DenyRead paths. The caller passes writeRestricted=false
// whenever DenyRead is configured, keeping #612's enforcement for exactly
// the profiles that need it.
flags := uint32(windowsDisableMaxPrivilege | windowsLUAToken)
if writeRestricted {
flags |= windowsWriteRestricted
}
var restricted windows.Token
result, _, callErr := procCreateRestrictedToken.Call(
uintptr(base),
uintptr(windowsDisableMaxPrivilege|windowsLUAToken|windowsWriteRestricted),
uintptr(flags),
0,
0,
0,
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/fsutil"
)
const windowsUnelevatedSetupMarkerSchemaVersion = 1
@@ -138,7 +140,7 @@ func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnele
_ = os.Remove(tmpPath)
return fmt.Errorf("close windows unelevated setup marker temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
if err := fsutil.RenameWithRetry(tmpPath, path, nil); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("replace windows unelevated setup marker: %w", err)
}
+48 -5
View File
@@ -15,6 +15,7 @@ import (
"io"
"os"
"path/filepath"
"sync/atomic"
"time"
)
@@ -22,11 +23,15 @@ const (
// secretBytes is the AES-256 key length kept in the per-user secret file.
secretBytes = 32
secretRetryAttempts = 500
secretRetryDelay = 2 * time.Millisecond
secretRetryAttempts = 500
secretRetryDelay = 2 * time.Millisecond
secureLockStaleAfter = 10 * time.Second
)
var openSecretLockFile = os.OpenFile
var (
openSecretLockFile = os.OpenFile
secureLockSeq atomic.Uint64
)
// Crypter encrypts a blob at rest with AES-256-GCM under a per-user random secret
// persisted (0600) at secretPath.
@@ -107,12 +112,25 @@ func loadOrCreateSecret(path string, create bool) ([]byte, error) {
func createSecretFile(path string) ([]byte, error) {
lockPath := path + ".lock"
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), secureLockSeq.Add(1))
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_ = lock.Close()
defer os.Remove(lockPath)
if _, werr := lock.WriteString(token); werr != nil {
_ = lock.Close()
_ = os.Remove(lockPath)
return nil, fmt.Errorf("securefile: write secret lock: %w", werr)
}
if cerr := lock.Close(); cerr != nil {
_ = os.Remove(lockPath)
return nil, fmt.Errorf("securefile: close secret lock: %w", cerr)
}
defer func() {
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = os.Remove(lockPath)
}
}()
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else if !errors.Is(rerr, os.ErrNotExist) {
@@ -133,6 +151,14 @@ func createSecretFile(path string) ([]byte, error) {
// contention/ACL problem is masked by the expected-while-waiting
// ErrNotExist once the retries are exhausted.
lastErr = err
// Reclaim a stale lock left by a crashed holder
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > secureLockStaleAfter {
if reclaimStaleLock(lockPath, token, secureLockStaleAfter) {
continue
}
}
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
}
@@ -141,6 +167,19 @@ func createSecretFile(path string) ([]byte, error) {
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
}
func reclaimStaleLock(lockPath, token string, staleAfter time.Duration) bool {
reclaimed := fmt.Sprintf("%s.stale.%s", lockPath, token)
if err := os.Rename(lockPath, reclaimed); err != nil {
return false
}
if info, err := os.Stat(reclaimed); err == nil && time.Since(info.ModTime()) <= staleAfter {
_ = os.Rename(reclaimed, lockPath)
return false
}
_ = os.Remove(reclaimed)
return true
}
func writeNewSecretFile(path string) ([]byte, error) {
secret := make([]byte, secretBytes)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
@@ -161,6 +200,10 @@ func writeNewSecretFile(path string) ([]byte, error) {
_ = tmp.Close()
return nil, fmt.Errorf("securefile: write secret: %w", werr)
}
if serr := tmp.Sync(); serr != nil {
_ = tmp.Close()
return nil, fmt.Errorf("securefile: sync secret: %w", serr)
}
if cerr := tmp.Close(); cerr != nil {
return nil, fmt.Errorf("securefile: write secret: %w", cerr)
}
+31
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"runtime"
"testing"
"time"
)
func TestCreateSecretFileRetriesOnPermissionContention(t *testing.T) {
@@ -107,3 +108,33 @@ func TestWrongSecretCannotDecrypt(t *testing.T) {
t.Fatal("expected decryption under a different secret to fail")
}
}
func TestCreateSecretFileReclaimsStaleLock(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
lockPath := secretPath + ".lock"
// Create a stale lock file manually
if err := os.WriteFile(lockPath, []byte("99999-12345-1"), 0o600); err != nil {
t.Fatal(err)
}
// Backdate the lock file's modification time so it is considered stale
staleTime := time.Now().Add(-2 * secureLockStaleAfter)
if err := os.Chtimes(lockPath, staleTime, staleTime); err != nil {
t.Fatal(err)
}
// Attempt to create the secret file; it should detect the stale lock, reclaim it, and succeed
secret, err := createSecretFile(secretPath)
if err != nil {
t.Fatalf("createSecretFile failed to reclaim stale lock: %v", err)
}
if len(secret) != secretBytes {
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
}
// The lock file should be removed after completion
if _, err := os.Stat(lockPath); err == nil {
t.Fatal("expected lock file to be cleaned up after successful acquisition")
}
}
+19 -12
View File
@@ -14,6 +14,8 @@ import (
"sync"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
)
const (
@@ -33,16 +35,21 @@ const (
EventProviderUsage EventType = "provider_usage"
EventUsage EventType = EventProviderUsage
EventError EventType = "error"
EventSessionCheckpoint EventType = "session_checkpoint"
EventSessionRewind EventType = "session_rewind"
EventCompaction EventType = "session_compaction"
EventSessionFork EventType = "session_fork"
EventSessionChild EventType = "session_child"
EventSpecialistStart EventType = "specialist_start"
EventSpecialistStop EventType = "specialist_stop"
EventSpecDraft EventType = "spec_draft"
EventSpecApproved EventType = "spec_approved"
EventSpecRejected EventType = "spec_rejected"
// EventRunIncomplete records a run that stopped with work clearly unfinished
// (plan stall, mid-step stop, max-turns cutoff) without a provider/tool error.
// Distinct from EventError so TUI resume replays it as a system notice, not a
// hard failure.
EventRunIncomplete EventType = "run_incomplete"
EventSessionCheckpoint EventType = "session_checkpoint"
EventSessionRewind EventType = "session_rewind"
EventCompaction EventType = "session_compaction"
EventSessionFork EventType = "session_fork"
EventSessionChild EventType = "session_child"
EventSpecialistStart EventType = "specialist_start"
EventSpecialistStop EventType = "specialist_stop"
EventSpecDraft EventType = "spec_draft"
EventSpecApproved EventType = "spec_approved"
EventSpecRejected EventType = "spec_rejected"
)
type SessionKind string
@@ -841,7 +848,7 @@ func (store *Store) writeMetadata(session Metadata) error {
if err := writeFileSync(tmp, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write zero session metadata: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
if err := fsutil.RenameWithRetry(tmp, path, nil); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace zero session metadata: %w", err)
}
@@ -904,7 +911,7 @@ func (store *Store) writeFileAtomicSync(path string, content []byte, perm os.Fil
if err := writeFileSync(tmp, content, perm); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
if err := fsutil.RenameWithRetry(tmp, path, nil); err != nil {
_ = os.Remove(tmp)
return err
}
+32 -21
View File
@@ -10,6 +10,9 @@ import (
"strings"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
"github.com/Gitlawb/zero/internal/lockutil"
)
// Mailbox is a per-agent, per-team message inbox persisted as a JSON array on
@@ -32,6 +35,9 @@ type Mailbox struct {
MaxMessages int
// LockTimeout bounds how long Send/MarkRead wait for the inbox lock.
LockTimeout time.Duration
// rename is used to override the rename operation in tests.
rename func(src, dst string) error
}
const (
@@ -214,7 +220,7 @@ func (m *Mailbox) Send(team, recipient string, msg Message) error {
return fmt.Errorf("%w: %d messages", ErrMailboxFull, len(messages))
}
messages = append(messages, msg)
return atomicWriteJSON(path, messages)
return m.atomicWriteJSON(path, messages)
}
// ReadAndConsume reads the recipient's inbox and marks every previously-unread
@@ -256,7 +262,7 @@ func (m *Mailbox) ReadAndConsume(team, recipient string) ([]Message, error) {
}
}
if changed {
if err := atomicWriteJSON(path, messages); err != nil {
if err := m.atomicWriteJSON(path, messages); err != nil {
return nil, err
}
}
@@ -299,7 +305,7 @@ func (m *Mailbox) readLocked(path string) ([]Message, error) {
// atomicWriteJSON writes data as pretty JSON to a sibling temp file (0600) then
// renames it over path, so a reader never observes a partial write.
func atomicWriteJSON(path string, data any) error {
func (m *Mailbox) atomicWriteJSON(path string, data any) error {
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("swarm: encode inbox: %w", err)
@@ -322,7 +328,7 @@ func atomicWriteJSON(path string, data any) error {
if err := tmp.Close(); err != nil {
return fmt.Errorf("swarm: close temp inbox: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
if err := m.renameWithRetry(tmpName, path); err != nil {
return fmt.Errorf("swarm: commit inbox: %w", err)
}
return nil
@@ -358,31 +364,32 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
os.Remove(lockPath)
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}
if !isLockContended(err) {
return nil, fmt.Errorf("swarm: acquire lock: %w", err)
}
// Lock held: break it if stale, otherwise wait. Reclaim ATOMICALLY via
// rename-with-verify. The previous content==content check read the same file
// twice microseconds apart, so it was always "unchanged" and gave no
// protection — two waiters could both Remove the lock while a third recreated
// it via O_EXCL, leaving two live holders. Renaming the file aside means only
// one racer wins the rename of a given inode; the moved file's mtime is then
// re-checked stale before deletion, and if a holder rotated a fresh lock in
// the gap it is renamed back rather than stolen. (AUDIT-M13)
// Lock held: break it if stale via the atomic rename-with-verify in
// lockutil.ReclaimStaleLock (AUDIT-M13), otherwise wait.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
reclaimed := lockPath + ".stale." + token
if os.Rename(lockPath, reclaimed) == nil {
if rinfo, rerr := os.Stat(reclaimed); rerr == nil && time.Since(rinfo.ModTime()) > lockStaleAfter {
os.Remove(reclaimed) // genuinely stale — drop it
} else {
_ = os.Rename(reclaimed, lockPath) // young again — restore, don't steal
}
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= lockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("swarm: reclaim stale lock: %w", rerr)
}
continue
if cleared {
continue // cleared a genuinely stale lock; retry the O_EXCL create now
}
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait instead of hot-spinning on a reclaim that never wins.
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("swarm: timed out acquiring lock %s", filepath.Base(lockPath))
@@ -392,3 +399,7 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
time.Sleep(2 * time.Millisecond)
}
}
func (m *Mailbox) renameWithRetry(src, dst string) error {
return fsutil.RenameWithRetry(src, dst, m.rename)
}
+50
View File
@@ -8,6 +8,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
)
@@ -295,6 +296,7 @@ func TestMailboxConcurrentSends(t *testing.T) {
defer wg.Done()
if err := mb.Send("team", "bob", Message{From: "a", Body: "concurrent"}); err != nil {
failures.Add(1)
t.Logf("Send error: %v", err)
}
}()
}
@@ -310,3 +312,51 @@ func TestMailboxConcurrentSends(t *testing.T) {
t.Fatalf("concurrent sends lost messages: got %d, want %d", len(msgs), n)
}
}
func TestMailboxRenameRetry(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("renameWithRetry only retries on Windows")
}
mb := newTestMailbox(t)
var attempts int
mb.rename = func(src, dst string) error {
attempts++
if attempts < 3 {
// Return a retryable Windows sharing violation error
return syscall.Errno(32)
}
// Then succeed
return os.Rename(src, dst)
}
err := mb.Send("team", "alice", Message{Body: "retry-test"})
if err != nil {
t.Fatalf("expected Send to succeed after retries, got: %v", err)
}
if attempts < 3 {
t.Errorf("expected at least 3 attempts, got %d", attempts)
}
}
func TestMailboxRenameNonRetryableError(t *testing.T) {
mb := newTestMailbox(t)
var attempts int
expectedErr := errors.New("non-retryable error")
mb.rename = func(src, dst string) error {
attempts++
return expectedErr
}
err := mb.Send("team", "alice", Message{Body: "retry-test"})
if err == nil {
t.Fatal("expected Send to fail immediately")
}
if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
t.Errorf("expected error %v, got %v", expectedErr, err)
}
if attempts != 1 {
t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts)
}
}
+15 -1
View File
@@ -3,7 +3,9 @@
package tools
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"time"
@@ -27,11 +29,23 @@ func hardenProcessLifetime(command *exec.Cmd) {
if command.Process == nil {
return nil
}
_ = exec.Command("taskkill.exe", "/T", "/F", "/PID", strconv.Itoa(command.Process.Pid)).Run()
taskkill := taskkillPath()
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(command.Process.Pid)).Run()
return nil
}
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
+28
View File
@@ -422,6 +422,34 @@ func TestStartExecProcessFallsBackAfterPTYStartMutation(t *testing.T) {
}
}
func TestStartExecProcessPTYFallbackPreservesSysProcAttr(t *testing.T) {
original := startPTYProcessFunc
t.Cleanup(func() { startPTYProcessFunc = original })
startPTYProcessFunc = func(command *exec.Cmd, _ *execOutputBuffer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty start failed")
}
command := exec.CommandContext(context.Background(), os.Args[0], "--zero-bash-helper", "success")
customAttr := &syscall.SysProcAttr{}
command.SysProcAttr = customAttr
output := newExecOutputBuffer()
stdin, tty, cleanup, err := startExecProcess(command, output, true)
if err != nil {
t.Fatalf("startExecProcess fallback failed: %v", err)
}
if tty {
t.Fatal("fallback process must report tty=false")
}
_ = stdin.Close()
_ = command.Wait()
cleanup()
if command.SysProcAttr != customAttr {
t.Fatalf("SysProcAttr was not preserved: got %+v, want %+v", command.SysProcAttr, customAttr)
}
}
// TestExecOutputBufferCapsUndrainedData: a session nobody polls must not grow
// its undrained buffer without bound — a long-lived background process that
// keeps writing while unpolled previously ran a session's memory into the
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more