Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 759133cdd3 test(sandbox): keep the SID helper from colliding with #886 2026-09-03 18:19:59 +05:30
Vasanthdev2004 cf40b8680b fix(sandbox): take Everyone out of the restricted-SID list
A profile that sets denyRead drops WRITE_RESTRICTED, and the token that
replaces it carried the World SID. Every principal carries Everyone, so the
restricted-SID check passed for free on any path whose DACL grants Everyone
write, and the workspace write jail fell back to the caller's own permissions,
which is the boundary the token exists to be stricter than. No privilege, no
symlink and no race: an Everyone-writable directory was enough, and share roots
opened Everyone:F and loose installer trees supply them. #865 closed this for
the WRITE_RESTRICTED token and left this half open.

It could not simply be dropped. Without WRITE_RESTRICTED the restricted-SID
check covers reads as well, and default Windows DACLs grant BUILTIN\Users
rather than anything in the list, so a token without Everyone cannot open
cmd.exe and dies at launch with a bare access denial. Confirmed by
impersonating both token shapes against C:\Windows\System32\cmd.exe.

So the read capability takes its place. BuildWindowsACLPlan already grants that
SID on every read root and denies it on every denyRead path under exactly the
same condition, so the read allowance and the restriction become one decision,
and it names only what setup granted rather than every principal on the
machine. The principal path already worked this way.

Regressions drive a real restricted token through a real impersonated write:
an Everyone-only DACL is refused, a directory the capability grants is written,
and the token's own restricted-SID list is read back from the token. A separate
test pins the plan and the token to the same answer, because the two halves are
decided in different files off the same field.

Closes #869
2026-09-03 18:17:48 +05:30
Vasanthdev2004 88dc53dd98 fix(sandbox): check the runtime root still carries its grant, not just its pathname
cleanupSandboxRuntimeRoots reclaims inactive sibling workspace roots on an age
and count policy, treating them as disposable cache state. Setup and its marker
treat their DACL as durable provisioned state. When the reclaimed workspace runs
again, command-side preparation recreates the same deterministic pathname as the
ordinary caller, and the new directory inherits from its parent without the
capability SID ACE elevated setup applied to the object that used to be there.

ValidateWindowsSandboxSetupMarker still passed, because it fingerprints pathnames
and actions rather than the identity of the ACL-bearing object. So the restricted
child launched and then failed its cache, temp and package-cache writes with a
bare access-denied and nothing pointing at setup.

The command now asks the object, not the pathname: before the token is minted it
confirms the runtime root carries an allow ACE for its capability SID, and
refuses with the setup remedy when it does not. That reconciles the two owners
without making cleanup preserve trees it is meant to reclaim.

The regression applies a real plan, reclaims the directory the way cleanup does,
recreates the pathname the way an ordinary run does, and asserts the refusal
names the root and the remedy; accepting a missing grant fails it on that. Not
covered, and said plainly: a write performed by a real capability token, which
needs a provisioned sandbox an unelevated box cannot create. The ACE presence is
the fact the command consumes, and that is what is pinned.
2026-09-03 13:57:05 +05:30
Vasanthdev2004 b203ad1201 fix(sandbox): validate runtime candidates before the elevated create, not after
ensureWindowsSandboxRuntimeCandidates ran as Administrator and used os.MkdirAll,
which follows links. Both candidate ancestries are prepared by the invoking user,
so a junction planted at a not-yet-created component below the cache directory,
or at the fallback anchor, made elevated setup build the runtime tree beneath a
redirected, Administrator-writable target. The ACL applier's no-follow check runs
afterwards and does spot the redirection, but the privileged create has already
happened by then and sits outside its rollback boundary. A leaf check after
MkdirAll would leave the same gap.

Candidates are now created by peermsg.EnsurePrivateDir, which descends
handle-relative with no-follow, refuses any component that is a link, and refuses
a leaf this user does not own. It is handed a physically resolved base first,
because it walks from the volume root and a redirected cache or TEMP above the
owned tail is the operator's business: the same pairing the fallback anchor
already uses, and the same mistake that refused every fallback on macOS when the
parent was left unresolved. physicalTempDir is generalised to physicalDir for
that, keeping GetFinalPathNameByHandle on Windows because EvalSymlinks does not
traverse a junction.

The regression plants a junction at the first owned component and asserts the
redirected target stays empty; letting the creator follow links again fails it on
that assertion.
2026-09-03 13:52:44 +05:30
Vasanthdev2004 e81ed96af3 fix(sandbox,cli): plan the grant and its guard together, and state the tier boundary
The .git rename guard belonged to one planner rather than to the grant. The
allow-write mask both backends share includes DELETE, and it inherits from each
write root onto .git; the carveouts that actually protect git are attached to
.git/config and .git/hooks as objects, so renaming .git aside and recreating it
discards them and hands back credential.helper and core.hooksPath. The principal
planner denied DELETE there and the capability planner did not, and the
capability backend is the default. Both now derive that object from one place, so
a change to the shared mask cannot update one consumer and miss the other. The
regression reads the ACE back off the applied object rather than asserting plan
shape, because the plan is what was wrong.

The unelevated tier now states its boundary before it mutates anything. A profile
carrying denyRead runs on a strict token, which applies the restricted-SID check
to reads, so the read capability must be granted at the volume root that
permissionProfileReadRoots seeds. Elevated setup can write that DACL; an ordinary
user cannot, and the common opener asks for WRITE_DAC on every entry, so this
tier failed on that one root on every command. Dropping the root ACE instead is
not available, because the strict token would then fail its own read check for
the executable. So it is refused up front, naming the root, the denyRead cause
and elevated setup as the remedy. That last part matters: the previous
after-the-fact diagnostic told the reader that running setup elevated would NOT
fix it, which is the opposite of the truth for this case.

Separately, `sandbox exec` no longer reports a signaled child as exit 255. On
Unix ExitCode() answers -1 for signal termination and os.Exit truncates that, so
a child killed by SIGTERM was indistinguishable from one that chose to exit 255.
It now returns the conventional 128+signal, read from the ProcessState, with
Windows keeping the exit code it already reports.
2026-09-03 13:45:34 +05:30
Vasanthdev2004 31da8a9abf fix(sandbox): resolve the temp dir physically before proving the fallback anchor
peermsg.EnsurePrivateDir walks every component from the root and refuses
any link. That is the right rule for the directory Zero owns and the wrong
rule for the operator's temp location above it: on macOS os.TempDir sits
under /var, a symlink to /private/var, so 8af05997 refused every fallback
on every Mac with "refusing non-directory or symlink runtime path component
var".

Anchor the fallback under the physical temp dir instead, so only the anchor
itself is a new component. EvalSymlinks off Windows; GetFinalPathNameByHandle
on Windows, where EvalSymlinks does not traverse a junction and a redirected
TEMP would trip the same way. The fixture compares against the resolved
temp dir rather than its spelling, since t.TempDir can come back as an 8.3
short name.
2026-09-02 12:56:38 +05:30
Vasanthdev2004 8af05997e1 fix(sandbox): root the fallback runtime tree beneath a validated private anchor
The fallback runtime root moved from a fresh os.MkdirTemp parent to the
predictable <temp>/zero/runtime/v1/<hash> so that elevated setup and the later
command process agree on it. That agreement is required and stays. But a
predictable path directly under the shared system temp is not a private
allocation. On an ordinary shared /tmp another local user can create /tmp/zero
with mode 0700 first, and every affected user then fails before the sandbox
command starts; a pre-existing redirected component makes host-side
preparation lease, create, chmod and clean somewhere other than the derived
tree. Determinism was being used as if it were ownership.

The tree now lives beneath <temp>/zero-runtime-<user>, and that anchor is
created and validated by peermsg.EnsurePrivateDir before anything is built
under it: handle-relative descent, no-follow, refused if any component is a
link or the leaf is not owned by the current user, mode 0700. That primitive
already existed with exactly the semantics jatmn described, so it is exported
and reused rather than written a fourth time. On Unix the uid is in the name
because /tmp is shared; on Windows os.TempDir is already per-user and the
private descriptor plus owner check do the rest.

Validation runs on both paths that can hand back a fallback root:
sandboxRuntimeRootFor returns one itself when the cache lands inside the
workspace, and prepareSandboxRuntime asks for one when the cache root cannot be
leased. Both are covered, each with a junction planted at the anchor, and both
refuse with the junction target untouched; a control shows a clean fallback
still prepares beneath the anchor.

Two things went wrong on the way and are worth recording. The first draft
called pathWithinRoot with its arguments reversed, which asked whether the
anchor was beneath the root, answered false every time, and skipped the
validation entirely while every dependent test reported success through the
junction. And the first lease-failure trigger planted its file at the derived
leaf, so preparation failed on that mkdir before ever reaching the fallback
branch. Both fixed; with validation disabled the two refusal tests now die on
"reported success" while the control stays green.
2026-09-02 12:36:42 +05:30
Vasanthdev2004 41590d74fd test(sandbox): stop the launch preflight test asserting a machine's token
TestPrincipalLaunchPreflightExplainsAMissingPrivilege ran the real preflight and
then asserted SeAssignPrimaryTokenPrivilege appeared in the message. Which
privilege is missing is a property of the account running the test, not of the
code: an unelevated user often holds neither, but this box holds
SeAssignPrimaryTokenPrivilege and not SeIncreaseQuotaPrivilege, so only the
other name appeared and the test failed for a reason that was never a defect.

Its own comment said it asserted "the shape of the answer rather than a fixed
verdict, because the verdict legitimately differs by machine", and then it
hardcoded one of the two names. The comment was right and the assertion did not
match it.

principalLaunchPrivilegeError splits the rendering out from the token work, so
each combination is driven directly: neither held, only assign-primary-token,
only increase-quota, and nothing missing. Each asserts the message names what IS
missing and does NOT name the one that is held, since sending an operator after
a privilege they already have is its own failure. Making the message always name
both fails the two single-privilege cases.

The real preflight keeps a test, reduced to what does not vary: if it refuses,
the refusal names at least one of the two rather than something the operator
cannot act on. Stability across calls is unchanged.
2026-08-31 15:32:01 +05:30
Vasanthdev2004 55b8e2699d test(sandbox): keep the runtime-root candidates inside test-owned storage
runtimeRootTestConfig put the workspace and sandbox home under t.TempDir, but
windowsSandboxRuntimeCandidates still derived a candidate through the
production sandboxUserCacheDir seam, and the test creates every candidate and
registers os.RemoveAll cleanup for each.

So the test reached into the real user cache: it fails outright on a read-only
home, and on an ordinary developer or CI account it creates and then deletes a
path outside its own storage. The workspace hash makes a collision unlikely; it
does not make somebody else's directory test-owned.

Redirected before any candidate is derived, and restored with t.Cleanup.
Production derivation is unchanged, and the assertion that setup materializes
every granted write root still stands.

A before/after count of the real cache cannot observe this, since the test
creates and removes in the same run. The mechanism is the evidence:
prepareSandboxRuntime reads sandboxUserCacheDir, and the test RemoveAlls every
candidate that derivation produces.
2026-08-31 15:26:25 +05:30
Vasanthdev2004 16fea97904 fix(sandbox): keep principal provisioning closed until a launch path exists
The gate asked whether the CALLING process held SeAssignPrimaryTokenPrivilege
and SeIncreaseQuotaPrivilege. That is the wrong lifetime, and it got the answer
backwards where it mattered.

The process that needs those privileges is the later, ordinary command process.
Setup does not run it, and nothing carries launch authority across the UAC
boundary. So the caller-token check refused unelevated setup, which was never
the dangerous case, and PASSED elevated setup, which is precisely the case that
lands a local account, its password, its logon-right assignments, workspace
ACEs, the recovery ledger and network filter state for a backend no later
command can use. The check existed to prevent durable machine state serving
something that cannot run, and in the one configuration that creates that state
it allowed it.

Requiring every sandboxed command to run elevated would line the privileges up
and is deliberately not done: it is a worse boundary than the restricted token
it would replace, and it is not the lifecycle this is for. Until a reviewed
bootstrap or broker exists, provisioning is closed rather than half-working.

windowsPrincipalLaunchAvailable replaces the preflight var and takes no
argument, because the answer is about which mechanisms exist rather than about
the current process. It is the one place that learns to say yes when a broker
lands, and everything below it -- the plans, the ledger, the ACL and filter
work -- still builds and is still tested against it.

The runner keeps its own privilege check at launch time, which is the correct
lifetime for one; only the setup wiring is removed.

The regression test asserts the property the old shape could not express: the
refusal is stable regardless of what the calling process holds, and it does not
tell the operator to re-run from an elevated terminal, which is the advice that
produced the unusable state. Restoring the caller-passes behaviour fails both
new tests.

Refs #662, which this is foundation for and does not close.
2026-08-31 15:25:00 +05:30
Vasanthdev2004 a40b50fe1f test(sandbox): assert nothing was created, not that a count held still
TestTeardownPathDerivationCreatesNothing compared the number of entries in the
SHARED temp directory before and after deriving the teardown paths. The
assertion it wants is that deriving a path creates nothing, and a count cannot
tell creation from removal: that root is also used by every other test binary
running at the same time and by the OS, so a concurrent cleanup made the count
fall and the failure read "temp directory gained -1 entries".

It compares the entry names now and reports anything that APPEARED, which is the
question actually being asked and is indifferent to whatever else disappears.
The failure also names the entry rather than a delta, so the next person sees
what was created instead of a number.
2026-08-27 14:23:38 +05:30
Vasanthdev2004 ef8f5c9003 fix(sandbox): refuse to provision a principal this caller could never launch
Measured on an ordinary unelevated session: the token holds neither
SeAssignPrimaryTokenPrivilege nor SeIncreaseQuotaPrivilege, so the principal
launch path can never engage for the process the runner is designed to be
called from. Elevated setup does not change that, because the command runs
later from the caller rather than from setup.

Until now setup succeeded completely in that situation. It created a local
account, its password, its logon-right assignments, the workspace ACEs, the
recovery ledger and the network filter state, and then every principal-mode
command refused before opening its executable. The operator was left with
durable machine state serving a backend that cannot run, and nothing said so at
the point they could still act on it.

The check runs in the caller's own process, which is the one whose privileges
decide the answer, and before anything crosses the UAC boundary. It is wired to
the same function the launch path uses, so a change to what a launch requires
cannot leave setup provisioning for a capability that no longer exists.

Three existing tests asserted argument plumbing with the opt-in on and passed
only because nothing checked; they stub the preflight now, so they no longer
depend on the privileges of whoever runs the suite.

This does not give the principal backend a working launch path. That needs a
different architecture and is not in this change.
2026-08-27 13:27:17 +05:30
Vasanthdev2004 746b6af602 fix(sandbox): name the two role inventories, and fingerprint the whole plan
Three follow-ups from going back over the review findings. None was raised
directly; two are the same shape as things that were.

windowsPrincipalPlanFingerprint was the one of three buildWindowsPrincipalACLPlan
call sites that did not pass DenyWrite. Apply and teardown both did, so the hash
the marker carries described a different plan than the one that actually gets
applied, and a change to the policy's deny-write paths moved the applied plan
while leaving the marker where it was. It is not a live hole, because the
capability ACLPlanHash covers the same paths and moves the marker anyway. That
is also exactly what would have kept it invisible until somebody changed the
capability plan's shape.

The role list was spelled out in three places and two of them are meant to
differ, which is why writing them out by hand kept going wrong.
windowsSandboxPrincipalIsInstalled asked only the offline and online roles while
teardown retires the legacy account too, so a machine still holding the untagged
pre-split account was reported clean and the opted-out marker claimed a teardown
that had not happened. Provisioning has the opposite constraint: legacy must
never appear there or setup would recreate that account on every run of an
already-upgraded machine. Both are named now, windowsSandboxLiveRoles and
windowsSandboxRetirableRoles, with a test pinning the legacy role into exactly
one of them.

And the opt-out error said "retire the principal" when a workspace has two plus
the legacy one, all of which that re-run retires.
2026-08-27 13:27:16 +05:30
Vasanthdev2004 3239565241 fix(sandbox): bind the ACL restore to the object it snapshotted
jatmn's P1 asked for creation AND cleanup to be handle-bound rather than
resolved from a pathname. Creation and the materialization unwind both are.
The DACL restore was not, and its comment read as though it were.

It re-opened the target with a no-follow open, which rules out a reparse point
swapped in since apply and nothing else. The other substitution passes it
untouched: rename the target aside and put an ordinary directory of the same
name in its place. Nothing there is a link, so the open succeeds, the pre-apply
DACL lands on the decoy, and the real object keeps the ACEs from the setup that
just aborted. The snapshot now records the volume serial and file index of the
object it read the DACL from, the restore proves it is writing back to that same
object, and a mismatch is refused rather than forced through. Leaving the real
object with the aborted setup's ACEs is the safe direction, since the caller is
failing anyway.

Three things nothing was pinning, all of which could be deleted with a green
suite. This repository has already had a fix silently reverted by a later
change, so these are worth more than their size.

rollbackWindowsACLSnapshots documented its reverse iteration as pinned by
TestRollbackUnwindsDescendantsBeforeAncestors. That test did not exist anywhere
in the repo; the only match for the name was the sentence claiming it. The
ordering is load-bearing twice over, because a materialized directory must be
empty before its own removal and SetSecurityInfo propagates inheritable ACEs
downward, so the ancestor has to go last. It exists now.

The principal ACL rollback restores the ledger alongside the DACLs, and the
neighbouring test asserted only the order of the two ACL reverts and never read
the ledger. Deleting the restore left the suite green while the paths it put
back were unnamed, so cleanup could not find them.

windowsSandboxUserIsManaged promises an account carrying the legacy bare comment
gets the workspace key stamped on. The probe for the legacy comment was seamed
and the upgrade itself was not, so nothing could observe the call. It is seamed
now, with the negative case covered too so the assertion cannot be satisfied by
an unconditional rewrite.
2026-08-27 13:27:16 +05:30
Vasanth T d9b72ba9d0 feat(sandbox): give each workspace an offline and an online principal (#812)
* feat(sandbox): give each workspace an offline and an online principal

The principal backend stood down whenever the network was denied, which is the
default, so opting into it left the restricted-token path doing all the work in
normal use. The reason was that network denial is enforced by block filters
keyed to the offline-marker SID, and a principal token cannot carry it:
LogonUser builds a token from an account's real group memberships, and the
marker is a synthetic capability SID.

A real local group closes that gap. ZeroSandboxOffline is created by setup, the
block filters name its SID alongside the marker, and a principal is denied the
network by being a member. Each workspace therefore gets two accounts that
differ only in that membership, and the command's network mode selects between
them. A group rather than each principal's own SID because principals are per
workspace: one filter set covers every offline principal on the machine instead
of needing a filter per workspace.

Both principals are provisioned together even though a given setup run sees one
profile, because setup needs elevation and commands do not. Provisioning lazily
would mean an unelevated command discovering it needs an account it cannot
create. They also get identical filesystem access, so an approved network
command sees the same filesystem as an ordinary one.

Two orderings are load bearing. The network plan is now built AFTER
provisioning, because the group it keys to is created there; planning first
installed filters naming only the marker and left every offline principal with
an open network while looking correctly set up. And the role tag sits before
the workspace hash in the account name, so truncation at the 20 character limit
eats hash characters rather than the tag, which would otherwise collide the two
roles onto one account on exactly the workspaces most likely to truncate.

Anything that is not an explicit allow maps to the offline principal, so an
unrecognised mode loses the network rather than keeping it.

The filter identity set is resolved rather than assumed, and stays absent until
the group exists, so a machine that never provisions principals computes the
same plan as before. That matters because the plan is hashed into the setup
marker and re-derived on every command; an identity set that differed between
setup and the command path would fail every command as out of date.

Cost worth stating: this doubles the sandbox accounts on a machine, to two per
workspace.

* fix(sandbox): prove ownership before deleting a sandbox account

removeWindowsSandboxIdentity is called with a DERIVED name, so it could be
pointed at a name that happens to belong to somebody else's local account.
Deleting a user is not a recoverable mistake, and the only thing standing
between the two cases was the name matching a pattern we generate ourselves.
Raised by CodeRabbit against the test fixtures, but the production teardown path
had the same hazard, so the guard belongs there rather than in the tests.

The ownership check itself now lives on the base branch, which grew the same
helper to stop provisioning ADOPTING a squatted account. This applies it to the
other end: an account that is not ours is left alone rather than deleted. The
gated tests get the protection for free, since their pre-clean goes through the
same helper.

Also appends the trimmed offline-group SID rather than the raw one. Worth noting
the reported consequence does not hold: newWindowsWFPUserCondition canonicalises
before converting, so a padded value would have been trimmed before reaching
StringToSid. The resolver returns SID.String(), which never carries whitespace,
so this is defensive tidying rather than a fix.

* fix(sandbox): assert the filters cover principals, and report a retained account

Two follow-ups from review, both on the same theme: a control that quietly does
nothing looks identical to one that works.

The network plan must be built AFTER provisioning, because provisioning creates
the group the block filters name. Built first, the filters name only the
offline marker and every offline principal has an open network while setup
reports success. That ordering is invisible at the call site, so setup now
checks the plan actually names the offline group before installing anything and
refuses if it does not. A later refactor that moves the plan build back fails
loudly instead of producing a security control that enforces nothing.

The predicate is separate so it can be asserted directly: a plan carrying only
the marker must read as uncovered, one carrying the group as covered, case
differences must not read as missing, and a host with no group provisioned has
no principal to miss and must not be refused. Making coverage always report
true fails that test.

Removal also reported plain success when it declined to delete an account Zero
did not create. Leaving it alone is right, but telling an operator cleanup
completed when a name they may care about was deliberately retained is not.
That case is now a distinguishable sentinel, and teardown treats it as success,
since "no principal of ours under this name" is the goal state either way.

* fix(sandbox): spare adopted principals when dual-role setup rolls back

Provisioning already declined to delete an account it had adopted, and the
outer setup rollback then appended an unconditional removal for every role
that got that far. With two roles that is the common case rather than an
unlucky one: the offline role usually succeeds, so a failure in the online
role or in ACL application destroyed a principal that was working before
the run started. The removal closure is now only appended for a principal
this run created.

Threads the workspace key into the delete path as well. Ownership was
proven from the account comment alone, which on a name collision belongs
to a DIFFERENT workspace, so deleting it would have been the same
unrecoverable mistake the check exists to prevent.

Policy DenyWrite now reaches the principal ACL plan here too, matching the
single-principal path.

Fixes the mode-independence test, which required exactly one identity SID
and so failed on any Windows host that already had ZeroSandboxOffline,
where the plan legitimately carries two. CI never saw it because the Linux
and macOS jobs leave the hook nil and a fresh Windows runner has no group.
The hook is now pinned, and the test additionally asserts the property it
is named for in the group-present case, including that the infra hash
changes when the group appears, which is the cross-workspace coupling
raised for a maintainer decision.

* test(sandbox): stub the password reset and pin the resolved group SIDs

Two review points on the tests added in the previous commit.

The provisioning stub left resetWindowsSandboxUserPassword as a real
call. Nothing under test reaches it any more, because rotation moved to
the caller, but a test that resets a real managed account's password if
the code ever moves back is not a risk worth carrying. It is now stubbed
to fail the test instead, which also states the contract.

The group-present assertion checked only that two identity SIDs were
present. A duplicated offline marker or an unrelated SID would satisfy
that while meaning something quite different, so it now pins both
positions.

Also drops a duplicated stub assignment left by the rebase.

* fix(sandbox): refuse an offline group zero does not own

ensureWindowsLocalGroup accepted NERR_GroupExists and ERROR_ALIAS_EXISTS as
success without inspecting the group it was about to reuse. Setup then resolved
that group's SID and installed it on the persistent WFP deny filters, and made
the sandbox principal a member of it.

If anything else on the machine already owns a group named ZeroSandboxOffline —
another tool, a policy, a prior unrelated convention — that is not a no-op. Every
existing member abruptly loses outbound access, because the filters now name
their group. In the other direction the sandbox principal inherits whatever
permissions that group carries, which is the opposite of what an offline
principal is for.

The add now reports its raw status and the already-exists branch verifies the
group carries this setup's managed marker before adopting it, failing with an
actionable message otherwise. A lookup error fails closed rather than adopting.

NetLocalGroupAdd and the ownership lookup sit behind seams so the branch is
reachable in tests without an elevated machine; the marker compared is the
group's own, so the principals group and the offline group cannot be confused.

Reported by jatmn on #812.

* fix(sandbox): recheck offline group membership before minting a token

Network denial does not follow from picking the offline account. The WFP block
filters match the offline GROUP'S SID, and LogonUser builds a token from the
account's real memberships — so membership is the whole enforcement, and the
command path never revalidated it.

An account that drifts out of ZeroSandboxOffline through local policy, an
administrator, or a re-setup that could not re-add it still resolves, still has
its stored secret, and still logs on. Its token no longer satisfies the filter
condition, so a NetworkDeny command gets full egress under a profile that asked
for none. The stale setup marker keeps the whole path looking healthy.

The offline role now confirms the membership its mode depends on before the
secret is read, and falls back to the restricted token when it is absent. That
direction is deliberate: the restricted token carries the offline marker the
same filters match, so egress stays blocked, and only read confinement is lost.
A failed lookup surfaces rather than downgrading silently. The online role is
not checked, since it is not in that group by design.

Reported by jatmn on #812.

* fix(sandbox): derive setup's runtime root deterministically or not at all

windowsSandboxRuntimeRootPath resolved through sandboxRuntimeRootFor, which
falls back to os.MkdirTemp when the user cache lives inside the workspace and
memoizes that only in-process.

Elevated setup is its own process. It granted the principals an ACE on temp root
A; the next command, being a new process, derived temp root B, where the
principal has no ACE, and failed ordinary cache writes with a bare ACCESS_DENIED
and nothing pointing at the sandbox. Teardown, a third process, cleaned a third
directory. The three callers that have to agree exactly could not agree at all.

Setup now uses the same side-effect-free derivation teardown already used, and
reports no runtime root when that derivation is unusable rather than inventing
one. A root only the granting process can name is worse than no root: the
principal loses the runtime tree, which is a degraded sandbox, instead of the
sandbox appearing provisioned while every command fails.

TestTeardownPathDerivationCreatesNothing asserted the opposite — that setup
"should still fall back to a usable tree" — so it is inverted here, with the
reasoning recorded in the test. That assertion encoded the assumption this
finding overturns: a per-process temp tree is not usable. Restoring the fallback
fails it with the invented path in the message, and a new
TestSetupAndTeardownDeriveTheSameRuntimeRoot pins the ordinary case, so
"report none" cannot quietly become the answer everywhere.

Reported by jatmn on #812.

* style(sandbox): separate the two doc paragraphs the rebase ran together

Adapting the ACL-record test to dual roles left #808's fail-open rationale
and #812's per-role rationale as one unbroken block. Both are worth
keeping; they are two points, not one.

* fix(sandbox): fail closed when offline-group coverage cannot be verified

The post-provisioning assertion ran inside `if groupErr == nil`, so a
failed lookup skipped it and setup carried on to install filters and
write a success marker. The comment directly above it says what that
costs: a machine reporting a successful setup while every offline
principal has an open network.

An empty SID was the same hole by a different route. Resolving to
("", nil) means the group does not exist, which is the ordinary state
before provisioning and an impossible one after it, and
WindowsNetworkPlanCoversPrincipals answers true for an empty SID
(correctly, for the pre-provisioning callers that ask it). So the
assertion passed vacuously in exactly the case where the group setup
had just created was missing.

Move the check into assertWindowsNetworkPlanCoversOfflineGroup, which
takes the resolver as a parameter and fails closed on every answer that
is not a definite yes: lookup error (wrapped, so the Win32 reason still
reaches the operator), empty SID, plan omitting the group, and a nil
resolver. Setup rolls back and exits 1 on each.

Taking the resolver as a parameter is what makes the error paths
testable, which is the regression the review asked for.

Reported by @anandh8x on #812.

* fix(sandbox): scope the offline-group assert to provisioned runs

c404ebd2 made the coverage assert reject an empty group SID, closing the
vacuous pass where a missing group counted as covered. It ran the assert
unconditionally, and the offline group is only created inside
provisionWindowsSandboxIdentity, which runs only under the
ZERO_WINDOWS_SANDBOX_IDENTITY opt-in.

So on a default machine with principals opted out, the resolver reports
("", nil) exactly as it should, and setup died with "the sandbox offline
group does not exist after provisioning" on a path that worked before
c404ebd2. The empty-SID rejection is correct after provisioning and wrong
before it.

Pass provisioned to the assert and return early when it is false, gated
at the call site on the same windowsSandboxIdentityEnabled check that
decides whether principals are provisioned at all. The fail-closed
behaviour anandh8x asked for is unchanged whenever provisioning ran.

Reported by @jatmn on #812.

* fix(sandbox): keep opted-out setup markers valid, and refuse a foreign offline group

Two of jatmn's findings on this PR.

Existing markers stay compatible (maintainer decision). The offline group is
machine-global, and the plan included its SID whenever the group existed. So the
first workspace to opt in changed the computed NetworkInfraHash for every OTHER
sandbox home on the machine, and those homes rejected their own stored markers
until each was re-run from an elevated terminal, having opted into nothing.

The inclusion is now gated on THIS home's opt-in rather than on the group
existing, so an opted-out home computes exactly the plan it computed before any
of this existed. Setup and the command path read the flag from the same
environment, so they agree. Opting in after setup does invalidate that home's
marker, which is correct: it has no principals yet.

Do not install filters for an unowned offline group (P1). The ownership check
only ran through principal provisioning, so an opt-out setup reached the resolver
and adopted any local alias carrying the name. applyWindowsNetworkPlan turns
every SID in the plan into an allowed-to-match WFP descriptor, so a foreign group
meant global deny filters against every one of ITS members: anyone with a local
group by that name loses the network for those accounts because we ran setup.
The resolver now requires the managed comment, and refuses rather than skipping,
because a plan whose filters cover no principal while setup reports success is
the failure this backend exists to prevent.

Three existing tests exercised the group path without the opt-in and now set it.
The new test asserts the other direction, that an opted-out home's hash is
unchanged when another workspace creates the group, since that is the property
the decision turns on. Verified both ways: disabling the gate fails the existing
tests, making it unconditional fails the new one.

* fix(doctor): report the principal that dual-role setup actually uses

jatmn's P2. This branch made the offline principal work under NetworkDeny, but
the doctor helper still described the old restricted-token standdown, so
`zero doctor` reported active:false and told operators reads were unconfined for
a correctly provisioned offline principal, recommending they enable network or
drop the opt-in to fix something that was not broken.

WindowsSandboxPrincipalInactiveReason is removed rather than reworded. Its only
condition was the deny-mode standdown, so after this branch it could never return
anything, and a check that cannot fire is worse than no check.

What replaced it matters more than what it said. That helper existed to be the
SINGLE rule doctor and the runtime both read, precisely so they could not drift,
and drift is what happened anyway when dual-role changed the behaviour under one
of them. WindowsSandboxPrincipalRoleForNetwork is now that shared rule:
windowsSandboxRoleForNetwork delegates to it and doctor calls it, so the reported
account and the used account cannot disagree. Doctor now names which principal a
command runs as instead of asserting a standdown.

One thing the existing tests caught. Routing the shared rule through
NormalizeNetworkMode case-folds, so "ALLOW" selected the ONLINE principal where
the runtime required an exact match and failed closed to offline. Sharing a rule
is only an improvement if it shares the stricter one, so the comparison is exact
and a test pins the casing.

* fix(sandbox): converge the dual-role branch with the rebased identity work

Rebasing #812 onto the new #808 needed real resolution rather than taking a
side, and this records what each conflict actually decided.

The account key. #808 made the principal key caller-scoped so elevated setup
provisions the account the caller will later look for. #812 derived usernames
from the workspace key alone. Every username derivation now uses the caller
scoped key, including the two inline call sites a blanket substitution missed:
the identity lookup in the unrecorded-retire path and the ledger read in
windowsPrincipalRevocationPaths. That second one is why teardown could not find
a recorded root the current policy no longer named. The setup LOCK stays keyed
to the workspace on purpose, because two users setting up one shared workspace
still write DACLs on the same paths and must serialize against each other.

The network plan stays where #812 put it, after provisioning, because the block
filters are keyed to the offline group that provisioning creates. Building it
earlier, as #808 does, would install filters naming only the marker and leave
every offline principal with an open network while looking correctly set up.

Group ownership converged on #812's implementation, not mine. #808 grew a check
hardcoded to the users group; #812 already had the general
ensureWindowsLocalGroup plus windowsLocalGroupOwnedByZero, which covers both
managed groups. The narrower version was removed and its test rewritten against
the general seams, so the users group keeps the coverage anandh8x asked for
while the offline group keeps its own.

Two functions the resolution dropped and the compiler caught:
windowsSandboxPrincipalKey and windowsCurrentUserSID. Worth naming because the
previous attempt at this convergence lost the same first function silently.

Also closes jatmn's remaining findings on this branch. The opt-out installed
check now asks about BOTH role accounts rather than one, since retiring one
while the other survives is exactly the half-done teardown an opted-out marker
must not report as success. And the post-provisioning filter-coverage assert now
resolves the offline group through the existing hook rather than the concrete
function, so it is stubbable like everything else around it.

The SensitiveEnvKeys omission jatmn reported on sandbox_exec.go arrives with the
rebase; it was fixed on #808.

* fix(sandbox): keep offline coverage and grant the fallback runtime root

Two ways the dual-role split left a workspace worse off than it looked.

The block filters are machine-global and every setup installs them by
deleting and recreating one fixed set, but the plan a home builds names
the offline group only when THAT home opted in. So an ordinary opted-out
setup for a second workspace replaced the filters without the group SID,
and the first workspace's offline principal, still in the group, still
passing the runtime membership check and still holding a valid marker,
was no longer matched by any filter. A NetworkDeny command there gained
egress silently, because the second setup did exactly what it was asked.

The gate itself is left alone, because it is load bearing for a different
reason: the plan is hashed into each home's marker, and keying it on the
group's existence made the first workspace to opt in invalidate every
other home's marker on the machine. What a home RECORDS is about its own
configuration; what setup INSTALLS is about the machine. Answering both
from one plan is what forced a choice between stale markers and a silent
hole, so WindowsNetworkPlanForApply answers the second question only, at
the apply call site, leaving the fingerprinted plan untouched.

Setup also granted the principals an ACE on the cache-derived runtime
root alone, and none at all when the cache sat inside the workspace. That
was correct when the other branch minted a random per-process directory
through MkdirTemp, but fallbackSandboxRuntimeRoot now derives its path by
hashing the workspace and creates nothing, so every process agrees on it.
Commands in that layout therefore DO select it and redirect TMP, GOCACHE
and the package caches into it, against a tree neither principal could
write. Setup now grants the same candidate set the capability plan
already covers, and creates each one, since applyWindowsACLPlan fails on
a target that does not exist.

Reverting either fix fails its regression: the opted-out plan installs
filters naming only the marker SID, and setup grants nothing while
commands write to Temp\zero\runtime\v1\<hash>.

Two existing assertions had to be inverted rather than adapted, and both
were asserting the old bug. One required setup to report NO runtime root
in the cache-inside-workspace layout; the other compared setup's single
root for equality against the command's choice. Setup covers the whole
candidate set now precisely because that choice is made per process, so
the contract is membership.

* fix(sandbox): treat an unreadable offline group as a failure, and retire the pre-split principal

Three findings from review.

A resolution failure in WindowsNetworkPlanForApply returned a marker-only plan,
and the machine's WFP filters are replaced wholesale from that plan. So an
opted-out setup whose group lookup failed transiently removed the offline group
SID another workspace's principals depend on, and that workspace's NetworkDeny
commands silently regained egress while its marker and its direct membership
check both still passed. The old reasoning was that refusing to install would
trade a partial denial for no denial; that is wrong, because the alternative to
installing is leaving the existing filters alone. It is fatal now, which is only
reachable from an opted-out home since assertWindowsNetworkPlanCoversOfflineGroup
is gated on provisioned.

Splitting the single principal into offline and online roles changed both
account names without changing the marker schema, so an installation made by the
previous version kept a valid marker, setup was never re-run, and the runner
found neither zero-sbx-d<key> nor zero-sbx-n<key> and fell back to the
restricted-token backend with no read confinement. The schema version is bumped
so that installation reports as out of date, and a legacy role derives the old
untagged name so the ordered retirement can remove the account, its secret, its
logon rights, its ACEs and its ledger. It is retired, never provisioned, and
there is a test for that because the two lists are one line apart.

Doctor reported that commands run as the selected principal whenever the marker
validated, but marker validation compares serialized plans and hashes and names
no account: deleting the account or its secret, or dropping the offline account
out of its group, leaves the marker valid while the runtime falls back or fails.
Verifying liveness needs Windows-only queries internal/doctor cannot make, so
the claim is narrowed to what the marker actually proves. The role is still
reported, since that part is derived rather than assumed.
2026-08-27 13:27:16 +05:30
Vasanthdev2004 e26e292d1a test(sandbox): compare normalized paths, not the raw input
TestACLComparablePathDoesNotResolveAReparsePoint failed on Windows CI
while passing locally. The test was wrong, not the code.

It asserted that windowsACLComparablePath returns a string equal to the
path passed in. GetLongPathName legitimately rewrites that string: a CI
runner's temp directory is an 8.3 short name, so expanding RUNNER~1 to
runneradmin produces a different string naming exactly the same object,
and the assertion failed on the one property the function is supposed to
have.

Both sides are now normalized before comparison, which states the real
property: a path THROUGH the reparse point must not normalize to the
target's normalization, or the guard would compare a redirected handle
against a redirected expectation and match itself. A second assertion
keeps the link component present so a resolution to something else
entirely still fails.

The test's documented limit is unchanged and still honest: a junction
cannot separate the old basis from the new one, because EvalSymlinks does
not resolve junctions either. It remains an invariant test.
2026-08-27 13:27:16 +05:30
Vasanthdev2004 695ef641f9 fix(sandbox): stop the redirect check resolving what it checks for
verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where
the handle landed, then compared that against
canonicalSandboxWorkspaceRoot(path), which runs filepath.EvalSymlinks.
That is the same resolution the kernel had just performed, so for a
directory symlink the two sides agreed precisely BECAUSE the redirect
happened: elevated setup went on to rewrite the DACL of an object outside
the workspace while the check reported success.

Junctions were rejected, but by accident rather than by design. Go reports
a junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks
refuses it, the canonicalization falls back to the lexical path, and the
mismatch surfaces. That is a property of the standard library's mode bits,
not of this guard, and a Go release that resolved mount points would
silently disarm the one case it was known to catch.

The expected side is now normalized without resolving anything.
GetLongPathName expands an 8.3 short name by reading directory entries and
does not follow a link to its target, and EqualFold still covers casing,
so the two spellings that legitimately name the same object still compare
equal. Every failure degrades to the lexically cleaned path, which can
only produce a spurious refusal, never a spurious match.

A target deliberately spelled through a symlink is now refused. That is
the intended direction: the question here is whether the object the handle
landed on is the object that was named.

On the tests, plainly: the directory-symlink regression is the one that
separates the old basis from the new, and it needs Developer Mode or
SeCreateSymbolicLinkPrivilege, so it skips on a machine without either and
skipped on mine. The junction test beside it passes against both bases and
says so in its own comment; it is an invariant test guarding the Go
behaviour the old code accidentally depended on, not a regression for this
change.
2026-08-27 13:27:15 +05:30
Vasanthdev2004 aecd614305 fix(sandbox): drop the unfollowable sandbox override from ACL failures
Both unelevated ACL failures told the reader to re-run with `--sandbox
forbid`. There is no such option: SandboxPreferenceForbid is an internal
engine state with no flag behind it, so acting on it produced an unknown
option and left them stuck on the failure they had just been told how to
clear. Advice that does not work costs more than none, because finding
that out takes the reader's time.

Name the real way out instead, the user config key, which is honored
from global config only so a cloned repo cannot set it. The
elevated-setup remedy beside it was already correct and stays.

Reported by jatmn against the same string on #640. It predates this
branch, having arrived with the unelevated fallback tier in #427, and
the copy on #886 is fixed separately in 1b304e13.

Also covers the secret write with the junction regression it was owed:
the caller owns the sandbox home, so they can put a reparse point where
the secret directory is expected, and the pathname version followed it
in an elevated process. The test asserts the refusal names the reparse
point and that nothing survives on the far side, since refusing while
still creating the file would leave the caller holding it.
2026-08-27 13:27:15 +05:30
Vasanthdev2004 7cad854012 fix(sandbox): stop the principal's environment naming the caller
The child environment starts as the invoking user's, and the deliberate
sandbox redirects only replace HOME, the temp variables and the per-tool
cache dirs. Everything identifying the account survived, so a command
running as the principal read USERPROFILE, APPDATA, LOCALAPPDATA,
HOMEDRIVE, HOMEPATH, USERNAME and USERDOMAIN describing the CALLER,
whose profile the principal deliberately cannot open. Native tools
resolve per-user state through exactly those, so they fail during
startup or quietly look somewhere they have no business reading.

Point them into the sandbox runtime tree, which is already granted to
the principal and already holds its caches, so the paths are writable by
construction. Naming the real Windows profile would need LoadUserProfile
to have run, and a variable pointing at a directory that does not exist
yet is a worse answer than one pointing somewhere usable.

Layered under the deliberate redirects rather than over them:
sandboxRuntimeEnvironment stays the single owner of HOME, TMPDIR, TMP
and TEMP, and a regression pins that so the two cannot drift into
setting the same variables from two places.

This is the environment half of the finding. Loading the principal's
profile and known folders is left until the launch mechanism is settled,
since LOGON_WITH_PROFILE would do it as a side effect.
2026-08-27 13:27:15 +05:30
Vasanthdev2004 1efc1aff4b fix(sandbox): report the privileges a principal launch actually needs
CreateProcessAsUser exempts only a restricted version of the caller's
own primary token from SE_ASSIGNPRIMARYTOKEN_NAME, which is precisely
why the ordinary restricted-token path works while holding nothing
special. A principal token comes from LogonUser against a separate local
account, so the exemption does not apply and both that privilege and
SE_INCREASE_QUOTA_NAME are required. Nothing enabled or checked either,
and a token measured on an ordinary unelevated process holds neither, so
the failure arrived as a bare "Access is denied" from inside process
creation, before the command's executable was ever opened, and read as
the command being rejected.

Enable them where they are held, since present-but-disabled still fails
the access check and that is where an elevated administrator lands, and
refuse with the specific names and a way out where they are not.

Detected by ENUMERATING the token. AdjustTokenPrivileges reports an
unheld privilege by returning success with ERROR_NOT_ALL_ASSIGNED, which
this binding does not surface: it returns nil for SeTcbPrivilege on an
ordinary process. A check built on its error passed everywhere, which is
worse than no check, since it would call the sandbox ready in exactly
the case it cannot run.

This does not make the principal launchable. It makes the reason legible
while the launch mechanism itself is settled.
2026-08-27 13:27:15 +05:30
Vasanthdev2004 ab76cfeb1e fix(sandbox): write the principal secret through one pinned handle
The elevated secret write resolved its path four separate times:
MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then WriteFile
by name. The sandbox home belongs to the invoking user, who is the party
this sandbox contains, so each resolution was a place to swap a
component. A symlink leaf lets an Administrator truncate a file of the
caller's choosing and then rewrite its DACL; a junction alone is enough
to plant the deterministic secret somewhere the caller controls.

Create the leaf relative to a pinned no-follow parent handle, refuse it
if it is a reparse point, and apply both the DACL and the bytes to that
handle. The name is never resolved again after the create.

The payload is now sealed before the file exists, so a failure there
leaves nothing on disk rather than an empty file for someone to race.
Cleanup on failure stays by name, which is safe in the direction that
matters: at worst it misses and leaves a locked-down file, never deletes
something it did not create.
2026-08-27 13:27:15 +05:30
Vasanthdev2004 3944704131 fix(sandbox): protect the gitdir pointer in a linked worktree
A linked worktree or submodule has .git as a FILE holding a `gitdir:`
pointer, not a directory. The principal plan names .git/config and
.git/hooks and materializes both, and the Windows materializer gets
there by descending through .git as a directory. A regular file cannot
have children, so opted-in elevated setup aborted and the sandbox could
not be used in a worktree at all. Zero's own development worktrees are
this shape, which is how it went unnoticed.

Deny the pointer file itself there instead. That is the stronger
protection rather than a fallback: a principal able to rewrite `gitdir:`
repoints the repository at a control directory of its choosing, which
subsumes editing config or planting a hook. The real control directory
sits outside the write root, so nothing is inherited there and no
carveout is needed.

Decided by an Lstat rather than a lexical guess, since the layout is a
property of the checkout. An absent .git keeps the directory-shaped
carveouts so they are still created before git first runs.
2026-08-27 13:27:15 +05:30
Vasanthdev2004 1e29354a0e test(sandbox): fix the workspace-root literal in the setup-args tests
A bulk edit dropped the separator, leaving the drive-relative
`C:workspace` where `C:\workspace` was meant. Windows resolves the
first well enough that the two halves still agreed; on Linux a backslash
is an ordinary character, so the command half derived no runtime
candidates at all and the marker carried two the command never saw.

The setup half no longer pre-augments either: BuildWindowsSandboxSetupArgs
folds the runtime roots in itself, and passing them in hid that from the
one test that covers it.
2026-08-27 13:27:15 +05:30
Vasanthdev2004 51976966aa fix(sandbox): give the principal's read roots a restricting SID
A profile with DenyRead drops WRITE_RESTRICTED, so Windows runs the
restricted-SID check over reads as well as writes. Read roots were
granted only to the principal's account SID, and the write jail removes
that SID from the restricting set on purpose, so reads passed the normal
check and matched nothing on the restricted one. The result was not a
narrower sandbox but an unusable one: no read root readable, including
the executable the command was trying to start.

Mint a read capability SID, grant it on every read root in the
capability plan, and carry it in the strict token's restricting set, so
the read allow-list and the restriction come from one value instead of
two that can drift.

Deny it on every DenyRead path too. The read roots begin at the
filesystem root, so without that the carveouts stay readable through the
new grant and the deny list stops meaning anything, which is the whole
reason the strict token is chosen. The no-write-roots case keeps its
ReadOnly deny as well: both SIDs the token can carry must be denied, not
only the newest.

Gated on DenyRead, which is what selects the strict token. Elsewhere
reads never reach the restricted check and the grant would be ACEs on
the filesystem root that buy nothing. Both halves decide from the
profile alone, so they cannot disagree about whether the entries exist.
2026-08-27 13:27:14 +05:30
Vasanthdev2004 1dd1709bff fix(sandbox): derive the runtime roots where TEMP is the operator's
Every sandboxed command failed marker validation with two plans of the
same size and different hashes:

  marker ...\windows-setup.json has 12 entries, hash 76fda2032e66;
  this command expects 12 entries, hash 4be1dbf8b642

The temp-derived runtime candidate reads os.TempDir(), and the sandbox
points TMPDIR, TMP and TEMP at its own runtime temp for everything it
launches. The command runner inherits that env, so when it derived the
candidate set it produced a root under the runtime tree while setup,
whose TEMP is untouched, produced one under the real temp. Same count,
different path, and no command could run.

A fingerprint both halves compare cannot be a function of the caller's
environment. Derive it only where TEMP is still the operator's: the
setup args builder, the Windows command plan, and doctor. The runner now
takes the profile it is handed, and commandConfig no longer re-derives
on behalf of whoever happens to call it.

The regression settles the profile first and redirects TEMP afterwards,
in that order, so it reproduces the mismatch against the old behaviour.
2026-08-27 13:27:14 +05:30
Vasanthdev2004 622371f73f fix(sandbox): say what changed when the setup marker is rejected
"permission roots or deny lists changed" told an operator that every
sandboxed command would now refuse to run, and nothing else. Not which
side is stale, not by how much, not even whether the marker belongs to
this workspace. Debugging it meant reading the source and guessing,
which is what happened.

Report the marker path, both entry counts and both hashes. The counts
separate the two shapes this takes: equal counts mean the same roots
spelled differently, unequal counts mean one side has roots the other
has never heard of.
2026-08-27 13:27:14 +05:30
Vasanthdev2004 2d755dba96 fix(sandbox): create the runtime roots setup grants
Elevated setup stopped working entirely:

  zero-windows-sandbox-setup.exe: windows ACL target does not exist:
  ...\AppData\Local\Temp\zero\runtime\v1\67b1b01412f588b9

Putting both runtime candidates into the capability plan added a write
root that nothing creates. The capability plan deliberately refuses to
materialize a write root, since an absent path is a typo or a stale
config and inventing the tree would grant write on a directory nobody
asked for, so the whole run fails on a path that is merely missing. Only
the selected root was ever created, and only on the principal path.

Create every candidate on the setup side, before the plan that grants
them is built. The regression walks the plan and fails on any granted
write root that does not exist, so the two halves cannot drift apart
again: today one function chooses the candidates and another creates
them, and nothing else couples them.

Found by the elevated end-to-end run, which is the only thing that
executes this path. No unit test reached it and CI does not run it.
2026-08-27 13:27:14 +05:30
Vasanthdev2004 16d7512e2a test(sandbox): make the jail aliasing assertion able to fail
The aliasing test appended a sentinel to the returned jail and then
ranged over the caller's slice looking for it. That can never fail:
append returns a new header, so the caller's length never grows and the
range never reaches the slot the write landed in. It passed against an
implementation that returns the caller's slice verbatim, which is the
exact thing it claims to rule out. Lint noticed the symptom as an
ineffectual assignment.

Assert through the backing array instead: element storage must not be
shared, and an append must not write into the caller's spare capacity.
Both fail against an aliasing implementation.

Also drop windowsSandboxDeterministicRuntimeRootPath, which lost its
last caller to the shared runtime-candidate helper and duplicated its
derivation.
2026-08-27 13:27:14 +05:30
Vasanthdev2004 c736616526 fix(sandbox): cover both runtime roots in the setup contract
A fresh setup marker rejected the very command it had just been written
for. Engine.run augments the profile with the selected runtime root
before the Windows runner sees it, but setup fingerprinted the bare
profile, so the extra write root changed the ACL plan hash and every
command on a restricted filesystem failed with "permission roots or
deny lists changed".

The runtime root also reached the principal ACL plan only. A principal
command runs on a WRITE_RESTRICTED token, where a write needs the
normal check and the restricting-SID check to both pass, so a root
carrying just the account ACE was still unwritable once the marker
agreed.

Derive the runtime candidates once and present them on both sides of
the setup protocol. Setup covers the cache-derived root and the
temp-derived fallback rather than whichever one it happened to select,
since selection is per process and a command that fell back would
otherwise land on an unprovisioned tree. Both are pure functions of the
workspace root, so setup can cover the set and a later process can
select from it.

Regressions fail without this: the marker one reproduces the exact
rejection, the capability-plan one shows the missing restricting-SID
grant.
2026-08-27 13:27:14 +05:30
Vasanthdev2004 1826326ed0 chore: drop a stray probe artifact from the branch
probe-inside.txt is a leftover from the elevated end-to-end probe and was never
meant to be committed. A `git add -A` in the write-jail commit swept it in.

It is what the automated review's diff-hygiene check was failing on: the file
carries trailing whitespace, so `git diff --check` reported a blocker while
tests, build and smoke all passed.
2026-08-27 13:27:13 +05:30
Vasanthdev2004 c981c3cb9a fix(sandbox): derive the fallback runtime root instead of minting one per process
Reported by jatmn. sandboxRuntimeRootFor falls back to a private tree when the
cache-derived runtime root would land inside the workspace, and that fallback
called os.MkdirTemp and cached the answer in a process-global map.

Separate processes therefore got separate answers. Elevated setup granted the
sandbox principal write access to the directory it created, and every later
__windows-command-runner process created a different one and pointed TMP,
GOCACHE, npm and the rest at it. Those directories are made by the calling user
and carry no ACE for the principal, so ordinary cache and temp writes failed
with a bare ACCESS_DENIED and nothing naming the sandbox. sandboxRuntimeRootFor
already documented that both callers must agree exactly; the fallback was the
branch where that could not hold.

It now hashes the workspace under os.TempDir, the same shape as the
cache-derived root, so every process reaches the same path with no shared state.
When even that lands inside the workspace it returns an error rather than
picking somewhere arbitrary: a runtime tree governed by the workspace's own
policy makes the sandbox's cache writes indistinguishable from the work it is
confining.

Two consequences worth naming.

It creates nothing now, so the split between deterministicSandboxRuntimeRoot and
the resolver is no longer about avoiding a side effect. The comments that
justified that split on those grounds were rewritten rather than left describing
behaviour the code no longer has.

Teardown can name the fallback tree for the first time. windowsPrincipalTeardown
Paths used the deterministic resolver precisely because the fallback was random
and unnameable, which meant an opted-out machine kept principal ACEs on whatever
tree the fallback had produced. It now goes through the shared resolver and
revokes what commands actually used.

Tests pin the property the defect turned on: a repeated call, which is what a
second process looks like with no shared state, must return the same root. The
old implementation could not have passed that, since only the in-process map
made repeat calls agree. Also pinned: per-workspace separation, that naming the
tree creates nothing so teardown leaves no directory behind, and that the root
stays outside the workspace.
2026-08-27 13:27:13 +05:30
Vasanthdev2004 c914d762fc fix(sandbox): stop the principal's own SID from defeating its write jail
Reported by jatmn. The principal path appended the account's own SID to the
restricting-SID list before building a WRITE_RESTRICTED token.

That token allows a write only when BOTH the normal token and the restricting
list allow it. The account SID is already enabled in the normal token, so
listing it as a restricting SID makes the second check a formality for anything
granted to that account: every path carrying a direct principal ACE passes both
halves and is writable wherever it sits. The principal's own profile directory,
which Windows creates on first logon with exactly such an ACE, is outside every
configured write root and was writable for that reason.

It is the same defect as the World SID in the restricted list (#865), with the
account SID in place of Everyone. A SID already carried by the normal token
cannot also restrict it.

The previous reasoning, recorded in the comment this replaces, was that the ACL
plan grants the workspace to the principal SID so removing it would jail the
principal out of its own tree. That is not so, because setup applies
BuildWindowsACLPlan on every path, not only the restricted-token one, so each
configured write root already carries a capability ACE as well as the principal
ACE. Confining to the capability SIDs therefore restores the intersection the
jail is supposed to be: a write root satisfies the normal token through its
principal ACE and the restriction through its capability ACE, while a path
holding only a principal ACE now fails the restricted check.

The account SID is passed to windowsPrincipalJailSIDs and excluded there rather
than simply not passed. Naming it makes the exclusion the function's contract
instead of an omission a later edit could undo silently, and it also strips the
SID should it ever arrive through the capability list, which is the route the
World SID took.

On the test jatmn asked for: the existing jail test grants WinBuiltinGuestsSid,
a GROUP, so it exercised a configuration the product never ships and could not
have caught this. The new test plants the account SID INSIDE the capability list
and asserts it is gone, which is deliberately falsifiable: a list that never
contained it would pass against any implementation, including one that appends
the SID straight back. Verified by mutation, disabling the filter fails it with
the reported symptom. A second test pins that the capability SIDs survive, so
the fix cannot degenerate into jailing the principal out of its workspace.

Still outstanding from the same review and not addressed here: the runtime-root
fallback that hands setup and each command runner a different directory, and the
elevated end-to-end evidence.
2026-08-27 13:27:13 +05:30
Vasanthdev2004 67f3d6145a fix(sandbox): refuse to adopt a ZeroSandboxUsers group Zero did not create
Reported by anandh8x as P2 #3. ensureWindowsSandboxGroup treated NERR_GroupExists
and ERROR_ALIAS_EXISTS as plain success, so any local group that happened to
carry our name was adopted. Its members, and every grant already keyed to it,
silently became part of the sandbox's identity.

A name is not proof of provenance, which is the same reasoning
windowsSandboxUserIsManaged already applies to an ACCOUNT of our name. The group
half was missing. An unprivileged user cannot create a local group, but an
administrator, an installer or an earlier build can, and the principal would
then inherit whatever it grants.

An existing group is now adopted only when it carries the managed comment, read
back with NetLocalGroupGetInfo. Anything else is refused by name rather than
adopted, renamed around or deleted: removing somebody else's group would be
destructive, and provisioning into it is the hole being closed.

The decision is split from the syscall into resolveWindowsSandboxGroupAdd so it
can be tested without Administrator and without leaving a real local group on
the machine running the suite. Tests cover both "already exists" statuses,
refusal for a foreign group, adoption of our own so re-running setup still
converges, no ownership probe when we just created it ourselves, an unreadable
probe surfacing rather than being guessed either way, and a real API failure
still failing.

Also rebased onto main, 17 commits behind, under the fresh-base rule. All 51
commits replayed with no conflicts.
2026-08-27 13:27:13 +05:30
Vasanthdev2004 0fda33eee2 fix(sandbox): fail opt-out only when the principal actually survived
Three corrections, the first to my own previous commit.

Making the opt-out retirement failure fatal was too broad.
removeWindowsSandboxPrincipalForSetup deliberately continues past a secret file
owned by another administrator's setup, reporting it at the end through
errors.Join, so that one unlinkable file cannot strand the account, its logon
rights and its ACEs. The account is already gone when that error is returned.
Treating it as fatal made `zero sandbox setup` fail permanently on such a
machine and rolled back its ACL work, taking the DEFAULT restricted-token
sandbox down over inert residue.

The rule is now the narrower one that was always meant: fatal when the ACCOUNT
survived, because that is exactly what makes an opted-out marker a lie. Asked of
the account rather than inferred from the error, since the error is a join whose
parts are not separable at the call site, and the lookup fails closed so an
undeterminable state counts as still installed. A retirement that left something
inert behind still completes, and still names what it left.

Second, assertWindowsSetupRunsAsCaller cannot fire on any path Zero takes, and
said otherwise. Zero never elevates the helper: runSandboxSetupHelper is a plain
exec.Command and runWindowsSandboxSetup refuses unless already elevated, so the
operator supplies elevation by opening an elevated terminal and BOTH halves run
there. The caller SID is resolved from the process that becomes the helper, so
the two are equal by construction. The guard is kept, because it is a
correctness assertion that becomes load-bearing the moment a ShellExecute
"runas" path is added and already covers a helper invoked directly, but its doc
now says plainly what it does not do. The residual hazard has a different shape
and is loud: elevating as another administrator provisions into that account's
sandbox home, and the operator's own session finds no marker and is told to run
setup.

Third, parseSandboxExecArgs claimed to scan for the separator up front. Every
branch of its loop body returned, so it only ever examined index 0 and the scan
did not exist. Rewritten as the straight-line decision it actually was, which is
also the rule we want: only the first token is the wrapper's.

sandbox exec also built its engine without SensitiveEnvKeys, so a key named by
apiKeyEnv in the user's config was scrubbed for every sandboxed tool call and
handed to the one command whose purpose is to reproduce that environment.

Tests: the opt-out branch now covers both sides of the rule, and
parseSandboxExecArgs gets the coverage it never had, including the reported
`-- cmd --help` case and an invariant that the returned command is always a
trailing slice of the input.
2026-08-27 13:27:13 +05:30
Vasanthdev2004 d608a3c087 fix(sandbox): keep elevated Windows setup tied to the caller's identity
Two failures on the elevated setup path, both reported by anandh8x.

The first is caller identity. windowsSandboxPrincipalKey sampled the CURRENT
process token, and elevated setup is not the caller's process: under
over-the-shoulder UAC or `runas` it belongs to whichever administrator typed the
credentials. Setup therefore provisioned an account, a secret, an ACL ledger and
a set of grants named after that administrator, while every later command
derived the ordinary user's key, found nothing, and fell back to the restricted
token. The admin-keyed leftovers were unreachable and nothing would ever reclaim
them.

The caller's SID now crosses the UAC boundary in the setup args, exactly as the
principal opt-in already does and for exactly the same reason, and the key is
derived from it. That fixes everything merely NAMED after the invoking user.

It cannot fix the secret. CryptProtectData derives its key from the user that
stores the blob, so a password sealed by another administrator could not be
unsealed by the caller no matter how the account were named. Provisioning is
refused up front in that case, before an account or a grant exists, with a
message that says how to clear it. The refusal is scoped to the principal
opt-in: the default restricted-token sandbox stores no secret and works fine
across that boundary.

The second is the opt-out path. Retiring an existing principal could fail, and
setup printed the error and carried on to write an opted-out marker and exit 0.
Teardown being idempotent was the argument for continuing, but it is the reason
this must fail instead: a machine with nothing to retire passes straight
through, so an error here means a real account, secret, logon right, ACE set and
ledger are still installed, and the marker now claims none of them exist. Setup
returns non-zero and rolls back its ACL work.

runWindowsSandboxSetup had no coverage at all, since every step wants an
elevated token, a machine-global mutex, real DACLs, the WFP engine or a local
account. The external effects are now seams in the idiom the package already
used for two of them, and the tests drive the step order and each failure path,
including the marker never being written.
2026-08-27 13:27:13 +05:30
Vasanthdev2004 09cc534e92 fix(sandbox): address jatmn's review on the principal backend and sandbox exec
Four of the five findings were real. Taking them in order of what they cost.

Cross-user principal collision (P1). The account name was keyed on the workspace
alone while its DPAPI secret and ACL ledger both live under the invoking user's
sandbox home, so two Windows users sharing one workspace derived the same account
with private bookkeeping. The second user found no ledger of their own and either
retired the first user's working principal or adopted it and rotated its password
while storing only their own secret; either way the first user's marker still
validated and their next command could not log on.

windowsSandboxPrincipalKey now folds the invoking user's SID into the key. The
setup LOCK deliberately keeps the workspace-only key: two users setting up one
shared workspace still write DACLs on the same paths, so they must keep
serializing even though they now provision separate accounts. Splitting the two
keys is the point, and there is a test asserting they differ.

Ledger not restored on rollback (P1). The narrowed ledger is written only after
everything succeeds, but the rollback closure restored the old ACEs and left it
narrowed. A later setup or teardown reads the ledger to decide what to revoke, so
those restored paths would never be revisited: access outside the current policy
held by an account nothing knows to clean up. The closure now writes back the
union recorded before any DACL changed. Over-recording is the safe direction.

sandbox exec skipped the production planning path (P2). It built a SandboxManager
directly, so it never reached prepareSandboxRuntime and ran without the runtime
write root that a real sandboxed command gets. It now goes through
Engine.BuildCommandPlan. This matters beyond tidiness: the command exists to
prove enforcement, and it was proving a path no tool call takes.

Plan resources leaked (P2). No plan.Cleanup(), so every invocation left a
/tmp/zero-sandbox-report-* behind on Linux. Deferred immediately after the build.

The `--` separator (P2). The reported case, `exec -- cmd --help`, already worked:
the loop hits the separator first and returns. The real break was the tolerated
separator-less form, where `exec mycmd --help` printed Zero's help and never ran
mycmd. The separator is now located before any help flag is interpreted, and help
is only read from the wrapper's own arguments.

Two existing ledger tests seeded state under a username derived from the
workspace key and had to move to the principal key. Worth noting for anyone
running this branch: an account provisioned before this commit will not be found
by the new key, and setup will retire it as unrecorded. That is correct behaviour
for an unmerged branch with no released accounts, but it is not a no-op locally.
2026-08-27 13:27:13 +05:30
Vasanthdev2004 ff077553bf fix(sandbox): serialize elevated setup with a per-workspace lock
anandh8x's finding #3, the last of the twelve.

Setup is a transaction, not a write. It rotates the principal's password,
stores the secret, read-modify-writes the ACL ledger, installs WFP filters and
writes the marker. Two setups for the same workspace at once interleave those
steps: the account ends up with one process's password while the stored secret
is the other's, so every later command fails to log on, and the ledger's
read-modify-write silently loses a path set so ACEs that should have been
revoked stay on disk with nothing recording them.

Atomic individual writes do not address this. Each step being all-or-nothing is
orthogonal to two processes interleaving BETWEEN steps, which is the actual
failure, so the lock spans from the elevation check through the marker write.

Nothing to port here: the reference implementation has the identical bug. Its
password rotation and secret write hold no lock, its named mutex guards only an
unrelated read-ACL-only mode and is machine-singleton, and the singleflight
that looks like a fix is process-local.

Decisions worth the reviewer's attention.

Global rather than Local namespace, because the protected state is not
session-scoped: the local account, its LSA logon rights and the WFP filters are
machine state, so setups in different logon sessions must still exclude each
other. Per-workspace rather than machine-wide, because every artifact the lock
protects is per-workspace and a global lock would serialize unrelated setups.

The DACL is Administrators and SYSTEM only, protected. A sandbox principal able
to open this object could hold it and stall every future setup, or create it
first and squat the name so real setup believes it holds a lock it does not.

The OS thread is pinned. Win32 mutex ownership is thread-affine, so a goroutine
that migrated between the wait and the release would call ReleaseMutex from a
thread that does not own the object, leaving the mutex held until process exit,
which is a deadlock for every later setup on the machine.

WAIT_ABANDONED is treated as acquired and reported. The previous holder died
inside the transaction, so the machine may be half set up; setup is idempotent
and re-running it is the repair, whereas refusing would leave no way forward.
A bounded wait rather than skip-and-succeed, because skipping here means not
setting the sandbox up at all.

On the tests: the production DACL means an unelevated process cannot open an
existing lock, so the contention test could not reach the mutex wait anywhere
that matters, including CI. The SDDL is therefore a var the wait tests relax
for themselves, and a separate test pins the production value so loosening it
takes an explicit change. Writing the test first is what surfaced this at all.
2026-08-27 13:27:12 +05:30
Vasanthdev2004 84709257a8 fix(sandbox): retire the principal on opt-out, and finish teardown past a foreign secret
anandh8x's findings #6 and #8. They are one commit because #6 calls straight
into the path #8 breaks, so fixing #6 alone would have shipped an opt-out that
aborts on exactly the machines #8 describes.

#8 first. readWindowsSandboxSecret already treats permission-denied as
unavailability rather than breakage, and documents why: an operator who
elevated with a separate administrative account ends up with a secret their
ordinary account cannot open. Removal did not agree. os.Remove returning a
permission error aborted teardown at its very first step, so the account, its
logon rights, its ACEs and its ledger all stayed installed because one file
could not be unlinked. The operator asked for removal, got an error, and kept a
working principal.

Removal now classifies that case distinctly and teardown carries on, reporting
the leftover secret at the end rather than swallowing it. An ordinary removal
failure is still fatal, and there is a test for the difference: misclassifying
one as the other would turn a real failure into a shrug.

#6. Opting out has to actually retire the principal, because that is what we
tell people it does. ValidateWindowsSandboxSetupMarker sends an operator here
in as many words. There was no opt-out branch at all, so the marker flipped to
opted-out while everything stayed exactly where it was: the instruction was a
lie, and the leftovers were invisible, because nothing afterwards looks for a
principal it believes was never provisioned.

Setup now runs teardown on the opt-out path. Not fatal if it does not complete:
teardown is idempotent and a machine that never had a principal passes straight
through, so a failure means genuine residue rather than a missing account. Say
so and finish a setup whose sandbox is otherwise fine.
2026-08-27 13:27:12 +05:30
Vasanthdev2004 4e1f4831de fix(sandbox): report an inactive principal in doctor, and finish the comment upgrade
anandh8x's findings #7 and #10.

#7. windowsSandboxPrincipalEligible stands the principal down under
network-deny, which is the DEFAULT policy, so an operator who set the opt-in to
confine reads silently gets the same-user restricted token and no read
confinement at all. Nothing told them. The runner cannot: it is re-exec'd per
command, so the notice would land on the stderr of essentially every tool call,
and it is not per-command actionable anyway. The setup marker validates happily
because setup really did provision the account. The runtime comment already
said this belonged on `zero doctor`, and doctor never carried it.

Doctor now warns, and the rule has one definition rather than two. The
eligibility check asks the same exported predicate doctor does, so the two
cannot drift into disagreeing about whether a principal is in play. Drift here
would mean doctor telling someone reads are confined while commands run on the
restricted token, which is worse than saying nothing.

A warning rather than a failure: commands work and the network is still
enforced. What is wrong is the operator's picture of what they have.

#10. windowsSandboxUserIsManaged has always claimed a legacy account is
"adopted and its comment rewritten on the way through", and it never was:
provisioning only ever set the password (USER_INFO_1003 is documented in this
file as the password-only form). So a pre-key account stayed permanently
unattributable, and every later run had to keep accepting the bare comment to
avoid orphaning it.

Adoption now stamps the workspace key via USER_INFO_1007, which ends that and
lets the bare form eventually be retired rather than accepted forever. Best
effort: the account is adopted and working either way, and failing provisioning
over a cosmetic stamp would strand a usable sandbox for bookkeeping. Reported
rather than swallowed, so a run that keeps re-adopting the same legacy account
is visible.

The legacy-comment probe is a separate function rather than folded into
windowsSandboxUserIsManaged, so that keeps its (bool, error) shape and the test
seam around it does not have to change.
2026-08-27 13:27:12 +05:30
Vasanthdev2004 8bb5546b49 fix(sandbox): fingerprint principal grants in the setup marker
anandh8x's finding #4. The marker hashed BuildWindowsACLPlan, the
capability-SID plan, while principal grants are built separately by
buildWindowsPrincipalACLPlan from the same profile. So the marker was blind to
them: narrowing or removing a principal read root left setup looking current
and the old AllowRead ACE sitting on disk. Narrowing a policy has to be able
to take access away, and this was the one path where it could not.

The fingerprint hashes the plan with a fixed placeholder trustee rather than
the real principal SID. The account is recreated with a fresh SID whenever it
is reprovisioned, so hashing the real one would move the fingerprint on every
rebuild even when the granted paths were identical, and every command would
then rerun setup. What must invalidate the marker is the set of paths and
actions, which is what this captures.

Empty when the principal backend is not opted into, so the default install's
marker does not churn. Schema version goes 5 to 6 so existing markers are
treated as stale rather than read as having no principal grants.

Validation refuses a mismatch with the action to take, since the remedy is
re-running elevated setup so the stale grants are actually revoked rather than
merely re-planned.
2026-08-27 13:27:12 +05:30
Vasanthdev2004 c11e350d2c feat(sandbox): add zero sandbox exec to run one command through the sandbox
Until now the sandbox could only be exercised through a full agent turn with a
model in the loop. `zero sandbox policy` reports the posture it would take and
`zero sandbox check` evaluates a hypothetical decision, but nothing ran a
command and let anyone look at what happened on disk afterwards.

That is the root of the verification gap on this branch. Enforcement is covered
almost entirely by tests asserting the shape of an ACL plan, and almost not at
all by tests asserting that a write was refused. The two are not the same
thing, and this branch has already produced the proof: the .git rename guard
was emitted correctly by the planner and silently dropped by the applier, and
four tests covering the plan passed while the ACE was absent from the
directory.

It goes through SandboxManager.BuildCommandPlan, the same path a shell tool
takes, so what it demonstrates is what users get rather than what a test double
does. Backend, enforcement level and workspace are written to stderr before the
command runs, and a downgrade is printed explicitly, so a harness can assert
the sandbox was actually engaged instead of passing because it quietly stood
down. Exit status is the command's own, which a harness asserting a refusal
needs.

Everything after `--` is the command, so its flags are never parsed as ours.

Verified by hand on Windows: a write inside the workspace succeeds and the file
exists; the same write to C:\Windows\Temp fails with UnauthorizedAccessException
and the file is not created. Both halves matter, since a command can fail for
an unrelated reason while the write still lands.

This is the prerequisite for a real smoke harness, which is the actual goal.
2026-08-27 13:27:12 +05:30
Vasanthdev2004 3cb40ce849 fix(sandbox): name the root that blocks unelevated ACL setup
Found while testing the default sandbox path, not the principal path this PR
is about. One write root that the current user cannot re-DACL fails the entire
unelevated ACL plan, and the success marker is only recorded on success, so
the identical failure repeats on every later command until that root leaves
the plan. The workspace is effectively unusable in the meantime.

Refusing to run is correct and is left alone: without those ACEs there is no
write jail, so continuing would run a command that believes it is sandboxed
and is not. The defect was the diagnosis. Every failure got the same guess,
"the workspace may be on a filesystem the current user does not own", and
recommended elevated setup, which does nothing when the real problem is a
system directory sitting in the root set.

An access denial now names the exact path, says the sandbox cannot enforce a
boundary there, points at TEMP and TMP as the usual way such a path gets in,
and states plainly that elevated setup will NOT help. Other failures keep the
old message.

The extractor reads the path back out of an error string produced two
functions away, which the compiler cannot check, so a test drives the real
producer rather than hand-writing the message: if openWindowsACLTarget rewords
its error the test fails instead of the diagnostic quietly going blank. That
test earned itself immediately by catching the first version splitting on the
first colon and returning "C", since on Windows the path opens with a drive
colon. It splits on colon-space now, which a drive colon can never be.

The helper sits behind the Windows build tag beside its only caller, for the
same reason windowsACLPlanPaths was moved earlier in this branch.
2026-08-27 13:27:12 +05:30
Vasanthdev2004 b86bfc17e9 docs(sandbox): attribute the Users-group premise the volume-root fix relies on
Cross-checking against another Windows sandbox implementation showed it joins
its account to the built-in Users group explicitly, and then verifies at apply
time whether Users already hold read before granting. Ours gets that membership
implicitly from NetUserAdd with USER_PRIV_USER and asserts the consequence in a
comment. Same conclusion, weaker footing, so the comment now says where the
membership comes from and that a hardened image which strips Users read would
need an explicit bounded read set instead.
2026-08-27 13:27:12 +05:30
Vasanthdev2004 37629a2fa5 fix(sandbox): stop granting a principal read at the volume root, and report failed ACE revocation
Two of anandh8x's findings on this PR.

The read grant. permissionProfileReadRoots seeds its list with the bare
separator, because the workspace-write posture is read-all with a write jail.
That costs nothing for the capability backend, whose child runs as the caller
and could read those paths anyway. For a principal it became a real,
persistent, inheritable allow-read ACE for a separate local account at the root
of the current drive, reaching every directory that does not block inheritance.
Verified against the shipped profile rather than argued: with the filter
removed the plan emits AllowRead on the drive root.

Dropping it does not remove the reads a principal needs. It is a member of
Users, and the machine's own ACLs already grant Users read on the system and
program directories. What the grant added was read access to the places Users
are deliberately kept out of, which is the opposite of what a sandbox is for.
The volume root is detected structurally, since filepath.Dir of a root is that
same root, so drive-qualified paths, a bare separator and UNC roots are all
covered on either build host.

The teardown. removeWindowsSandboxPrincipalForSetup discarded the result of
revokeWindowsPrincipalACEs, deleted the account, deleted the ledger and
returned success. A failed revocation therefore left ACEs naming that SID on
the user's tree while the only record of which paths they sat on was removed
moments later: residue nothing could find again.

The existing reasoning for not failing hard was right and is kept, since
refusing to remove the account would strand the principal and its logon rights
permanently. So teardown still completes, but the error is now remembered, the
ledger is kept when revocation failed, and the returned error says the ledger
was kept. A record that outlives its principal is a smaller problem than
unfindable residue.

The new read-root test is built from the production profile, and skips loudly
rather than passing silently if that profile ever stops carrying a volume root.
It also asserts the workspace itself stays reachable, so the fix cannot trade a
real grant for a broken sandbox.
2026-08-27 13:27:11 +05:30
Vasanthdev2004 279c7bb6e8 fix(sandbox): apply the .git rename guard on a workspace that had no .git
Found by gnanam1990 and anandh8x independently, and confirmed on disk before
fixing: the deny-delete ACE that stops a principal replacing .git was silently
absent on exactly the workspace shape that matters most.

.git deliberately carries no Materialize, because creating an empty one breaks
`git init`. Groups are applied in ascending path order, so the .git group ran
while .git did not yet exist, was skipped as a non-materializing missing
target, and nothing revisited it when the .git\config carveout created .git as
its parent chain moments later. windowsACLGroupRequiresExistingTarget did not
catch it either, since it only treats AllowWrite as requiring an existing
target. Net effect: the principal could rename .git aside and recreate it
without the config and hooks carveouts, which is the escape the guard exists
to stop.

Groups skipped for a missing target are now retried once after the pass, since
a later group can create what an earlier one needed. Groups that are genuinely
absent simply skip again. Ordering is otherwise untouched, so the reverse-order
unwind that rollback depends on still holds.

Fixing the gate to demand an existing target for deny-delete would have been
wrong: it turns a fresh clone into a setup failure. Materializing .git is
likewise ruled out by git init.

The four existing guard tests assert the mask and what the planner emits, and
all four pass with the guard absent from disk, which is why a green suite said
nothing. The new tests apply the plan and read the actual DACL back through
SDDL. Verified the fresh-workspace case fails without this change and passes
with it, while the already-has-.git case passes either way, so the new coverage
is pinned to the defect rather than to the code.

Also moves windowsACLPlanPaths behind the Windows build tag. Every caller is
Windows-tagged, so defining it in the portable file made it dead code on Linux
and macOS and failed make lint-static, as anandh8x reported.
2026-08-27 13:27:11 +05:30
Vasanthdev2004 0a80db48fe fix(sandbox): reject protected metadata names that escape the write root
ProtectedMetadataNames is documented as names, but it is joined onto the write
root to place a deny ACE and to materialize the directory that ACE protects. A
value containing ".." or a separator puts both outside the workspace: the deny
lands on a directory the sandbox does not own, and elevated setup creates it
there. Raised by CodeRabbit on this PR.

Not reachable today, since the only caller passes a package constant. It is
guarded so that stays true if a future caller sources these from config, which
is the kind of change that would not obviously be a security decision.

The component check moves to the portable file so both users share it. The
handle-relative primitives need it because NtCreateFile resolves a relative
name containing separators the ordinary way, following any junction inside it;
the plan builder needs it for the escape above. Separators are matched
explicitly rather than through filepath.Base, because these are Windows paths
whatever the build host is, and on Linux filepath.Base leaves a
backslash-joined name untouched and would wave it through.

Rejection tests are in the portable test file, so they run on all three
platforms alongside the rest of the plan-builder coverage.
2026-08-27 13:27:11 +05:30
Vasanthdev2004 9b9937bc3d fix(sandbox): bind windows ACL materialization and rollback to handles
Closes the two path-swap findings on this PR. Both had the same cause: an
object was addressed by a pathname that the kernel re-resolves at the moment
the call runs, so a workspace owner could change what that name meant in the
gap between checking it and using it. Junctions need no privilege, so this was
reachable by exactly the unprivileged user the sandbox exists to contain.

Materialization verified a component, closed the handle, then handed the same
string to os.Mkdir. Swapping the verified ancestor for a junction inside that
gap got the component created outside the approved tree, as Administrator, and
verifying again afterwards cannot un-create it. It now opens the deepest
existing ancestor once and creates each missing component relative to the
handle of the level above, so the tree it descends is pinned to objects rather
than named by strings.

Rollback called os.RemoveAll on the target pathname, so an ancestor swapped to
a junction after creation sent a recursive elevated delete into an unrelated
tree. It now records what it created as single components under an anchor and
unwinds them handle-relative, deepest first. It also removes the whole chain
rather than only the final component, which the old comment claimed was fixed
and was not, and it removes only what the kernel confirmed this run created,
so a racing creator no longer gets its directory deleted on teardown.

Three things this turned up that were not in the original findings:

The delete primitive used FILE_DELETE_ON_CLOSE, which defers the removal to
cleanup and reports a non-empty directory to nobody: open succeeded, close
succeeded, directory still there. Wired into rollback that would have been
worse than the os.RemoveAll it replaced, since rollback would report success
while leaving state on disk. It now sets the disposition explicitly, so
STATUS_DIRECTORY_NOT_EMPTY comes back to the caller. Residue is preferable to
recursing through a path someone else controls; lying about it is not.

The child create never inspected the handle it returned, so an existing
junction was handed back as the next parent in the walk and every deeper
create landed on the far side of it. Component names are now validated as
single components too: NtCreateFile resolves a relative name containing
separators the ordinary way, which would have walked straight through an
intermediate junction.

Rollback re-opens the anchor by pathname and checks its volume and file index
against what materialization saw, because replacing a directory with another
real directory of the same name needs no reparse point at all and passes every
no-follow check there is. Holding the handle instead would be stronger, but
three call sites deliberately discard the rollback closure and would leak.

Testing. makeWindowsACLDirChainNoFollow carries a seam that fires between
verifying the anchor and creating anything, because a race nobody can trigger
on demand is not a regression test. The existing junction test plants its
junction before the walk starts and so never reached this. The new test has a
control arm that performs the identical swap and creates by pathname, and
asserts the object does escape, so the fixed arm proves the hole was open
rather than proving some code ran. Also covers the file target, rollback
through the closure callers actually hold, and the non-empty case that the
old empty-directory-only delete test hid.

Validation now runs before the filesystem is touched, so a malformed entry no
longer creates a chain and then fails.
2026-08-27 13:27:11 +05:30
Vasanthdev2004 52d1a31657 fix(sandbox): keep the git rename guard tests portable
The two rename-guard tests built their expectations from a hardcoded
C:\work\repo, but buildWindowsPrincipalACLPlan normalizes every write
root before it names an ACE. On Windows that root is already absolute,
so normalizing is a no-op and both tests passed locally. On Linux and
macOS it is not absolute, so the plan named a different path and both
tests failed. The plan builder is portable code with no build tag, so
these tests run on every platform.

Use an OS-neutral root and normalize the expected path the same way the
plan does, which is what the other untagged test in this package
already does. The Windows-only assertions about the ACE mask and its
inheritance stay where they are, in the file that is tagged for it.
2026-08-27 13:27:11 +05:30
Vasanthdev2004 9b54fb48b5 feat(sandbox): handle-relative directory create and delete on Windows
Groundwork for the two remaining materialization P1s. No call sites yet;
the walk and the rollback move onto these next.

Every pathname-based call re-resolves the whole path inside the kernel
when it runs, so verifying a component and then creating through it are
two separate resolutions of the same string. A workspace owner can swap
an ancestor for a junction in that gap. Demonstrated, not theorised: with
the same swap performed between verify and create, os.Mkdir put the new
directory OUTSIDE the approved tree ("landed inside approved tree: false,
ESCAPED outside approved tree: true"), and the post-create check cannot
un-create it.

A handle pins the object rather than the name, so a create resolved
against it cannot be redirected however the path is later rearranged.
os.Mkdir, os.OpenFile and os.RemoveAll are pathname-based by construction
with no relative form on Windows, hence NtCreateFile with
OBJECT_ATTRIBUTES.RootDirectory. x/sys/windows already exposes every
piece, so this adds no hand-rolled syscall bindings.

createWindowsACLChildDirectory reports whether it created or opened,
because rollback must delete only what it made; removing a directory that
already existed would destroy a user's data over an unrelated failure.
deleteWindowsACLChildDirectory is the counterpart that rollback needs:
os.RemoveAll on a pathname whose ancestor has since become a junction
recurses outside the workspace.

The fix also makes the race testable. Against the old code the swap had
to be threaded into the middle of a function; here it is three ordinary
lines between an open and a create, because the handle is held across
them. Both tests perform the real swap and assert the operation stayed
inside the verified directory, and the delete test leaves a bystander
under the decoy whose survival proves the delete never resolved by path.

Refs #808.
2026-08-27 13:27:11 +05:30
Vasanthdev2004 66724c6e0e fix(sandbox): stop a principal replacing .git to shed its carveouts
The write-denied carveouts guarding .git are attached to .git/config and
.git/hooks as OBJECTS. The workspace allow grant is inheritable and
carries DELETE, and nothing denied DELETE on .git itself, so a principal
could rename .git aside, recreate it, and create fresh config and hooks
that inherit the allow with no deny of their own. That restores
credential.helper and core.hooksPath, and with them arbitrary code
execution on the next git command.

.git could not simply join sandboxFullyProtectedMetadataNames next to
.zero and .agents: that list emits DenyWrite, whose mask includes
FILE_GENERIC_WRITE, and git has to write index, objects and refs. Its
absence from that list was correct and was also the hole.

Add WindowsACLDenyDelete: DELETE, WRITE_DAC and WRITE_OWNER only.
Renaming a directory needs DELETE on that directory, so denying it is
what closes the replacement. WRITE_DAC and WRITE_OWNER come along because
a guard the principal can rewrite is not a guard. FILE_GENERIC_WRITE and
FILE_DELETE_CHILD stay out so git keeps working.

The ACE is applied uninherited, which is why the narrow mask is safe:
inherited onto .git's children it would deny DELETE on every file inside
and git could not remove a lock file or a ref. windowsExplicitAccessEntries
hardcoded SUB_CONTAINERS_AND_OBJECTS_INHERIT for every directory entry,
so inheritance is now decided per action.

The entry is not materialized. git creates .git, and an empty one made by
setup breaks git init.

Reported by @jatmn on #808.
2026-08-27 13:27:10 +05:30
Vasanthdev2004 03e556bce2 test(sandbox): compare ACL record paths the way the plans do
The two new record tests failed on the Windows runner and passed
everywhere else. The plan builder runs every root through
normalizeProfilePath, whose EvalSymlinks expands the 8.3 short name
GitHub's runners hand out for TEMP, so the record held
C:\Users\runneradmin\... while t.TempDir() had returned
C:\Users\RUNNER~1\... and an EqualFold on the raw spellings called two
spellings of one directory different paths.

Production was never affected: the recorded paths and the newly planned
paths both go through that same normalization, so setup and teardown agree
with each other. This was only the test being naive about what "same path"
means on Windows, and a developer whose volume has 8.3 name generation
disabled cannot reproduce it — which is how it shipped.

The comparison now normalizes both sides through normalizeProfilePath and
keys them with windowsCapabilityPathKey, which is what the ACL plans
themselves use. Re-checked against the mutation that matters: reverting the
revocation to the new plan's paths alone still fails
TestReSetupRevokesARootTheNarrowedPolicyDropped, so the looser-looking
comparison has not defanged the assertion.
2026-08-27 13:27:10 +05:30
Vasanthdev2004 542728a7da fix(sandbox): revoke principal ACEs on roots that left the policy
applyWindowsPrincipalACLs revoked the trustee only from the paths of the
plan it was about to apply. A root that LEFT the policy is by definition
absent from that plan, so its ACE survived the re-setup marker validation
forces and the principal kept write access the current policy no longer
grants -- the sandbox widened as a result of being tightened. Teardown
repeated the same current-plan-only calculation, so retiring the principal
cleaned every path except that one, and then deleted the account, leaving
the ACE naming a SID nothing could resolve.

Nothing on Windows can answer "which paths hold an ACE for this SID"
without walking every volume, so the grants are now written down as they
are made: a per-principal record beside the secret, keyed the same way
because one sandbox home serves every workspace on the machine. Setup
revokes over the union of the recorded paths and the new plan's; teardown
revokes over the same union.

The record is written as that union BEFORE any DACL changes and narrowed
to the granted set after, so a crash in between leaves a superset rather
than a record missing paths the run granted. A superset is the safe
direction: revoking a path that holds no ACE for the trustee is a no-op.

The interesting case is a principal from an earlier setup with no record.
Proceeding with an empty prior set would be the fail-open, on the one path
where the prior set is not empty but unenumerable. Setup retires that
account before provisioning instead: Windows never reuses a deleted local
account's RID, so whatever ACEs cannot be found end up naming a principal
that no longer exists, and the SID minted next is one no DACL on the
machine can already carry. It needs no new operator action, which matters
-- there is no `zero sandbox teardown` to send anyone to.

Every guard is mutation-checked. Reverting the union to the new plan's
paths fails the end-to-end test against real DACLs with "the principal
kept its grant on a root the narrowed policy removed"; moving the record
after the grant, dropping the retirement, reverting teardown, and trusting
an unknown schema each turn a different test red.

Two existing tests move to the new applyWindowsPrincipalACLs signature.
2026-08-27 13:27:10 +05:30
Vasanthdev2004 c13799d56a fix(sandbox): carry the principal opt-in through the setup protocol
Elevated setup and the commands that later use a principal each read
ZERO_WINDOWS_SANDBOX_IDENTITY from their own process environment, and setup runs
in a separate, UAC-elevated process whose environment is not the caller's.
commandConfig() returned a nil Env, so the gate in windows_setup_windows.go fell
through to os.Getenv in the elevated helper. The two halves could disagree and
nothing detected it.

Direction B is the dangerous one: command opted in, setup did not. Marker
validation passed (the marker recorded nothing about the opt-in), the principal
lookup then declined with a nil error, and the command ran on the same-user
restricted token — which by its own comment does not confine reads — while the
operator believed an account boundary was isolating it. No warning fired.
Direction A left an orphaned account holding batch-logon rights and workspace
ACEs that teardown never retires, because teardown sits inside the same opt-in
branch.

The opt-in is now resolved in the shell the user typed `zero sandbox setup` into
and serialized across the UAC boundary as --sandbox-principal 0|1. The marker
records it (schema 4 -> 5) and validation refuses on mismatch, before the ACL and
network checks. Both directions refuse rather than fall back silently: an
unreadable value is rejected outright, since guessing "off" would provision a
weaker sandbox than asked for and report success.

PrincipalOptIn is a *bool, not a bool. Unset means "consult the environment"
rather than "opted out", so the existing smoke-test callers that do not set it
keep working instead of serializing --sandbox-principal 0 while the command half
still consults os.Getenv.

Dropped the deny-mode fallback warning added alongside this. Announcing it looked
right, since deny is the default mode and an opted-in operator therefore never
gets a principal for ordinary commands. But this runner is re-exec'd per command,
so its sync.Once is once per COMMAND: the notice would print on nearly every tool
call, and noise that repeats gets filtered rather than acted on. It is also not
actionable per command. `zero doctor` carries the opt-in now and is the right
surface for a standing configuration fact. The deny-mode behaviour stays pinned
by TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied.

Reported by jatmn on #808.
2026-08-27 13:27:10 +05:30
Vasanthdev2004 53fb2e8daa fix(sandbox): keep the principal inside the Windows write jail
The principal branch handed the raw LogonUser token to CreateProcessAsUser. That
token is a full token for the account, so the sandboxed child kept every write
its ambient memberships grant. The ACL plan can add grants and denies at named
paths, but it cannot revoke what BUILTIN\Users, Authenticated Users or
NT AUTHORITY\BATCH already allow elsewhere — so an opted-in command whose
profile permitted writes only to the workspace and runtime roots could still
write C:\Users\Public\Documents, which grants BATCH modify and which a batch
logon therefore satisfies.

The principal now gets its own identity AND the restricted token, not one or the
other: reads stay confined by its ACEs, writes by the restricted-SID check.

The principal's own SID joins the capability SIDs deliberately.
applyWindowsPrincipalACLs grants the workspace to identity.SID rather than to a
capability SID, so omitting it would leave the workspace grant matching nothing
in the restricted list — a jail that locks out the inmate and no one else. The
SID is read back from the token itself rather than threaded through the call, so
it cannot drift from the identity actually running.

Not fixed here, and separate from this finding: worldSID is unconditionally in
the restricted-SID list, so any path whose DACL grants Everyone still satisfies
the restricted check on both this path and the pre-existing fallback. That
predates the principal work and is raised with the maintainers separately.

Reported by jatmn on #808.
2026-08-27 13:27:09 +05:30
Vasanthdev2004 11534a4f1f fix(sandbox): reject reparse ancestors before creating an ACL target
FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL path component being followed.
materializeWindowsACLTarget built its target with os.MkdirAll and os.OpenFile on
the pathname, and both resolve ancestors — so the ancestor reparse check on the
subsequent no-follow re-open ran only after the objects already existed.

An ordinary workspace owner needs no privilege to create a junction. Turning
.git into one before elevated setup runs, with config absent, had setup create
the target at a location of the attacker's choosing as Administrator. Rejecting
it afterwards does not undo that, and the failure path removes only the final
component, so every intermediate directory MkdirAll created outside the
workspace survived permanently.

Creation now goes through makeWindowsACLDirChainNoFollow, which walks up to the
deepest existing ancestor and verifies it no-follow first. One check suffices for
the whole chain above it because GetFinalPathNameByHandle answers for the entire
resolved path. Missing components are then created one at a time, each
re-verified immediately after creation, so a component swapped for a junction
mid-walk is caught before anything lands underneath it.

Taken over the relative-handle NtCreateFile route because x/sys/windows offers no
ergonomic relative-create primitive, and this leaves a window of one component
with an immediate post-create check rather than create-everything-then-verify.

Reported by jatmn on #808.
2026-08-27 13:27:09 +05:30
Vasanthdev2004 2d5dcfa259 fix(sandbox): surface a failed stale-secret cleanup after rotation
The provisioning rollback removes the stored secret when this run created the
account or rotated an adopted one's password, because in both cases what is on
disk cannot authenticate and absent beats stale: the command path treats a
missing secret as "not provisioned" and falls back, while a stale one fails the
logon and reports a broken sandbox.

That removal ignored its own error. When it failed, the invariant it exists to
keep was not restored — a credential for a password that no longer works stayed
on disk — and setup said nothing. The operator met a provisioned-but-unusable
principal on the next command instead of hearing it from the run that broke it.

undo now returns that one error and the five failure paths join it onto the
error they were already returning, so the original cause and the cleanup failure
both surface. The message names the file and what to do about it. The other undo
steps still swallow: they leave residue, while this one leaves a credential.

Reported by jatmn on #808.
2026-08-27 13:27:09 +05:30
Vasanthdev2004andClaude Opus 5 7681a95656 fix(sandbox): stop teardown creating a directory while naming one
windowsPrincipalTeardownPaths said the runtime root was "resolved without
creating it, since teardown has no business making directories on its way
out". That was my comment and it was false: it went through
sandboxRuntimeRootFor, whose fallback calls os.MkdirTemp when the
cache-derived root would land inside the workspace. So cleanup created a
fresh temp directory, and a useless one — the fallback root is random per
process and could never match the tree the commands actually used.

sandboxRuntimeRootFor is split: deterministicSandboxRuntimeRoot computes the
cache-derived path and says whether it is usable, creating nothing, and the
existing resolver keeps the fallback on top of it. Teardown takes the pure
one and simply has no runtime tree to revoke when it reports unusable, which
is correct — there is no way to name the random root from here anyway.

The first version of the test called the pure resolver directly. It passed,
and reverting the call site to the creating one left it passing. It now
drives windowsPrincipalTeardownPaths and counts temp-directory entries
across the call, and that mutation fails it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:09 +05:30
Vasanthdev2004andClaude Opus 5 1767a08872 fix(sandbox): close the delete-through-parent, junction-ancestor and rollback gaps
Three findings from jatmn's review, all reachable only under the opt-in
principal backend but all real.

FILE_DELETE_CHILD is no longer granted. On a parent it authorises deleting a
child whatever the child's own DACL says, so granting it on a write root
handed back the carve-outs underneath: delete .git/config, recreate it, and
the replacement inherits the grant with no deny of its own — restoring the
credential.helper and core.hooksPath control the carve-out exists to
prevent. It was granted to keep the mask symmetric with the deny mask, which
is the wrong instinct: denying a capability is not a reason to grant it. The
comment two lines up already made that argument for WRITE_DAC and
WRITE_OWNER. DELETE alone still covers removing and renaming files inside
the roots, which is what the grant is actually for — verified before
removing it.

Note this does NOT close the second route jatmn described: .git itself
carries no ACE, so renaming the whole directory aside needs only DELETE.
That needs a guard on .git and is not in this commit.

ACL targets are now rejected when a PARENT is a reparse point. CreateFile
resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the
final-component check passed while elevated setup rewrote the DACL of an
object outside the workspace. Junctions need no privilege to create, unlike
symlinks, so this was reachable by exactly the unprivileged user the sandbox
contains. GetFinalPathNameByHandle answers where the handle really landed,
covering every component in one call instead of walking the path and racing
between checks. The comparison is against the path's own resolved form, so a
differently-cased or 8.3 spelling is still accepted.

The revocation's rollback is returned instead of discarded. Discarding it
was justified on the grounds that the only failure path removes the
principal outright — true for a principal this run CREATED, false for one it
ADOPTED, which #812 keeps alive on failure rather than destroying someone
else's working account. The account survived with its previous ACEs stripped
and the new ones rolled back: logged on, and unable to reach its own
workspace. Teardown still discards it deliberately, since putting ACEs back
on an account about to be deleted is the opposite of the point.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:09 +05:30
Vasanthdev2004andClaude Opus 5 1a6b08fe5f test(sandbox): assert the ancestor walk, not the old all-or-nothing contract
TestCanonicalWorkspaceRootFallsBackWhenResolutionFails asserted that a path
with missing segments came back as the plain cleaned path. That was the
behaviour before the ancestor walk, and Windows CI failed it correctly:

    canonical("C:\Users\RUNNER~1\...\001\never-created\deeper")
      = "C:\Users\runneradmin\...\001\never-created\deeper", want the cleaned path

The existing ancestor resolved and the missing remainder was re-appended,
which is precisely what the walk exists to do. The assertion now says that:
the result equals the canonical parent joined with the segments that do not
exist, and those segments survive rather than collapsing to the ancestor.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:09 +05:30
Vasanthdev2004andClaude Opus 5 e1c57564a0 fix(sandbox): resolve through path segments that do not exist yet
The previous fix normalized both the workspace root and the cache root, and
macOS CI still failed the same way. EvalSymlinks fails outright when the
LEAF does not exist, and a cache root has not been created at the point it
is first normalized — so the workspace resolved (/var to /private/var)
while the cache root did not, and the containment check that decides
whether the runtime tree must move out of the workspace compared the two
anyway.

canonicalSandboxWorkspaceRoot now resolves the longest existing ancestor
and re-appends the remainder, so a path normalizes the same way whether or
not its final segments exist:

    /var/.../001/.cache        leaf missing, walk up
    /var/.../001               resolves
    /private/var/.../001/.cache

Terminates at the filesystem root, where it falls back to the cleaned
absolute path, and a path with no symlink anywhere along it is unchanged.

The regression test needs a symlink to produce a spelling only resolution
reconciles, so it skips on Windows — where creating one needs privilege —
and runs on the platforms that caught this. I could not reproduce either CI
spelling locally: this box's temp paths are already canonical and 8.3 alias
creation is disabled on the volume.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:09 +05:30
Vasanthdev2004andClaude Opus 5 b1fb0ab42a fix(sandbox): normalize the cache root too, not just the workspace root
sandboxRuntimeRootFor compares the workspace root against the runtime root
it derives from the cache root, and falls back to a private temp tree when
the derived root would land inside the workspace. The previous commit
canonicalized only the workspace root, so that comparison ran on two
different spellings of the same path and the containment check missed:

    macOS:   /var/folders/... vs /private/var/folders/...
    Windows: C:\Users\RUNNER~1\... vs C:\Users\runneradmin\...

The fallback never fired and the runtime tree was placed inside the
workspace it exists to stay out of. Both CI runners caught it; my box did
not, because its temp paths are already canonical and 8.3 alias creation is
disabled on the volume, so I could not reproduce either spelling locally.

Both inputs now go through canonicalSandboxWorkspaceRoot, on the
cross-platform path and the Windows setup path.

The regression test uses a symlink, which is the portable way to produce a
spelling only resolution reconciles — Clean cannot see through one. It
skips on Windows, where creating one needs privilege, and runs on the
platforms that caught the bug.

Two things about that test are deliberate. My first version used a
redundant-segment path, which Clean already normalizes, so reverting the
fix left it passing. My second resolved nothing before asserting, and
through the link the runtime root shares no textual prefix with the
workspace — it would have called a root sitting physically inside the
workspace "outside" and passed against the exact bug it exists for. It now
resolves before comparing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:08 +05:30
Vasanthdev2004andClaude Opus 5 1945d47ef8 fix(sandbox): canonicalize the workspace root on both sides, not just setup
Windows CI caught two faults in the previous commit. Both came from
canonicalizing one side of a pair.

setupWindowsSandboxRuntimeRoot resolved the workspace root while
prepareSandboxRuntime still only cleaned it, so the two disagreed exactly
where they had to agree. It passed locally because my temp paths were
already canonical; a Windows runner's TEMP is an 8.3 short path that
resolution expands:

    setup granted ...\runtime\v1\5e2d212300ccdfba
    commands use  ...\runtime\v1\92c31f8cf536dfde

The canonicalization moves to canonicalSandboxWorkspaceRoot in
runtime_state.go and both sides call it, which is what the original fix
should have done.

The carveout shape was rebuilt from the RESOLVED write root and compared
against subpaths that cannot resolve, since .git/config does not exist at
setup and normalizeProfilePath falls back to Clean when EvalSymlinks fails.
Two spellings of the same path therefore missed the lookup and .git/config
went back to being created as a directory — the original bug, reintroduced
quietly by its own fix. gitMetadataCarveoutIsFile now matches on the
trailing segments, derived from the spec list so it cannot drift from it,
and no reconstructed absolute path is compared at all.

Both failures now have regression tests that reproduce the non-canonical
root by lowercasing, which needs no short name and no privilege. Reverting
either fix fails them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:08 +05:30
Vasanthdev2004andClaude Opus 5 d0693ffa8d fix(sandbox): revoke ACEs on teardown and key setup off the resolved root
Two ways setup and the command path disagreed.

Teardown removed the secret, the LSA rights and the account, but never the
ACEs. Once the account is gone its SID stops resolving and every ACE naming
it becomes an orphaned raw-SID entry on the user's own tree — precisely the
residue the capability-SID model left behind and this one exists to avoid.
Revocation now runs while the SID still resolves, by trustee so it also
clears grants written by older versions. A revoke failure is deliberately
not fatal: a path the user has since deleted cannot be cleaned, and
refusing to remove the account over it would strand the principal and its
logon rights permanently, which is worse than a leftover ACE.

The runtime root was derived from filepath.Clean(WorkspaceRoots[0]) at
setup while Engine.resolveCommandDir cleans, absolutizes and then
EvalSymlinks it. That needs no symlink to diverge — Windows opens a path in
any casing and EvalSymlinks canonicalizes it:

    setup sees   c:\users\me\myworkspace
    command sees C:\Users\me\MyWorkspace

so setup granted the principal one runtime tree and every command used
another. The grant that exists to make npm/go/pip caches writable landed
where nothing reads, surfacing as a bare ACCESS_DENIED on a cache write.
Both now go through canonicalWindowsSandboxWorkspaceRoot. An unresolvable
root falls back to the cleaned absolute path, matching the command path
rather than failing.

setupWindowsSandboxRuntimeRoot is split into derivation and creation so
teardown can name the tree without making directories on its way out.

The first version of the divergence test called the canonicalization helper
directly. It passed, and reverting setup to filepath.Clean — the actual bug
— left it passing. It now drives windowsSandboxRuntimeRootPath, and that
mutation fails it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:08 +05:30
Vasanthdev2004andClaude Opus 5 dfe7fa3eb1 fix(sandbox): revoke stale principal ACEs before re-applying the plan
windowsPrincipalRevokePlan was implemented and tested but had no production
caller, so nothing ever used it. applyWindowsACLPlan merges into the
existing DACL, which means a re-run after narrowing a write root or
shortening a deny list left the previous, wider ACEs sitting beside the new
ones: the principal kept access the current policy no longer granted, and
the sandbox silently widened as a result of being tightened.

Setup does get the chance to notice — marker validation already refuses
commands with "permission roots or deny lists changed" until setup runs
again — so the re-apply is exactly where this belongs.

The ACL step is extracted into applyWindowsPrincipalACLs: build the plan,
revoke every ACE naming this trustee on the paths it touches, then apply.
Revocation is by trustee rather than by remembered path, so it also clears
grants written by an older version of Zero. Its rollback is discarded on
purpose — the only failure path from here removes the principal outright,
and restoring stale ACEs for an account about to be deleted is the residue
this exists to prevent.

Extracting it also makes the ordering testable without new provisioning
seams, which #812 already adds with a different signature; adding them here
would have collided on its rebase.

Three tests: revocation actually drops a grant on a root that left the
policy while keeping the one that stayed (asserted against the real DACL,
counting deny ACEs as well as allow, since trustee revocation drops both);
revoking a path that was never created is a no-op rather than an error; and
the production path revokes BEFORE it applies. That last one is the one
that matters — the first two pass just as happily with the call site
deleted, and deleting it kills only the third.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:08 +05:30
Vasanthdev2004andClaude Opus 5 6315181bbf fix(sandbox): re-check principal privilege when minting a command token
Provisioning refuses an account that is already in Administrators, Power
Users or Backup Operators, but group membership is not frozen at setup. An
account provisioned clean can be added afterwards — by an operator, or by
an attacker who already has that access and would like the sandbox to hand
it back. Every command after that minted a token for a privileged account.

The re-check goes on the path that mints the token, not inside
lookupWindowsSandboxIdentity. Teardown resolves the same identity to revoke
its logon rights before deleting the account, so refusing there would leave
the very account this guards against permanently undeletable by Zero. The
command path already propagates anything that is not the not-provisioned
sentinel, so this surfaces to the operator instead of silently dropping
back to the restricted token.

Also make the gating test hermetic. Its "absent" case passed an empty map,
which falls through to os.Getenv, so a developer with the opt-in exported
saw a different result from CI:

    ZERO_WINDOWS_SANDBOX_IDENTITY=1 go test ./internal/sandbox/
    --- FAIL: TestWindowsSandboxIdentityGating/absent
        enabled = true, want false for ""

Every case there supplies an explicit map entry, so the process variable is
now pinned to prove none of them consult it. The os.Getenv fallback is what
elevated setup actually runs on — it passes no Env — so it gets its own
table rather than riding on a case that also has a map entry.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:08 +05:30
Vasanthdev2004andClaude Opus 5 1391376f9d fix(sandbox): materialize .git/config as a file, not a directory
Materializing the git control-plane carveouts creates a missing target so
the deny-write ACE is in place before git first runs. It did that with
os.MkdirAll on the full path, which is right for .git/hooks and wrong for
.git/config: git wants a file there.

The consequence is worse than a mis-ACL'd path. On a fresh workspace
neither carveout exists — which is exactly the case materialization was
added for, so this is the common path rather than a corner — and elevated
setup would leave a directory where git's config file belongs:

    warning: unable to access '<ws>/.git/config': Permission denied
    fatal: unknown error occurred while reading the configuration files

git init then fails outright and the workspace is unusable.

Materialization now takes the shape from the carveout definition:
gitMetadataWriteCarveoutSpecs is the single source of truth and
gitMetadataWriteCarveouts derives its list from it, so a carveout cannot be
added in one place and have its shape forgotten in the other. A file target
gets its parent chain created and then an empty file; a directory target is
unchanged. A racing creator winning the O_EXCL is treated as success, since
the target existing is all materialization needed.

The regression test runs a real `git init` over the applied plan. It names
Guests as the principal rather than Everyone — with Everyone the deny ACE
also denies the test process and git fails for an unrelated reason, which
would have made the test pass for the wrong reason once the shape was fixed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 13:27:08 +05:30
Vasanthdev2004 bf7a530b33 fix(sandbox): refuse to adopt a principal in a privileged group
Adoption takes over an account whose name and ownership comment match,
resets its password and hands it to the sandbox. An account that is also
in Administrators, Power Users or Backup Operators would give the sandbox
the rights it exists to withhold: rewriting the ACLs confining it, reading
the secret locked to the invoking user, and stopping Zero. The name is
derived rather than discovered, so an account can match without anyone
intending it to.

Membership is resolved by well-known SID rather than by group name, so a
localised install where the group is Administratoren or Administrateurs is
still recognised.

Raised as a non-blocking follow-up in review; it is cheap enough to do now
rather than track.
2026-08-27 13:27:08 +05:30
Vasanthdev2004 6d9b437028 fix(sandbox): grant the principal the runtime tree commands write to
Four review findings on the elevated setup path.

The principal had no access to the sandbox runtime root.
permissionProfileWithRuntime appends that root to WriteRoots on every
command and redirects HOME, GOCACHE, npm_config_cache and similar into it,
but it lives under the user cache rather than the workspace, so the
profile setup builds its ACL plan from never contains it. On the
restricted-token path that costs nothing, since the child still runs as
the caller. A principal is a separate local account with none of those
rights, so every npm install, go build or pip install would have failed on
a cache write with a bare ACCESS_DENIED and nothing naming the sandbox as
the cause. Setup now resolves the same root and grants it.

The derivation is extracted so both callers share it. If setup and
prepareSandboxRuntime ever disagreed, the ACE would land on one directory
while commands used another, which is the same failure with a harder
diagnosis, so a test asserts the two agree.

The git control-plane carveouts are materialized. .git/config and
.git/hooks arrive as ReadOnlySubpaths, and applyWindowsACLPlan skips an
absent target, so on a workspace where git had not run yet the deny ACEs
were never written and the principal kept inherited write access once git
created them.

Command-time lookup verifies workspace ownership. The account name carries
only 11 characters of the workspace digest; the comment carries all of it.
Provisioning already refused a foreign account, but the command path
resolved the name straight to a SID, so the workspace that lost a
collision would have run as the other one's principal. SID resolution
still runs first, so an absent account stays the unavailable sentinel
rather than becoming a collision error.

The gated round-trip test asserted a logon with the password from a second
provisioning call. Rotation moved to the setup path, so that value is a
fresh string the account never held. It now exercises the guarantee the
setup path actually makes: the stored secret logs the principal on.
2026-08-27 13:27:07 +05:30
Vasanthdev2004 239894103e fix(sandbox): keep an adopted principal's logon rights on rollback
Second round of review findings, both on the elevated setup path.

The rollback revoked logon rights whenever they had been attempted,
without regard to whether this run created the account.
revokeWindowsSandboxLogonRights passes AllRights, which drops every right
the account holds and deletes its LSA object outright. On an adopted
principal that is not a rollback but destruction: a transient grant,
secret-path or secret-write failure during a re-run stripped the
SeBatchLogonRight and deny-logon rights an earlier setup had established,
leaving exactly the broken-but-present principal this path exists to
avoid. Revocation is now scoped to accounts this run created. The rights
granted to an adopted account are the ones it is supposed to hold, so
leaving them is the safe direction.

A secret the current user cannot read now falls back instead of failing
the command. The secret's DACL names whoever ran setup, so an operator who
elevated with a separate administrative account, through runas or an
over-the-shoulder UAC prompt, leaves a secret their ordinary account
cannot open. That is the documented fail-soft case, and treating it as a
hard error made every sandboxed command fail on a machine that was merely
set up by a different admin. Permission errors from the removal path are
deliberately still reported, since incomplete teardown is worth knowing
about.

Both are covered by injected-failure tests and fail if the guard is
removed. The secret read is seamed to inject the permission error, because
producing a real ERROR_ACCESS_DENIED needs DACL surgery and would test the
platform rather than the mapping.
2026-08-27 13:27:07 +05:30
Vasanthdev2004 815d220a2f fix(sandbox): stop setup destroying a principal it did not create
Six findings from review, all on the elevated setup path.

Teardown was not scoped to what the run created. provisionWindowsSandbox
PrincipalForSetup was careful never to delete an account it had adopted,
and then setupWindowsSandboxPrincipal called removePrincipal on any ACL
failure with no such guard. Re-running elevated setup on a working machine
and hitting one transient ACL error therefore deleted the local account,
its secret and its logon rights. It now returns whether it created the
principal and the outer teardown honours it; ACEs are still reverted,
since this run applied them.

Password rotation moved to immediately before the secret is committed.
Resetting an adopted account's password at the top of provisioning meant
every later step ran against an account whose password had been replaced
with no copy stored. Any failure there left a live account authenticated
by a password nothing on disk knew, and since the account pre-existed the
rollback correctly declined to delete it, so the command path read the
absent secret as "not provisioned" and fell back to the weaker backend for
good. The two operations are now adjacent. The rollback also stops
removing the secret when this run neither created the account nor rotated
it, because that secret still works.

Policy DenyWrite now reaches the principal ACL plan. The capability plan
has always emitted these; the principal plan denied write only on
protected metadata and read-only subpaths, so once the runner used a
principal token a policy deny elsewhere was not enforced at all.

Principal deny-read entries are materialized, matching the capability
plan, so a path created after setup still gets a deny ACE.

Logon-right revocation is keyed to the attempt rather than to success.
Rights are added one at a time and the grant returns on first failure, so
a partial grant left LSA entries behind pointing at a SID that deleting
the account then made unresolvable.

The ownership comment now carries the full workspace key. The account name
holds only 11 characters of the digest, so two workspaces could derive one
name and silently share an account, a secret and an ACL identity; a
mismatch is now refused. Accounts provisioned before the key was recorded
are still adopted.

Also warns once on stderr when the opt-in is set and a provisioned
principal cannot be used, rather than downgrading in silence.
2026-08-27 13:27:07 +05:30
Vasanthdev2004 0b6b37eca5 fix(sandbox): grant delete to the principal and keep rollback able to find it
Two findings from review, both consequences of the principal being a
separate account rather than the calling user.

WindowsACLAllowWrite granted FILE_GENERIC_WRITE, which covers creating and
modifying but not removing or renaming, and a rename needs delete on the
source. Under the old same-user token this was invisible because the caller
already held inherited rights on its own tree. A principal inherits nothing,
so it could write files it could never delete, which fails ordinary editing
and most git operations rather than an edge case. DELETE and
FILE_DELETE_CHILD are now part of the grant, matching WindowsACLDenyWrite,
which already treats delete as part of write. WRITE_DAC and WRITE_OWNER stay
out: they are denied so the principal cannot rewrite its own restrictions.

provisionWindowsSandboxIdentity returned a zero identity alongside
created=true when group attachment or SID resolution failed after NetUserAdd
had already created the account. The caller's rollback deletes by
identity.Username, so it was asked to delete the empty string and left the
account behind. Group attachment is the case that matters, being both the
enforcement boundary and something local policy can refuse. The name now
comes back with the error.

The four provisioning calls are indirected so the failure paths are
reachable in a test. Seaming only the post-creation pair would not have been
enough: every step needs an elevated caller, so the test would have stopped
at the group check and passed without reaching what it names.

Also seeds the empty-secret test with a genuinely empty file. The previous
whitespace seed was several bytes, so it never reached the length check and
failed later in DPAPI instead, which another test already covers.
2026-08-27 13:27:07 +05:30
Vasanthdev2004 324d9f32fb test(sandbox): check the ACE type before decoding its trustee
GetAce returns a generic ACE_HEADER and the helper reinterprets it as an
ACCESS_ALLOWED_ACE. That holds for the fixed-layout types, but an object
ACE carries Flags and two GUIDs ahead of the trustee, so SidStart would
land mid-structure and Copy would read whatever bytes follow. The caller
asserts that no unexpected trustee appears in the DACL, and on such an ACE
it would print a nonsense SID rather than name the entry that does not
belong.

Nothing under test builds anything but allowed ACEs today, so this changes
no current outcome. It keeps the failure legible if that ever changes.
2026-08-27 13:27:07 +05:30
Vasanthdev2004 706e1a4bee fix(sandbox): drop the stored secret whenever provisioning fails
The cleanup added for partial provisioning left the window it existed for
uncovered. It only removed the on-disk secret when this run had written one,
and it derived the secret path after the logon-rights grant, so a failure
before that point had nothing to remove.

That is exactly the case that matters. Provisioning ALWAYS sets the account's
password, including resetting a pre-existing account's, so from the moment it
returns the stored secret is already stale. A failure in the rights grant then
left that stale secret on disk against a password that had just changed, and
the next command failed the logon and reported a broken sandbox instead of
falling back.

The path is now resolved from the account name before anything can fail, and
removal is unconditional rather than gated on having written one. Absent beats
stale: the command path treats a missing secret as "not provisioned" and falls
back to the restricted token, which is the outcome a failed setup should leave
behind.

Raised by CodeRabbit, twice from different angles, on the commit that added the
cleanup.
2026-08-27 13:27:07 +05:30
Vasanthdev2004 b46d7d19c6 fix(sandbox): refuse a squatted account name and clean up partial provisioning
Two problems in the provisioning path, both raised in review.

The account name is derived from a workspace hash rather than discovered, so it
can be occupied by a local account that has nothing to do with Zero, whether by
coincidence or because somebody put it there. Provisioning treated "NetUserAdd
says it exists" as "this is ours", reset the account's password, added it to the
managed group and adopted it. That is a stranger's account taken over during an
elevated setup, on the strength of a name matching a pattern we generate
ourselves.

Ownership is now proven from the comment provisioning stamps before anything is
touched, and a name held by an account Zero did not create fails with a typed
collision error instead of being adopted.

Second, a failure anywhere after the account existed left it behind. The
rollback the setup path installs is only built once provisioning has returned
successfully, so nothing could undo a failure between creating the account and
storing its secret; the account, and possibly its granted logon rights, simply
stayed. Provisioning now unwinds what the run actually did, in reverse, on every
failure path.

Scoped to what THIS run created, deliberately. An account that already existed
and belongs to Zero is a working principal from an earlier setup, so deleting it
because a later run failed would turn a partial failure into a total one. For
that case the repair is dropping the stored secret instead: this run reset the
password, so the secret no longer matches, and absent beats stale because the
command path treats a missing secret as "not provisioned" and falls back rather
than failing.

The ownership gate is asserted against real accounts every Windows install
carries, which needs no privilege because it only has to establish that they are
not ours. Classifying everything as managed makes it fail.
2026-08-27 13:27:06 +05:30
Vasanthdev2004 ae294d8f98 fix(sandbox): revoke logon rights before deleting a principal
Retiring a principal deleted the account but left its LSA account rights
behind, keyed to a SID that no longer resolves. That is the orphaned residue
this model is supposed to avoid, and the reason ACE revocation is keyed to the
trustee rather than to a record of what was granted; the logon-rights half was
simply missing. CodeRabbit spotted it as a test-cleanup gap, but the production
teardown path had the same hole.

revokeWindowsSandboxLogonRights drops every right held by the principal and
removes its LSA entry, and setup teardown now calls it BEFORE deleting the
account, while the SID still resolves. Removing all rights rather than naming
them is deliberate: the principal is being retired, so rights granted by an
older setup that this one no longer knows about should go too.

An account that holds no rights is not an error, since that is the state
teardown wants. That tolerance depends on STATUS_OBJECT_NAME_NOT_FOUND
surviving LsaNtStatusToWinError as something errors.Is can still match, which
is the sort of Windows errno assumption that is often wrong, so there is now an
unprivileged test asserting it, including that the tolerance does not also
swallow access-denied.

Both gated tests now clean up rights and account, in that order. The
provisioning round trip had no cleanup at all and, since it started granting a
batch logon right, was leaving both behind on whatever machine ran it.
2026-08-27 13:27:06 +05:30
Vasanthdev2004 01dcfdc782 fix(sandbox): reset the password when the account already exists
NetUserAdd leaves a pre-existing account completely untouched, password
included, and ensureWindowsSandboxUser treated that status as success. So a
second setup run generated a fresh random password, stored it as the secret,
and left the account still authenticating with the old one. Every later command
then failed to log on with a principal that looked correctly provisioned. Two
comments claimed the caller reset the password in that case; nothing did.
Caught by CodeRabbit.

ensureWindowsSandboxUser now reports whether the account already existed, and
provisioning resets the password through NetUserSetInfo when it did, so the
value it returns is always the account's real password. The comments now
describe what the code does.

The gated provisioning test provisions twice and then logs on with the password
from the SECOND run, which is the only honest assertion here: a stale password
is indistinguishable from a correct one until something tries to authenticate
with it.

Also makes the syscall keep-alives explicit. The LSA and LogonUser call sites
borrow Go memory that was either not kept alive at all (the policy attributes,
the rights descriptor, the three logon strings) or kept alive only after the
error check, so the failure path returned with it already collectable. The two
netapi32 sites that used a deferred no-op closure now use runtime.KeepAlive as
well, so one idiom is used throughout.
2026-08-27 13:27:06 +05:30
Vasanthdev2004 38bac64c50 fix(sandbox): surface a squatted principal name instead of falling back
lookupWindowsSandboxIdentity collapsed every SID-resolution failure into the
"no principal is provisioned" sentinel, which threw away the check
resolveWindowsSandboxSID deliberately makes: a name that resolves to a group or
alias rather than a user account. The command path treats that sentinel as
permission to fall back quietly, so an account name squatted by something that
is not a user reached the operator as silence and a downgrade to the restricted
token. Caught by gnanam in review.

Only ERROR_NONE_MAPPED now means setup has not run. Anything else is a
principal that exists but cannot be used, and the runtime path propagates it
rather than swallowing it, which is where the description already said the line
should sit.

The decision lives in its own function because the lookup derives its account
name from a workspace key, so a test cannot hand it a name that resolves to a
group. The test drives the classifier with a real error from a well-known local
group, needs no privilege, and fails if the old collapse-everything behaviour
is restored.

Also corrects a comment pointing at sandboxRuntimeKey, which does not exist.
The function is windowsSandboxWorkspaceKey.
2026-08-27 13:27:06 +05:30
Vasanthdev2004 672f407d6b feat(sandbox): encrypt the stored principal password to the invoking user
The file ACL stays the primary control and is what keeps the sandbox
principal from reading its own credential. It only binds while the filesystem
is the one being asked, though, so a backup or a mounted image hands over the
password in the clear. CryptProtectData ties the ciphertext to the invoking
user's logon secret, which covers exactly that gap.

The principal name is passed as entropy, so a blob copied onto another
principal's path fails to decrypt rather than authenticating the wrong
account. A secret written by an older build reads as unavailable and falls
back to the restricted token; the next elevated setup rewrites it.

The round-trip test needs no privilege, so it runs everywhere rather than
joining the gated set, and it asserts the password does not appear verbatim
in the stored bytes.
2026-08-27 13:27:06 +05:30
Vasanthdev2004 9bdfa4c618 fix(sandbox): keep the restricted token when the network is denied
Network denial is enforced by WFP filters keyed to the offline-marker SID.
The restricted token carries that SID; a token from LogonUser cannot, because
it names the account rather than a synthetic capability SID. Routing a
denied-network command through a sandbox principal therefore left the block
filters matching nothing and dropped egress enforcement altogether, and deny
is the default mode.

The principal now stands down whenever the network is denied and the
restricted-token backend runs instead, so read confinement is never traded
for a silent loss of network denial. Keying the filters to the principal's
own SID is the follow-up that lifts the restriction.

The decision sits in its own predicate rather than inline: on a machine with
nothing provisioned the lookup declines for its own reasons, so a test that
called through it would have passed with the guard removed.

Also names the opt-out variable when a provisioned principal cannot be used,
since the backend is opt-in and the operator needs a way back.
2026-08-27 13:27:05 +05:30
Vasanthdev2004 39e0e02cc8 feat(sandbox): provision a sandbox principal during elevated setup
Completes the chain. Until now the principal entry points had no non-test
callers, so `zero sandbox setup` created no account and the runner seam always
fell back: the feature was inert end to end.

Setup now provisions this workspace's principal, grants it the batch logon
right, stores its password locked to the invoking user, and applies the ACL plan
that gives it read+write on the workspace and read on the declared read roots. A
principal is a separate account with no inherent access to the caller's tree, so
those grants are what make the sandbox able to run at all, and their absence
elsewhere is what puts credential stores out of reach.

Provisioning is folded into the existing rollback rather than each later failure
path having to remember it, and the rollback revokes ACEs before deleting the
account: removing the account first would leave ACEs naming a SID that no longer
resolves, which is the orphaned residue this model exists to avoid.

Gated on the same opt-in as the runner. Account creation is visible in
`net user` and is exactly what endpoint protection and enterprise policy object
to, so it happens only when asked for; without the opt-in the capability-SID
backend remains the whole of setup, unchanged.
2026-08-27 13:27:05 +05:30
Vasanthdev2004 a56e6f86c9 test(sandbox): cover the logon-rights and token-minting half
Provisioning is now validated on real Windows, but LsaAddAccountRights and
LogonUser had still never executed, so the principal was proven to exist without
being proven usable.

The batch logon doubles as the assertion that the rights grant worked: a
LOGON32_LOGON_BATCH logon fails with ERROR_LOGON_TYPE_NOT_GRANTED unless
SeBatchLogonRight is actually held, so a token coming back is evidence the grant
landed rather than merely that the call returned success. Granting twice is
exercised too, since setup re-runs must not fail on rights already held.

The token's user SID is compared against the principal's. If a token came back
belonging to the caller the identity boundary would be an illusion and reads
would still run as the user, which is the whole thing this model exists to stop.

Removes any leftover account first and cleans up after itself, because an
interrupted earlier run would leave an account whose password no longer matches
a freshly generated one.
2026-08-27 13:27:05 +05:30
Vasanthdev2004 f855802c85 test(sandbox): name the per-shell env syntax in the provisioning skip
The skip fired on an unset environment variable but read as though elevation
was missing. `set VAR=1` is cmd syntax and sets a shell variable rather than an
environment variable in PowerShell, so the test skipped silently after the
operator believed they had enabled it. Spell out all three shells.
2026-08-27 13:27:05 +05:30
Vasanthdev2004 718e064711 feat(sandbox): use a sandbox principal for Windows command execution
Wires the principal model into the runner and settles where the account's
password lives.

The secret is stored under the sandbox home with an explicit,
inheritance-protected DACL naming only the invoking user and SYSTEM. The sandbox
principal is deliberately absent from it: a principal that could read the file
could mint its own token and the identity boundary would be decorative. The ACL
is applied to an empty file before the password is written, so the bytes never
exist under the config directory's inherited permissions, and PROTECTED drops any
inherited ACE outright.

At command time the runner asks for a principal token first and uses it in place
of the restricted token, because a separate account has reads denied by the
filesystem rather than left open the way a same-user restricted token must leave
them. The lookup is fail-soft: opt-out, no provisioned account, or no stored
secret all report "not available" and the existing restricted-token path runs
unchanged. Only a provisioned-but-unusable identity surfaces an error, since
that means setup ran and the sandbox is broken rather than absent.

The backend stays behind ZERO_WINDOWS_SANDBOX_IDENTITY=1 while the privileged
paths are unvalidated, so no existing install changes behaviour.
2026-08-27 13:27:05 +05:30
Vasanthdev2004 2e2ea27dcf feat(sandbox): add Windows sandbox principals
Groundwork for closing the Windows half of #662 and #675, where
credentialDenyReadPaths is a no-op today.

Every Windows backend currently derives its token from the calling user via
CreateRestrictedToken, so the sandbox can constrain writes but not reads: a deny
ACE that would stop the sandboxed child reading a credential store names the
same account Zero runs as, and would lock Zero out too. That is why deny-read is
skipped on Windows rather than merely unimplemented.

This adds a separate local account per workspace, held in one managed group, so
the sandbox has an identity of its own:

- provisioning: managed group, stable per-workspace account name inside the
  20-character limit, crypto/rand password meeting complexity policy, SID
  resolution, idempotent so setup re-runs converge
- logon rights: grants only SeBatchLogonRight and explicitly denies interactive,
  network, remote-interactive and service logon, then mints a token with
  LogonUser pinned to the local machine
- ACLs keyed to the principal: denies emitted before allows so carve-outs
  survive, workspace granted read+write, read roots granted read, protected
  metadata denied write and materialized
- removal: revocation by trustee, so retiring a principal drops every ACE naming
  it without needing a record of what was granted

The inversion is the point. A separate account has no access to the caller's
profile at all, so credential stores are unreachable by construction rather than
by enumerating deny rules, and the same SID is what a write grant or a firewall
rule can be keyed to.

Nothing is wired into command execution yet: these paths are additive and no
existing behavior changes. See the pull request for the open question about
where the principal's password lives.
2026-08-27 13:27:05 +05:30
1041 changed files with 21325 additions and 71467 deletions

No files matched your search

-3
View File
@@ -29,9 +29,6 @@ reviews:
- path: "tests/**/*.ts"
instructions: |
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
- path: "**/*.go"
instructions: |
Do not request Plan 9 or other unsupported GOOS/GOARCH shims. The five native prebuilt release targets are linux-x64, linux-arm64, macos-arm64, macos-x64, and windows-x64. Android/Termux is also a supported runtime through the documented android-arm64 source build and Linux artifact fallback. Other GOOS/GOARCH combinations are not a review criterion.
tools:
github-checks:
timeout_ms: 300000
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Twigpine/zero/discussions/categories/q-a
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Twigpine/zero/discussions/categories/ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Twigpine/zero/security/advisories/new
url: https://github.com/Gitlawb/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
+80 -158
View File
@@ -6,109 +6,10 @@ on:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
quality:
name: Code Quality & Lint
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: make fmt-check
- name: Vet
run: go vet ./...
- name: deadcode (advisory)
continue-on-error: true
run: make deadcode
- name: golangci-lint (advisory)
continue-on-error: true
run: make lint-static
test:
name: Unit Tests & Coverage
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Test with coverage
run: |
go test -count=1 -coverprofile=coverage.out -covermode=atomic ./...
- name: Publish coverage summary
if: always()
shell: bash
run: |
if [ -f coverage.out ]; then
go tool cover -func=coverage.out | awk '
BEGIN {
total = "unknown"
rows = ""
}
$1 == "total:" {
total = $3
next
}
{
rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3)
}
END {
print "### 📊 Test Coverage Summary: **" total "**"
print "<details><summary>Coverage per function</summary>\n"
print "| File | Function | Coverage |"
print "| :--- | :--- | :--- |"
printf "%s", rows
print "</details>"
}' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: zero-coverage-report
path: coverage.out
if-no-files-found: warn
smoke:
name: Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
@@ -117,40 +18,6 @@ jobs:
- macos-latest
- windows-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
with:
go-version-file: go.mod
cache: true
- name: Test (Fast Non-Race)
run: go test ./...
- name: Build binary
run: go run ./cmd/zero-release build
- name: Smoke binary
run: go run ./cmd/zero-release smoke
race:
name: Race Detector
runs-on: ubuntu-latest
permissions:
contents: read
# A race the detector newly reports can present as a deadlock rather than a
# failure, and the default job timeout is six hours. This job is expected to
# be the longest in the workflow, so the ceiling is generous but finite.
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -163,29 +30,72 @@ jobs:
go-version-file: go.mod
cache: true
# Invoke the Makefile target rather than repeating its flags. `make test`
# is already `go test ./... -race -count=1`, so running it here is what
# stops the project's declared test command and what CI actually runs
# from drifting apart again. The race detector needs cgo, which the
# hosted ubuntu runner supplies without an extra setup step.
- name: Test with the race detector
run: make test
- name: Install Make on Windows
if: matrix.os == 'windows-latest'
shell: pwsh
run: choco install make --no-progress -y
- name: Validate quality targets with stock macOS Make
if: matrix.os == 'macos-latest'
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: grep.patternType
GIT_CONFIG_VALUE_0: fixed
run: make -n vulncheck
- name: Check vulnerabilities through native Windows Make
if: matrix.os == 'windows-latest'
shell: cmd
run: make vulncheck
- name: Check dead code through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make deadcode
- name: Run static lint through native Windows Make
if: matrix.os == 'windows-latest'
continue-on-error: true
shell: cmd
run: make lint-static
- name: Check formatting
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt needed on:" >&2
echo "$unformatted" >&2
exit 1
fi
- name: Vet
if: matrix.os == 'ubuntu-latest'
run: go vet ./...
- name: Test
run: go test ./...
- name: Build binary
run: go run ./cmd/zero-release build
- name: Smoke binary
run: go run ./cmd/zero-release smoke
performance:
name: Performance Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
cache: true
@@ -194,39 +104,51 @@ jobs:
run: go run ./cmd/zero-release build
- name: Performance smoke
run: |
mkdir -p dist/perf
go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci
- name: Upload performance report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: zero-performance-smoke
path: dist/perf/perf-bench.json
if-no-files-found: warn
security:
name: Security & Code Health
name: Security & code health
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
cache: true
- name: govulncheck (linux)
# Hard gate: fails the build when code reaches a known vulnerability. A stdlib
# CVE is cleared by a toolchain bump (see go.mod). May also flag a newly
# published advisory on an unrelated PR — intentional: do not ship known vulns.
- name: govulncheck
run: make vulncheck
- name: govulncheck (windows)
run: make vulncheck-windows
# Advisory: reports functions unreachable from any cmd/* main so dormant
# code is visible in CI. Non-blocking while the dormant subsystems are
# still being wired or removed.
- name: deadcode (advisory)
continue-on-error: true
run: make deadcode
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: make lint-static
-1
View File
@@ -24,7 +24,6 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
+55 -28
View File
@@ -34,15 +34,12 @@ jobs:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Checkout requested ref
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
@@ -100,21 +97,19 @@ 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 package named in package.json (@twigpine/zero): 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.
# 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 + the npm-publish
# environment as a trusted publisher, and provenance is then generated
# automatically. npm checks provenance against package.json `repository`,
# so that must name this repository. Note npm only allows configuring a
# trusted publisher on an EXISTING package, so a new package name starts
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
# 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:
@@ -127,15 +122,13 @@ jobs:
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@twigpine/zero
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
@@ -203,13 +196,47 @@ jobs:
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform payloads first under the `platform` dist-tag, then the wrapper,
# with the `latest` dist-tag checked before and after (platform versions
# are semver PRERELEASES of the wrapper version and must never become
# `latest`). npm applies publishes asynchronously, so the script waits
# until npm shows every platform version before the wrapper goes out,
# and until `latest` moves after it. Versions already on the registry
# are skipped, so "Re-run failed jobs" is safe after a partial publish.
- name: Publish npm packages
# 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: scripts/npm/publish-packages.sh dist/npm
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
-15
View File
@@ -5,8 +5,6 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
push:
branches:
@@ -14,13 +12,8 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
permissions:
contents: read
jobs:
validate:
name: Validate action.yml
@@ -31,14 +24,6 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- name: Run action-summary test suite
run: node --test scripts/action-summary.test.mjs
- name: Parse action.yml and assert documented inputs
shell: bash
run: |
+1 -1
View File
@@ -1 +1 @@
{".":"0.12.0"}
{".":"0.8.0"}
-7
View File
@@ -81,13 +81,6 @@ These classes drive multi-round reviews. Fix them before requesting review:
assert raw `t.TempDir()` spellings (`/var` vs `/private/var` on macOS, short
paths on Windows). Prefer one cross-platform function with small `GOOS`
checks over duplicated helpers that drift.
- **Supported targets only:** CONTRIBUTING.md names the five native prebuilt
release artifacts (`linux-x64`, `linux-arm64`, `macos-arm64`, `macos-x64`,
`windows-x64`) and the additional supported Android/Termux source-build and
artifact-fallback path. Other `GOOS`/`GOARCH` combinations, including
`plan9`, are unsupported and are not a review criterion. Do not block a PR
for failing to compile on an unsupported `GOOS`/`GOARCH`. Tests may use
`plan9` as a fixture for "unsupported".
- **Security edges:** Keep secrets out of argv, env dumps, and logs. Redact
success and error paths (including stderr). Fail closed on ownership, lease,
and permission checks. Do not rely on pre-open path resolution
-143
View File
@@ -6,149 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
### Features
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
### Bug Fixes
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
### Features
* **providers:** add Concentrate provider preset ([#1147](https://github.com/Twigpine/zero/issues/1147)) ([61b0936](https://github.com/Twigpine/zero/commit/61b09364f0f45b0fa8aebaf0a6c3d1c55dbe01c3))
### Bug Fixes
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
### Performance Improvements
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
## [0.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
### Features
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
### Bug Fixes
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
### Features
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
### Bug Fixes
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
### Performance Improvements
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
+5 -30
View File
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
@@ -41,7 +41,7 @@ There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Propose a feature in
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
@@ -126,31 +126,6 @@ not enough. Dependency changes need a clear project benefit, such as fixing a
specific bug, addressing a security issue, improving compatibility, or supporting
an approved feature.
## Supported platforms
Zero publishes five native prebuilt artifacts from
`.github/workflows/release-artifacts.yml`:
- `linux-x64` (`linux/amd64`)
- `linux-arm64` (`linux/arm64`)
- `macos-arm64` (`darwin/arm64`)
- `macos-x64` (`darwin/amd64`)
- `windows-x64` (`windows/amd64`)
The shell installer and npm wrapper resolve the applicable artifacts from that
list. The PowerShell installer supports the published `windows-x64` artifact;
there is no native Windows ARM64 artifact. Windows on ARM users can run the x64
build under emulation or build from source.
Android/Termux is also supported even though it has no distinct release
artifact. The npm wrapper maps Android to the Linux artifact, and
`docs/INSTALL.md` documents the native `android/arm64` source build needed on
devices whose seccomp policy blocks the Linux build's syscall path.
Other `GOOS`/`GOARCH` values, including Plan 9, are unsupported. They are not a
review criterion. Do not block a pull request for failing to compile on an
unsupported target. Tests may use `plan9` as a fixture for an unsupported OS.
## What We Prioritize
The core team will prioritize:
@@ -193,7 +168,7 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
@@ -222,7 +197,7 @@ contribution phase.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a).
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+3 -9
View File
@@ -7,7 +7,7 @@ DEADCODE_VERSION := v0.46.0
GOLANGCI_LINT_VERSION := v2.12.2
GOVULNCHECK_VERSION := v1.3.0
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help
.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help
# Build the main CLI binary into ./zero.
build:
@@ -44,8 +44,7 @@ lint: fmt-check vet
# the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep
# works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS.
# The target-specific export is shell-independent.
lint-static deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off
lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN)
lint-static:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./...
@@ -56,11 +55,6 @@ deadcode:
vulncheck:
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
vulncheck-windows:
mkdir -p "$(CURDIR)/.cache/gobin"
GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./...
tidy:
go mod tidy
@@ -83,4 +77,4 @@ baseline: build
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline"
+23 -224
View File
@@ -4,18 +4,11 @@
<p align="center"><strong>A terminal coding agent you own.</strong></p>
<p align="center">
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
@@ -30,83 +23,10 @@ zero exec "fix the failing test in ./pkg"
zero exec --output-format stream-json < turns.jsonl
```
## Partners
<table align="center">
<tr>
<td align="center" width="150" height="80">
<a href="https://twigpine.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://mimo.mi.com">
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://novita.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://concentrate.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://exa.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
</tr>
<tr>
<td align="center" width="150" height="80">
<a href="https://www.gmicloud.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
## Why Zero
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
OpenAI-/Anthropic-compatible endpoint.
- **Stay in control.** File writes, shell commands, network access, and
out-of-workspace writes go through Zero's permission and sandbox policy.
@@ -126,7 +46,7 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -140,38 +60,26 @@ 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.
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
0.10.0 and the old name gets no further releases. Both packages provide the
`zero` command, so quit any running `zero`, then remove the old package first:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
Your config, sessions, and logins are kept; they live outside the package. For
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
### Install scripts
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### From source
Source builds require Go 1.26.10+.
Source builds require Go 1.26.6+.
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -183,18 +91,17 @@ If you are testing before the first public release, build from source:
go build -o zero ./cmd/zero
```
On Linux, native sandboxing needs Bubblewrap. The main `zero` binary re-executes
itself as the helper when `zero-linux-sandbox` is absent. A separate helper next
to `zero` or on `PATH` still takes precedence:
On Linux, build the sandbox helper too if you want native sandboxing:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` on `PATH` (`~/.local/bin` is a good default). macOS does not need an
extra helper binary. Windows source builds can use the main `zero.exe` as their
sandbox helper; release archives still ship standalone Windows helper executables.
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
Windows source builds can use the main `zero.exe` as their sandbox helper; release
archives still ship standalone Windows helper executables.
More install details: [docs/INSTALL.md](docs/INSTALL.md).
@@ -226,8 +133,6 @@ export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -250,18 +155,6 @@ To configure Fireworks AI directly, run:
zero providers setup fireworks --set-active
```
To configure GMI Cloud directly, run:
```bash
zero providers setup gmicloud --set-active
```
To configure Concentrate directly, run:
```bash
zero providers setup concentrate --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -289,12 +182,8 @@ zero providers add custom-openai-compatible \
--set-active
```
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
load a model and enable its local OpenAI-compatible API (default
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
loaded model ID in the add command. If no usable ID is discovered, load a model
and retry. Model IDs requiring shell-specific quoting use interactive setup.
For local models, run Ollama or LM Studio and then use `zero setup` or
`zero providers detect`.
## Daily Use
@@ -384,89 +273,6 @@ zero sandbox grants list
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
and MCP support for additional tools.
`web_fetch` refuses loopback, private and other special-use addresses: it checks
the URL before asking permission, resolves the host, and dials the address it
validated so a name cannot resolve to something else in between.
If `HTTP_PROXY`/`HTTPS_PROXY` is set, `web_fetch` and the provider connectivity
probe use it, and that last step changes: the proxy is dialed and the target
hostname is sent to it, so the proxy decides which address the request actually
reaches. The URL is still checked and resolved locally first, but a proxy that
answers differently can reach a private service. A forward proxy already sees
and can rewrite every request through it, so this is the trust you accept by
configuring one. Leave the variables unset for the checks to be enforced end to
end.
The built-in `web_search` tool is registered only when a search backend is
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
base URL, or the AI/ML API Toolbox:
```bash
export ZERO_WEBSEARCH_PROVIDER=aimlapi
```
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
billed to your AI/ML API balance: about $0.0013 per result, with a documented
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
which the Toolbox enforces. It is used only when
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
enables it. The key is read from your user configuration only; provider
profiles in a project `.zero` config are not used. It is the aimlapi profile's
key (the active profile when it is aimlapi, otherwise the first aimlapi
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
aimlapi profile exists. It is resolved again on every search;
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
that reads `AIMLAPI_API_KEY`, points at a base URL other than
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
may belong to a proxy, so it is not sent and the search fails without a request.
Requests go only to
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
`include_domains`, and results are still filtered locally.
### AI/ML API Toolbox backend for `web_fetch`
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
```bash
export ZERO_WEBFETCH_PROVIDER=aimlapi
```
Having an AI/ML API key never switches it on by itself, and any other value (or
none) keeps the direct fetch. The variable is read on every fetch.
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
priced above that is refused before it starts and not charged. Rate-limited
or gateway-failed requests are retried with the same idempotency key, so a
retry is never billed twice.
- **Key:** the key comes from your `aimlapi` provider profile (including a key
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
the key may then belong to a proxy.
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
endpoint, with `Authorization: Bearer <key>` and the attribution headers
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
aimlapi.com calls.
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
returns an error and does not fetch the page directly. Unset the variable to
go back to direct fetches.
- **Remote fetch:** the page is fetched by a remote service, which may serve a
cached copy, and the full URL (query string included) is sent to it. The URL
checks above still run first, so loopback, private and metadata URLs are
never handed to it. The result is Markdown with a
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
and `format: "raw"` is refused because the original response is not
available. The complete Toolbox run response is limited to 8 MiB before JSON
decoding; larger responses return an error even if `max_bytes` is smaller.
The remote run may already have been billed when this limit is detected.
For local dev servers, use shell commands such as `curl` through `exec_command`
so the normal sandbox and permission policy applies. Long-running commands stay
attached to a background terminal session and can be listed or stopped from the
@@ -490,7 +296,7 @@ zero context context-budget report
zero repo-map deterministic repository map
zero repo-info local repository summary
zero search | find search local session history
zero sessions inspect, resume, fork, rewind, and prune sessions
zero sessions inspect, resume, fork, and rewind sessions
zero spec manage spec-mode drafts
zero specialist manage specialist subagents
zero skills manage markdown instruction skills
@@ -508,13 +314,6 @@ zero update --check check for newer releases
zero upgrade download, verify, and install the latest release
```
Cron keeps the newest 1,000 run outcomes per job in `runs.jsonl`. Each new run
atomically replaces the history with the retained tail; existing larger histories
are trimmed on their next run. Archive the file before that run if you need older
outcomes. This retention does not delete session directories or reset fire counts.
Malformed records and legacy lines of 1 MiB or more are skipped when reading or
compacting history; new records of that size are rejected.
## Extending Zero
### Project and personal instructions
@@ -626,19 +425,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Twigpine/zero/issues/new/choose); security reports
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
+18 -125
View File
@@ -4,18 +4,11 @@
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
<p align="center">
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
</p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
@@ -28,82 +21,9 @@ zero exec "修复 ./pkg 中失败的测试"
zero exec --output-format stream-json < turns.jsonl
```
## 合作伙伴
<table align="center">
<tr>
<td align="center" width="150" height="80">
<a href="https://twigpine.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://mimo.mi.com">
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://novita.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://concentrate.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://exa.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
</tr>
<tr>
<td align="center" width="150" height="80">
<a href="https://www.gmicloud.ai/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
</picture>
</a>
</td>
<td align="center" width="150" height="80">
<a href="https://vercel.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
</picture>
</a>
</td>
</tr>
<tr>
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
</tr>
</table>
## 为什么选择 Zero
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
@@ -115,41 +35,32 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
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)。
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.26.10+。
源码构建需要 Go 1.26.6+。
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -160,14 +71,14 @@ go run ./cmd/zero
go build -o zero ./cmd/zero
```
在 Linux 上,原生沙箱需要 Bubblewrap。主程序 `zero` 在找不到 `zero-linux-sandbox` 时会以自身作为辅助进程重新执行。同目录或 PATH 上的独立 helper 仍优先:
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # 可选
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 放在 `PATH` 上(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
@@ -195,8 +106,6 @@ export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
@@ -205,23 +114,7 @@ export CONCENTRATE_API_KEY=...
zero providers setup longcat --set-active
```
要直接配置 GMI Cloud,运行:
```bash
zero providers setup gmicloud --set-active
```
要直接配置 Concentrate,运行:
```bash
zero providers setup concentrate --set-active
```
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
## 日常使用
@@ -321,7 +214,7 @@ zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉、回滚和清理会话
zero sessions 检查、恢复、分叉和回滚会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
@@ -384,14 +277,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
+1 -13
View File
@@ -33,16 +33,4 @@ Please include, where possible:
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
out of scope — but please report anything you are unsure about.
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
non-empty user Authorization header is configured, OAuth is selected, project
config has touched the server, or the key is missing or not a single token
(including Unicode whitespace). See
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
+5 -5
View File
@@ -2,8 +2,8 @@
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Twigpine/zero/discussions). Use
[Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) so answers can
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
@@ -18,19 +18,19 @@ The fastest way to a useful answer is to include:
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Twigpine/zero/issues/new/choose). If you are not
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas). Zero is
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Twigpine/zero/security/advisories/new)
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
(see SECURITY.md).
+2 -2
View File
@@ -2,7 +2,7 @@ name: ZERO
description: >-
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
model- and provider-agnostic: you supply the provider and API key as inputs.
author: Twigpine
author: Gitlawb
branding:
icon: terminal
@@ -110,7 +110,7 @@ inputs:
zero-repo:
description: Repository to install the ZERO release from (owner/repo).
required: false
default: Twigpine/zero
default: Gitlawb/zero
outputs:
exit-code:
+13 -63
View File
@@ -1,63 +1,11 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
// release was also published once under the old name, so 0.9.0's `zero
// upgrade` and anyone still installing the old name land on a working build
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
// published under the old name after that.
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
// The npm name this wrapper was published under, read from its own
// package.json so the name lives in one place. The platform payload alias and
// every reinstall hint below derive from it.
function readPackageName(root) {
try {
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
if (typeof name === 'string' && name !== '') return name;
} catch {
// An unreadable manifest must not stop the CLI from launching.
}
return CURRENT_PACKAGE_NAME;
}
const packageName = readPackageName(packageRoot);
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
// Uninstalling first is the only safe order: both packages own the `zero` bin,
// so npm refuses to install the new one over the old (EEXIST), and forcing it
// lets a later uninstall of the old package delete the shared bin.
function legacyPackageNotice() {
return (
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
'To keep getting updates, quit zero and run:\n' +
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
);
}
// Printed when the wrapper exits rather than before zero starts, because the
// TUI takes over the screen and would hide anything printed first. Only on a
// terminal, so scripts that capture stderr see nothing new. `zero update` and
// `zero upgrade` print the same note themselves.
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
process.on('exit', () => {
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
// Synchronous: an exit handler cannot wait for an asynchronous write.
writeSync(2, legacyPackageNotice());
}
});
}
function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
@@ -133,10 +81,10 @@ function bunRecoveryParagraph() {
return (
'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 ${packageName} (project install)\n` +
` bun pm -g trust ${packageName} (global install)\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": ["${packageName}"]\n` +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
);
@@ -145,14 +93,14 @@ function bunRecoveryParagraph() {
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
`Normally npm installs it as an optional dependency of ${packageName}\n` +
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\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.'
);
}
@@ -165,7 +113,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
` npm install -g ${packageName}\n` +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
@@ -234,12 +182,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
);
}
// The platform payload is a version of this same package, installed under an
// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// 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 = `${packageName}-${process.platform}-${process.arch}`;
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
@@ -279,6 +227,8 @@ function downloadMissingBinary(packageRoot) {
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
// taskOptions configures the `zero-perf-bench tasks` subcommand: the reproducible
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
)
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/Twigpine/zero/internal/review"
"github.com/Gitlawb/zero/internal/review"
)
func main() {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/release"
"github.com/Gitlawb/zero/internal/release"
)
func main() {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"os/exec"
"syscall"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -5
View File
@@ -3,13 +3,9 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/cli"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/cli"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == sandbox.LinuxSandboxHelperSubcommand {
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
}
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+2 -65
View File
@@ -110,8 +110,6 @@ Discovery roots (earlier wins on name collisions):
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
```text
~/.local/share/zero/skills/ # primary (install/remove target)
run-benchmarks/
@@ -142,15 +140,6 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
```text
pdf/
SKILL.md
scripts/fill.py
references/form-fields.md
```
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
@@ -203,11 +192,7 @@ Events the agent emits (in dispatch order):
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). 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.
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.
@@ -261,52 +246,6 @@ 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.
#### Built-in servers
Zero seeds two MCP servers before your config is merged. Both show up in
`zero mcp list`, and any field can be overridden in your user config.
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
`zero mcp disable exa` turns it off.
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
discovery, image/video/audio generation, chat, embeddings and balance tools)
is **off by default**, because its tool runs and generations are billed to
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
writes only `"disabled": false` to your user config, and off again with
`zero mcp disable aimlapi`. A project config cannot turn it on.
When `aimlapi` connects, Zero sends your AI/ML API key as
`Authorization: Bearer <key>`. The key is resolved again on every request and
is never written to `config.json`. With an aimlapi.com provider profile
(`zero providers setup aimlapi`), the key is chosen in this order:
1. An inline key on the profile (if set in config).
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
(the catalog default is `AIMLAPI_API_KEY`).
3. Otherwise the key held in the credential store when the profile uses
`apiKeyStored`.
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
whenever the inline key is empty — MCP requests may bill the environment key's
account, not the stored key.
No automatic key is sent when an aimlapi profile, or any profile reading
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
to a proxy. No key is sent when the resolved value is not a single token (for
example a comma- or newline-separated pool), when you set your own
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
when project config touched the server. Without a key the server answers 401.
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
server and run `zero mcp oauth login aimlapi`.
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
whatever the server is named, including one you add by hand) also carries the
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
and gets the key under the rules above. Other MCP servers never receive these
headers or the key.
### As a server — expose Zero's tools to another agent
```bash
@@ -351,12 +290,10 @@ Install and manage:
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
zero plugins disable github-pr-review # sets "enabled": false in plugin.json
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
zero plugins remove github-pr-review # alias: rm
```
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the `enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
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.
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
with:
prompt: ${{ inputs.task }}
provider: openai
@@ -66,7 +66,7 @@ jobs:
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
## Outputs
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
with:
prompt: |
Investigate this issue and propose a fix.
@@ -134,7 +134,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
id: zero
with:
prompt-file: .github/zero/upgrade-deps.md
@@ -176,7 +176,7 @@ jobs:
- **Least-privilege tokens.** Grant only the permissions the workflow needs
(`contents: write` to edit files, `pull-requests: write` to comment). The
default `GITHUB_TOKEN` is scoped to the repository.
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
workflow run uses a known ZERO version. `zero-version` lets you pin the
installed binary independently of the action ref.
- **Linux and macOS runners** are supported; Windows runners are rejected with a
+3 -13
View File
@@ -302,9 +302,8 @@ Conceptually, one requested tool goes through these phases:
the model can inspect it and decide what to do next.
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
writer, or ACP client. TUI and exec record rich tool/session events for
resume/history; ACP persists conversational messages and completed tool-call
starts/results for load replay. Live streaming details and permission
exchanges remain client notifications rather than durable history.
resume/history; ACP currently persists conversational messages and streams
tool/permission updates to the client.
Tool success and failure are both informative context. A successful `read_file`
result gives the model file contents; a failed `edit_file` result gives the model
@@ -628,16 +627,7 @@ Core tool groups include:
skills, `ask_user`, permission requests.
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
- **Network tools**: `web_fetch` and optional web search backend. `web_fetch`
validates the URL before the permission prompt, refuses loopback/private/
special-use addresses, and dials the address it resolved. With
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
target itself, so destination enforcement ends at the proxy; see the note in
the README.
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
it), the same URL checks run and the page is then fetched as Markdown by the
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
direct fetch; see the README.
- **Network tools**: `web_fetch` and optional web search backend.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
+30 -78
View File
@@ -5,11 +5,11 @@ blessed one.
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
@@ -29,7 +29,7 @@ Release and verify its checksum.
## npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -56,47 +56,15 @@ cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
```
### Moving from @gitlawb/zero
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
`@twigpine/zero`, and the old name gets no further releases.
Both packages provide the `zero` command, so npm refuses to install the new
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
uninstall of the old package removes the shared `zero` command. Quit any
running `zero` (on Windows a running `zero.exe` cannot be removed), then
uninstall the old package before installing the new one:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
With another package manager, the same two steps:
```bash
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
```
Config, sessions, logins, and plugins live in your user config and home
directories, not in the package, so they carry over unchanged.
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
updates lands on a working build. That build prints these steps when `zero`
exits in a terminal, and `zero upgrade` and `zero update --check` print them
too.
## Linux And macOS Script
Install the latest release:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -121,7 +89,7 @@ scripts/install.sh --install-dir "$HOME/bin"
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
@@ -132,7 +100,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -155,7 +123,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
@@ -166,13 +134,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Twigpine/zero
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Twigpine/[email protected]
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
@@ -185,19 +153,13 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
go install github.com/Gitlawb/zero/cmd/zero@latest
```
Every release so far declares the module path `github.com/Gitlawb/zero`, so
`go install` has to use that path; `github.com/Twigpine/zero/cmd/zero@latest`
fails with "module declares its path as" until a release is tagged with the new
module path.
This builds from source, so it needs Go 1.26.10+ and it does not go through the
This builds from source, so it needs Go 1.26.6+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
the helper in this order: an executable `zero-linux-sandbox` next to `zero`,
then `PATH`, then self-exec of the main binary. Standalone helpers remain
optional and preferred. See
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
@@ -207,7 +169,7 @@ macOS and Windows need no extra helper.
## From Source
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -218,32 +180,25 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.10+.
Source builds require Go 1.26.6+.
### Sandbox Helpers For Source Builds
Release archives include the platform sandbox helpers. A source build of the
main `zero` binary is enough for Linux native sandboxing. Zero resolves the
helper in this order:
Release archives include the platform sandbox helpers. If you build directly
from source, build the helpers you need:
1. an executable `zero-linux-sandbox` next to `zero`
2. then `PATH`
3. then self-exec of the main binary
Bubblewrap must still be installed. Standalone helpers remain optional and
preferred.
A separate helper remains supported if it sits next to `zero` or on `PATH`:
Linux:
```bash
go build -o zero ./cmd/zero
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp
```
Put `zero` on `PATH`, for example `~/.local/bin`. `zero-seccomp` is kept as a
compatibility wrapper; the sandbox helper applies the Unix-socket filter itself
when that sandbox option is enabled.
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
helper applies the Unix-socket filter itself when that sandbox option is enabled.
Linux native sandboxing also requires Bubblewrap to be installed.
macOS uses the system sandbox and does not need an extra helper binary.
@@ -258,7 +213,7 @@ seccomp filter on Android:
pkg install golang
# Build Zero for Android
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
@@ -347,10 +302,7 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
the old `@gitlawb/zero` package refuses instead and prints the steps in
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
further releases, so npm would only reinstall the same version.
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
-26
View File
@@ -1,26 +0,0 @@
# Local per-model completion limits
Add `modelRPM` to the existing Zero config file:
```json
{"modelRPM": {"gpt-4.1": 30, "claude-sonnet-4.5": 15}}
```
Keys resolve through the model registry; custom IDs match exactly after trimming
whitespace. Missing keys and zero mean unlimited. Negative limits and conflicting
aliases are configuration errors. Project config may add or tighten a user cap,
but cannot relax it. Changes take effect on the next Zero process start.
For `zero exec` and interactive agent turns, admission happens immediately before
the wrapped turn session's `Stream`. N admissions fit in any sliding 60-second
window; N+1 returns a local rate-limit error before entering that method. The
error reports when a slot expires. Exec uses its existing provider-error exit
code plus a local-limit hint. There is no sleep or hidden retry. Failed admitted
requests still consume a slot.
Sessions and model switches share a limiter in one process. Separate processes
have independent windows; restarting clears them. This is not an account-wide
cap. It counts Stream admissions, not all HTTP requests: setup/prewarm,
compaction, provider-internal retries, discovery and direct calls outside that
boundary are not counted. No TPM, cost budgets, persistence, cross-process
coordination or new runtime/Python dependency is introduced.
+18 -77
View File
@@ -1,6 +1,6 @@
# npm packaging
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
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
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
## Goals
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
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,
@@ -25,15 +25,15 @@ The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
@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 `@twigpine/zero` package**,
- 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.
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
### Binary resolution in `bin/zero.js`
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
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.
@@ -103,22 +103,10 @@ dependencies of the wrapper:
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
### One package name
The npm name lives in `package.json` and nothing else repeats it:
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
versions, the alias names, and the alias targets from it, and `bin/zero.js`
reads its own `package.json` to derive the alias it resolves. The Go side
needs the name as a constant (`npmPackageName` in
`internal/update/installmethod.go`), and a test holds it equal to
`package.json`.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
registry.
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
@@ -130,67 +118,20 @@ registry.
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 are on the registry 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. Ordering the uploads is not enough: npm applies a publish
asynchronously, and in 0.10.0 a platform version appeared ten minutes
after its upload returned, after the wrapper. The publish script waits
until npm shows every platform version before it publishes the wrapper,
and waits the same way for `latest` afterwards (up to 30 minutes in all).
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. The publish script refuses to start when
the wrapper's aliases do not match the platform payloads beside it.
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. npm checks provenance against
`package.json` `repository`, so it must name the repository the workflow
runs in.
5. **Re-runs are safe.** A version already on the registry is skipped rather
than republished, so re-running a job that failed between the platform and
wrapper publishes finishes the release. Only npm's E404 counts as "not
published"; a lookup that fails any other way is retried and then stops the
job before anything is published.
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@twigpine/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
## The move from `@gitlawb/zero`
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
from 0.10.0. There is no in-place migration: both packages own the `zero`
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
lets a later uninstall of the old package delete the shared bin. The user
has to uninstall the old package and install the new one, in that order.
Deprecating the old package alone would not have reached existing users.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name, npm reinstalls nothing, prints no deprecation
warning (it only warns when it installs something), and Zero reports the
update as done. So 0.10.0 was published once more under the old name, as a
bridge:
- The release job assembled the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and published
them with the same script, after the `@twigpine/zero` publish. The steps
that did this ran once and have been removed from `release-artifacts.yml`.
- The bridge build knows its name from the `package.json` beside it. The Go
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
with the switch steps instead of reinstalling it, and `zero update --check`
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
terminal only. This handling stays in the code, since only a build
installed under the old name ever triggers it.
- Updating from 0.9.0 installs that bridge build, so npm also shows the
deprecation message.
`@gitlawb/zero` is deprecated on every version and no longer has a trusted
publisher, so this release workflow cannot publish it again; another version
would take a maintainer publishing by hand. Its versions stay on the
registry: existing installs resolve their platform payload through them, so
they must never be unpublished.
`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
+9 -10
View File
@@ -2,8 +2,7 @@
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
`bin` wrapper.
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -38,24 +37,24 @@ path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@twigpine
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
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/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
"/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/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
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 (`@twigpine/zero`), version,
- `package.json` has the expected package name (`@gitlawb/zero`), version,
`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 `@twigpine/zero@<version>-<platform>-<arch>` with matching
`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`
-1
View File
@@ -6,7 +6,6 @@ Zero.
## User Docs
- [Install](INSTALL.md)
- [Local per-model completion limits](MODEL_RPM.md)
- [Update flow](UPDATE.md)
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
+7 -21
View File
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Twigpine/zero
zero update --check --repo Gitlawb/zero
zero update --check --target windows-x64
zero upgrade
@@ -28,11 +28,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
of the old `@gitlawb/zero` package refuses, without a release check, and
prints the steps to switch to `@twigpine/zero` (see
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
too.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
@@ -42,7 +38,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @twigpine/zero@latest` and
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
@@ -69,7 +65,7 @@ Endpoint resolution order:
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
@@ -97,24 +93,14 @@ binary:
| State on disk | Meaning |
|---|---|
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker, or a per-user update-recovery record naming that copy | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. The `.keep` marker is in the installation directory and can be deleted; the identity-bound record is not, so deleting the marker alone does not end the refusal. |
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
The refusal names the paths involved and the two moves that end the state:
either move the recovery binary back over the executable path, or — if the
installed binary is the one you want — delete the recovery copy itself (not
only the `.keep` marker) after verifying the installed binary, then update
again. Deleting the marker alone is not enough: a failed restore also records
that copy in per-user state outside the installation directory, and the next
update consults that record even if the marker is gone.
That trusted record lives under `%AppData%\zero\update-recovery` (per-user).
The recovery copy and its `.keep` marker sit in the installation directory
(per-machine). A second Windows account on the same machine does not see the
first account's unresolved record; the sibling marker is the machine-visible
signal for every account. There is no separate machine-wide store: writing one
would need a shared writable location and ACL rules the updater does not own.
installed binary is the one you want — delete the `.keep` marker (or the
`.recovery` copy, once you have verified the installed binary) and update again.
This is deliberately fail-closed and differs from older releases, which deleted
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
<circle cx="6.4" cy="6.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#FFFFFF"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#FFFFFF"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#FFFFFF"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#FFFFFF"/><circle cx="6.4" cy="16" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#FFFFFF"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#FFFFFF"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#FFFFFF"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#FFFFFF"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#FFFFFF"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#FFFFFF"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#FFFFFF"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#FFFFFF"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#FFFFFF"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#FFFFFF"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#FFFFFF"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#FFFFFF"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#FFFFFF"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#FFFFFF"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#FFFFFF"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#FFFFFF"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
<circle cx="6.4" cy="6.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#0D0E10"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#0D0E10"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#0D0E10"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#0D0E10"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#0D0E10"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#0D0E10"/><circle cx="6.4" cy="16" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#0D0E10"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#0D0E10"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#0D0E10"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#0D0E10"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#0D0E10"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#0D0E10"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#0D0E10"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#0D0E10"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#0D0E10"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#0D0E10"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#0D0E10"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#0D0E10"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#0D0E10"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#0D0E10"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#0D0E10"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#0D0E10"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#0D0E10"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#0D0E10"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#0D0E10"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#0D0E10"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#0D0E10"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#0D0E10"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#0D0E10"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#0D0E10"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#0D0E10"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#0D0E10"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#0D0E10"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#0D0E10"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#0D0E10"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#0D0E10"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#0D0E10"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#0D0E10"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#0D0E10"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#0D0E10"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#0D0E10"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#0D0E10"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#0D0E10"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#0D0E10"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#0D0E10"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#0D0E10"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#0D0E10"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="white"/>
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="white"/>
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="white"/>
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="#0143D9"/>
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="black"/>
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="black"/>
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#fff">
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#0D0E10">
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-11
View File
@@ -1,11 +0,0 @@
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_novita_dark)">
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="white"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="white"/>
<defs>
<clipPath id="clip0_novita_dark">
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-11
View File
@@ -1,11 +0,0 @@
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_125_11573)">
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="black"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="black"/>
<defs>
<clipPath id="clip0_125_11573">
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

+2 -3
View File
@@ -1,6 +1,6 @@
module github.com/Twigpine/zero
module github.com/Gitlawb/zero
go 1.26.10
go 1.26.6
require (
charm.land/bubbles/v2 v2.1.1
@@ -18,7 +18,6 @@ require (
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.45.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.13.1
)
-4
View File
@@ -70,9 +70,5 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+75 -756
View File
File diff suppressed because it is too large. Load diff
+24 -2232
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"fmt"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
-181
View File
@@ -1,181 +0,0 @@
package acp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestACPImageAttachmentHistory(t *testing.T) {
for _, tc := range []struct {
name string
text string
images int
withoutVision bool
want string
}{
{name: "image only", images: 1, want: "[1 image]"},
{name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"},
{name: "ignored by model", images: 1, withoutVision: true, want: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
modalities := []string{"text", "image"}
if tc.withoutVision {
modalities = []string{"text"}
}
return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil
}
type observedTurn struct {
prompt string
images int
}
turns := make(chan observedTurn, 8)
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
turns <- observedTurn{prompt: prompt, images: len(opts.Images)}
return agent.Result{FinalAnswer: "received"}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
h := newHarness(t, deps)
defer h.stop()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
const imageData = "image-history-bytes-not-for-storage"
encoded := base64.StdEncoding.EncodeToString([]byte(imageData))
var blocks []ContentBlock
if tc.text != "" {
blocks = append(blocks, TextBlock(tc.text))
}
for range tc.images {
blocks = append(blocks, ImageBlock(encoded, "image/png"))
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil {
t.Fatal(err)
}
live := <-turns
wantLiveImages := tc.images
if tc.withoutVision {
wantLiveImages = 0
}
if live.images != wantLiveImages {
t.Fatalf("live images = %d, want %d", live.images, wantLiveImages)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil || len(events) < 2 {
t.Fatalf("read completed turn: events=%+v err=%v", events, err)
}
var user struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments struct {
Images int `json:"images"`
} `json:"attachments"`
}
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
t.Fatal(err)
}
if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages {
t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages)
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) {
t.Errorf("image data leaked into session event: %s", event.Payload)
}
}
followUp := func(t *testing.T, client *clientHarness) {
t.Helper()
if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")},
}, &PromptResult{}); err != nil {
t.Fatal(err)
}
got := <-turns
if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) {
t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt)
}
if got.images != 0 {
t.Errorf("historical images reattached: %d", got.images)
}
}
followUp(t, h)
h.stop()
// Fresh agents and stores reproduce a process restart, with no shared
// in-memory history to hide a missing attachment in the event log.
for _, method := range []string{MethodSessionLoad, MethodSessionResume} {
t.Run(method, func(t *testing.T) {
restoredDeps := deps
restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir})
loader := newHarness(t, restoredDeps)
defer loader.stop()
if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil {
t.Fatal(err)
}
if method == MethodSessionLoad {
select {
case update := <-loader.notifications:
if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want {
t.Errorf("replayed user = %+v, want %q", update, tc.want)
}
case <-ctx.Done():
t.Fatal("user turn was not replayed")
}
} else {
select {
case update := <-loader.notifications:
t.Errorf("resume replayed transcript: %+v", update)
default:
}
}
followUp(t, loader)
})
}
})
}
}
func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()})
if err != nil {
t.Fatal(err)
}
for i, payload := range []string{
`{"role":"user","content":"old text"}`,
`{"role":"user","content":"","attachments":{"images":1,"documents":2}}`,
`{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`,
`{"role":"assistant","content":"answer","attachments":{"images":1}}`,
} {
if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil {
t.Fatalf("append message %d: %v", i, err)
}
}
a := &Agent{deps: deps}
_, messages, _, err := a.loadHistory(meta.SessionID, false)
if err != nil {
t.Fatal(err)
}
want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"}
if len(messages) != len(want) {
t.Fatalf("messages = %+v", messages)
}
for i, text := range want {
if messages[i].content != text {
t.Errorf("message %d = %q, want %q", i, messages[i].content, text)
}
}
}
+10 -43
View File
@@ -89,13 +89,6 @@ type Conn struct {
handlers map[string]HandlerFunc
notifiers map[string]NotifyFunc
// Notifications for one method are an ordered stream: session/update in
// particular is stateful, so dispatching every frame in an unrelated
// goroutine can make a later chunk overtake an earlier one. Different
// methods retain independent tails, keeping session/cancel responsive while
// an update handler is busy.
notifyMu sync.Mutex
notifyTails map[string]chan struct{}
mu sync.Mutex
nextID int64
@@ -113,12 +106,11 @@ type Conn struct {
// lifetime.
func NewConn(r io.Reader, w io.Writer) *Conn {
return &Conn{
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
notifyTails: make(map[string]chan struct{}),
pending: make(map[int64]chan rpcMessage),
rawReader: r,
w: w,
handlers: make(map[string]HandlerFunc),
notifiers: make(map[string]NotifyFunc),
pending: make(map[int64]chan rpcMessage),
}
}
@@ -311,7 +303,11 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}(msg)
case msg.isNotify():
if fn := c.notifiers[msg.Method]; fn != nil {
c.dispatchNotification(ctx, msg, fn)
c.wg.Add(1)
go func(m rpcMessage) {
defer c.wg.Done()
fn(ctx, m.Params)
}(msg)
}
default:
// Malformed frame; reply only if we can identify a request id.
@@ -321,35 +317,6 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
}
}
// dispatchNotification preserves wire order within one method without making
// unrelated notification methods wait for each other. The read loop installs
// each tail before starting its goroutine, so goroutine scheduling cannot
// reorder the chain it observes.
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
c.notifyMu.Lock()
previous := c.notifyTails[msg.Method]
done := make(chan struct{})
c.notifyTails[msg.Method] = done
c.notifyMu.Unlock()
c.wg.Add(1)
go func() {
defer c.wg.Done()
defer func() {
close(done)
c.notifyMu.Lock()
if c.notifyTails[msg.Method] == done {
delete(c.notifyTails, msg.Method)
}
c.notifyMu.Unlock()
}()
if previous != nil {
<-previous
}
fn(ctx, msg.Params)
}()
}
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
fn := c.handlers[msg.Method]
if fn == nil {
-80
View File
@@ -360,86 +360,6 @@ func TestConnNotification(t *testing.T) {
}
}
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
firstStarted := make(chan struct{})
releaseFirst := make(chan struct{})
delivered := make(chan int, 2)
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
var in struct{ Sequence int }
_ = json.Unmarshal(params, &in)
if in.Sequence == 1 {
close(firstStarted)
<-releaseFirst
}
delivered <- in.Sequence
})
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
t.Fatalf("first notify: %v", err)
}
select {
case <-firstStarted:
case <-time.After(2 * time.Second):
t.Fatal("first notification did not start")
}
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
t.Fatalf("second notify: %v", err)
}
select {
case sequence := <-delivered:
t.Fatalf("notification %d overtook the blocked first notification", sequence)
case <-time.After(25 * time.Millisecond):
}
close(releaseFirst)
for want := 1; want <= 2; want++ {
select {
case got := <-delivered:
if got != want {
t.Fatalf("notification order = ...,%d; want %d", got, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("notification %d was not delivered", want)
}
}
}
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
a, b, stop := connPair(t)
defer stop()
updateStarted := make(chan struct{})
releaseUpdate := make(chan struct{})
cancelDelivered := make(chan struct{})
b.HandleNotify("update", func(context.Context, json.RawMessage) {
close(updateStarted)
<-releaseUpdate
})
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
close(cancelDelivered)
})
if err := a.Notify("update", nil); err != nil {
t.Fatalf("update notify: %v", err)
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("update notification did not start")
}
if err := a.Notify("cancel", nil); err != nil {
t.Fatalf("cancel notify: %v", err)
}
select {
case <-cancelDelivered:
case <-time.After(2 * time.Second):
t.Fatal("different notification method was blocked behind update")
}
close(releaseUpdate)
}
func TestConnMethodNotFound(t *testing.T) {
a, _, stop := connPair(t)
defer stop()
-213
View File
@@ -1,213 +0,0 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// captureAgentOptions makes every turn record the agent.Options it was built
// with, so a test can assert the permission mode the next prompt actually runs
// with rather than only what the wire reports.
func captureAgentOptions(deps *Deps) *agent.Options {
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
return &captured
}
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
t.Helper()
h := newHarness(t, deps)
t.Cleanup(h.stop)
return h
}
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
t.Helper()
if err := h.client.Call(ctx, method, params, result); err != nil {
t.Fatalf("%s: %v", method, err)
}
}
// restartHarness replaces the live agent with a fresh one over the same store,
// which is the process restart the persisted mode has to survive.
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
t.Helper()
h.stop()
return newHarnessT(t, deps)
}
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
t.Helper()
for _, option := range options {
if option.ID == configIDMode {
return option.CurrentValue
}
}
t.Fatalf("no mode config option in %+v", options)
return ""
}
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
t.Helper()
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
}
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
t.Fatalf("mode config option = %q, want %q", got, want)
}
}
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModePlan) {
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
}
// LOAD after a restart: the response must report the stored mode, and the
// next prompt must actually run with it.
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModePlan)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
// RESUME after another restart reports the same.
h = restartHarness(t, h, deps)
var resumed ResumeSessionResult
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
assertModeReported(t, resumed, agent.PermissionModePlan)
}
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
deps := testDeps(t)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
var configured SetSessionConfigOptionResult
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured)
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
t.Fatalf("configured mode = %q, want ask", got)
}
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModeAsk) {
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModeAsk)
}
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
// the historical auto default, and a stored value outside the three offered
// modes must never come back weaker than ask (and never unsafe).
func TestACPModeRestoreFallback(t *testing.T) {
cases := []struct {
name string
seed string // "" means metadata written before modes were persisted
want agent.PermissionMode
}{
{name: "unset keeps auto", want: agent.PermissionModeAuto},
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
if tc.seed != "" {
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
t.Fatalf("seed mode: %v", err)
}
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, tc.want)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
if captured.PermissionMode != tc.want {
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
}
})
}
}
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
// mode cannot be saved the request fails and the live session is untouched.
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
// Remove the metadata file so UpdateMode fails but the live session stays
// promptable (turn persistence only warns when the file is gone).
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
var rpcErr *rpcError
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
}
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModeAuto {
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
}
}
+2 -4
View File
@@ -3,7 +3,7 @@ package acp
import (
"encoding/json"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
// permission.go maps ZERO's permission prompt model onto ACP's
@@ -142,15 +142,13 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
// session/request_permission request from a ZERO permission request.
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
args := marshalArgs(req.Args)
upd := ToolCallUpdate{
return ToolCallUpdate{
ToolCallID: req.ToolCallID,
Title: toolTitle(req.ToolName, string(args)),
Kind: toolKindFor(req.ToolName),
Status: ToolStatusPending,
RawInput: rawInputBytes(args),
}
attachBrowserToolDetails(&upd, req.ToolName)
return upd
}
func marshalArgs(args map[string]any) []byte {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
+1 -15
View File
@@ -3,7 +3,7 @@ package acp
import (
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
func TestBuildPermissionOptions(t *testing.T) {
@@ -82,17 +82,3 @@ func TestPermissionToolCall(t *testing.T) {
t.Error("expected rawInput from args")
}
}
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
call := permissionToolCall(agent.PermissionRequest{
ToolCallID: "browser-1",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
})
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
t.Fatalf("browser descriptor = %#v", got)
}
if call.Title != "browser connect" {
t.Fatalf("title = %q", call.Title)
}
}
+7 -171
View File
@@ -2,14 +2,11 @@ package acp
import (
"encoding/json"
"net/url"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
@@ -20,14 +17,6 @@ func agentMessageChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
}
func replayMessageChunk(role, messageID, text string) ContentChunk {
update := UpdateAgentMessageChunk
if role == "user" {
update = UpdateUserMessageChunk
}
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
}
func agentThoughtChunk(delta string) ContentChunk {
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
}
@@ -55,125 +44,12 @@ func toolKindFor(name string) string {
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
func toolTitle(name, rawArgs string) string {
if browser, ok := browserToolDetails(name); ok {
return browserToolTitle(browser.Command, rawArgs)
}
if hint := primaryArgHint(rawArgs); hint != "" {
return name + " " + hint
}
return name
}
// browserToolDetails identifies ZERO's local browser helpers without treating
// similarly named MCP tools as browser automation. The descriptor intentionally
// contains no request data: ACP tool input is already protocol-visible, but a
// durable UI must not need to retain text, local CDP targets, or full URLs just
// to recognise the browser operation.
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
const prefix = "browser_"
command, ok := strings.CutPrefix(name, prefix)
if !ok {
return nil, false
}
switch command {
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
return &BrowserToolDetails{Version: 1, Command: command}, true
default:
return nil, false
}
}
const zeroBrowserMetaKey = "github.com/Twigpine/zero/browser"
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
// extension channel. Keeping this in one helper prevents start, result, and
// permission payloads from drifting onto different wire shapes.
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
browser, ok := browserToolDetails(name)
if !ok {
return
}
raw, err := json.Marshal(browser)
if err != nil {
return
}
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
}
// browserToolTitle avoids putting browser_type text, an attached DevTools
// endpoint, or a URL query/fragment in a tool-card title. Those values can
// carry credentials or session data; the UI only needs the operation and, for
// navigation, a human-recognisable origin.
func browserToolTitle(command, rawArgs string) string {
switch command {
case "action":
action, ok := exactJSONStringArg(rawArgs, "command")
if !ok {
return "browser action"
}
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
return "browser action " + action
}
return "browser action"
case "open":
rawURL, ok := exactJSONStringArg(rawArgs, "url")
if !ok {
return "browser open"
}
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
return "browser open"
}
u, err := url.Parse(normalized)
if err != nil || u.Scheme == "" || u.Host == "" {
return "browser open"
}
origin := u.Scheme + "://" + u.Host
if !browserTitleTextSafe(origin) {
return "browser open"
}
return "browser open " + truncateHint(origin)
default:
return "browser " + command
}
}
// browserTitleTextSafe validates text after URL parsing has decoded escaped
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: whitespace, control,
// format/bidi, and line/paragraph separator runes can reorder or split the
// permission label shown to a user. The execution URL remains unchanged.
func browserTitleTextSafe(text string) bool {
if !utf8.ValidString(text) {
return false
}
for _, r := range text {
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return false
}
}
return true
}
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
// exact JSON key is considered, and a non-string value is invalid. In
// particular, an incidental "URL" key must not change a permission title when
// browser_open will only read "url".
func exactJSONStringArg(rawArgs, key string) (string, bool) {
var args map[string]json.RawMessage
if json.Unmarshal([]byte(rawArgs), &args) != nil {
return "", false
}
raw, ok := args[key]
if !ok {
return "", false
}
var value string
if json.Unmarshal(raw, &value) != nil {
return "", false
}
return value, true
}
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
// raw JSON arguments. Best-effort; returns "" when it can't parse.
func primaryArgHint(rawArgs string) string {
@@ -213,7 +89,7 @@ func rawInput(args string) json.RawMessage {
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
// update (status in_progress — ZERO executes immediately after advertising).
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
upd := ToolCallUpdate{
return ToolCallUpdate{
SessionUpdate: UpdateToolCall,
ToolCallID: call.ID,
Title: toolTitle(call.Name, call.Arguments),
@@ -221,8 +97,6 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
Status: ToolStatusInProgress,
RawInput: rawInput(call.Arguments),
}
attachBrowserToolDetails(&upd, call.Name)
return upd
}
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
@@ -242,64 +116,26 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
if locs := toolResultLocations(result); len(locs) > 0 {
upd.Locations = locs
}
attachBrowserToolDetails(&upd, result.Name)
return upd
}
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
if text == "" {
return appendToolResultDiffs(content, result.FileDiffs)
return nil
}
content = append(content, ToolContent(TextBlock(text)))
return appendToolResultDiffs(content, result.FileDiffs)
}
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
for _, diff := range diffs {
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
continue
}
var oldText *string
if diff.OldExists {
old := diff.OldText
oldText = &old
}
var newText *string
if diff.NewExists {
updated := diff.NewText
newText = &updated
}
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
}
return content
return []ToolCallContent{ToolContent(TextBlock(text))}
}
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
for _, diff := range result.FileDiffs {
path := diff.Path
if path == "" || seen[path] {
continue
}
seen[path] = true
locs = append(locs, ToolCallLocation{Path: path})
}
// FileDiff.Path is canonical absolute path data while ChangedFiles is
// normally workspace-relative. Without the trusted workspace root these
// coordinate systems cannot be correlated safely: a suffix match would let
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
// shared seen set deduplicates only identities already exactly comparable.
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
for _, f := range result.ChangedFiles {
if f == "" || seen[f] {
if strings.TrimSpace(f) == "" {
continue
}
seen[f] = true
locs = append(locs, ToolCallLocation{Path: f})
}
return locs
+5 -427
View File
@@ -1,30 +1,14 @@
package acp
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
t.Helper()
raw, ok := update.Meta[zeroBrowserMetaKey]
if !ok {
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
}
var details BrowserToolDetails
if err := json.Unmarshal(raw, &details); err != nil {
t.Fatalf("decode browser metadata: %v", err)
}
return details
}
func TestAgentMessageAndThoughtChunks(t *testing.T) {
m := agentMessageChunk("hello")
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
@@ -72,223 +56,6 @@ func TestToolTitleAndHint(t *testing.T) {
}
}
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
start := toolCallStart(agent.ToolCall{
ID: "browser-1",
Name: "browser_open",
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
})
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
t.Fatalf("browser descriptor = %#v, want open", got)
}
if start.Title != "browser open https://example.com" {
t.Fatalf("browser title = %q", start.Title)
}
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
var wire struct {
Meta map[string]json.RawMessage `json:"_meta"`
}
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
t.Fatalf("browser wire metadata = %#v", wire.Meta)
}
typed := toolCallStart(agent.ToolCall{
ID: "browser-2",
Name: "browser_type",
Arguments: `{"ref":"email","text":"[email protected]"}`,
})
if got := browserDescriptor(t, typed); got.Command != "type" {
t.Fatalf("browser type descriptor = %#v", got)
}
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
t.Fatalf("browser type title = %q", typed.Title)
}
action := toolCallStart(agent.ToolCall{
ID: "browser-3",
Name: "browser_action",
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
})
if action.Title != "browser action keyboard_insert_text" {
t.Fatalf("browser action title = %q", action.Title)
}
result := toolCallResult(agent.ToolResult{
ToolCallID: "browser-2",
Name: "browser_type",
Status: tools.StatusOK,
})
if got := browserDescriptor(t, result); got.Command != "type" {
t.Fatalf("browser result descriptor = %#v", got)
}
}
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{
ID: "start",
Name: "browser_open",
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
}),
toolCallResult(agent.ToolResult{
ToolCallID: "result",
Name: "browser_type",
Status: tools.StatusOK,
}),
permissionToolCall(agent.PermissionRequest{
ToolCallID: "permission",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
}),
}
type protocolToolCallUpdate struct {
SessionUpdate string `json:"sessionUpdate,omitempty"`
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(encoded, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["browser"]; ok {
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
}
var protocol protocolToolCallUpdate
if err := json.Unmarshal(encoded, &protocol); err != nil {
t.Fatal(err)
}
forwarded, err := json.Marshal(protocol)
if err != nil {
t.Fatal(err)
}
var roundTripped ToolCallUpdate
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
t.Fatal(err)
}
details := browserDescriptor(t, roundTripped)
if details.Version != 1 || details.Command == "" {
t.Fatalf("round-tripped browser descriptor = %#v", details)
}
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
if strings.Contains(descriptorJSON, secret) {
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
}
}
}
}
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
t.Fatalf("bare-host title = %q", got)
}
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
t.Fatalf("case-variant URL title = %q", got)
}
if got := browserToolTitle("open", `{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`); got != "browser open https://actual.example.test" {
t.Fatalf("exact URL key title = %q", got)
}
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
t.Fatalf("unknown browser action title = %q", got)
}
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
t.Fatalf("bounded browser origin title = %q", title)
}
}
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
for _, rawURL := range []string{
"https://safe.example%C2%A0evil.test/path",
"https://safe.example%E2%80%83evil.test/path",
"https://safe.example%E3%80%80evil.test/path",
"https://safe.example%E2%80%AEevil.test/path",
"https://safe.example%E2%81%A6evil.test/path",
"https://safe.example%C2%85evil.test/path",
"https://safe.example%E2%80%A8evil.test/path",
"https://safe.example%E2%80%A9evil.test/path",
} {
t.Run(rawURL, func(t *testing.T) {
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
t.Fatalf("execution URL rejected: %v", err)
}
if normalized != rawURL {
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
}
args, err := json.Marshal(map[string]any{"url": rawURL})
if err != nil {
t.Fatal(err)
}
if title := browserToolTitle("open", string(args)); title != "browser open" {
t.Errorf("unsafe origin accepted before permission translation: %q", title)
}
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var decoded ToolCallUpdate
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Title != "browser open" {
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
}
for _, r := range decoded.Title {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
}
}
}
})
}
}
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
if len(start.Meta) != 0 {
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"browser"`) {
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
}
}
func TestToolCallStart(t *testing.T) {
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
if upd.SessionUpdate != UpdateToolCall {
@@ -307,26 +74,21 @@ func TestToolCallStart(t *testing.T) {
}
func TestToolCallResult(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
ok := toolCallResult(agent.ToolResult{
ToolCallID: "tc1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "applied\n",
ChangedFiles: []string{"a.go", ""},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
})
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
t.Fatalf("unexpected ok result: %+v", ok)
}
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
t.Fatalf("unexpected content: %+v", ok.Content)
}
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
t.Fatalf("unexpected diff content: %+v", diff)
}
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
}
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
@@ -335,190 +97,6 @@ func TestToolCallResult(t *testing.T) {
}
}
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
content := appendToolResultDiffs(nil, []tools.FileDiff{
{Path: path, OldExists: false, NewExists: true, NewText: ""},
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
})
if len(content) != 3 {
t.Fatalf("diff content = %#v", content)
}
for index, diff := range content {
encoded, err := json.Marshal(diff)
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if wire["path"] != path {
t.Fatalf("wire diff %d = %s", index, encoded)
}
switch index {
case 0:
oldText, present := wire["oldText"]
if !present || oldText != nil || wire["newText"] != "" {
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
}
case 1:
if wire["oldText"] != "before" || wire["newText"] != "" {
t.Fatalf("empty replacement diff = %s", encoded)
}
case 2:
newText, present := wire["newText"]
if wire["oldText"] != "before" || !present || newText != nil {
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
}
}
}
}
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
root := t.TempDir()
rootPath := filepath.Join(root, "a.go")
nestedPath := filepath.Join(root, "sub", "a.go")
diff := func(path string) tools.FileDiff {
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
}
for _, tc := range []struct {
name string
diffs []tools.FileDiff
want []string
}{
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
} {
t.Run(tc.name, func(t *testing.T) {
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
FileDiffs: tc.diffs,
})
if len(locations) != len(tc.want) {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
for index := range tc.want {
if locations[index].Path != tc.want[index] {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
}
})
}
}
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
relativePath := " report.txt "
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
}},
})
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
}
}
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{path, path},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
})
if len(locations) != 1 || locations[0].Path != path {
t.Fatalf("exact duplicate locations = %#v", locations)
}
}
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
relativePath := "deleted.go"
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
}},
})
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("deleted file locations = %#v", update.Locations)
}
}
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
path := filepath.Join(t.TempDir(), "secret.txt")
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
}}})
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
}
}
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
scrubbed := tools.ScrubResultSecrets(tools.Result{
ChangedFiles: []string{"credentials.txt"},
FileDiffs: []tools.FileDiff{{
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
}},
})
update := toolCallResult(agent.ToolResult{
ToolCallID: "call", Status: tools.StatusOK,
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
})
if len(update.Content) != 0 {
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
}
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
t.Fatalf("ACP path fallback = %#v", update.Locations)
}
}
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
for name, separator := range map[string]string{
"combining grapheme joiner": "\u034f",
"variation selector": "\ufe0f",
} {
for _, side := range []string{"old", "new"} {
t.Run(name+" "+side, func(t *testing.T) {
obfuscated := secret[:20] + separator + secret[20:]
diff := tools.FileDiff{
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
OldText: "safe old", NewText: "safe new",
}
if side == "old" {
diff.OldText = obfuscated
} else {
diff.NewText = obfuscated
}
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
}
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 0 {
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
}
})
}
}
}
func TestPlanUpdateAndStatus(t *testing.T) {
upd := planUpdate([]tools.PlanItem{
{Content: "step a", Status: "completed"},
+8 -93
View File
@@ -12,8 +12,6 @@ const (
MethodAuthenticate = "authenticate"
MethodSessionNew = "session/new"
MethodSessionLoad = "session/load"
MethodSessionList = "session/list"
MethodSessionResume = "session/resume"
MethodSessionPrompt = "session/prompt"
MethodSessionCancel = "session/cancel" // notification
MethodSessionUpdate = "session/update" // notification (agent -> client)
@@ -64,16 +62,8 @@ type PromptCapabilities struct {
}
type AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
}
// Empty capability objects are presence flags in ACP v1. Pointers preserve
// the wire distinction between an advertised `{}` and an omitted capability.
type SessionCapabilities struct {
List *struct{} `json:"list,omitempty"`
Resume *struct{} `json:"resume,omitempty"`
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
}
type AuthMethod struct {
@@ -127,14 +117,9 @@ type McpServer struct {
}
type NewSessionParams struct {
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
// AdditionalDirectories is declared by the protocol and consumed nowhere in
// this repo yet. When it is wired up it must go through requestedWorkspace
// like Cwd does: these are client-supplied paths with no absoluteness rule of
// their own, which is the same shape as the resume-cwd hole that made
// {"sessionId":"known"} activate a session against this process's directory.
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
Cwd string `json:"cwd"`
McpServers []McpServer `json:"mcpServers"`
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
}
type NewSessionResult struct {
@@ -155,39 +140,6 @@ type LoadSessionResult struct {
Modes *SessionModeState `json:"modes,omitempty"`
}
// ListSessionsParams and the following types implement ACP v1 session/list.
// Transcript contents stay behind session/load; this method returns metadata
// only and leaves the optional pagination cursor opaque.
type ListSessionsParams struct {
Cwd string `json:"cwd,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
type SessionInfoMeta struct {
ModelID string `json:"modelId,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
type SessionInfo struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd"`
Title string `json:"title,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Meta *SessionInfoMeta `json:"_meta,omitempty"`
}
type ListSessionsResult struct {
Sessions []SessionInfo `json:"sessions"`
NextCursor string `json:"nextCursor,omitempty"`
}
// session/resume takes session/load's wire shape, so its cwd is a plain string
// too and an omitted one decodes exactly like an empty one. Sharing the type is
// safe only because activatePersistedSession requires an absolute cwd from the
// request itself — nothing downstream may supply a default for either method.
type ResumeSessionParams = LoadSessionParams
type ResumeSessionResult = LoadSessionResult
// ---- prompt turn ----
type PromptParams struct {
@@ -222,7 +174,6 @@ type SessionNotification struct {
// ContentBlock under "content"; the variant is set via SessionUpdate.
type ContentChunk struct {
SessionUpdate string `json:"sessionUpdate"`
MessageID string `json:"messageId,omitempty"`
Content ContentBlock `json:"content"`
}
@@ -259,21 +210,6 @@ type ToolCallUpdate struct {
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
// namespaced key so protocol-shaped clients can preserve them while decoding
// and re-encoding a tool call.
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
// BrowserToolDetails identifies the browser helper operation behind a tool
// call. Version is the schema version for this optional ZERO extension;
// Command is one of install, launch, connect, open, snapshot, click, type,
// press, or action. Future fields must remain display-safe and must not
// include browser profile data, cookies, typed text, URL paths/queries, or
// DevTools endpoints.
type BrowserToolDetails struct {
Version int `json:"version"`
Command string `json:"command"`
}
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
@@ -284,30 +220,9 @@ type ToolCallContent struct {
// type == "content"
Content *ContentBlock `json:"content,omitempty"`
// type == "diff"
Path string `json:"path,omitempty"`
OldText *string `json:"oldText,omitempty"`
NewText *string `json:"newText,omitempty"`
}
// MarshalJSON preserves ACP's discriminated content union. A diff always has
// path, while nullable oldText/newText distinguish creation, deletion, and an
// existing file replaced with empty content. Other content variants omit all
// diff fields rather than serializing irrelevant nulls.
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
if content.Type == "diff" {
return json.Marshal(struct {
Type string `json:"type"`
Path string `json:"path"`
OldText *string `json:"oldText"`
NewText *string `json:"newText"`
}{
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
})
}
return json.Marshal(struct {
Type string `json:"type"`
Content *ContentBlock `json:"content,omitempty"`
}{Type: content.Type, Content: content.Content})
Path string `json:"path,omitempty"`
OldText string `json:"oldText,omitempty"`
NewText string `json:"newText,omitempty"`
}
func ToolContent(block ContentBlock) ToolCallContent {
-309
View File
@@ -1,309 +0,0 @@
package acp
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// `zero exec` persists every provider usage report as a session usage event, so
// `zero usage report --session <id>` can total a run. The ACP agent did not set
// OnUsage, which dropped the token counts of every ACP session. A turn with two
// provider requests (a tool round trip) must leave two usage events, in the shape
// the usage report reads, next to the turn's messages.
func TestACPPromptPersistsProviderUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: "done"}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
return agent.Result{FinalAnswer: "done"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
}, &result); err != nil {
t.Fatalf("session/prompt: %v", err)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
type usagePayload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CachedInputTokens int `json:"cachedInputTokens"`
}
var got []usagePayload
messages := 0
for _, event := range events {
switch event.Type {
case sessions.EventUsage:
var payload usagePayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("usage payload %s: %v", event.Payload, err)
}
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
got = append(got, payload)
case sessions.EventMessage:
messages++
}
}
want := []usagePayload{
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
}
if len(got) != len(want) {
t.Fatalf("usage events = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
}
}
if messages != 2 {
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
}
}
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
// turn leaves nothing durable, exactly as it does for the turn's messages.
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, errors.New("injected hard run failure")
}
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
return agent.Result{}, errors.New("injected hard run failure")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Fatalf("aborted turn left durable events: %+v", events)
}
}
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
// provider already reported were spent, so they stay in the session log even
// though the client cancelled. A hard error (the test above) is the path that
// drops them.
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, context.Canceled
}
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
return agent.Result{FinalAnswer: "partial"}, context.Canceled
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
}, &result); err != nil {
t.Fatalf("cancelled turn returned an RPC error: %v", err)
}
if result.StopReason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
usageEvents := 0
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
usageEvents++
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
var payload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
}
}
if usageEvents != 1 {
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
}
}
// Usage events sit in the same log session/load and session/resume read, but
// history reconstruction skips them. The wire replay stays the conversation,
// and the next turn's prompt stays the conversation: token counts never become
// context and never cross the ACP wire.
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
const userLine = "unique-acp-user-line"
const answer = "unique-acp-answer"
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: answer}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
return agent.Result{FinalAnswer: answer}, nil
}
workspace := t.TempDir()
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
h.stop()
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
sawUsage := false
for _, event := range events {
if event.Type == sessions.EventUsage {
sawUsage = true
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
}
}
if !sawUsage {
t.Fatal("session log has no usage event to ignore")
}
loader := newHarness(t, deps)
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
var replayed []string
for {
select {
case update := <-loader.notifications:
replayed = append(replayed, update.Content.Text)
case <-time.After(150 * time.Millisecond):
goto drained
}
}
drained:
loader.stop()
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
}
for _, text := range replayed {
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
t.Fatalf("session/load replayed usage onto the wire: %q", text)
}
}
var gotPrompt string
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
gotPrompt = prompt
return agent.Result{FinalAnswer: "continued"}, nil
}
resumer := newHarness(t, deps)
defer resumer.stop()
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &ResumeSessionResult{}); err != nil {
t.Fatalf("session/resume: %v", err)
}
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err != nil {
t.Fatalf("resumed session/prompt: %v", err)
}
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
}
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
}
}
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
// has no model escalation, and the writer must not grow a prompt, tool
// argument, or other raw field.
var usagePayloadAllowedKeys = map[string]bool{
"promptTokens": true,
"completionTokens": true,
"totalTokens": true,
"cachedInputTokens": true,
"cacheWriteTokens": true,
"reasoningTokens": true,
}
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
t.Helper()
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
t.Fatalf("usage payload %s: %v", payload, err)
}
if len(raw) == 0 {
t.Fatalf("usage payload is empty: %s", payload)
}
for key := range raw {
if !usagePayloadAllowedKeys[key] {
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
}
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// providerCallingAskUserThenAnswer returns a mock provider whose first turn calls
+20 -8
View File
@@ -8,9 +8,8 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/testutil"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
@@ -112,11 +111,7 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
testutil.WaitFor(t, "worker idle", func() bool {
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
return diagnostics.working == nil
})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
@@ -128,6 +123,23 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
-34
View File
@@ -1,34 +0,0 @@
package agent
import "github.com/Twigpine/zero/internal/tools"
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
// input is normalized through the registered built-in tool, so absolute headers
// are rewritten relative to the selected write root and cwd is set: checkpoint
// capture and execution therefore agree on the files at stake.
//
// Best-effort: nil when the call has no capture targets or its arguments cannot
// be resolved. Checkpoint capture never fails a run.
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
if registry == nil {
return nil
}
args := map[string]any{}
if call.Freeform {
tool, ok := registry.Get(call.Name)
if !ok || !tools.IsBuiltInApplyPatch(tool) {
return nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return nil
}
}
return tools.MutationTargets(workspaceRoot, call.Name, args)
}
-99
View File
@@ -1,99 +0,0 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
func TestCheckpointTargets(t *testing.T) {
root := t.TempDir()
// Absolute headers must be compared against the physical root: t.TempDir
// differs from it on macOS (/var -> /private/var) and via Windows short names.
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
unbuiltinRegistry := tools.NewRegistry()
unbuiltinRegistry.Register(&customApplyPatchTool{})
for _, tc := range []struct {
name string
registry *tools.Registry
call ToolCall
want []string
}{
{
name: "freeform relative header",
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform absolute header stays workspace-relative",
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
want: []string{"main.txt"},
},
{
name: "freeform multi-file patch",
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
want: []string{"main.txt", "notes.txt"},
},
{
name: "json arguments",
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
want: []string{"main.txt"},
},
{
name: "freeform traversal is rejected",
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
want: nil,
},
{
name: "freeform custom apply_patch is not prepared",
registry: unbuiltinRegistry,
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
want: nil,
},
{
name: "unknown freeform tool",
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
want: nil,
},
{
name: "malformed json arguments",
call: ToolCall{Name: "apply_patch", Arguments: "{"},
want: nil,
},
} {
t.Run(tc.name, func(t *testing.T) {
reg := registry
if tc.registry != nil {
reg = tc.registry
}
got := CheckpointTargets(root, reg, tc.call)
if !slices.Equal(got, tc.want) {
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
}
})
}
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"runtime"
"strings"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"runtime"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"unicode/utf8"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Session compaction.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction preservation.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"unicode/utf8"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stateConversation is a long enough conversation that Compact elides a middle
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"regexp"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sync/atomic"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var markerPattern = regexp.MustCompile(`msg-\d+`)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// --- Pure Compact() tests -------------------------------------------------
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// someRequestContains reports whether any message sent to the provider across all
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"encoding/json"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Context budget.
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMeasureContextSplitsByCategory(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
+2 -2
View File
@@ -2,8 +2,8 @@
package agent
import (
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/Twigpine/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strconv"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Guardrail thresholds for the agent loop. These keep a runaway model from
+2 -66
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
@@ -80,9 +80,6 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
if result.FinalAnswer == maxTurnsAnswer {
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
@@ -516,22 +513,6 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
return turns
}
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
// to put a productive tool turn before an empty-turn streak.
type alwaysSucceedingTool struct{}
func (alwaysSucceedingTool) Name() string { return "ok" }
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
func (alwaysSucceedingTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (alwaysSucceedingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
@@ -548,51 +529,6 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
if len(provider.requests) != toolFailureStopAt {
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
}
if result.Incomplete {
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
}
}
// Under the headless completion gate both guardrail stops are unfinished runs,
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
// Thresholds and stop answers stay pinned by the two tests above.
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
for _, tc := range []struct {
name string
turns [][]zeroruntime.StreamEvent
wantTurns int
wantReason string
}{
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
toolTurn("ok-1", "ok", "{}"),
emptyTurn(), emptyTurn(), emptyTurn(),
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(alwaysFailingTool{})
registry.Register(alwaysSucceedingTool{})
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
Registry: registry,
MaxTurns: 12,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Turns != tc.wantTurns {
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
}
if !result.Incomplete {
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
}
})
}
}
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
)
func TestAppendHookFeedbackFormatsOutput(t *testing.T) {
+39 -133
View File
@@ -11,33 +11,28 @@ import (
"strings"
"sync"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
// on a fresh connection before giving up. Incomplete tool-call previews are
// allowed through the gate (they are never dispatched before the error return).
// The safety claim is only that visible answer prose is not duplicated on retry —
// not that every partial stream shape is excluded. Covers a stalled/dead pooled
// connection or a socket abort before tool dispatch.
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
//
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
// (~5min) before the stall is even detected, so 2 retries left an interactive
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
// while bounding the worst case to ~2× the idle timeout. Mid-stream transport
// aborts share this bound (do not raise it for #973).
// while bounding the worst case to ~2× the idle timeout.
const maxStreamStallRetries = 1
const (
@@ -379,11 +374,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
rec.StampFirstToken()
}
if onText != nil {
// Zero-length chunks are not committed answer prose; counting
// them would block an eligible mid-stream abort retry.
if s != "" {
forwardedVisibleText = true
}
forwardedVisibleText = true
onText(s)
}
}
@@ -459,14 +450,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout OR mid-stream transport abort (#973 — wsarecv /
// connection reset / unexpected EOF / connection closed / broken pipe) is
// safely re-issued when the turn committed NO answer text — no forwarded
// visible prose (forwardedVisibleText) and no collected final text
// (collected.Text). The abort gate matches midStreamAbortNeedles only, not
// the full shouldReconnect list: connect-phase timeouts and connection
// refused are NOT retried here (a slow healthy server must not cost a
// second prefill). This covers three cases:
// A stream idle/stall timeout is safely re-issued when the turn committed NO
// answer text — no forwarded visible prose (forwardedVisibleText) and no
// collected final text (collected.Text). This covers two cases:
// 1. Nothing streamed at all before the connection died (the original
// macOS stale-pooled-connection hang past the response-header timeout).
// 2. The model streamed transient reasoning and began a tool call (e.g. a
@@ -477,27 +463,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// re-render is transient previews, not duplicated answer text. This is
// why the gate no longer excludes collected.ToolCalls: an incomplete
// tool call from a timed-out stream is discard-and-retry, not output.
// 3. Mid-stream socket abort after connect succeeded (Windows WSAECONNABORTED /
// wsarecv, connection reset by peer). Same no-tool-executed safety: the
// error returns before tool dispatch, so a bounded retry is safe.
// A turn that forwarded real prose is NOT retried (it would duplicate visible
// answer text) and falls through to the error return below. Capped +
// exponential backoff, with a user-visible notice per attempt.
for attempt := 1; attempt <= maxStreamStallRetries &&
(isStreamTimeoutError(collected.Error) || isMidStreamTransportAbort(collected.Error)) &&
!forwardedVisibleText &&
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
collected.Text == ""; attempt++ {
var notify reconnectNotifier
if isStreamTimeoutError(collected.Error) {
notify = stallRetryNoticeFor(options)
} else {
notify = reconnectNoticeFor(options)
// Count this post-connect reissue even when the replacement
// connect succeeds immediately. streamWithReconnect only
// increments reconnect_count after a failed connect attempt.
options.Trace.Counter(trace.CounterReconnectCount, 1)
}
if notify != nil {
if notify := stallRetryNoticeFor(options); notify != nil {
notify(attempt, maxStreamStallRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
@@ -510,22 +482,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
if ctx.Err() != nil {
return result, ctx.Err()
}
return result, retryErr
}
stallGenSpan := options.Trace.Span(trace.SpanGeneration)
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
stallGenSpan.End()
}
// Recheck ctx after a retried CollectStream: helpers.go stores
// ctx.Err().Error() in collected.Error, and errors.New of that string
// drops the context.Canceled sentinel ACP branches on.
if ctx.Err() != nil {
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
@@ -536,10 +498,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, stop
}
if ctx.Err() != nil {
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
@@ -587,14 +545,6 @@ 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)
// Under the headless completion gate this stop is unfinished work,
// not a successful final answer: the guard exists precisely because
// the model made no progress. Interactive runs keep the gate off, so
// their behavior is unchanged.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -696,25 +646,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// recorded. They travel as user messages, and a user message between two
// tool_results breaks strict provider replay — Anthropic coalesces them
// into one user block list and requires the tool_result blocks first, so
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
// Anthropic's tool_result content is a string, Gemini's is a
// functionResponse, and OpenAI guards its image parts to the user role
// — so attaching them there would silently deliver nothing. A separate
// message also keeps the one-tool-result-per-tool-call pairing intact,
// which the providers validate. Vision-gate evaluation is deferred until
// after any model switch this turn resolves, so images can reach an
// escalated vision-capable model.
var toolResultsWithImages []ToolResult
buildToolImageMessages := func() []zeroruntime.Message {
var out []zeroruntime.Message
for _, tr := range toolResultsWithImages {
if imageMessage, ok := toolResultImageMessage(tr, options); ok {
out = append(out, imageMessage)
}
}
return out
}
// interleaving yields [tool_result, text, image, tool_result] and a 400.
// Same reason the self-correction feedback below is deferred.
var toolImageMessages []zeroruntime.Message
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
@@ -771,8 +705,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
})
if len(toolResult.Images) > 0 {
toolResultsWithImages = append(toolResultsWithImages, toolResult)
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
// Anthropic's tool_result content is a string, Gemini's is a
// functionResponse, and OpenAI guards its image parts to the user role
// — so attaching them there would silently deliver nothing. A separate
// message also keeps the one-tool-result-per-tool-call pairing intact,
// which the providers validate.
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
toolImageMessages = append(toolImageMessages, imageMessage)
}
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
@@ -784,13 +725,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
if abortErr != nil {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, buildToolImageMessages()...)
messages = append(messages, toolImageMessages...)
result.Messages = copyMessages(messages)
return result, abortErr
}
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, buildToolImageMessages()...)
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolResult.ModelOutput()
result.StopReason = stopReason
result.Messages = copyMessages(messages)
@@ -814,15 +755,8 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// messages stay valid for a strict provider replay (Anthropic
// rejects a tool_use with no answering tool_result).
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, buildToolImageMessages()...)
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
// Same headless contract as the empty-turn guard: halting on a
// repeated failure is an unfinished run, not success. Gate-off
// (interactive / --no-completion-gate) keeps the old behavior.
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "the " + call.Name + " tool failed " + strconv.Itoa(outcome.Count) + " times in a row with the same error"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -840,6 +774,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
// Every tool_result for this turn is now recorded, including aborted
// placeholders, so the images can follow without splitting them.
messages = append(messages, toolImageMessages...)
toolImageMessages = nil
// Run post-edit self-correction once over the union of files this turn
// changed, then append any feedback after every tool_result is recorded so
@@ -927,12 +865,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Every tool_result, self-correct notice, and model-switch update for this
// turn is now in place; evaluate and append image messages against the
// effective (potentially escalated) model.
messages = append(messages, buildToolImageMessages()...)
toolResultsWithImages = nil
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
// calls executed above; surface the dropped call too so it is never
// silently ignored just because the turn also did real work. This is
@@ -1595,7 +1527,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
@@ -1902,10 +1833,6 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
}
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
// explicitly cross the same transcript/redaction boundary before its result
// can be forwarded through ACP.
result = tools.ScrubResultSecrets(result)
output, outputRedacted := scrubInterceptedOutput(result.Output)
display := result.Display
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
@@ -1933,7 +1860,6 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
@@ -2227,7 +2153,6 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
@@ -3532,10 +3457,8 @@ func copyMessages(messages []Message) []Message {
//
// The text names the tool so the model can tell which call an image came from
// when several ran in one turn — the images arrive detached from their tool
// result, so nothing else associates them. If the effective model cannot
// accept images, the tool's text result is left unchanged and this follow-up
// is a notice with no image parts, matching the CLI/TUI vision gate.
func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Message, bool) {
// result, so nothing else associates them.
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
for _, image := range result.Images {
if len(image.Data) == 0 {
@@ -3556,26 +3479,9 @@ func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Mes
if label == "" {
label = "tool"
}
if !modelAcceptsToolImages(options) {
return zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: "Image output from " + label + " was not sent because the current model does not support image input. Switch to a vision-capable model before retrying image delivery, including any images skipped by the tool's image budget.",
}, true
}
return zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: "Image output from " + label + ":",
Images: images,
}, true
}
func modelAcceptsToolImages(options Options) bool {
if options.SupportsVision != nil {
return options.SupportsVision(options.Model)
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return modelregistry.VisionCapableByName(options.Model)
}
return modelregistry.SupportsVision(registry, options.Model)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// imageEchoProvider records the messages of the first request it receives, then
Loaded 100 of 1041 files, more files were not shown because too many files have changed in this diff. Show more