Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 58803032cb Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-10-25 19:32:59 +05:30
Vasanth T 5d080ef46b test(tui): pin the cwd in tests that assert text is absent from the view (#1170)
* test(tui): pin the cwd in tests that assert text is absent from the view

Nine tests built their model with an empty Options.Cwd, so newModel took
the package directory and the title bar showed the developer's real
branch and checkout path. Each then asserted that some text was absent
from the whole view, and failed wherever that branch or path happened to
contain it: TestSuggestionOverlayRenders on a branch with /model in its
name, TestSuggestionOverlayCapsRowsWithoutMoreText on a branch or path
containing "more" or "compact", the provider wizard tests on "offline",
"chatgpt" or "gpt-4.1", and so on.

They now pin Options.Cwd to isolatedCwd, a path that does not exist, so
no branch is read and the path is the same on every machine. A guard
test checks that isolatedCwd stays outside any git checkout.

* test(tui): make isolatedCwd fully qualified and absent by construction

The fixed /workspace/zero path could exist on some machine, a dev
container for one, and newModel would then read whatever is there. On
Windows it was also only drive-relative: \workspace\zero is not a fully
qualified path.

isolatedCwd is now a child of the home TestMain isolates that nothing
creates, so it is fully qualified, absent and outside any checkout on
every platform, and the title bar shows ~/not-a-checkout everywhere. A
t.TempDir path would have put the user name and the test name into the
view. The guard test checks all three properties.

* test(tui): clear the branch in view-test models instead of trusting the cwd

isolatedCwd is a child of the isolated home, which lives under the
temporary directory. With TMPDIR inside a checkout, gitBranch walks up
from that absent path to the checkout and puts its branch in the title
bar, so the guard failed on an ordinary branch and a colliding branch
broke the nine view tests again.

newViewTestModel now builds those models: it sets the isolated cwd and
clears the branch after construction, since no path can promise there
is no checkout above it. The guard keeps only what a path can promise
(fully qualified and absent), and a new test puts the home inside a
checkout on feat/model-picker and checks that neither that branch nor
the real checkout path reaches the view.

* test(tui): assert no checkout path reaches the view in the enclosing-checkout test

The test checked the branch strings and that the isolated cwd shows,
but a view could show both the isolated cwd and a real checkout path
without failing it. It now also asserts that neither the enclosing
checkout nor the directory the tests run from appears in the view.
2026-10-22 06:52:51 -07:00
Vasanth T 1d233c0a9b test(modelregistry): clear ZERO_DISABLE_MODELS_FETCH in the models.dev tests (#1169)
The variable turns off both the models.dev fetch and the cached overlay,
and four tests in modelsdev_test.go inherited it from the shell. With it
set, TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering and
TestDefaultModelEntriesAppliesFreshCache fail, and
TestCachedModelsDevProvidersIgnoresStaleCache and
TestCachedModelsDevProvidersRequiresOptIn pass without reaching the
check they are named for: the nil they expect comes from the variable.

Clear it in each of them, as the two sibling refresh tests already do.
2026-10-21 07:33:36 -07:00
Vasanth T de1443b2ed fix(deps): refresh the lockfile to hono 4.13.13 (#1171)
GHSA-5r4p-p66f-jhc7 (moderate): hono's serveStatic decodes the request
path a second time, so middleware on static paths can be bypassed.
Versions 4.12.4 to 4.13.10 are affected and main pins 4.13.9 through
tuistory, which release packaging installs as a local-control helper.

npm update takes hono to 4.13.13, inside every range that asks for it.
package.json is unchanged and nothing else in the lock moves.
2026-10-21 07:12:22 -07:00
Felix-Ayush 84c1b15540 fix(oauth): reuse the crash-safe secret store and sync encrypted publications (#1165)
* fix(oauth): use the crash-safe secret store and sync token publications

The encrypted-file OAuth backend carried its own AES-GCM secret implementation that had drifted from internal/securefile: a crash during first login left <secret>.lock behind and every later Save timed out instead of reclaiming it, and the secret and token blobs were published without fsync.\n\nUse securefile.Crypter with an opt-in publication directory so the secret still publishes through the sandbox-denied .secret.publish directory, add file and directory barriers to the encrypted token blob, and extend securefile with the publication directory plus destination-directory sync.\n\nMove the concurrent-creation and wrong-sized-secret coverage to securefile, add a stale-lock regression through the OAuth store, and test publication-dir use, sync ordering, sync-failure preservation, and retry after a failed secret sync. The stale-lock test fails on the previous code.

* test(oauth): cover post-rename directory-sync failures

The existing failure tests inject a file sync error before the rename. Add coverage for a destination-directory barrier failure after the token or secret has been atomically replaced: the write must report the error and the newly published bytes must remain readable. Also cover the corresponding securefile secret path, including its expected size.

* fix(securefile): never clobber a newer lock when restoring a reclaimed one

reclaimStaleLock could rename a lock that had just been replaced, then restore the quarantined copy with os.Rename, replacing a third holder's new lock. Both holders could then publish different secrets for the same file. Restore with O_CREATE|O_EXCL and discard the quarantined copy when the destination is already held.

Extend the directory-sync failure test to prove a later caller actually loads the published secret, not just its size. Add restore tests for the free and already-held cases.
2026-10-21 18:41:38 +08:00
Vasanth T 6393a90ed6 fix: bump Go to 1.26.10 for GO-2026-6809 (#1168)
govulncheck now reports GO-2026-6809 (crypto/x509 distrusts compromised
intermediate certificates) against Go 1.26.9, reachable through
daemon.WriteFrame, so Security & Code Health fails on main and on every
open PR. It is fixed in 1.26.10.

CI and release builds read the toolchain from go.mod. The stated
source-build minimum in README.md, README_ZH.md and docs/INSTALL.md
moves with it.
2026-10-21 16:37:49 +08:00
Vasanth T cd3e0b0a9f test(cli): isolate the exec image tests from the models.dev refresh (#1161)
runExec starts a background models.dev download unless
ZERO_DISABLE_MODELS_FETCH is set. TestRunExecImageAttachmentHistory moved
the user directories into a TempDir by hand but left the fetch on, so a
5.7 MB download landed in the TempDir while its cleanup ran, and the
macOS runner failed with "unlinkat ... directory not empty". The two
older image tests had no isolation and fetched into the real user cache.

Use isolateCLIUserState in all three, which also points the cache path
into the fixture and turns the fetch off.
2026-10-18 10:08:09 +08:00
Felix-Ayush aa9ba8412e fix(tools): grep stops where a file's line breaks stop (#1160)
* fix(tools): grep stops where a file's line breaks stop

scanGrepFile treated EOF after a trailing line break - and an empty file -
as the START of another line, and handed the matcher an empty line one
number past the real content. Every pattern that matches an empty string
then reported a match on a line no reader sees:

  a.txt holds "x\n"; grep pattern "^$"
    -> a.txt:2:           (grep -c '' GNU-style reports no such line)
  same file, pattern ".*", count mode
    -> "2 matches found"  (one real line, one phantom)
  an empty file, pattern "^$"
    -> a.txt:1:           (the file has no lines at all)

read_file and the file tracker's countLines already state the rule the
scan violated: "the trailing newline does not open a line", and an empty
file has no lines. The scan now stops there too, so anchored and
empty-matching patterns can only match the lines the content carries.

Real matches, numbering, count-per-hit accounting for non-empty patterns,
file mode, glob and symlink handling are unchanged.

* test(tools): make the grep line-number parity test catch the phantom

The parity test matched only "needle", a pattern that cannot match the
empty line the old EOF branch invented, so its absence-of-line-4
assertion passed on the code being fixed. The pattern now also matches an
empty line, and the expected output is asserted exactly: on the pre-fix
scanner the same run reported

    grep = "a.txt:2: needle two\na.txt:4: ",
        want exactly the line read_file numbers 2

so the phantom is visible right beside the real line, and the test is
about the numbering claim it states. Also gofmt-aligns the case table
that made the CI formatting check fail.
2026-10-18 10:07:34 +08:00
Felix-Ayush a97db8832f fix(redaction): redact credentials whose trigger a greedy span swallowed (#1158)
Two credentials glued end to end share one recovering property: the second
one's trigger prefix is where redaction can re-anchor. For fixed-length
bodies that anchor is exact (AKIA{16} stops at the next key), which is the
case the anchored end-chaining always covered. For unbounded bodies it
isn't: github's [A-Za-z0-9]{36,} body eats the literal "ghp" of the token
glued behind it, so the first match ends PAST the second trigger, the scan
that re-anchors at the span end finds nothing there, and the second
credential leaves the output as +_bbbb... / .eyJzdWIi... plaintext:

  RedactString("ghp_aaa..36a..ghp_bbb..36b..")
    -> "[REDACTED]_bbbb......"   (second token's body printed in full)
  RedactString(jwtA+jwtB)
    -> "[REDACTED].eyJzdWIiOiI5ODc2...SA" (second token's payload+sig)

The same holds across shapes (a github key directly feeding "sk-ant-..."
through its body) and for three tokens in one run.

redactAdjacent now follows the run a match starts: at each boundary it
re-binds the match at the EARLIEST trigger occurrence inside the span that
stills validates as its shape (the head before the trigger must match the
anchored pattern exactly, and the run chained from the trigger must reach
past the span, so a mid-body lookalike and a mid-span cut that recovers
nothing leave the span alone), and continues under whichever shape matched
the swallowed trigger. Candidate examination is capped at
maxTriggerOccurrences per span so body spam of trigger lookalikes costs
what the span costs, and the first validating cut wins.

End-chaining for fixed-length bodies, single-credential shapes, custom
replacement templates, and the split path's announced no-op-for-contiguous
text guarantee are all unchanged; the regression tests pin the glued pairs
that previously leaked plus the AWS chaining and lone-token cases.
2026-10-18 10:07:04 +08:00
Vasanthdev2004 65ded9dc05 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-10-17 17:27:05 +05:30
Vasanthdev2004 786d4fc23e refactor(cli): use PermissionModeFullAuto instead of its deprecated alias
This branch marks PermissionModeUnsafe deprecated, so staticcheck reports
SA1019 at every use outside the agent package, including three in cli and
one in a test that came in from main. The alias is the same value, so
nothing behaves differently.
2026-10-17 16:40:16 +05:30
Vasanth T ef4da1323f fix(workspaceindex): import gitsafe under the Twigpine module path (#1153)
#1149 was opened before #1105 moved the module to github.com/Twigpine/zero
and merged right after it, so its two imports of internal/gitsafe still
used the old path. Nothing provides github.com/Gitlawb/zero any more, so
main stopped building at 2ffe1831.
2026-10-17 16:59:52 +08:00
zzucs 1e848492e5 fix(sessions): preserve image attachment summaries on resume (#1152)
* fix(sessions): preserve image attachment summaries on resume

Persist TUI-compatible attachment counts for ACP and exec user events.
Restore readable markers in live history, session replay and resumed
prompts without storing or resending historical image data.

Cover image-only and mixed prompts, restart recovery, malformed metadata
and the aborted-turn boundary with regression tests.

Fixes #1135

* fix(workspaceindex): follow the current module path

Use github.com/Twigpine/zero/internal/gitsafe in the ignore scanner and its
tests. The upstream module rename preceded the scanner's gitsafe change,
leaving these two imports on the old path and preventing the rebased tree
from compiling.

* fix(sessions): count only images accepted by the model

Take ACP and exec attachment counts after the vision capability gate,
matching the TUI's persisted attachment semantics. Update the ignored-image
regressions to require zero saved images and no historical image marker.

Refs #1135
2026-10-17 16:59:21 +08:00
Vasanthdev2004 ad3c77cc44 chore: follow the Twigpine module path
main moved the module to github.com/Twigpine/zero in #1105. Rename this
branch's imports to match, including the two in internal/workspaceindex
that main itself still carries until #1153 lands.
2026-10-17 11:30:59 +05:30
Vasanthdev2004 08a8b9f466 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/tui/run.go
2026-10-17 11:30:58 +05:30
Vasanth T 2ffe1831b0 fix(workspaceindex): run Scan's git calls through gitsafe (#1149)
* fix(workspaceindex): run Scan's git calls through gitsafe

Scan asks git for ignored paths on every turn with its own gitCommand, which
turned off core.fsmonitor and dropped the repository-location variables but
still let a clean filter or diff driver from the repository's own config run
during status. It now goes through gitsafe, like the TUI and the scratch-file
warning: hooks are off as well, and a repository whose config defines a filter
or diff command takes the non-git path. The status call also passes
--ignore-submodules=dirty.

Scan makes two git calls, so gitsafe gains Check, which reads the repository
config once and builds several commands from it. Command is now Check plus one
command, and each command gets its own copy of the environment.

* test(workspaceindex): pin Scan's fallback when gitsafe refuses the repository

A repository whose own config defines a filter or diff command is refused by
gitsafe, and Scan then takes the non-git path. Scan reaches gitsafe.Check
through a package variable now, so a test can stand in for such a repository
without configuring one that names a command, and
TestScanFallsBackWhenGitsafeRefusesTheRepository checks both sides: with git
consulted, .gitignore decides and the fixed list does not apply; refused, the
fixed list applies and .gitignore is not read.
2026-10-17 07:14:03 +08:00
PierrunoYTandClaude Opus 5.5 13e2596b2e chore: move repository references to Twigpine/zero (#1105)
Update the Go module path, imports, install scripts, docs, workflows,
packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm
scope (already @twigpine/zero on main, with its @gitlawb/zero migration
notes), LICENSE, action author, the OpenGateway provider and historical
CHANGELOG links are unchanged.

The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what
AIMLAPI sees for attribution, so changing it needs a maintainer's yes.
docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because
every tagged release so far declares that module path. The Homebrew
formula comment names the Twigpine/homebrew-tap tap.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 07:13:21 +08:00
HazyLabandcl-ment 8c4e759798 feat(sandbox): support self-exec pattern to eliminate separate helper binary (#946)
* feat(sandbox): support self-exec pattern to eliminate separate helper binary

Linux sandboxing previously required distributing a separate 'zero-linux-sandbox'
binary and looking it up on PATH.

This wires an internal '__sandbox-helper' entrypoint directly in cmd/zero/main.go
and configures findLinuxSandboxHelperCommand to fallback to os.Executable(),
enabling single-binary zero distributions with fully functional sandboxing.

* fix(sandbox): prefer PATH helper before self-exec fallback

Colocated zero-linux-sandbox stays first. A standalone helper on PATH
is used next. Self-exec of the main zero binary is only the last
fallback, so an installed helper is not ignored.

* feat(sandbox): clean formatting in main.go and add self-exec unit tests

* feat(sandbox): insert __sandbox-helper verb in bwrap plan for self-exec binary

* fix(sandbox): drive Linux selection from the self-exec helper resolver

Availability and command-plan construction now share the same helper
resolution and keep the __sandbox-helper prefix on the backend, so a
main-only install with bwrap reaches native sandboxing.

* fix(sandbox): self-exec only the zero main binary, not test binaries

* fix(sandbox): carry standalone vs self-exec identity into the inner bwrap plan

A versioned physical helper (zero-linux-sandbox-v1) must not receive
__sandbox-helper. The inner plan uses an explicit SelfExec flag, threaded
from argv, instead of guessing from filepath.Base.

* docs(install): describe self-exec as the last helper fallback

Self-execution runs only when neither a colocated nor a PATH
zero-linux-sandbox is available.

* docs(install): state colocated, PATH, then self-exec helper order

---------

Co-authored-by: cl-ment <[email protected]>
2026-10-16 14:09:28 +08:00
github-actions[bot] d597f0e095 chore(main): release 0.11.0 (#1146)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-16 11:28:23 +08:00
HazyLabandcl-ment ce2fcffaf1 perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings (#945)
* perf(redaction): add fast-path byte checks to eliminate regex allocations on clean strings

Evaluating cascading regular expressions across every log line, tool output,
and session event incurred ~23 µs and 51 heap allocations even on strings with
zero secrets.

This introduces fast-path substring checks before invoking expensive regex
substitutions, yielding a 70x speedup and 0 B/op heap allocation on clean
strings.

* test(redaction): lengthen ghp_ fixtures and cover conditional gates

GitHub classic tokens need 36 characters after ghp_. Benchmarks and
shape tests now use a matching fixture. A table-driven test checks each
fast-path gate, including near-misses and case-insensitive Authorization.

* perf(redaction): structure triggered patterns and avoid allocation in header case checks

* test(redaction): cover secret-header and URL-password gates

Keep the redaction marker literal after url.URL rewrites userinfo, so
callers still see [REDACTED] instead of a percent-encoded form.

* fix(redaction): unescape only the generated URL password

A path or query that already contains the encoded redaction marker
must stay encoded. Only the userinfo password is rewritten in the
clear [REDACTED] form.

* fix(redaction): re-escape URL username after parse

Username() is decoded. Re-encoding with url.User keeps control bytes
out of the redacted URL so a %0AAuthorization userinfo cannot inject
a header after the header pass.

* fix(redaction): fail closed on password URLs without a host

* test(redaction): isolate query-gate coverage from assign and text secrets

* fix(redaction): fold Unicode U+017F and U+212A in containsCaseInsensitive

Go (?i) regular expressions apply Unicode simple case folding, where U+017F (ſ) folds to 's' and U+212A (K) folds to 'k'. Teach containsCaseInsensitive to fold these runes to ASCII lowercase with zero allocations so case-insensitive header gates cannot be bypassed by Unicode-folded equivalents.

---------

Co-authored-by: cl-ment <[email protected]>
2026-10-15 21:15:42 +08:00
Kevin Codex 61b09364f0 feat(providers): add Concentrate provider preset (#1147)
* feat(providers): add Concentrate provider preset

Register Concentrate (https://api.concentrate.ai/v1) as an
OpenAI-compatible catalog provider. Concentrate is already listed in the
README Partners section; this adds the provider itself.

- models.dev has no Concentrate provider, so the id `concentrate` and the
  key name CONCENTRATE_API_KEY match OpenClaude's Concentrate
  integration. `Concentrate AI`, `concentrate.ai` and `concentrate-ai`
  resolve to it.
- Default model deepseek-v4-flash-0731: the dated id behind the
  deepseek-v4-flash alias that OpenClaude uses. Concentrate's /v1/models
  keeps the rolling name only in `aliases`, and onboarding keeps the
  default selected only when it matches a live id, so an alias default
  made setup land on the first model in the list (claude-fable-5).
- Curated models: deepseek-v4-flash-0731 (default), deepseek-v4-pro,
  kimi-k3 and glm-5.3. Context windows (1,048,576 each), tool calling and
  thinking support come from Concentrate's public /v1/models.
- Regression test: a Concentrate-shaped live list must contain the
  preset's default model.
- README and README_ZH: key line and setup command.

* test(providers): prove the Concentrate default check runs on the live list

The regression test passed whenever the default model was in the result,
and the curated fallback also contains it. Assert that the mock served
/v1/models and that the result includes claude-fable-5, a model only the
live response lists, so a silent fall back to the curated catalog fails.
2026-10-15 21:14:16 +08:00
fchengyan d1a0d048ee fix(cli): let providers subcommands run with no active provider (#1143)
* fix(cli): let providers subcommands run with no active provider

`zero providers list`, `use`, `remove` and `rename` exited 3 with
`no active provider configured: active provider "" not found` as soon as the
environment supplied more than one provider and config.json had no active
provider: normalizeProvidersWithOptions only elects an active provider by
itself when there is exactly one, and resolveCommandCenterConfig treated the
resulting error as fatal, so the command stopped before doing anything. A
base URL without a key counts as a second provider too (applyProviderEnv
builds a profile from a base URL alone).

The resolver already returns the successfully normalized list alongside
ErrNoActiveProvider, and the interactive TUI already falls back to a usable
entry from it in runInteractiveTUIWithSetup. Do the same here instead of
failing, so these subcommands can answer for providers that only come from
the environment.

Also pin the four env-derived provider tests to a fixed environment and a
temporary user config. They set OPENAI_API_KEY but resolved through the real
resolver against the developer's own config.json and ambient provider
variables, so they failed on any machine that also exported another provider
variable and whose user config had no active provider.

Adds regression coverage for the two-env-provider case: `providers list`
must report every detected provider and `use`/`remove`/`rename` must give
their usual env-derived answer. Verified against the fix (pass) and with the
fix reverted (exit 3, `active provider "" not found`).

Fixes #1130

* refactor(cli): report the configured active provider, not an elected one

Provider-management subcommands answer for the SAVED configuration, while
the TUI's recovery path answers "what can I run with". Adopting
firstUsableProvider in resolveCommandCenterConfig conflated the two: with
two env-derived providers it made `providers current`/`list` present a
provider that `zero exec` would still refuse to run with, and a
base-URL-only profile failed `providers list` because the election demands
a credential. Return the resolver's partial config untouched instead, so
nothing is reported as active when nothing is selected.

Also removes the OAuth store read this path picked up with firstUsableProvider.

Adds assertions that `current` shows nothing selected, `list` marks nothing
active, `use --json` leaves activeProvider empty, plus a base-URL-only case.

* fix(cli): scope the no-active-provider exception to provider management

Returning the resolver's partial config made `zero config` exit 0 with a
partial summary, contradicting the resolver's documented headless contract:
`zero config` / `zero exec` still fail with the actionable message and only
the interactive TUI recovers from a missing active provider. Split the
helper so only issue #1130's commands — `providers list`/`current` and the
unpersisted `use`/`remove`/`rename` reporters — tolerate the state; `zero
config`, `detect`, `models` and `check` keep failing as before.

Also pin the OAuth store in providerEnvSetupDeps: summarizeConfig reads
OAuth login state through the default store, so a developer's stored login
could flip these assertions. Point ZERO_OAUTH_TOKENS_PATH at a temp file
and force ZERO_OAUTH_STORAGE=file so an ambient keyring setting cannot pull
the OS keychain in either.
2026-10-15 15:40:43 +08:00
Fabio Tavares Leitão c88c77bf70 fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only (#1117)
* fix(workspaceindex): skip git-ignored and build-cache dirs in Scan only

Scan has a fixed file budget, and build output (Cargo target/, Python
virtualenvs, caches) could exhaust it before real source was reached:
repo-map reported truncation and ranked fingerprint files above code.

Inside a git work tree, Scan now asks git for the ignored set
(`git status --porcelain=v1 -z --ignored=matching .`), so the repo's own
.gitignore, nested ignore files, info/exclude and core.excludesFile all
apply, including names no fixed list anticipates (.locust_env/). Outside
a work tree, or when git is missing, older than 2.16, fails, or exceeds
5s, a fixed list of common build/cache directory names stands in.

ShouldSkipDir is unchanged: glob, grep, list_directory and path
autocomplete share it and must keep seeing target/, .venv/ and friends.
The lookup runs with GIT_OPTIONAL_LOCKS=0 so a per-turn scan never takes
.git/index.lock next to a concurrent git command.

Fixes #1108

* fix(workspaceindex): skip a rename's original-path field when parsing status

In `git status --porcelain=v1 -z`, a rename or copy record is followed by one
more NUL field holding the original path, with no XY prefix. The parser
inspected every field, so an original name beginning with "!! " read as an
ignored entry: after `git mv "!! a" b`, a tracked file "a" disappeared from
Scan.

Move the parsing into parseIgnoredStatus, which consumes that field for R or C
in either status column, and test it directly and end to end.

* fix(workspaceindex): disable repo fsmonitor and force untracked-files=normal for status

Scan's git status lookup must not run a repository's core.fsmonitor hook or fail
when status.showUntrackedFiles=no would reject --ignored=matching.

* fix(workspaceindex): ignore inherited git repository location

Git exports GIT_DIR and GIT_WORK_TREE to hooks. With those pointing at
another repository, Scan kept .locust_env/lib/x.py, which root's own
.gitignore excludes.

* fix(workspaceindex): quote the fsmonitor hook path in the regression

On Windows, sh treats an unquoted backslash path as escapes, so the hook
ran and the marker the test stats was never created. Require a status
without the override to create that marker first.

* fix(workspaceindex): pass the fsmonitor hook path to git with forward slashes

Smoke (windows-latest) failed TestGitIgnoredPathsDisablesRepoFsmonitor
because core.fsmonitor was set to a backslash script path. Git for Windows
hands that value to sh, which consumes the separators, so the hook never
runs and the marker is never created. The test now stores a quoted forward-slash
path. The product code is unchanged.
2026-10-15 15:34:28 +08:00
Vasanth T 640d936bfb fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls (#1133)
* fix(tui,cli): stop a repository's git config from running programs in Zero's own git calls

Zero runs git status and git diff on its own: the TUI's files sweep at
startup and after each command, its pull request diffstat, and the
scratch-file warning after a run. A repository's own config can name
programs those commands run (core.fsmonitor on every status, hooks when
the index is rewritten, clean filters and diff drivers assigned through
.gitattributes), so a workspace unpacked from an archive ran its own code
on the first turn, although Zero refuses the same workspace's project
hooks and plugins until the user trusts it.

internal/gitsafe builds those commands with core.fsmonitor and
core.hooksPath turned off and GIT_OPTIONAL_LOCKS=0, and drops the
repository-location variables git exports to hooks, using the same
environment for its config check and for the command. A repository whose
own config, or per-worktree config when extensions.worktreeConfig is on,
defines a filter or diff driver command is refused, and the callers
already treat that like a directory that is not a repository. The status
and diff calls also pass --ignore-submodules=dirty, because a submodule's
own config is not checked.

* test(gitsafe): keep the real-git setup off an inherited GIT_DIR

The setup helper in TestCommandAgainstRealGit ran git with the inherited
environment, so from a hook, or a shell that exports GIT_DIR or
GIT_WORK_TREE, git init and git config would act on the repository those
name instead of the temporary one. It now uses the same environment as
Command, and the test sets both variables to a decoy repository and checks
after each setup step that the decoy was not touched.
2026-10-15 15:33:32 +08:00
Kevin Codex 8e363fcaa5 fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps (#1145)
* fix(npm): wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps

npm applies a publish asynchronously: `npm publish` returns once the upload
is accepted, and the version appears later. In the 0.10.0 release the
@twigpine/zero wrapper appeared 9 seconds after publish-packages.sh stopped
checking for it, and the last platform payload about ten minutes after its
upload. Two things went wrong:

- The wrapper became visible before four of its five platform payloads, so
  installs in that window resolved aliases that 404 and fell back to the
  GitHub-release download. Ordering the uploads does not order what npm
  shows.
- The final `latest` check gave up after 25 seconds and failed the job even
  though every publish landed, which also skipped the @gitlawb/zero steps
  until a re-run.

publish-packages.sh now waits until npm shows every platform version before
it publishes the wrapper, then waits until `latest` is the wrapper version.
One deadline bounds the waits (ZERO_NPM_PUBLISH_WAIT_TIMEOUT, default 30
minutes). On timeout it fails before the wrapper goes out and says what npm
never showed. These lookups treat an error as "not yet", unlike the skip
decision, which still stops on anything but E404.

The test fake can now apply a publish only after a number of later lookups,
and records which aliased versions were missing when the wrapper was
published. Against the script as released in v0.10.0, the two new tests fail
the way the release did: the job exits 1 although everything landed, and the
wrapper goes out while its platform payload is not visible.

The two release steps that published 0.10.0 once more as @gitlawb/zero have
done their job and are removed; docs/NPM_PACKAGING.md and the comments that
pointed at them now describe what happened. The Go and wrapper handling of
the old name stays, since only a build installed under it can trigger it.

* fix(npm): validate the publish wait settings, and correct the legacy publishing claim

ZERO_NPM_PUBLISH_WAIT_TIMEOUT went into Bash arithmetic as typed. A leading
zero reads as octal (0600 waited 384 seconds), and 08 is an arithmetic error
that, in a script, does not stop it: Bash prints the error, leaves the
deadline unset and carries on, so the run only died at its first wait, after
the platform payloads were published. A negative timeout ended the wait
before it started, and a bad ZERO_NPM_PUBLISH_RETRY_DELAY only failed at the
first sleep, also after publishing began.

Both settings are now checked before anything is published: whole seconds
only, converted with 10# so a leading zero is decimal, bounded before the
conversion, and capped (timeout 86400, delay 600). A bad value fails with
the variable's name.

TestPublishPackagesReadsTheWaitTimeoutAsDecimal makes the script wait, which
is where the old code broke: against the reviewed script it publishes the
platform payload and then exits 1 on "08: value too great for base".
TestPublishPackagesRejectsAnInvalidWaitSetting covers -5, abc, 1e3, an
over-range and an overflowing timeout, and a non-numeric delay; the old
script published with five of them and crashed on the sixth with
"abc: unbound variable".

docs/NPM_PACKAGING.md said nothing could publish @gitlawb/zero again.
Removing the trusted publisher stops this workflow; a maintainer could
still publish by hand, and the doc now says that.
2026-10-15 13:30:11 +08:00
Vasanthdev2004 6fce00c070 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/acp/agent_test.go
2026-10-12 12:38:29 +05:30
Vasanthdev2004 ccc041c855 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
Conflicts in internal/acp/agent_test.go and
internal/cli/user_state_isolation_test.go were additions on both sides; both
are kept. Both sides also added the charmbracelet/x/ansi import to
internal/tui/view.go, which merged without a conflict marker but did not
compile; main's import, in the third-party group, is the one kept.
2026-10-12 11:33:17 +05:30
Vasanthdev2004 8608699881 fix(tui): end a full-auto offer on cross-session peer traffic
The offer made by shift+tab is valid only until the next user-visible input
transition, whatever delivers it. Paste, blur, mouse, the clipboard and
dictation all honoured that; peer delivery did not. A peer message raises an
approval prompt or lands in the inbox, a held message is released after a
mode sync, a status line or a peer error is appended to the transcript, an
approval expires and its prompt goes away. Each changes what the user is
looking at, so shift+tab, then peer traffic, then an unrelated ctrl+g still
entered full-auto with the confirmation several events behind them.

The offer is cleared for the whole peer message family at the dispatch
boundary in updateModel, not in the three handlers the report named. Those
were the ones that reproduced; the decision, expiry and error deliveries are
the same kind of transition and were armed the same way, and a rule written
per handler is a rule the next handler forgets.

isPeerDelivery is a closed list, and a test reads the package source for
every peer*Msg type and fails by name if one is not classified and not
driven. The regression sends one production message of each of the seven
types through Update, requires the offer gone and ctrl+g to leave the mode
alone, and then requires a fresh shift+tab to offer and confirm again, so it
cannot pass on a mode cycle that has become unreachable. The message that
needs approval also has to have raised its prompt. Reported by jatmn.
2026-09-21 13:37:01 +05:30
Vasanthdev2004 9f427ba172 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier 2026-09-21 13:31:50 +05:30
Vasanthdev2004 7be892028c fix(tui): only offer full-auto where the footer can show the confirmation key
The footer reserves columns for a docked pet before it renders the status line,
so at 24 and 30 columns the offer came out truncated with no ctrl+g in sight
while ctrl+g still confirmed. The armed flag now means what it says only when
the footer, at its effective width after the reservation, shows the key: the
decision is made on the same truncation the footer applies, an offer that
cannot be shown is not raised, and a live offer is withdrawn when the terminal
shrinks under it. Pinned through the complete View at 24 and 30 columns with a
pet, with 30 pet-free and 96 with a pet as controls.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 6f228bfa00 test(cli): isolate user state in the full-auto parity helper and pin it
execListToolsFor supplied a temporary cwd and a mocked provider config, but
runWithDeps filled the rest with production dependencies, so --list-tools
refreshed the developer's models cache, started their MCP servers and could
touch their config before returning. The helper applies isolateCLIUserState
first, and the helper-isolation table now covers it.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 89eab02ee2 fix(tui): deliver peer callbacks to the event loop through an ordered forwarder
Service.UpdateIdentity invokes the held-release handler synchronously for every
message that becomes deliverable, and the shell calls UpdateIdentity from inside
Update on the shift+tab and ctrl+g paths that change the permission class. The
handler called program.Send, which waits on the unbuffered message channel that
only the loop reads, and the loop was inside that Update: confirming full-auto
with a bypass-mode peer's message held froze the shell. Peer callbacks now
append to a FIFO drained by one goroutine, so the caller returns, order among
peer events is kept, and a late callback after exit returns on the cancelled
program context. The wiring is a function with a fake-able service so the
regression drives a real Bubble Tea program through the production shape.
2026-09-15 10:51:52 +05:30
Vasanthdev2004 c9d8ac91a0 Merge origin/main into split/permission-mode-and-classifier
Keeps both sides of the two model.go hunks: a mouse event and a paste still
answer a pending full-auto offer with not now, and then the terminal-attach
overlay from #1055 takes the event as main intends.
2026-09-15 10:26:35 +05:30
Vasanthdev2004 3b52175bd8 fix(tui): keep the full-auto offer visible before ctrl+g can accept it
The offer renders through modeLabel into the left chip, and several statuses
replaced that chip wholesale: an active recording, a transcription, and a model
download. Arming while the mic was live therefore showed REC while ctrl+g still
committed full-auto, so the user was asked to confirm something the footer never
put in front of them, and permission prompts went away.

offerOwnsStatusChip makes a live offer outrank the statuses that merely report
what is happening, in both statusLine branches, since the tiny and non-tiny
paths reach the chip differently. The two confirmations that ask their own
question still win, because those are what the user just pressed a key for.

The confirm key consults the same rule through offerConfirmable, which takes the
armed flag as an argument because the handler captures and clears m.unsafeArmed
before its switch runs. Sharing one expression is the point: an offer that
cannot be displayed is not one that can be accepted, so a status added later
cannot reopen the gap by taking the chip.

The regression asserts the implication through the real renderer and the real
handler, matrixed over the contending states and three widths: if the mode
became full-auto, the frame shown before the key contained the offer. It also
asserts the converse, so a fix that merely made confirmation unreachable would
fail it.

Reported by @jatmn.
2026-09-13 19:57:00 +05:30
Vasanthdev2004 f87a0afa10 Merge remote-tracking branch 'origin/main' into split/permission-mode-and-classifier
# Conflicts:
#	internal/acp/agent_test.go
2026-09-12 20:34:36 +05:30
Vasanthdev2004 32c3527794 fix(sandbox): keep framework builds under the network gate
Splitting LocalServer out of the network classification also dropped localServerPrograms from commandUsesNetwork, so next build, vite build, nuxt generate and astro check came back Network=false. Those CLIs run the repository config, plugins and application code during a build as surely as during a serve, and on the platforms where the approval gate is the network boundary an ordinary shell grant would have run them without the separate network decision main applies. The test that pinned Network=false for builds is replaced.

Network stays conservative for the whole framework inventory; LocalServer alone carries the serve/build distinction. The framework classifier now walks the positions a subcommand can occupy instead of reading the first operand, so a global option before the subcommand (vite --config vite.prod.ts build) no longer makes a build count as a server.

Regressions: build-like and option-bearing forms are Network=true and LocalServer=false at the analyzer, serving forms are both, and Engine.Evaluate still returns the network prompt for a framework build after a bash allow grant. Reported by @gnanam1990.
2026-09-12 11:47:34 +05:30
KRATOS 677b3f0c4f fix(sandbox): a flagged invocation classifies like its unflagged self at any arity
Raised by CodeRabbit. Skipping flags is not enough, because it does not skip the
words they CONSUME: `npm --prefix ./web install` resolved to "./web", the
option's VALUE, and stopped being recognised at all — so a command genuinely
fetching from the network lost the approval its plain spelling gets.

The first fix offered a fixed window of two positions. That covers exactly ONE
value-taking option, and a second walks straight past it. Measured before this
change:

  npm --prefix ./web install                            -> network (correct)
  npm --prefix ./web --loglevel warn install            -> NOT network
  npm --registry https://r.test --prefix ./web install  -> NOT network
  pnpm -C ./web --filter web add left-pad               -> NOT network

All four genuinely fetch.

ADJACENCY DECIDES NOW, because it is the actual grammar: an option can only
consume the word immediately after it. Every leading operand sitting right after
a flag might be that flag's value, so each opens another position; the first
operand NOT preceded by a flag cannot be anyone's value, and the window closes
there. The arity is counted rather than assumed, and no option is ever named —
which was the point of not enumerating them, since that is four tools each free
to add one.

The run has to be CONTIGUOUS, not a count of flags anywhere in the line.
Counting every flag lets TRAILING options widen the window back over words they
have nothing to do with, and three cases flip to false positives when it does:

  npm run --grep foo --tag dev            -> network AND localServer
  npm run test --reporter x --filter dev  -> network
  npm run build --a x --b start           -> network

The false positives the old cap protected against are unaffected, and for a
stronger reason than the cap gave: `npm run build --workspace dev` and `npm run
test -- --grep start` put an unflagged operand FIRST, so the window is one
position wide however many flags follow. The cap held only because two happened
to be short enough.

Tested as a PROPERTY rather than a list: each flagged form is paired against its
unflagged baseline, so a change that shifts both still fails. Eight pairs, the
"="-joined forms and repeated flags included. Two mutations, each caught —
restoring the fixed window mis-classifies five, dropping the contiguity
requirement widens three.

Also from a verification pass:

The doc comment claimed the cap at two was justified purely by argument-position
false positives and never said a second value-taking option defeated it. It now
describes what ships.

`npm --prefix dev install` reads a directory named "dev" as a script name. Pinned
rather than fixed: the ambiguity is the point of the window, Network is already
correct because the install really does fetch, and LocalServer only implies
Network — so the union is conservative in the safe direction.

An earlier note justified leaving internal/tui/session.go unnormalised by saying
the value feeds transcript rendering and the render cache. It feeds only the
cache fingerprint — grepping every reader of PermissionMode in that package
returns render_cache.go and nothing else — so the tradeoff described did not
exist. It is normalised now, and two spellings of one mode no longer evict the
cache for nothing.

The ACP tests asserted on ConfigOptions[1], a magic index that would move onto a
different option if the advertised order changed. They select by ID.

Rebased onto ad34dc8d. Pre-existing here and on main:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 b75e7cb2b7 fix(tui,sandbox): cancel the offer on a streaming partial, and derive the network fallback from the analyzer
Two findings, both follow-ons to fixes I made last round.

A streaming dictation partial did not cancel the full-auto offer. The final
transcript did, and my note there said starting dictation is a keypress that
already clears the arm, which does not cover this: the offer can be armed AFTER
dictation is running, the next partial rewrites the composer, and active
dictation is not a blocking modal, so an ordinary ctrl+g still confirmed with
the user several actions past the confirmation. Cleared at the dispatch boundary
rather than inside handleDictationPartial, so a future partial-delivery path
inherits the rule. The regression drives a genuinely live session and fails if
the partial never reaches the composer, so it cannot pass on a session-mismatch
early return.

The unparseable network fallback is meant to be a superset of everything the AST
path flags, and it was maintained as a separate inventory, so it drifted. The
analyzer accepts "py" as a Python launcher and the regex listed only python,
python2 and python3:

  py -m http.server 8000                                     parsed=true  network=true
  if "%OS%"=="Windows_NT" (py -m http.server 8000) else (..)  parsed=false network=false
  if "%OS%"=="Windows_NT" (python -m http.server ...) else .. parsed=false network=true

On Windows that approval IS the egress control. Rather than adding the alias and
leaving two lists to drift again, the launchers are one named inventory the
analyzer uses at both call sites, and the fallback test iterates it, so a
launcher added there fails immediately if the regex does not cover it.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 1cc95f39f3 fix(sandbox,tui,cli): close the unparseable network gap and the deferred-input arm
Five findings from review.

The unparseable fallback lost the serving forms on the reasoning that they bind
rather than fetch. The AST path sets Network for every recognized LocalServer
anyway, deliberately, because nothing consumes LocalServer yet and `npm run dev`
is matched by script name where the repository decides what dev and predev do.
So dropping them did not align the two paths, it split them: the POSIX parser
rejects Windows shell syntax the invoked shell accepts, and

  if "%OS%"=="Windows_NT" (npm run dev) else (npm start)

came back parsed=false network=false while every parseable spelling got network.
That is the approval gate disappearing on the platform where it is the only
egress control. Restored as a superset, with a regression that asserts both
spellings of the same intent classify alike and fails loudly if the parser ever
learns the syntax.

tea.PasteMsg cancels the full-auto offer, but a right-click paste does not
arrive that way: it starts a clipboard read, and shift+tab can reach the offer
while that read is in flight. The delayed delivery inserted text and left the
arm alive, so an ordinary ctrl+g afterwards turned permission prompts off with
the user several actions past the confirmation. Cleared before the branches, on
the image delivery too, and on transcribed dictation for the same reason.

Specialist registration read the raw CLI fields while execution read the
resolved mode, so `--permission-mode full-auto` composed a registry without Task
and swarm tooling while `--auto high` and `--full-auto` composed one with it. The
mode is resolved before the registry now and the predicate consumes it, so a new
alias cannot create another unequal capability set.

The full-auto warning matched the typed --permission-mode value against three
spellings while the resolver accepts four, so `--permission-mode full_auto` was
told --auto high was responsible. It echoes what was typed instead of matching a
list that has already drifted once.

Help and validation text still led with the deprecated spelling: --permission-mode
listed "unsafe" as its value, --auto high was described as enabling "unsafe
tools", and the root-flag errors named --skip-permissions-unsafe. All now present
full-auto as the mode and the primary flag; the deprecated alias stays where its
compatibility is the point.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 714ce649bc fix(tui): cancel the full-auto offer when the terminal loses focus
Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:23 +05:30
Vasanthdev2004 54613e4ff9 fix(sandbox,cli,tui): keep serving commands gated, and accept the canonical mode
All five of jatmn's findings.

The two P1s were the same mistake in two places: the LocalServer classification
was treated as a REPLACEMENT for Network when nothing consumes it. No policy or
runner code reads LocalServer, so classifying a serving command as local-only
granted it no host listener; it only removed the network approval it used to
get, and the command then ran under the default deny profile. On Linux that is a
network namespace and on macOS (deny network*), so `python -m http.server` and
`vite` started with no prompt and could not serve a preview to the operator's
browser.

The package-manager half is the sharper one. `npm run dev` is matched by SCRIPT
NAME, and the repository decides what `dev` and `predev` actually do; either can
curl before anything binds a port. On Windows the approval gate IS the network
protection, so inferring no-egress from a name lets that egress run unprompted.

LocalServer is now additive: the classification stays, because a scoped
host-listener path will want it, and the approval path stays until that path
exists. One line covers both findings, since both flow through the same
classification.

The PR's own tests asserted the premise being corrected, so they were rewritten
rather than flipped: building still counts as no egress, serving now asserts it
keeps BOTH the LocalServer classification and the network approval, and the
table rows moved with it.

The three P2s:

resolveExecPermissionMode accepted only the deprecated `unsafe`, so
`--permission-mode full-auto` failed usage validation before
NormalizePermissionMode was ever reached, while the warning code already had an
unreachable full-auto branch. It now accepts the canonical spelling and keeps
unsafe as the alias, and the usage text names full-auto.

newModel copied the raw mode through while the TUI tests only for
PermissionModeFullAuto, so an entry point handed the accepted legacy value
rejected ! shell escapes, published a prompting peer identity and chose low
self-correction, even though agent.Run normalized the same run to full-auto. One
value, two behaviours, decided by which layer looked at it. It is normalized at
the TUI boundary now.

Both completion inventories listed only --skip-permissions-unsafe, so no
generated shell script could suggest the documented --full-auto. Both now carry
the canonical flag alongside the deprecated alias.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:11:22 +05:30
Vasanthdev2004 30459e167b test(tui): move the plan-mode guard onto advancePermissionMode
The rebase renamed nextPermissionMode to advancePermissionMode, and main's
plan-mode test still called the old name, so internal/tui did not build.

Also asserts the second return value. Plan must never carry a full-auto OFFER
either, or two presses from Plan would reach the mode that turns permission
prompts off entirely, starting from the one mode that promises no mutation.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 874db9ff34 fix: make the full-auto rename reach the values that travel as data
The rename kept a Go alias for PermissionModeUnsafe, which covers callers that
name the constant. It does not cover a mode that moves as a string, and several
do, so those paths went on comparing against "unsafe" and stopped matching.

Three places were affected:

- swarm permissionRank did not rank "full-auto", so it scored 0, the strictest
  tier. A full-auto parent clamped its members harder than an ask parent.
- specialist specialistAutonomy did not match it either, so a full-auto parent's
  specialists dropped to read-only "low" autonomy.
- the agent loop compares Options.PermissionMode directly, so a caller passing
  the old spelling got prompts instead of full-auto.

All three fail safe, which is why nothing broke loudly. Added
agent.NormalizePermissionMode and applied it at the single point the loop reads
the mode off Options, and taught the swarm and specialist constants both
spellings. Their existing tables only listed "unsafe", which is exactly how the
rename passed review.

Separately, permission_mode_arm_test.go was never compiled. Go read the trailing
"_arm" as a GOARCH constraint and excluded the file from every amd64 build, so
the five tests covering the shift+tab full-auto offer had never run, locally or
in CI. Two panicked once they did. The file is renamed, the model construction
fixed, and a repository-wide test now fails on any _test.go whose name ends in a
GOOS/GOARCH token without an explicit build tag.

With those tests actually running, the offer gate had a real hole: unsafeArmed
was cleared only in the key handler, so a paste or a mouse click left the offer
live and a later ctrl+g committed full-auto with nobody having accepted it.
Paste and deliberate mouse actions now cancel it. Passive motion does not, since
terminals stream motion while tracking is on and cancelling on a twitch would
make the confirm key unreachable.

Also: commandRunsLocalServer matched on program name alone, so "next build" and
"vite build" claimed to bind a port while compiling. Nothing reads the flag yet,
which is why it could be wrong quietly. Fixed to require a serving subcommand,
falling back to bare-invocation behaviour when firstSubcommand lands on an
option value rather than a subcommand.

Remaining wording fixes name full-auto rather than unsafe in the retry reasons,
the exec warning, and the shell-escape notice.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 f608752821 fix: keep the old permission-mode constant names as deprecated aliases
The rename broke CI, and it broke it in the merge rather than on the branch:
this branch is ten commits behind main, CI builds the merge, and newer main
code still references PermissionModeUnsafe and PermissionUnsafe. Deleting a
constant that other work is actively using turns an ordinary merge into a
compile failure for whoever merges second.

Both old names are restored as deprecated aliases of the full-auto constants.
Same value, so behaviour is identical, and code on either side of the rename
compiles.

My local check missed this because `go test ./internal/...` returned
"ok (cached)" for internal/agent, and a cached result cannot catch a compile
error. Verified this time with -count=1 and against an actual trial merge with
origin/main rather than the branch alone.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 dbdd281d59 refactor: rename the unsafe permission mode to full-auto
"unsafe" was a judgement rather than a description, and it sat oddly beside
auto and ask. full-auto says what the mode does and reads as the obvious third
step: ask, auto, full-auto.

Not "unrestricted", which was the other candidate and is factually wrong. This
mode turns off permission PROMPTS. The OS sandbox stays on, the write jail
still holds, and a label implying nothing restrains the agent would be wrong in
the dangerous direction.

The on-disk value changes to "full-auto" and "unsafe" is accepted permanently
as an alias rather than migrated. That value lives in user configs and in
scripts, and a session silently falling back to auto because its saved mode no
longer parsed would be a confusing downgrade rather than a visible error.

--full-auto is the new flag; --skip-permissions-unsafe keeps working and is
documented as the deprecated spelling. The exec warning names "the full-auto
flag" generically, because either spelling reaches it and the bool does not
record which was typed: claiming --full-auto when the caller passed the old one
would be a small lie in a warning, which is the wrong place for one.

The warning also says "prompt-gated tools run without approval" rather than
anything broader, for the same reason the mode is not called unrestricted.

What deliberately does NOT change is the colour. The name can be calm; the
indicator should not be, so full-auto keeps the alarm style in the status bar
and the ctrl+g confirm gate is untouched.

The Go "unsafe" package is imported in fifteen files here, so the rename was
done by identifier token only and the imports were verified intact afterwards
rather than assumed.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:57 +05:30
Vasanthdev2004 eda9f3051c fix(sandbox): stop classifying local dev servers as network egress
`python -m http.server` was denied with "network access requires approval".
Serving files out of the workspace on a local port is not reaching out, and
treating it as egress made the single most common thing an agent is asked to
do while building something stop for an approval that protected nobody.

The analyzer already had a localServerPrograms map sitting beside
networkPrograms, but commandUsesNetwork returned true for it anyway, so the
distinction had been drawn and never acted on. It is acted on now:
AnalysisResult carries LocalServer separately, so the information is preserved
rather than dropped, and a caller that does care about inbound can still see
it.

Covers the same set consistently. `next dev` and `npm run dev` are the same
intent, so classifying one as binding and the other as fetching would have left
users hitting exactly the same wall through the other door. Both are
LocalServer now, across npm, pnpm, yarn and bun, as a direct subcommand and
behind `run`.

What still counts as network is unchanged: install, add, ci, create, publish,
login, exec, and every fetching program. `npx http-server` in particular stays
network, because npx downloads the package when it is missing; the test caught
that when a first pass flipped it, which is why the bind-versus-fetch pairs are
now pinned side by side in the table.

The unparseable fallback regex is updated to agree. Left alone it would have
flagged an obfuscated dev server for network while the same command written
plainly did not, which is the kind of inconsistency that is very hard to
explain to whoever hits it.

Being honest about the edge: some of these touch the network incidentally, and
`npm run dev` may install first. The claim is narrow, that BINDING is not
EGRESS, not that dev tooling is inert. Anything that actually fetches still
matches through its own program or subcommand.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:22 +05:30
Vasanthdev2004 1bcb2bfadd feat(tui): put unsafe mode in the shift+tab cycle behind a confirm key
Unsafe was reachable only via --skip-permissions-unsafe at launch, which is
bad discoverability: the mode exists, works, and nothing in the TUI tells you
so. It is now the third position in the shift+tab cycle, alongside auto and
ask.

What it is NOT is reachable by repeating a navigation key. Unsafe turns
permission prompts off entirely, so shift+tab only ever OFFERS it; committing
takes ctrl+g while that offer is live. Press shift+tab again instead and the
offer is declined and the cycle continues to auto, so every mode stays
reachable with shift+tab alone.

The offer lives on the model across keypresses, and that is the part worth
being careful about: a stale flag would turn a later innocent ctrl+g into a
silent drop into unsafe. So it is cleared unconditionally at the top of the key
handler and re-armed only by the shift+tab branch. Forgetting a path therefore
cancels the offer rather than leaving it live, which is the harmless direction
to be wrong in. Tests drive the real handler for eight different cancelling
keys and assert the consequence, not just the flag.

ctrl+g is guarded in the case expression rather than the body, so without a
live offer the key is not consumed at all and falls through to whatever would
normally handle it. That is what stops it being a standalone shortcut into
unsafe, and there is a test that presses it repeatedly from every other mode.

Leaving unsafe stays one press and is never gated. Getting stricter should not
need confirming.

Returning to auto from ask now costs two presses, since it passes through the
offer. That is inherent to a third position in a cycle rather than a
regression, and TestShiftTabCyclesPermissionMode was updated to walk the whole
loop rather than assume two positions.

An earlier draft of this had the second shift+tab commit unsafe, which silently
removed ask -> auto from the cycle entirely. TestPermissionModeCycleNeverReaches
UnsafeOnItsOwn walks the full loop and asserts every mode stays reachable, so
that cannot come back unnoticed.

Origin-Session: local-c962d7 | Claude Code | 5 prompts
Origin-Snapshot: 3bb420a0a97b
2026-09-12 10:10:22 +05:30
723 changed files with 8804 additions and 2352 deletions

No files matched your search

+4 -49
View File
@@ -206,55 +206,10 @@ jobs:
# 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`). Versions already on the registry are skipped, so "Re-run
# failed jobs" is safe after a partial publish.
# `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
shell: bash
run: scripts/npm/publish-packages.sh dist/npm
# One final release under the pre-rename name @gitlawb/zero. Zero 0.9.0's
# `zero upgrade` runs `npm install -g @gitlawb/zero@latest`, and people
# reinstall the old name from shell history and old tutorials, so this
# release is published once more under it: the same verified artifacts,
# assembled as @gitlawb/zero, which the binary and wrapper recognise and
# answer with the switch steps (docs/NPM_PACKAGING.md). Runs after the
# @twigpine/zero publish, so the package it points at already exists.
#
# The gate keeps this to a single release: it is owed only while the old
# name's latest is still the last pre-rename version, or is this version
# (a re-run after it went out). After this release, `npm deprecate` the
# old package and delete these two steps.
- name: Check whether the final @gitlawb/zero release is owed
id: legacy-name
shell: bash
env:
LAST_PRE_RENAME_VERSION: 0.9.0
run: |
version="$(node -p "require('./package.json').version")"
latest=""
for _ in 1 2 3; do
latest="$(npm view @gitlawb/zero dist-tags.latest 2>/dev/null || true)"
if [ -n "$latest" ]; then
break
fi
sleep 10
done
if [ -z "$latest" ]; then
echo "::error::could not read the latest dist-tag of @gitlawb/zero from the registry" >&2
exit 1
fi
if [ "$latest" = "$LAST_PRE_RENAME_VERSION" ] || [ "$latest" = "$version" ]; then
owed=true
else
owed=false
fi
echo "@gitlawb/zero latest is ${latest}; final release under the old name owed: ${owed}"
echo "owed=${owed}" >> "$GITHUB_OUTPUT"
- name: Publish the final @gitlawb/zero release
if: ${{ steps.legacy-name.outputs.owed == 'true' }}
shell: bash
run: |
node scripts/npm/build-platform-packages.mjs \
--artifacts-dir dist/artifacts --out-dir dist/npm-legacy --package-name @gitlawb/zero
scripts/npm/publish-packages.sh dist/npm-legacy
+1 -1
View File
@@ -1 +1 @@
{".":"0.10.0"}
{".":"0.11.0"}
+20
View File
@@ -6,6 +6,26 @@ 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.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)
+17 -9
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-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>
<br>
@@ -158,7 +158,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### From source
Source builds require Go 1.26.9+.
Source builds require Go 1.26.10+.
```bash
git clone https://github.com/Twigpine/zero.git
@@ -173,17 +173,18 @@ If you are testing before the first public release, build from source:
go build -o zero ./cmd/zero
```
On Linux, build the sandbox helper too if you want native sandboxing:
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:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
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.
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.
More install details: [docs/INSTALL.md](docs/INSTALL.md).
@@ -216,6 +217,7 @@ 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=...
```
@@ -244,6 +246,12 @@ To configure GMI Cloud directly, run:
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:
+13 -6
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-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>
<br>
@@ -136,7 +136,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
### 从源码构建
源码构建需要 Go 1.26.9+。
源码构建需要 Go 1.26.10+。
```bash
git clone https://github.com/Twigpine/zero.git
@@ -150,14 +150,14 @@ go run ./cmd/zero
go build -o zero ./cmd/zero
```
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
在 Linux 上,原生沙箱需要 Bubblewrap。主程序 `zero` 在找不到 `zero-linux-sandbox` 时会以自身作为辅助进程重新执行。同目录或 PATH 上的独立 helper 仍优先:
```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` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
将 `zero` 放在 `PATH` 上(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
@@ -186,6 +186,7 @@ export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
@@ -200,6 +201,12 @@ zero providers setup longcat --set-active
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`。
+5 -5
View File
@@ -2,8 +2,8 @@
## Questions, setup help, configuration
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
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
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/Gitlawb/zero/issues/new/choose). If you are not
[bug report](https://github.com/Twigpine/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/Gitlawb/zero/discussions/categories/ideas). Zero is
[Ideas](https://github.com/Twigpine/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/Gitlawb/zero/security/advisories/new)
[GitHub Security Advisories](https://github.com/Twigpine/zero/security/advisories/new)
(see SECURITY.md).
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/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/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/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/Gitlawb/zero/internal/review"
"github.com/Twigpine/zero/internal/review"
)
func main() {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/release"
"github.com/Twigpine/zero/internal/release"
)
func main() {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"os/exec"
"syscall"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func main() {
+5 -1
View File
@@ -3,9 +3,13 @@ package main
import (
"os"
"github.com/Gitlawb/zero/internal/cli"
"github.com/Twigpine/zero/internal/cli"
"github.com/Twigpine/zero/internal/sandbox"
)
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))
}
+27 -14
View File
@@ -185,13 +185,19 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.9+ and it does not go through the
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
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
- 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
[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.
@@ -212,25 +218,32 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.9+.
Source builds require Go 1.26.10+.
### Sandbox Helpers For Source Builds
Release archives include the platform sandbox helpers. If you build directly
from source, build the helpers you need:
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:
Linux:
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`:
```bash
go build -o zero ./cmd/zero
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 # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
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.
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.
macOS uses the system sandbox and does not need an extra helper binary.
+26 -44
View File
@@ -130,9 +130,14 @@ 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 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.
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).
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
@@ -161,54 +166,31 @@ 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 does not reach 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 is published once more under the old name, as a bridge:
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 publish job assembles the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and publishes
them with the same script, after the `@twigpine/zero` publish. A gate runs
it only while the old name's `latest` is still 0.9.0 (or is this version,
on a re-run), so it cannot happen twice.
- 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.
- Updating from 0.9.0 now installs a real version, so npm also shows the
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.
### Runbook
Before the first release under the new name:
1. Create the package, since npm only attaches a trusted publisher to a
package that exists: publish an empty `@twigpine/[email protected]` by hand with
`npm publish --access public --tag bootstrap`, then
`npm deprecate @twigpine/[email protected] "Placeholder; install @twigpine/zero@latest"`.
2. Trust this workflow for it:
`npm trust github @twigpine/zero --repo Twigpine/zero --file release-artifacts.yml --env npm-publish --allow-publish`.
3. Point the old package's trusted publisher at this repository too, for the
bridge publish: `npm trust list @gitlawb/zero`, revoke the existing entry
with `npm trust revoke @gitlawb/zero --id <id>`, then run the same
`npm trust github` command for `@gitlawb/zero`.
4. Confirm Settings → Environments → `npm-publish` has a required reviewer.
The environment exists either way; without a reviewer the job does not
wait for approval.
After the release, once both packages show 0.10.0 as `latest`:
1. `npm deprecate @gitlawb/zero "Zero moved to @twigpine/zero. Switch with: npm uninstall -g @gitlawb/zero && npm install -g @twigpine/zero"`
(no version range, so every version, including the platform payloads,
carries it). Never unpublish: existing installs resolve their platform
payload through these versions.
2. Revoke the old package's trusted publisher (`npm trust revoke`), so
nothing can publish under that name again.
3. Delete the two `@gitlawb/zero` steps from `release-artifacts.yml`. The Go
and wrapper handling of the old name can stay; it only activates in a
build installed under that name.
`@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.
## Runbook: bumping the vendored helpers
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
module github.com/Twigpine/zero
go 1.26.9
go 1.26.10
require (
charm.land/bubbles/v2 v2.1.1
+40 -24
View File
@@ -13,18 +13,18 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/providercatalog"
"github.com/Twigpine/zero/internal/providermodelcatalog"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/usage"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Deps are the ZERO capabilities the ACP Agent drives. The CLI fills these with
@@ -528,8 +528,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
queue := func(input sessions.AppendEventInput) {
pendingEvents = append(pendingEvents, input)
}
queue(messageEvent("user", userText))
visionCache := make(map[string]bool)
var visionCacheMu sync.Mutex
supportsVision := func(modelID string) bool {
@@ -553,6 +551,9 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
images = nil
}
// Match the TUI: only images actually sent to the model enter history.
attachments := sessions.MessageAttachments{Images: len(images)}
queue(messageEvent("user", userText, attachments))
opts := agent.Options{
Cwd: sess.cwd,
@@ -593,7 +594,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
if result.FinalAnswer != "" {
queue(messageEvent("assistant", result.FinalAnswer))
queue(messageEvent("assistant", result.FinalAnswer, sessions.MessageAttachments{}))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
@@ -614,7 +615,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
// one file write, which is the contiguity restored here — not a disk
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
persistenceErr := a.persistTurn(sess.id, pendingEvents)
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
sess.appendHistory(turnRecord{user: attachments.WithContent(userText), assistant: result.FinalAnswer})
a.warnPersistence(
note,
"save session history",
@@ -699,7 +700,13 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
// Normalized before the switch, for the same reason the TUI normalizes at its
// own boundary: the mode arrives as data, so the accepted legacy "unsafe"
// spelling reaches this switch unrewritten and falls through to default. The
// client that asks for the disallowed mode by its documented old name is then
// told it does not exist, which reads as "try another spelling" rather than
// "this door is closed over ACP".
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.ModeID))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
@@ -707,7 +714,7 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
// Unsafe = run every tool with no prompt. The TUI gates this behind an
// explicit --skip-permissions-unsafe operator flag; an editor client must
// not be able to grant itself unconfined, no-prompt access over the wire.
@@ -737,14 +744,17 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
// Normalized like handleSetMode above: this is the second advertised mode
// door, and an alias that only one of them rewrites is a difference
// between two paths that are meant to be the same contract.
mode := agent.NormalizePermissionMode(agent.PermissionMode(p.Value))
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
case agent.PermissionModeFullAuto:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
default:
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
@@ -930,10 +940,14 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput
return err
}
func messageEvent(role, content string) sessions.AppendEventInput {
func messageEvent(role, content string, attachments sessions.MessageAttachments) sessions.AppendEventInput {
payload := map[string]any{"role": role, "content": content}
if role == "user" && (attachments.Images > 0 || attachments.Documents > 0) {
payload["attachments"] = attachments
}
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": role, "content": content},
Payload: payload,
}
}
@@ -1094,14 +1108,16 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
continue
}
var msg struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
Attachments sessions.MessageAttachments `json:"attachments"`
}
if json.Unmarshal(raw, &msg) != nil {
continue
}
switch msg.Role {
case "user":
msg.Content = msg.Attachments.WithContent(msg.Content)
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
if havePending {
records = append(records, turnRecord{user: pendingUser})
+96 -12
View File
@@ -19,13 +19,13 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// fakeProvider streams a canned assistant message and ends the turn — enough to
@@ -205,8 +205,8 @@ func TestACPEndToEndPrompt(t *testing.T) {
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
}
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
if mode := configOptionByID(t, newRes.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("mode config option = %+v", mode)
}
// session/prompt
@@ -455,7 +455,7 @@ func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
}
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
if mode := configOptionByID(t, loaded.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) {
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
}
}
@@ -654,7 +654,7 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeFullAuto)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
}
// An unknown mode must be rejected.
@@ -663,6 +663,70 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}
// The legacy "unsafe" spelling has to reach the full-auto arm of both mode
// doors, not fall through to "unknown mode".
//
// It is an accepted alias everywhere else in the tree, so a client that sends it
// is naming the mode ACP deliberately refuses. Unnormalized, both handlers
// answered "unknown mode: unsafe" — the mode was still refused, so nothing
// escalated, but the client was told the wrong thing about why: that the mode
// does not exist, rather than that this transport will not grant it. That
// invites retrying under another spelling instead of stopping.
//
// Both doors are checked because they are two entry points onto one contract,
// and the previous round of this bug was exactly one layer normalizing while
// another did not.
func TestACPRejectsLegacyUnsafeAliasAsDisallowedNotUnknown(t *testing.T) {
h := newHarness(t, testDeps(t))
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
assertDisallowed := func(door string, err error) {
t.Helper()
if err == nil {
t.Fatalf("%s: legacy unsafe alias was accepted over ACP", door)
}
if !strings.Contains(err.Error(), "mode not permitted over ACP") {
t.Errorf("%s: error = %q, want the disallowed-mode message", door, err)
}
if strings.Contains(err.Error(), "unknown mode") {
t.Errorf("%s: error = %q, want the alias resolved instead of reported as unknown", door, err)
}
}
// Spelled literally on purpose: this is the value that travels over the wire,
// and the Go alias for it is the canonical string, not the legacy one.
const legacyModeID = "unsafe"
assertDisallowed("set_mode", h.client.Call(ctx, MethodSessionSetMode,
SetSessionModeParams{SessionID: newRes.SessionID, ModeID: legacyModeID}, &SetSessionModeResult{}))
assertDisallowed("set_config_option", h.client.Call(ctx, MethodSessionSetConfigOption,
SetSessionConfigOptionParams{SessionID: newRes.SessionID, ConfigID: configIDMode, Value: legacyModeID}, &SetSessionConfigOptionResult{}))
// Refusing must leave the session where it was, not in a half-applied state.
var configured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured); err != nil {
t.Fatalf("set_config_option ask: %v", err)
}
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAsk) {
t.Fatalf("mode after the refusals = %q, want ask", got)
}
// A genuinely unknown mode must still say so, or the assertions above would
// hold for a handler that answered "not permitted" to everything.
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{})
if err == nil || !strings.Contains(err.Error(), "unknown mode") {
t.Fatalf("set_mode bogus = %v, want an unknown-mode error", err)
}
}
// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
@@ -960,7 +1024,11 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")},
SessionID: created.SessionID,
Prompt: []ContentBlock{
TextBlock("failed question"),
ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"),
},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
@@ -2542,6 +2610,22 @@ func payloadString(payload any, key string) string {
return value
}
// configOptionByID finds an advertised option by its identity rather than by
// position. Asserting on ConfigOptions[1] made every one of these tests depend
// on the ORDER the options are advertised in: reordering them would move the
// assertion onto a different option, or panic, instead of failing with a
// message about the option it means.
func configOptionByID(t *testing.T, options []SessionConfigOption, id string) SessionConfigOption {
t.Helper()
for _, option := range options {
if option.ID == id {
return option
}
}
t.Fatalf("no config option %q was advertised; got %+v", id, options)
return SessionConfigOption{}
}
// Load is best effort about a history it cannot read, but a session that zero
// sessions prune holds is refused by load and resume alike, and not published:
// this process could not hold it open while prune may be removing it.
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
+181
View File
@@ -0,0 +1,181 @@
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)
}
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
"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
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"encoding/json"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
// permission.go maps ZERO's permission prompt model onto ACP's
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Twigpine/zero/internal/agent"
)
func TestBuildPermissionOptions(t *testing.T) {
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
@@ -83,7 +83,7 @@ func browserToolDetails(name string) (*BrowserToolDetails, bool) {
}
}
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
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
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
"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
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// providerCallingAskUserThenAnswer returns a mock provider whose first turn calls
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/testutil"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Session compaction.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/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/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Compaction preservation.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/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/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/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/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
var markerPattern = regexp.MustCompile(`msg-\d+`)
+7 -7
View File
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// --- Pure Compact() tests -------------------------------------------------
@@ -365,7 +365,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 1000, // ~250 token 80% threshold; easily exceeded
CompactionPreserveLast: 2,
})
@@ -393,7 +393,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 0, // disabled
})
if err != nil {
@@ -534,7 +534,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
// only the reactive path can save the run.
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
Trace: recorder,
@@ -620,7 +620,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
var deltas []string
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
ContextWindow: 10_000_000,
CompactionPreserveLast: 2,
OnText: func(delta string) { deltas = append(deltas, delta) },
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/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/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Context budget.
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestMeasureContextSplitsByCategory(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/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/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
+2 -2
View File
@@ -2,8 +2,8 @@
package agent
import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Twigpine/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/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Guardrail thresholds for the agent loop. These keep a runaway model from
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
)
func TestAppendHookFeedbackFormatsOutput(t *testing.T) {
+19 -15
View File
@@ -11,15 +11,15 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/modelregistry"
"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"
"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"
)
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
@@ -188,7 +188,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
registry = tools.NewRegistry()
}
permissionMode := options.PermissionMode
// Normalized once, here, because this is the single place the run's mode is
// read off Options; every comparison downstream takes it as a parameter from
// this local. Normalizing at the individual comparison sites instead would
// leave the next one added to be found by whoever it breaks.
permissionMode := NormalizePermissionMode(options.PermissionMode)
if permissionMode == "" {
permissionMode = PermissionModeAuto
}
@@ -1282,7 +1286,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return executeRequestPermissions(ctx, call, args, permissionMode, options)
}
permissionGranted := permissionMode == PermissionModeUnsafe
permissionGranted := permissionMode == PermissionModeFullAuto
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
permissionGranted = true
}
@@ -1604,10 +1608,10 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
}
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits unsandboxed retry", nil, nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
@@ -1658,9 +1662,9 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
}
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
if permissionMode == PermissionModeFullAuto {
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
return retry, nil, true, PermissionDecisionAllow, "full-auto permission mode permits sandbox network retry", nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// imageEchoProvider records the messages of the first request it receives, then
+10 -10
View File
@@ -13,13 +13,13 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/specmode"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type mockProvider struct {
@@ -2737,7 +2737,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
@@ -2763,7 +2763,7 @@ func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
if event.Action != PermissionActionAllow || !event.PermissionGranted {
t.Fatalf("expected unsafe approval permission event, got %#v", event)
}
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeUnsafe {
if event.ToolName != "write_file" || event.PermissionMode != PermissionModeFullAuto {
t.Fatalf("unexpected unsafe approval metadata: %#v", event)
}
}
@@ -2968,7 +2968,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
result, err := Run(context.Background(), "write outside", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
PermissionMode: PermissionModeFullAuto,
Autonomy: "high",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
type safetyTool struct {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// midStreamAbortProvider connects successfully but emits a transport-abort
@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type propagationOutputTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"sync"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// countingSchemaTool records how many times its Parameters() schema is read, so a
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
func TestPermissionScope(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func planUpdateTurn(status string) zeroruntime.CollectedStream {
@@ -4,8 +4,8 @@ import (
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
+1 -1
View File
@@ -1,7 +1,7 @@
package agent
import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
// profileController observes per-turn run signals and decides at most one
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sort"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// promptSubstrings are the cacheable components of the prompt a run sends to a
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Stale tool-output pruning reclaims context at ZERO token/latency cost before
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// bigToolResult builds a tool-result message with approximately tokens worth of
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/errhint"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// Mid-stream reconnect: a long autonomous task (a big refactor, a swarm member,
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// flakyProvider fails the first failBefore connect attempts with failErr, then
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
+4 -4
View File
@@ -7,10 +7,10 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Twigpine/zero/internal/lsp"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/verify"
)
// defaultSelfCorrectMaxAttempts bounds how many corrective rounds a single run
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Twigpine/zero/internal/lsp"
"github.com/Twigpine/zero/internal/verify"
)
type fakeVerifier struct {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
+4 -4
View File
@@ -9,10 +9,10 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/repomap"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/workspaceseed"
)
// coreSystemPrompt is the de-branded coding-craft instruction set: identity,
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sandbox"
)
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
type taskStatus string
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestTaskStateReplayIsDeterministic(t *testing.T) {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestNonVisionImageNoticeQualifiesBudgetRecovery(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// sessionProvider adapts a TurnSession back to the Provider interface so the
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// fakeTurnSession wraps an inner provider for streaming while counting the
+47 -11
View File
@@ -2,14 +2,15 @@ package agent
import (
"context"
"strings"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"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"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"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"
)
type Message = zeroruntime.Message
@@ -22,10 +23,24 @@ type PermissionAction string
type PermissionDecisionAction string
const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeFullAuto PermissionMode = "full-auto"
// PermissionModeUnsafe is the former name of PermissionModeFullAuto.
//
// Kept as an alias rather than deleted because this constant is referenced
// across packages and by code that lands independently of this branch, so
// removing it turns an ordinary merge into a compile failure for whoever
// merges second. It is the same value, so behaviour is identical either way.
//
// Deprecated: use PermissionModeFullAuto.
PermissionModeUnsafe = PermissionModeFullAuto
// legacyFullAutoPermissionMode is the raw string full-auto used to be. The
// Go alias above keeps SOURCE compatible, but a value that arrives as data
// rather than as an identifier is unaffected by it, so it still needs
// mapping. See NormalizePermissionMode.
legacyFullAutoPermissionMode PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode. It applies
// to the CURRENT session (unlike spec-draft, which drafts in a separate
// session): the agent may inspect the workspace and shape the plan with
@@ -518,3 +533,24 @@ func (result Result) TruncationNotice() string {
return "Response ended early (" + result.FinishReason + ") and may be incomplete."
}
}
// NormalizePermissionMode maps a permission mode that arrived as data onto its
// canonical value.
//
// full-auto was renamed from "unsafe", and a Go alias only covers callers that
// name the constant. A value that travels as a string does not go through the
// alias: it comes off a command line, out of a swarm member spec, or across a
// protocol, and after the rename "unsafe" stops matching the comparisons the
// loop makes against PermissionModeFullAuto. The mode then reads as unrecognized
// and the run silently behaves as though full-auto was never requested.
//
// Only the legacy spelling is rewritten. Unknown values are returned unchanged
// rather than folded to a default, because this package has modes the sandbox
// layer does not know about (spec-draft, member-auto) and quietly rewriting one
// of those would be a worse bug than the one being fixed.
func NormalizePermissionMode(mode PermissionMode) PermissionMode {
if PermissionMode(strings.ToLower(strings.TrimSpace(string(mode)))) == legacyFullAutoPermissionMode {
return PermissionModeFullAuto
}
return mode
}
@@ -6,7 +6,7 @@ import (
"io"
"os"
"github.com/Gitlawb/zero-fixture/internal/release"
"github.com/Twigpine/zero-fixture/internal/release"
)
func smokeTarget(alreadyBuilt bool) string {
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Gitlawb/zero-fixture
module github.com/Twigpine/zero-fixture
go 1.22
Loaded 100 of 723 files, more files were not shown because too many files have changed in this diff. Show more