Compare commits

..
Author SHA1 Message Date
KRATOS 227fac9ef6 fix(acp): commit a completed turn as one store batch
A completed turn was buffered until its outcome was known and then persisted
one event per call. Each call took and released the store's session lock, so a
second ACP instance holding the same session -- its turnMu is its own, only the
store is shared -- could append its whole turn between two of them: user A,
user B, answer B, answer A. Every write was individually locked and the log
was valid, but a fresh load pairs prose by order and reconstructed three wrong
turns from two right ones. Before this branch the user and assistant events
went to AppendEvents together; buffering until the outcome is known was the
right change and splitting the commit into singleton writes was not.

The whole buffer now goes through one Store.AppendEvents call, which writes the
batch under one lock and one file write. That is the contiguity being restored:
not a disk transaction, and not a promise about a crash mid-fsync. The outcome
gate still runs first; a hard failure still commits nothing; cancellation still
commits through the same boundary. A persistence failure is now the failure of
the whole batch, so there is no dependent suffix to protect and the rewritten
regression asserts that nothing of the turn is durable, the warning is raised,
and a fresh load replays none of it.

The two-instance regression holds both writers at the commit boundary until
each has arrived and then delegates to the real batch API under the real lock;
it observes what production hands it rather than batching anything itself, so
reverting to singleton writes fails it before any interleaving has to be
provoked. The restored prompt keeps both prose pairs and no historical tool
output.

Also normalizes the expected workspace in the listing test through
filepath.Clean, matching the resolver's output contract, so a TMPDIR spelled
with a ".." component no longer fails a correct result.

Rebased onto main to pick up the ACP browser metadata, specialist model
restoration and worktree pointer protection changes. Both reported by @jatmn.
2026-09-12 17:18:43 +05:30
KRATOS 9515f381ff fix(acp): preserve replay and restore identity 2026-09-12 17:18:42 +05:30
KRATOS fb8f1e00b7 fix(acp): restore session lifecycle boundaries 2026-09-12 17:18:42 +05:30
KRATOS c0bf29a8c4 fix(acp): preserve session restore invariants 2026-09-12 17:18:42 +05:30
KRATOS 718ffc899a test(acp): bound replay notification waits 2026-09-12 17:18:42 +05:30
KRATOS 386aa2585c fix(acp): persist tool activity for session replay 2026-09-12 17:18:42 +05:30
KRATOS 07bb5820fe fix(acp): resume the conversation a session became, and fail closed when it cannot
Three defects in ACP session restoration, all reported by @jatmn.

loadHistory read the raw event log and kept only EventMessage. A compacted
session stores its original prefix alongside an EventCompaction naming the
events it replaced and carrying their summary, so restoring from the raw log
replayed superseded turns AND dropped the summary that replaced them. It now
reads the same rehydrated view the TUI and exec paths use, and explicitly
projects the compaction summary -- switching readers alone would still drop it,
because rehydration substitutes the compaction event in place of what it
replaced.

historyErr only suppressed replay and raised a warning: the session was
registered and reported ready regardless, so an unreadable events file left the
caller holding a live, promptable session ID whose next prompt ran as a fresh
conversation under the old identity. Resume now fails. Load keeps the
best-effort policy deliberately rather than by inheriting the shared helper.

Tool calls and their results were dropped from session/load, so a restored
transcript showed prose asserting edits with no record that any tool ran. They
now replay through the same toolCallStart/toolCallResult mapping a live turn
uses, keyed on the stored toolCallId so results pair with their calls. They do
not enter turnRecord, so load and resume still consume the same effective
history. Resume stays replay-free.

Also asserts that an unset sessionCapabilities is omitted rather than
serialized as null, raised by CodeRabbit.
2026-09-12 17:18:42 +05:30
KRATOS 62672974f6 fix(acp): activation requires an absolute cwd in the REQUEST, not just the record
Reported by @jatmn.

ResumeSessionParams is a type alias for LoadSessionParams, so JSON decoding turns
an OMITTED resume cwd into an empty string. That blank reached the shared
activation path, whose blank-cwd fallback substitutes meta.Cwd — so
{"sessionId":"known"} activated a persisted session, even though ACP v1 requires
session/resume to carry an absolute working directory.

This is a different hole from the persisted-cwd one fixed earlier on this branch.
That guard asks whether the STORED workspace is identifiable; this asks whether
the CALLER named one at all. The earlier fix does not cover it, because a stored
absolute cwd passes that check and the blank request then silently inherits it.

requestedWorkspace validates the request's own cwd before anything reaches the
fallback: absent, empty and whitespace-only are all invalid params, and so is a
relative path. Applied to both activating methods rather than to resume alone —
load's omitted-cwd fallback was inheriting the same way, and leaving one door
open is how this class survived the last fix.

Mutation-checked at the wire, which is where the defect lives: making the blank
case return no error compiles and fails the regression on four separate requests
— session/load and session/resume, each with cwd omitted and with cwd blank. A
Go-level test would not have caught it, since the defect is in decoding an absent
field.

Two notes from a verification pass, neither a defect:

Error precedence changed: a blank cwd with an UNKNOWN session id now reports the
cwd problem instead of "session not found". Kept deliberately and pinned — it
stops the server confirming whether a session exists to a request that named no
workspace.

AdditionalDirectories is declared on two params structs and consumed nowhere in
the repo. Not a hole today, but it is the same shape — client-supplied paths with
no absoluteness rule — so the field now carries a note saying it must go through
requestedWorkspace when wired up.

Rebased onto ad34dc8d. go test -race ./internal/acp/ -count=5: clean.
Pre-existing here and on main: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS 3447fd773e test(acp): the resume guard is asserted through both activating methods
Raised by CodeRabbit: the test is named for resume and called session/load.

session/load and session/resume are separate entry points that today share
activatePersistedSession, so an assertion through either one passes while the
guard holds — but the name promised a surface it was not touching. Both are now
named explicitly, which keeps that true: if resume is ever given its own path,
this fails rather than quietly covering half of what it claims to.

With the guard removed, both methods accept a relative persisted workspace on
the no-cwd fallback. The named-workspace case was already refused by the
existing mismatch check; the fallback was the only door open, and it is open on
both.

Not taken in this PR, from the same review: threading a rooted directory handle
through ResolveWorkspaceRoot and workspace construction so a root rename or link
swap cannot redirect later file operations. That is a real question and a
pre-existing one — this change adds a refusal and no path handling — but it is a
capability refactor across workspace and tool access with its own race test, not
something to fold into a session-list fix. Worth its own issue.

Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS 9e6b732e2d fix(acp): a relative persisted workspace is omitted, not rebased
Reported by @anandh8x. Resolving a stored relative cwd does not recover the
session's workspace, it invents one: ResolveWorkspaceRoot joins it against
whatever directory the ACP server happens to be running in, and that invented
absolute path was then advertised as the session's workspace and accepted as
its home on resume.

Reproduced on f2c6fc9a, a session persisted with cwd ".":

  LISTED legacy-rel as cwd="/Users/kratos/dev/f914/internal/acp"
  resume with an UNRELATED workspace -> err=... cwd does not match its persisted workspace
  resume with NO cwd (falls back to ".") -> err=<nil>

The mismatch check does its job when the client names a workspace, so the only
opening was the fallback path, where the rebased value was compared against
itself and always agreed. A conversation created for one project could be
resumed against another project's files, configuration and tools.

Both doors now take the same guard: handleSessionList omits an entry whose
persisted cwd is not absolute, and activatePersistedSession refuses one rather
than resolving it. The original base is not knowable from the metadata, so
guessing at it is not an option a fix can take.

This reverses an earlier assertion in TestSessionListResolvesEveryWorkspace,
which expected the relative entry to be normalised and retained. That was
requested in review on the grounds that dropping it loses a resumable session.
It does, but the entry was never resumable into its own workspace, only into
this process's. The test now asserts it is dropped, and a new
TestResumeRefusesARelativePersistedWorkspace covers the fallback path that the
listing filter alone leaves open.

Both guards mutation-checked: removing either one fails its test.
Pre-existing on this branch and on its merge-base, unrelated to this change:
TestRunDoctorFormatsRedactedProviderDiagnostics and
TestRunDoctorConnectivityProbesProvider both exit 3 in this environment.

Origin-Session: local-8cd239 | Claude Code | 11 prompts
Origin-Snapshot: 365efe3045f2
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS d3406f4c3b test(acp): assert the relative entry is retained, not merely absolute
CodeRabbit's catch, and the test was genuinely weaker than it looked. It checked
that the deleted workspace was gone, the live one kept, and every listed cwd
absolute — all of which a "fix" that simply DISCARDED any non-absolute entry
would satisfy, while losing a resumable session.

Presence is now asserted separately from spelling: the relative entry must still
be listed, and listed with an absolute path.

Mutation-checked: skipping non-absolute entries instead of resolving them now
fails with "a session with a resolvable relative workspace was dropped rather
than normalised". The first attempt at that mutation did not compile, so it
proved nothing until it was rewritten — worth saying, because a mutation that
fails to build looks exactly like a test that passes.

Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS 76e2e283c4 fix(acp): resolve every listed workspace, not only when a filter was supplied
@anandh8x's P1, both halves reproduced before changing anything.

The loop resolved item.Cwd only when a cwd filter was present, and skipped only a
blank one. Two shapes stayed on the menu that session/resume then refuses:

  - a session whose persisted workspace has since been deleted, advertised as
    resumable
  - a legacy entry holding a relative path, reported as cwd "." although ACP
    requires SessionInfo.cwd to be absolute

Every entry is now resolved unconditionally, anything that cannot resolve is
omitted, and the RESOLVED root is what SessionInfo carries — absolute as the
contract requires, and the same value the client hands back on resume. The
optional identity filter then applies to the resolved roots, which is also where
it belonged.

Listing is a menu: activatePersistedSession resolves and refuses what it cannot
reach, so anything this loop cannot resolve is something a client would be
offered and then denied.

Mutation-checked: restoring the resolve-only-when-filtered shape re-advertises
the deleted workspace and re-emits the relative cwd.

Origin-Session: local-76c8d7 | Claude Code | 6 prompts
Origin-Snapshot: 259b715cf0fd
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS 7b906fca7b fix(acp): test the alias guard where the bug lives, and stop listing unusable sessions
THE TEST COULD NOT RUN ON THE PLATFORM THE BUG IS FROM. @Vasanthdev2004's point,
and he is right that it is not a nit. The alias test built its second name with
os.Symlink, which needs a privilege an ordinary Windows session does not hold, so
it SKIPPED there — and Windows is where junctions exist and where this defect came
from. The identity guard was verified by CI on the two platforms that never had
the problem. It now builds the alias with mklink /J on Windows, which needs no
privilege and is how he found the defect in the first place, and keeps the symlink
arm elsewhere.

This is the second time in this series a correct fix shipped with a test that
could not exercise it: the same helper shape was added to internal/memory for the
same reason a day earlier.

A SESSION WITH NO PERSISTED WORKSPACE IS NOT RESUMABLE, SO IT IS NOT LISTED.
CodeRabbit's finding. activatePersistedSession refuses an empty Cwd, but the
listing advertised it anyway — a menu entry that only fails when taken. The test
asserts both halves, because the listing is only correct relative to what resume
will accept: the omitted session is checked to really fail on resume, and a usable
session is checked to survive the filter.

Mutation-checked: removing the filter advertises the unusable session again.
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS c41104dd67 fix(acp): compare workspaces by filesystem identity, not by spelling
@Vasanthdev2004's three findings, all reproduced before changing anything.

TWO SPELLINGS OF ONE DIRECTORY WERE TWO WORKSPACES. Both comparisons were string
equality on ResolveWorkspaceRoot output, and that resolver is abs plus
filepath.Clean plus a stat — it does not fold case and does not resolve
junctions. A session persisted from the TUI was unresumable from an editor
holding a different spelling of the same project folder, and session/list
filtered by the other spelling returned nothing, which makes it an invisible
failure rather than a reported one. It lands on exactly the case this feature
exists for, and on the two processes most likely to disagree about spelling.

os.SameFile asks the filesystem which directories these are, which is the
question. filepath.EvalSymlinks is NOT the fix on Windows — it normalises a
drive letter and returns a junction path unchanged, so the alias survives it,
and junctions need no privilege. String equality stays as the fast path, and a
stat failure falls back to it rather than widening the match: this gate refuses
access to another workspace's files and configuration, so an unanswerable
comparison denies.

THE TEST COULD NOT SEE ANY OF IT. testDeps resolves with the identity function,
so the guard degenerated to "are these two strings different" fed two unrelated
temp directories — it could only ever answer yes. The rejection direction was
pinned and the acceptance direction was asserted nowhere. The new test uses a
resolver reproducing the production normalisation and drives the ACCEPTANCE
direction through an alias, skipping if the filesystem folds the alias away so
it never passes vacuously. Reverting to string equality fails it three ways:
load, resume, and a list that returns zero.

THE WIRE KEYS ARE AN EXTERNAL CONTRACT. Nothing pinned sessionId, cwd, title,
updatedAt, _meta, modelId, createdAt, sessions, nextCursor, cursor, loadSession,
promptCapabilities, sessionCapabilities, list or resume, so renaming a Go field
would break every client and leave the suite green. Renaming modelId to model_id
now fails.

Origin-Session: local-79d7a0 | Claude Code | 5 prompts
Origin-Snapshot: c175cabb9d50
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS 5a3ee23e06 fix(acp): bind sessions to persisted workspaces
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS 17cde6452a fix(acp): preserve notification wire order
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
KRATOS 12f0779424 feat(acp): add standard session list and resume
Origin-Session: local-abff1c | Claude Code | 7 prompts
Origin-Snapshot: b7d0806d49f9
Origin-Session: local-13d543 | Claude Code | 5 prompts
Origin-Snapshot: 6b5eb8ba4e5b
Origin-Session: local-c962d7 | Claude Code | 3 prompts
Origin-Snapshot: d2b8f44a9abc
2026-09-12 17:18:42 +05:30
110 changed files with 340 additions and 9131 deletions

No files matched your search

-1
View File
@@ -24,7 +24,6 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
-5
View File
@@ -34,15 +34,12 @@ jobs:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Checkout requested ref
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
@@ -132,8 +129,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
-12
View File
@@ -5,8 +5,6 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
push:
branches:
@@ -14,8 +12,6 @@ on:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- scripts/action-summary.mjs
- scripts/action-summary.test.mjs
- .github/workflows/zero-action-smoke.yml
permissions:
@@ -31,14 +27,6 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- name: Run action-summary test suite
run: node --test scripts/action-summary.test.mjs
- name: Parse action.yml and assert documented inputs
shell: bash
run: |
+1 -1
View File
@@ -1 +1 @@
{".":"0.9.0"}
{".":"0.8.0"}
-55
View File
@@ -6,61 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
### Features
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
### Bug Fixes
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
### Performance Improvements
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
+4 -42
View File
@@ -3,7 +3,6 @@ package acp
import (
"encoding/json"
"net/url"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
@@ -247,59 +246,22 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
}
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
if text == "" {
return appendToolResultDiffs(content, result.FileDiffs)
return nil
}
content = append(content, ToolContent(TextBlock(text)))
return appendToolResultDiffs(content, result.FileDiffs)
}
func appendToolResultDiffs(content []ToolCallContent, diffs []tools.FileDiff) []ToolCallContent {
for _, diff := range diffs {
if !filepath.IsAbs(diff.Path) || (!diff.OldExists && !diff.NewExists) {
continue
}
var oldText *string
if diff.OldExists {
old := diff.OldText
oldText = &old
}
var newText *string
if diff.NewExists {
updated := diff.NewText
newText = &updated
}
content = append(content, ToolCallContent{Type: "diff", Path: diff.Path, OldText: oldText, NewText: newText})
}
return content
return []ToolCallContent{ToolContent(TextBlock(text))}
}
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
locs := make([]ToolCallLocation, 0, len(result.FileDiffs)+len(result.ChangedFiles))
seen := make(map[string]bool, len(result.FileDiffs)+len(result.ChangedFiles))
for _, diff := range result.FileDiffs {
path := diff.Path
if path == "" || seen[path] {
continue
}
seen[path] = true
locs = append(locs, ToolCallLocation{Path: path})
}
// FileDiff.Path is canonical absolute path data while ChangedFiles is
// normally workspace-relative. Without the trusted workspace root these
// coordinate systems cannot be correlated safely: a suffix match would let
// /workspace/sub/a.go consume the fallback for a distinct root a.go. The
// shared seen set deduplicates only identities already exactly comparable.
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
for _, f := range result.ChangedFiles {
if f == "" || seen[f] {
if strings.TrimSpace(f) == "" {
continue
}
seen[f] = true
locs = append(locs, ToolCallLocation{Path: f})
}
return locs
+3 -193
View File
@@ -2,7 +2,6 @@ package acp
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"unicode"
@@ -301,26 +300,21 @@ func TestToolCallStart(t *testing.T) {
}
func TestToolCallResult(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
ok := toolCallResult(agent.ToolResult{
ToolCallID: "tc1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "applied\n",
ChangedFiles: []string{"a.go", ""},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before\n", NewText: "after\n"}},
})
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
t.Fatalf("unexpected ok result: %+v", ok)
}
if len(ok.Content) != 2 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
t.Fatalf("unexpected content: %+v", ok.Content)
}
if diff := ok.Content[1]; diff.Type != "diff" || diff.Path != path || diff.OldText == nil || *diff.OldText != "before\n" || diff.NewText == nil || *diff.NewText != "after\n" {
t.Fatalf("unexpected diff content: %+v", diff)
}
if len(ok.Locations) != 2 || ok.Locations[0].Path != path || ok.Locations[1].Path != "a.go" {
t.Fatalf("unproven absolute/relative aliases must both remain visible, got %+v", ok.Locations)
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
}
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
@@ -329,190 +323,6 @@ func TestToolCallResult(t *testing.T) {
}
}
func TestToolCallDiffJSONDistinguishesEmptyFilesAndDeletion(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
content := appendToolResultDiffs(nil, []tools.FileDiff{
{Path: path, OldExists: false, NewExists: true, NewText: ""},
{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: ""},
{Path: path, OldExists: true, NewExists: false, OldText: "before"},
})
if len(content) != 3 {
t.Fatalf("diff content = %#v", content)
}
for index, diff := range content {
encoded, err := json.Marshal(diff)
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if wire["path"] != path {
t.Fatalf("wire diff %d = %s", index, encoded)
}
switch index {
case 0:
oldText, present := wire["oldText"]
if !present || oldText != nil || wire["newText"] != "" {
t.Fatalf("create diff = %s, want null oldText and empty newText", encoded)
}
case 1:
if wire["oldText"] != "before" || wire["newText"] != "" {
t.Fatalf("empty replacement diff = %s", encoded)
}
case 2:
newText, present := wire["newText"]
if wire["oldText"] != "before" || !present || newText != nil {
t.Fatalf("deletion diff = %s, want oldText and null newText", encoded)
}
}
}
}
func TestToolResultLocationsPreserveDistinctPathIdentities(t *testing.T) {
root := t.TempDir()
rootPath := filepath.Join(root, "a.go")
nestedPath := filepath.Join(root, "sub", "a.go")
diff := func(path string) tools.FileDiff {
return tools.FileDiff{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}
}
for _, tc := range []struct {
name string
diffs []tools.FileDiff
want []string
}{
{name: "both rich", diffs: []tools.FileDiff{diff(rootPath), diff(nestedPath)}, want: []string{rootPath, nestedPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "root rich", diffs: []tools.FileDiff{diff(rootPath)}, want: []string{rootPath, "a.go", filepath.Join("sub", "a.go")}},
{name: "nested rich", diffs: []tools.FileDiff{diff(nestedPath)}, want: []string{nestedPath, "a.go", filepath.Join("sub", "a.go")}},
} {
t.Run(tc.name, func(t *testing.T) {
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{"a.go", filepath.Join("sub", "a.go")},
FileDiffs: tc.diffs,
})
if len(locations) != len(tc.want) {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
for index := range tc.want {
if locations[index].Path != tc.want[index] {
t.Fatalf("locations = %#v, want %#v", locations, tc.want)
}
}
})
}
}
func TestToolCallResultPreservesWhitespaceInFilePaths(t *testing.T) {
relativePath := " report.txt "
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: true, OldText: "before", NewText: "after",
}},
})
if len(update.Content) != 1 || update.Content[0].Path != absolutePath {
t.Fatalf("diff content path = %#v, want %q", update.Content, absolutePath)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("locations = %#v, want exact paths %q and %q", update.Locations, absolutePath, relativePath)
}
}
func TestToolResultLocationsDeduplicateOnlyExactPaths(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.go")
locations := toolResultLocations(agent.ToolResult{
ChangedFiles: []string{path, path},
FileDiffs: []tools.FileDiff{{Path: path, OldExists: true, NewExists: true, OldText: "before", NewText: "after"}},
})
if len(locations) != 1 || locations[0].Path != path {
t.Fatalf("exact duplicate locations = %#v", locations)
}
}
func TestDeletedFileEmitsDiffAndKeepsLocations(t *testing.T) {
relativePath := "deleted.go"
absolutePath := filepath.Join(t.TempDir(), relativePath)
update := toolCallResult(agent.ToolResult{
ChangedFiles: []string{relativePath},
FileDiffs: []tools.FileDiff{{
Path: absolutePath, OldExists: true, NewExists: false, OldText: "before",
}},
})
if len(update.Content) != 1 || update.Content[0].OldText == nil || *update.Content[0].OldText != "before" || update.Content[0].NewText != nil {
t.Fatalf("deleted file diff = %#v, want oldText with null newText", update.Content)
}
if len(update.Locations) != 2 || update.Locations[0].Path != absolutePath || update.Locations[1].Path != relativePath {
t.Fatalf("deleted file locations = %#v", update.Locations)
}
}
func TestToolCallResultEmitsOnlyRedactedFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
path := filepath.Join(t.TempDir(), "secret.txt")
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{{
Path: path, OldExists: true, NewExists: true, OldText: "token=" + secret, NewText: "safe",
}}})
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusError, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 1 || update.Content[0].OldText == nil || strings.Contains(*update.Content[0].OldText, secret) {
t.Fatalf("ACP content leaked unredacted diff: %#v", update.Content)
}
}
func TestToolCallResultOmitsSemanticallyUnchangedRedactedDiff(t *testing.T) {
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
scrubbed := tools.ScrubResultSecrets(tools.Result{
ChangedFiles: []string{"credentials.txt"},
FileDiffs: []tools.FileDiff{{
Path: filepath.Join(t.TempDir(), "credentials.txt"), OldExists: true, NewExists: true,
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
}},
})
update := toolCallResult(agent.ToolResult{
ToolCallID: "call", Status: tools.StatusOK,
ChangedFiles: scrubbed.ChangedFiles, FileDiffs: scrubbed.FileDiffs,
})
if len(update.Content) != 0 {
t.Fatalf("ACP emitted semantically unchanged redacted diff: %#v", update.Content)
}
if len(update.Locations) != 1 || update.Locations[0].Path != "credentials.txt" {
t.Fatalf("ACP path fallback = %#v", update.Locations)
}
}
func TestToolCallResultOmitsDefaultIgnorableSplitSecretsOnEitherSide(t *testing.T) {
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
for name, separator := range map[string]string{
"combining grapheme joiner": "\u034f",
"variation selector": "\ufe0f",
} {
for _, side := range []string{"old", "new"} {
t.Run(name+" "+side, func(t *testing.T) {
obfuscated := secret[:20] + separator + secret[20:]
diff := tools.FileDiff{
Path: filepath.Join(t.TempDir(), "secret.txt"), OldExists: true, NewExists: true,
OldText: "safe old", NewText: "safe new",
}
if side == "old" {
diff.OldText = obfuscated
} else {
diff.NewText = obfuscated
}
scrubbed := tools.ScrubResultSecrets(tools.Result{FileDiffs: []tools.FileDiff{diff}})
if !scrubbed.Redacted || len(scrubbed.FileDiffs) != 0 {
t.Fatalf("registry boundary retained an obfuscated secret: %#v", scrubbed)
}
update := toolCallResult(agent.ToolResult{ToolCallID: "call", Status: tools.StatusOK, FileDiffs: scrubbed.FileDiffs})
if len(update.Content) != 0 {
t.Fatalf("ACP content retained an obfuscated secret: %#v", update.Content)
}
})
}
}
}
func TestPlanUpdateAndStatus(t *testing.T) {
upd := planUpdate([]tools.PlanItem{
{Content: "step a", Status: "completed"},
+3 -24
View File
@@ -284,30 +284,9 @@ type ToolCallContent struct {
// type == "content"
Content *ContentBlock `json:"content,omitempty"`
// type == "diff"
Path string `json:"path,omitempty"`
OldText *string `json:"oldText,omitempty"`
NewText *string `json:"newText,omitempty"`
}
// MarshalJSON preserves ACP's discriminated content union. A diff always has
// path, while nullable oldText/newText distinguish creation, deletion, and an
// existing file replaced with empty content. Other content variants omit all
// diff fields rather than serializing irrelevant nulls.
func (content ToolCallContent) MarshalJSON() ([]byte, error) {
if content.Type == "diff" {
return json.Marshal(struct {
Type string `json:"type"`
Path string `json:"path"`
OldText *string `json:"oldText"`
NewText *string `json:"newText"`
}{
Type: content.Type, Path: content.Path, OldText: content.OldText, NewText: content.NewText,
})
}
return json.Marshal(struct {
Type string `json:"type"`
Content *ContentBlock `json:"content,omitempty"`
}{Type: content.Type, Content: content.Content})
Path string `json:"path,omitempty"`
OldText string `json:"oldText,omitempty"`
NewText string `json:"newText,omitempty"`
}
func ToolContent(block ContentBlock) ToolCallContent {
+18 -6
View File
@@ -8,7 +8,6 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -112,11 +111,7 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
testutil.WaitFor(t, "worker idle", func() bool {
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
return diagnostics.working == nil
})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
@@ -128,6 +123,23 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
-7
View File
@@ -1527,7 +1527,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
@@ -1834,10 +1833,6 @@ func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, n
}
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
// PrePermissionRejecter runs before Registry.RunWithOptions, so it must
// explicitly cross the same transcript/redaction boundary before its result
// can be forwarded through ACP.
result = tools.ScrubResultSecrets(result)
output, outputRedacted := scrubInterceptedOutput(result.Output)
display := result.Display
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
@@ -1865,7 +1860,6 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
@@ -2159,7 +2153,6 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
-17
View File
@@ -27,23 +27,6 @@ type mockProvider struct {
requests []zeroruntime.CompletionRequest
}
func TestPrePermissionRejectScrubsFileDiffs(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
result := toolResultFromPrePermissionReject(ToolCall{ID: "call", Name: "test"}, tools.Result{
Status: tools.StatusError,
FileDiffs: []tools.FileDiff{{
Path: filepath.Join(t.TempDir(), "secret.txt"),
OldExists: true,
NewExists: true,
OldText: "token=" + secret,
NewText: "safe",
}},
})
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || !result.Redacted {
t.Fatalf("pre-permission FileDiff = %#v, redacted = %t", result.FileDiffs, result.Redacted)
}
}
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
call := ToolCall{Name: tools.ExecCommandToolName}
-1
View File
@@ -84,7 +84,6 @@ type ToolResult struct {
Images []zeroruntime.ImageBlock
Redacted bool
ChangedFiles []string
FileDiffs []tools.FileDiff
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
+6 -1
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -146,7 +147,11 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
-23
View File
@@ -49,14 +49,6 @@ const (
exitInterrupted = 130
)
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
// An interactive run can be continued past the shared interactive default by the
// user; an exec run has nobody to unstick it, so evals and automation would
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
// still finite against a genuinely runaway loop, which the loop's empty-turn
// guard also cuts independently of this budget.
const defaultExecMaxTurns = config.MaxTurnsCeiling
type execOutputFormat string
type execInputFormat string
@@ -328,7 +320,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
resolved.MaxTurns = execTurnBudget(resolved)
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
@@ -1231,20 +1222,6 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
}
// execTurnBudget applies the headless default to the resolved turn budget: an
// interactive run can be continued past the shared default by the user, but an
// exec run has nobody to unstick it, so an unconfigured budget starts at the
// documented ceiling instead of truncating mid-task. Explicit sources
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
// MaxTurnsSet and pass through; an exec-profile budget still wins via
// applyProfileTurnBudget afterward.
func execTurnBudget(resolved config.ResolvedConfig) int {
if resolved.MaxTurnsSet {
return resolved.MaxTurns
}
return defaultExecMaxTurns
}
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
// The profile displaces the RESOLVED value, not the flag: a pinned budget
// always wins and the profile backs off entirely, while an env/config budget
-20
View File
@@ -1975,23 +1975,3 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
}
}
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
cases := []struct {
name string
resolved config.ResolvedConfig
want int
}{
// A headless run has no user to continue past the shared interactive
// default, so an unconfigured budget starts at the ceiling.
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
}
for _, tc := range cases {
if got := execTurnBudget(tc.resolved); got != tc.want {
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
}
}
}
-5
View File
@@ -166,7 +166,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
MaxTurnsSet: cfg.maxTurnsSet,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
@@ -235,7 +234,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
}
for _, provider := range src.Providers {
mergeProvider(dst, provider)
@@ -296,7 +294,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
}
for _, provider := range src.Providers {
candidate := providerMergeCandidate(*dst, provider)
@@ -609,7 +606,6 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
cfg.maxTurnsSet = true
}
}
@@ -730,7 +726,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
}
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
cfg.maxTurnsSet = true
}
if overrides.Sandbox.Enabled != nil {
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
-42
View File
@@ -2252,45 +2252,3 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
t.Fatalf("error = %v", err)
}
}
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
const providerJSON = `{
"activeProvider": "p",
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
}`
providerWithTurns := strings.Replace(providerJSON, `"activeProvider": "p"`, `"activeProvider": "p", "maxTurns": 40`, 1)
cases := []struct {
name string
opts ResolveOptions
set bool
}{
{"built-in default only", ResolveOptions{
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, false},
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
{"project config", ResolveOptions{
UserConfigPath: writeConfig(t, providerJSON),
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
}, true},
{"env", ResolveOptions{
Env: map[string]string{MaxTurnsEnv: "120"},
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, true},
{"cli override", ResolveOptions{
Overrides: Overrides{
MaxTurns: 42,
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
},
}, true},
}
for _, tc := range cases {
resolved, err := Resolve(tc.opts)
if err != nil {
t.Fatalf("%s: Resolve() error = %v", tc.name, err)
}
if resolved.MaxTurnsSet != tc.set {
t.Fatalf("%s: MaxTurnsSet = %v, want %v (MaxTurns=%d)", tc.name, resolved.MaxTurnsSet, tc.set, resolved.MaxTurns)
}
}
}
+4 -13
View File
@@ -364,10 +364,6 @@ type FileConfig struct {
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
// maxTurnsSet records that some merge source supplied a positive maxTurns —
// i.e. the value is configured, not the built-in default. Unexported like
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
maxTurnsSet bool
// Extra preserves top-level fields written by newer Zero versions or
// extensions so a read-modify-write through this version is non-destructive.
Extra map[string]json.RawMessage `json:"-"`
@@ -465,15 +461,10 @@ type Overrides struct {
}
type ResolvedConfig struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
// MaxTurnsSet reports whether MaxTurns came from an explicit source (a
// config file, a provider command, ZERO_MAX_TURNS, or CLI overrides) rather
// than the built-in default — callers that need a different default can
// tell the two apart.
MaxTurnsSet bool
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
+2 -31
View File
@@ -30,35 +30,15 @@ var processAlive = osProcessAlive
// only to enrich a contention error with the current holder's PID; the kernel
// lock, not PID metadata, is authoritative.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
return lockutil.TryAcquireFileLock(path)
}, func() (int, error) {
return readPidFile(path)
})
}
func acquireLockRoot(root *os.Root, name, displayPath string, isAlive func(pid int) bool) (*fileLock, error) {
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
return lockutil.TryAcquireFileLockRoot(root, name, displayPath)
}, func() (int, error) {
return readPidFileRoot(root, name)
})
}
func acquireLockWith(
isAlive func(pid int) bool,
acquire func() (*lockutil.FileLock, error),
readPID func() (int, error),
) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
lock, err := acquire()
lock, err := lockutil.TryAcquireFileLock(path)
if err != nil {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, err
}
pid, perr := readPID()
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
@@ -82,15 +62,6 @@ func (l *fileLock) release() error {
// readPidFile reads and parses the PID recorded in a lock file.
func readPidFile(path string) (int, error) {
data, err := os.ReadFile(path)
return parsePidFile(data, err)
}
func readPidFileRoot(root *os.Root, name string) (int, error) {
data, err := root.ReadFile(name)
return parsePidFile(data, err)
}
func parsePidFile(data []byte, err error) (int, error) {
if err != nil {
return 0, err
}
+14 -4
View File
@@ -7,8 +7,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
// --- test doubles ---------------------------------------------------------
@@ -193,7 +191,7 @@ func TestPoolQueuesWhenFull(t *testing.T) {
}()
<-started
// Wait until the first run holds the only slot.
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
secondDone := make(chan struct{})
go func() {
@@ -226,7 +224,7 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
close(runDone)
}()
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
pool.Drain() // KillTimeout elapses, straggler is force-killed
if atomic.LoadInt32(&straggler.killed) != 1 {
@@ -244,6 +242,18 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("condition not met within timeout")
}
func TestPoolRunSurfacesStdoutReadError(t *testing.T) {
// A worker stdout read error must surface as a failure (not be swallowed and
// reported as clean success), and the worker is killed so it can't block on an
+7 -162
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
)
@@ -24,23 +23,6 @@ type Server struct {
listener net.Listener
conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return
lock *fileLock
// runtimeRoot is retained for the full default-daemon lifecycle. Every file
// child is addressed relative to this capability; runtimeDir is used only to
// verify the unavoidable AF_UNIX pathname bind still names the same object.
runtimeRoot *os.Root
runtimeDir string
// socketRoot captures the directory that actually received the pathname
// bind. It can differ from runtimeRoot only when the configured directory is
// exchanged in the final pre-bind window; retaining it makes rollback target
// the socket that was really created rather than re-resolving the pathname.
socketRoot *os.Root
socketName string
// statusRoot binds status publication and shutdown cleanup to the same
// directory object. statusCommitted is set only after this server publishes
// its document, so a failed startup never removes a previous daemon's status.
statusRoot *os.Root
statusName string
statusCommitted bool
ctx context.Context
cancel context.CancelFunc
@@ -59,14 +41,6 @@ type ServerOptions struct {
Now func() time.Time
Log func(string)
isAlive func(int) bool // test hook for the single-instance lock
// beforeStatusReplace, replaceStatusFile, and syncStatusParent are test hooks
// for the status-file commit boundary. nil selects production behavior.
beforeStatusReplace func()
replaceStatusFile func(root *os.Root, src, dst string) error
syncStatusParent func(root *os.Root) error
afterRuntimeRootOpen func() // test hook at the default-root trust boundary
beforeSocketBind func() // test hook before the final root verification
afterSocketPreflight func() // test hook in the unavoidable pathname-bind window
}
// NewServer validates options and builds a Server.
@@ -106,41 +80,11 @@ func (s *Server) Serve() error {
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
return err
}
defaultRoot, isDefault, err := openDefaultRuntimeRoot(s.opts.Paths)
if err != nil {
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
return err
}
if isDefault {
s.runtimeRoot = defaultRoot
s.runtimeDir = filepath.Dir(s.opts.Paths.Socket)
s.statusRoot = defaultRoot
s.statusName = filepath.Base(s.opts.Paths.Status)
if s.opts.afterRuntimeRootOpen != nil {
s.opts.afterRuntimeRootOpen()
}
if err := runtimeRootStillNamesPath(defaultRoot, s.runtimeDir); err != nil {
s.closeRuntimeRoots()
return err
}
} else {
if err := secureCustomRuntimeParents(s.opts.Paths); err != nil {
return err
}
statusRoot, err := openStatusRoot(s.opts.Paths.Status)
if err != nil {
return fmt.Errorf("daemon: open status directory: %w", err)
}
s.statusRoot = statusRoot
s.statusName = filepath.Base(s.opts.Paths.Status)
}
var lock *fileLock
if s.runtimeRoot != nil {
lock, err = acquireLockRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Lock), s.opts.Paths.Lock, s.opts.isAlive)
} else {
lock, err = acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
}
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
if err != nil {
s.closeRuntimeRoots()
return err
}
s.lock = lock
@@ -148,22 +92,7 @@ func (s *Server) Serve() error {
// A leftover socket file from an unclean exit would make Listen fail with
// "address already in use"; we hold the lock, so any socket here is stale.
if s.runtimeRoot != nil {
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("daemon: remove stale control socket: %w", err)
}
if s.opts.beforeSocketBind != nil {
s.opts.beforeSocketBind()
}
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
return err
}
if s.opts.afterSocketPreflight != nil {
s.opts.afterSocketPreflight()
}
} else {
_ = os.Remove(s.opts.Paths.Socket)
}
_ = os.Remove(s.opts.Paths.Socket)
listener, err := net.Listen("unix", s.opts.Paths.Socket)
if err != nil {
@@ -172,22 +101,6 @@ func (s *Server) Serve() error {
s.mu.Lock()
s.listener = listener
s.mu.Unlock()
if s.runtimeRoot != nil {
boundRoot, boundName, err := captureBoundSocketRoot(s.opts.Paths.Socket)
if err != nil {
_ = listener.Close()
return fmt.Errorf("daemon: capture bound control socket: %w", err)
}
s.socketRoot = boundRoot
s.socketName = boundName
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
return err
}
info, err := s.runtimeRoot.Lstat(filepath.Base(s.opts.Paths.Socket))
if err != nil || info.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("daemon: bound control socket is outside the secured runtime directory")
}
}
// If Shutdown already fired during the bind window, close now and bail so a
// shutdown requested at startup is never lost (the accept loop would otherwise
// block forever waiting for a connection that never comes) (D4).
@@ -198,7 +111,7 @@ func (s *Server) Serve() error {
return nil
default:
}
if err := s.hardenSocket(); err != nil {
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
return fmt.Errorf("daemon: harden control socket: %w", err)
}
s.startedAt = s.opts.Now()
@@ -277,56 +190,11 @@ func (s *Server) cleanup() {
if s.listener != nil {
_ = s.listener.Close()
}
if s.socketRoot != nil {
if err := s.socketRoot.Remove(s.socketName); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logf("daemon: remove control socket: %v", err)
}
} else if s.runtimeRoot != nil {
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logf("daemon: remove control socket: %v", err)
}
} else {
_ = os.Remove(s.opts.Paths.Socket)
}
if s.statusRoot != nil {
if s.statusCommitted {
if err := s.statusRoot.Remove(s.statusName); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logf("daemon: remove status file: %v", err)
}
}
}
_ = os.Remove(s.opts.Paths.Socket)
_ = os.Remove(s.opts.Paths.Status)
if s.lock != nil {
_ = s.lock.release()
}
s.closeRuntimeRoots()
}
func (s *Server) hardenSocket() error {
if s.runtimeRoot != nil {
return hardenSocketFileRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Socket))
}
return hardenSocketFile(s.opts.Paths.Socket)
}
func (s *Server) closeRuntimeRoots() {
if s.socketRoot != nil {
if err := s.socketRoot.Close(); err != nil {
s.logf("daemon: close bound socket directory: %v", err)
}
}
s.socketRoot = nil
if s.statusRoot != nil && s.statusRoot != s.runtimeRoot {
if err := s.statusRoot.Close(); err != nil {
s.logf("daemon: close status directory: %v", err)
}
}
s.statusRoot = nil
if s.runtimeRoot != nil {
if err := s.runtimeRoot.Close(); err != nil {
s.logf("daemon: close runtime directory: %v", err)
}
}
s.runtimeRoot = nil
}
func (s *Server) writeStatusFile() error {
@@ -340,30 +208,7 @@ func (s *Server) writeStatusFile() error {
if err != nil {
return err
}
root := s.statusRoot
ownedRoot := false
if root == nil {
var err error
root, err = openStatusRoot(s.opts.Paths.Status)
if err != nil {
return fmt.Errorf("daemon: write status file: %w", err)
}
ownedRoot = true
}
committed, err := writeStatusFileAtomicallyRoot(root, filepath.Base(s.opts.Paths.Status), data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent)
if ownedRoot {
if closeErr := root.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("close status directory: %w", closeErr))
}
}
if committed {
s.statusCommitted = true
}
if err != nil {
if committed {
s.logf("daemon: status file publication committed with warning: %v", err)
return nil
}
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
return fmt.Errorf("daemon: write status file: %w", err)
}
return nil
+16 -334
View File
@@ -2,202 +2,20 @@ package daemon
import (
"errors"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/testutil"
)
func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permission compatibility regression")
}
dir, err := os.MkdirTemp("/tmp", "zero-serve-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
paths := Paths{
Socket: filepath.Join(dir, "daemon.sock"),
Lock: filepath.Join(dir, "daemon.lock"),
Status: filepath.Join(dir, "daemon.status"),
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
srv.Shutdown()
if err := <-serveErr; err != nil {
t.Fatalf("Serve with 0755 custom runtime directory: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o755 {
t.Fatalf("custom runtime directory permissions = %04o, want unchanged 0755", got)
}
}
func TestServeKeepsDefaultRootBoundAcrossCoordination(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
}
home, err := os.MkdirTemp("/tmp", "zero-root-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
paths, err := DefaultPaths()
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(paths.Socket)
moved := filepath.Join(home, "bound-runtime")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
srv.opts.beforeSocketBind = func() {
if err := os.Rename(dir, moved); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
}
err = srv.Serve()
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
t.Fatalf("Serve error = %v, want replaced-runtime rejection", err)
}
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
t.Fatalf("rooted lock was not created in the bound directory: %v", err)
}
for _, path := range []string{paths.Lock, paths.Socket, paths.Status} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("substitute runtime entry %s was touched: %v", path, err)
}
}
}
func TestServeRemovesSocketBoundAfterFinalRuntimeSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
}
home, err := os.MkdirTemp("/tmp", "zero-bind-swap-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
paths, err := DefaultPaths()
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(paths.Socket)
moved := filepath.Join(home, "trusted-runtime")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
srv.opts.afterSocketPreflight = func() {
if err := os.Rename(dir, moved); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
}
err = srv.Serve()
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
t.Fatalf("Serve error = %v, want post-bind runtime replacement rejection", err)
}
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
t.Fatalf("rooted lock was not retained in the trusted directory: %v", err)
}
if _, err := os.Lstat(paths.Socket); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("substitute runtime retained stale socket: %v", err)
}
listener, err := net.Listen("unix", paths.Socket)
if err != nil {
t.Fatalf("substitute runtime remained unbindable after rollback: %v", err)
}
if err := listener.Close(); err != nil {
t.Fatal(err)
}
}
func TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows DACL hardening has platform-specific coverage")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
paths, err := DefaultPaths()
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(paths.Socket)
if err := os.MkdirAll(dir, 0o777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
file, logPath, err := OpenRuntimeLog(paths)
if err != nil {
t.Fatalf("OpenRuntimeLog: %v", err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if logPath != filepath.Join(dir, "daemon.log") {
t.Fatalf("log path = %q", logPath)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got&0o077 != 0 {
t.Fatalf("default runtime directory permissions = %04o, want owner-only", got)
}
}
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
t.Helper()
dir := t.TempDir()
secureStatusTestDir(t, dir)
paths := Paths{
Socket: filepath.Join(dir, "d.sock"),
Lock: filepath.Join(dir, "d.lock"),
Status: filepath.Join(dir, "d.status"),
}
return newTestServerWithPaths(t, launcher, paths), paths
}
func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Server {
t.Helper()
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
if err != nil {
t.Fatalf("NewPool: %v", err)
@@ -210,7 +28,19 @@ func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Serve
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return srv
return srv, paths
}
func waitForFile(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("file %s did not appear within timeout", path)
}
func TestServerEndToEnd(t *testing.T) {
@@ -220,7 +50,7 @@ func TestServerEndToEnd(t *testing.T) {
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
waitForFile(t, paths.Status)
// --- run a session and collect its stream-json output ---
runClient, err := Dial(paths.Socket)
@@ -306,154 +136,6 @@ func TestServerEndToEnd(t *testing.T) {
}
}
func TestServePreservesPreviousStatusWhenReplacementFails(t *testing.T) {
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
dir, err := os.MkdirTemp("", "zero-daemon-status-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
secureStatusTestDir(t, dir)
paths := Paths{Socket: filepath.Join(dir, "d.sock"), Lock: filepath.Join(dir, "d.lock"), Status: filepath.Join(dir, "d.status")}
srv := newTestServerWithPaths(t, launcher, paths)
previous := []byte(`{"pid":7,"socket":"previous","version":1}`)
if err := os.WriteFile(paths.Status, previous, 0o600); err != nil {
t.Fatal(err)
}
srv.opts.replaceStatusFile = func(*os.Root, string, string) error {
return errors.New("injected replacement failure")
}
if err := srv.Serve(); err == nil || !strings.Contains(err.Error(), "injected replacement failure") {
t.Fatal("Serve succeeded despite injected status replacement failure")
}
got, err := os.ReadFile(paths.Status)
if err != nil {
t.Fatalf("previous status was removed after failed publication: %v", err)
}
if string(got) != string(previous) {
t.Fatalf("previous status changed after failed publication: %q", got)
}
}
func TestServeCleansStatusThroughBoundDirectoryAfterSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not permit renaming the directory containing the live socket")
}
parent, err := os.MkdirTemp("", "zero-daemon-swap-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(parent) })
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
secureStatusTestDir(t, dir)
paths := Paths{
Socket: filepath.Join(dir, "d.sock"),
Lock: filepath.Join(dir, "d.lock"),
Status: filepath.Join(dir, "d.status"),
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
srv := newTestServerWithPaths(t, launcher, paths)
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
srv.opts.beforeStatusReplace = func() {
if err := os.Rename(dir, movedDir); err != nil {
t.Fatalf("move status directory: %v", err)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute directory: %v", err)
}
if err := os.WriteFile(paths.Status, substitute, 0o600); err != nil {
t.Fatalf("write substitute status: %v", err)
}
}
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
movedStatus := filepath.Join(movedDir, "d.status")
testutil.WaitFor(t, "file "+movedStatus, func() bool { _, err := os.Stat(movedStatus); return err == nil })
srv.Shutdown()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
if _, err := os.Stat(filepath.Join(movedDir, "d.status")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("bound status survived cleanup: %v", err)
}
got, err := os.ReadFile(paths.Status)
if err != nil {
t.Fatalf("substitute status was removed by pathname cleanup: %v", err)
}
if string(got) != string(substitute) {
t.Fatalf("substitute status changed during cleanup: %q", got)
}
}
func TestServerPublishesDefaultStatusAfterCrashReportCreatesRuntimeDirectory(t *testing.T) {
home, err := os.MkdirTemp("", "zero-home-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_RUNTIME_DIR", "")
if _, err := observability.WriteCrashReport(
observability.DefaultCrashDir(),
"cli",
"boom",
[]byte("stack"),
time.Now(),
); err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
paths, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if paths.Status != filepath.Join(home, ".zero", "daemon.status") {
t.Fatalf("default status path = %q, want path beneath temporary home", paths.Status)
}
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
srv := newTestServerWithPaths(t, launcher, paths)
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
deadline := time.NewTimer(3 * time.Second)
defer deadline.Stop()
for {
if _, err := os.Stat(paths.Status); err == nil {
break
}
select {
case err := <-serveErr:
t.Fatalf("Serve returned before publishing status: %v", err)
case <-deadline.C:
t.Fatal("daemon did not publish its default status file")
case <-time.After(2 * time.Millisecond):
}
}
srv.Shutdown()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
}
func TestServerSecondInstanceFails(t *testing.T) {
block := make(chan struct{})
defer close(block)
@@ -462,7 +144,7 @@ func TestServerSecondInstanceFails(t *testing.T) {
serveErr := make(chan error, 1)
go func() { serveErr <- srv1.Serve() }()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
waitForFile(t, paths.Status)
// A second server on the same paths must refuse to start (single instance).
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
@@ -483,7 +165,7 @@ func TestServerRejectsUnknownCommand(t *testing.T) {
srv, paths := newTestServer(t, launcher)
go func() { _ = srv.Serve() }()
defer srv.Shutdown()
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
waitForFile(t, paths.Status)
client, err := Dial(paths.Socket)
if err != nil {
+2 -4
View File
@@ -6,8 +6,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
func drain(t *testing.T, buffered []string, live <-chan string) []string {
@@ -113,7 +111,7 @@ func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
testutil.WaitFor(t, "session running", func() bool { return sa.State() == SessionRunning })
waitFor(t, func() bool { return sa.State() == SessionRunning })
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
// b must stay queued (its worker not yet launched) while a holds the slot.
@@ -199,7 +197,7 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
testutil.WaitFor(t, "both sessions running", func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
if _, ok := mgr.Get("r1"); !ok {
t.Fatal("a running session must never be evicted, even past the cap")
}
+4 -179
View File
@@ -1,12 +1,9 @@
package daemon
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/privatedir"
)
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
@@ -15,182 +12,10 @@ import (
// safe cross-platform ceiling.
const maxUnixSocketPath = 103
func secureCustomRuntimeParents(paths Paths) error {
parents := []struct {
name string
path string
}{
{name: "socket", path: filepath.Dir(paths.Socket)},
{name: "lock", path: filepath.Dir(paths.Lock)},
{name: "status", path: filepath.Dir(paths.Status)},
}
seen := make(map[string]struct{}, len(parents))
for _, parent := range parents {
absolute, err := filepath.Abs(parent.path)
if err != nil {
return fmt.Errorf("daemon: resolve %s directory: %w", parent.name, err)
}
if _, ok := seen[absolute]; ok {
continue
}
seen[absolute] = struct{}{}
if err := os.MkdirAll(absolute, 0o700); err != nil {
return fmt.Errorf("daemon: create %s directory: %w", parent.name, err)
}
root, err := os.OpenRoot(absolute)
if err != nil {
return fmt.Errorf("daemon: open custom %s directory: %w", parent.name, err)
}
validateErr := validateStatusRoot(root)
closeErr := root.Close()
if validateErr != nil {
return fmt.Errorf("daemon: validate custom %s directory: %w", parent.name, validateErr)
}
if closeErr != nil {
return fmt.Errorf("daemon: close custom %s directory: %w", parent.name, closeErr)
}
}
return nil
}
// OpenRuntimeLog secures the default runtime root before acquiring the
// detached daemon's log descriptor. Custom endpoint layouts remain supported,
// but their existing parent permissions are caller-owned and left untouched.
func OpenRuntimeLog(paths Paths) (*os.File, string, error) {
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
root, isDefault, err := openDefaultRuntimeRoot(paths)
if err != nil {
return nil, logPath, err
}
if isDefault {
defer root.Close()
file, err := openRootAppendRegular(root, filepath.Base(logPath))
if err != nil {
return nil, logPath, fmt.Errorf("daemon: open runtime log: %w", err)
}
return file, logPath, nil
}
parent := filepath.Dir(logPath)
if err := os.MkdirAll(parent, 0o700); err != nil {
return nil, logPath, fmt.Errorf("daemon: create custom log directory: %w", err)
}
info, err := os.Lstat(logPath)
if err == nil && !info.Mode().IsRegular() {
return nil, logPath, fmt.Errorf("daemon: custom runtime log is not a regular file")
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, logPath, fmt.Errorf("daemon: inspect custom runtime log: %w", err)
}
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, logPath, fmt.Errorf("daemon: open custom runtime log: %w", err)
}
return file, logPath, nil
}
func openDefaultRuntimeRoot(paths Paths) (*os.Root, bool, error) {
defaults, err := DefaultPaths()
if err != nil {
// A caller-supplied layout remains usable even when the process has no
// resolvable home directory. Production default paths can only reach this
// helper after DefaultPaths has already succeeded.
return nil, false, nil
}
matches, err := samePaths(paths, defaults)
if err != nil {
return nil, false, err
}
if !matches {
return nil, false, nil
}
root, err := privatedir.Open(filepath.Dir(defaults.Socket))
if err != nil {
return nil, false, fmt.Errorf("daemon: secure default runtime directory: %w", err)
}
return root, true, nil
}
func samePaths(left, right Paths) (bool, error) {
pairs := [][2]string{
{left.Socket, right.Socket},
{left.Lock, right.Lock},
{left.Status, right.Status},
}
for _, pair := range pairs {
leftAbs, err := filepath.Abs(pair[0])
if err != nil {
return false, fmt.Errorf("daemon: resolve runtime path: %w", err)
}
rightAbs, err := filepath.Abs(pair[1])
if err != nil {
return false, fmt.Errorf("daemon: resolve default runtime path: %w", err)
}
if filepath.Clean(leftAbs) != filepath.Clean(rightAbs) {
return false, nil
}
}
return true, nil
}
func runtimeRootStillNamesPath(root *os.Root, path string) error {
opened, err := root.Stat(".")
if err != nil {
return fmt.Errorf("inspect bound runtime directory: %w", err)
}
current, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect runtime directory entry: %w", err)
}
if current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
return fmt.Errorf("daemon: default runtime directory changed after it was secured")
}
return nil
}
func captureBoundSocketRoot(path string) (*os.Root, string, error) {
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return nil, "", fmt.Errorf("open bound socket directory: %w", err)
}
name := filepath.Base(path)
info, err := root.Lstat(name)
if err != nil {
_ = root.Close()
return nil, "", fmt.Errorf("inspect bound socket: %w", err)
}
if info.Mode()&os.ModeSocket == 0 {
_ = root.Close()
return nil, "", fmt.Errorf("bound socket path is not a socket")
}
return root, name, nil
}
func openRootAppendRegular(root *os.Root, name string) (*os.File, error) {
info, err := root.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
return root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY|os.O_APPEND, 0o600)
}
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("runtime log is not a regular file")
}
file, err := root.OpenFile(name, os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
}
opened, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
if !opened.Mode().IsRegular() {
_ = file.Close()
return nil, fmt.Errorf("runtime log changed while opening")
}
return file, nil
// secureSocketParent creates the socket's parent directory owner-only (0700 on
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
func secureSocketParent(socketPath string) error {
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
}
// checkSocketPathLength rejects an over-long unix socket path before bind.
-4
View File
@@ -10,7 +10,3 @@ import "os"
func hardenSocketFile(path string) error {
return os.Chmod(path, 0o600)
}
func hardenSocketFileRoot(root *os.Root, name string) error {
return root.Chmod(name, 0o600)
}
-4
View File
@@ -2,12 +2,8 @@
package daemon
import "os"
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
// mode bits, and the daemon places the socket under the per-user profile
// directory, which is already ACL-restricted to the owner. A dedicated restricted
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
func hardenSocketFile(string) error { return nil }
func hardenSocketFileRoot(*os.Root, string) error { return nil }
-20
View File
@@ -1,20 +0,0 @@
//go:build !windows
package daemon
import (
"fmt"
"os"
"syscall"
)
func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("status directory ownership metadata is unavailable")
}
if int(stat.Uid) != os.Geteuid() {
return fmt.Errorf("status directory is owned by uid %d, not the current user", stat.Uid)
}
return nil
}
@@ -1,51 +0,0 @@
//go:build !windows
package daemon
import (
"os"
"strings"
"syscall"
"testing"
"time"
)
func secureStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
if err := os.Chmod(dir, 0o700); err != nil {
t.Fatal(err)
}
}
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
}
func TestCheckStatusDirOwnerRejectsMissingMetadata(t *testing.T) {
err := checkStatusDirOwner(nil, statusDirOwnerTestInfo{})
if err == nil || !strings.Contains(err.Error(), "metadata is unavailable") {
t.Fatalf("checkStatusDirOwner error = %v, want unavailable metadata rejection", err)
}
}
func TestCheckStatusDirOwnerRejectsDifferentUser(t *testing.T) {
info := statusDirOwnerTestInfo{sys: &syscall.Stat_t{Uid: uint32(os.Geteuid() + 1)}}
err := checkStatusDirOwner(nil, info)
if err == nil || !strings.Contains(err.Error(), "not the current user") {
t.Fatalf("checkStatusDirOwner error = %v, want owner mismatch rejection", err)
}
}
type statusDirOwnerTestInfo struct {
sys any
}
func (statusDirOwnerTestInfo) Name() string { return "." }
func (statusDirOwnerTestInfo) Size() int64 { return 0 }
func (statusDirOwnerTestInfo) Mode() os.FileMode { return os.ModeDir | 0o700 }
func (statusDirOwnerTestInfo) ModTime() time.Time { return time.Time{} }
func (statusDirOwnerTestInfo) IsDir() bool { return true }
func (info statusDirOwnerTestInfo) Sys() any { return info.sys }
-137
View File
@@ -1,137 +0,0 @@
//go:build windows
package daemon
import (
"errors"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
const statusDirectoryWriteAccess = windows.ACCESS_MASK(
windows.GENERIC_ALL |
windows.GENERIC_WRITE |
windows.DELETE |
windows.WRITE_DAC |
windows.WRITE_OWNER |
windows.FILE_WRITE_DATA |
windows.FILE_APPEND_DATA |
windows.FILE_WRITE_ATTRIBUTES |
windows.FILE_WRITE_EA |
0x40, // FILE_DELETE_CHILD
)
// checkStatusDirOwner validates ownership and write access through a handle
// opened beneath root. Path-based ACL inspection would recreate the ancestor
// swap race that Root is intended to close.
func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open status directory for access validation: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close status directory access handle: %w", err))
}
}()
raw, err := directory.SyscallConn()
if err != nil {
return fmt.Errorf("access status directory handle: %w", err)
}
var descriptor *windows.SECURITY_DESCRIPTOR
var queryErr error
if err := raw.Control(func(handle uintptr) {
descriptor, queryErr = windows.GetSecurityInfo(
windows.Handle(handle),
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
}); err != nil {
return fmt.Errorf("inspect status directory access: %w", err)
}
if queryErr != nil {
return fmt.Errorf("inspect status directory owner and DACL: %w", queryErr)
}
if descriptor == nil {
return fmt.Errorf("status directory security descriptor is unavailable")
}
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return fmt.Errorf("resolve current Windows user: %w", err)
}
tokenOwner, err := currentWindowsTokenOwner(token)
if err != nil {
return fmt.Errorf("resolve current Windows token owner: %w", err)
}
owner, _, err := descriptor.Owner()
if err != nil {
return fmt.Errorf("read status directory owner: %w", err)
}
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
return fmt.Errorf("status directory is not owned by the current Windows token")
}
dacl, _, err := descriptor.DACL()
if err != nil {
return fmt.Errorf("read status directory DACL: %w", err)
}
if dacl == nil {
return fmt.Errorf("status directory has an unrestricted Windows DACL")
}
for index := uint16(0); index < dacl.AceCount; index++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
return fmt.Errorf("read status directory DACL entry %d: %w", index, err)
}
switch ace.Header.AceType {
case windows.ACCESS_DENIED_ACE_TYPE:
continue
case windows.ACCESS_ALLOWED_ACE_TYPE:
default:
return fmt.Errorf("status directory DACL entry %d has unsupported type %d", index, ace.Header.AceType)
}
if ace.Mask&statusDirectoryWriteAccess == 0 {
continue
}
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
if !allowedStatusDirectoryTrustee(trustee, user.User.Sid) {
return fmt.Errorf("status directory DACL grants write access to unexpected trustee %s", trustee.String())
}
}
return nil
}
type statusDirectoryTokenOwner struct {
owner *windows.SID
}
func currentWindowsTokenOwner(token windows.Token) (*windows.SID, error) {
var size uint32
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
if err != windows.ERROR_INSUFFICIENT_BUFFER {
return nil, err
}
buffer := make([]byte, size)
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
return nil, err
}
owner := (*statusDirectoryTokenOwner)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
func allowedStatusDirectoryTrustee(trustee, user *windows.SID) bool {
return trustee != nil && (trustee.Equals(user) ||
trustee.IsWellKnown(windows.WinLocalSystemSid) ||
trustee.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
trustee.IsWellKnown(windows.WinCreatorOwnerSid) ||
trustee.IsWellKnown(windows.WinCreatorOwnerRightsSid))
}
@@ -1,97 +0,0 @@
//go:build windows
package daemon
import (
"fmt"
"os"
"runtime"
"strings"
"testing"
"golang.org/x/sys/windows"
)
func secureStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
t.Fatal(err)
}
descriptor, err := windows.SecurityDescriptorFromString(
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
)
if err != nil {
t.Fatal(err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
t.Fatal(err)
}
if err := windows.SetNamedSecurityInfo(
dir,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
dacl,
nil,
); err != nil {
t.Fatal(err)
}
}
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
t.Helper()
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
if err != nil {
t.Fatal(err)
}
var pinner runtime.Pinner
pinner.Pin(worldSID)
defer pinner.Unpin()
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
AccessPermissions: windows.GENERIC_ALL,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
Trustee: windows.TRUSTEE{
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
},
}}, nil)
if err != nil {
t.Fatal(err)
}
if err := windows.SetNamedSecurityInfo(
dir,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
broadDACL,
nil,
); err != nil {
t.Fatal(err)
}
}
func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) {
dir := t.TempDir()
secureStatusTestDirPlatform(t, dir)
broadenStatusTestDirPlatform(t, dir)
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
info, err := root.Stat(".")
if err != nil {
t.Fatal(err)
}
err = checkStatusDirOwner(root, info)
if err == nil || !strings.Contains(err.Error(), "unexpected trustee") {
t.Fatalf("checkStatusDirOwner error = %v, want broad DACL rejection", err)
}
}
-169
View File
@@ -1,169 +0,0 @@
package daemon
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/Gitlawb/zero/internal/fsutil"
)
const (
statusTempPrefix = ".daemon-status-"
statusTempPattern = statusTempPrefix + "*"
)
func openStatusRoot(path string) (*os.Root, error) {
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return nil, fmt.Errorf("open status directory: %w", err)
}
if err := validateStatusRoot(root); err != nil {
_ = root.Close()
return nil, err
}
return root, nil
}
// writeStatusFileAtomicallyRoot publishes through a caller-owned, already
// bound directory root. The returned boolean crosses the commit boundary: once
// true, err is a durability/cleanup warning and the complete document is live.
func writeStatusFileAtomicallyRoot(
root *os.Root,
statusName string,
data []byte,
perm os.FileMode,
beforeReplace func(),
replace func(root *os.Root, src, dst string) error,
syncParent func(root *os.Root) error,
) (committed bool, returnErr error) {
if err := validateStatusRoot(root); err != nil {
return false, err
}
temp, tempName, err := createStatusTemp(root, perm)
if err != nil {
return false, err
}
closed := false
defer func() {
if !closed {
if err := temp.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close temporary status file during cleanup: %w", err))
}
closed = true
}
if err := root.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary status file: %w", err))
}
}()
if err := temp.Chmod(perm); err != nil {
return false, fmt.Errorf("set temporary status file permissions: %w", err)
}
if _, err := temp.Write(data); err != nil {
return false, fmt.Errorf("write temporary status file: %w", err)
}
if err := temp.Sync(); err != nil {
return false, fmt.Errorf("sync temporary status file: %w", err)
}
closeErr := temp.Close()
closed = true
if closeErr != nil {
return false, fmt.Errorf("close temporary status file: %w", closeErr)
}
if beforeReplace != nil {
beforeReplace()
}
if err := prepareStatusReplacement(root, tempName, statusName); err != nil {
return false, fmt.Errorf("prepare status replacement: %w", err)
}
rename := root.Rename
if replace != nil {
rename = func(src, dst string) error { return replace(root, src, dst) }
}
// On Windows an existing reader that omitted FILE_SHARE_DELETE can block
// this replacement even though an in-place write would succeed. Keep the
// atomic publication boundary: RenameWithRetry bounds transient sharing
// failures to ten short attempts, and a persistent failure leaves the old
// status document intact instead of exposing a partially written one.
var committedWarning error
if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil {
var committedReplacement *fsutil.CommittedReplacementCleanupError
if !errors.As(err, &committedReplacement) {
return false, fmt.Errorf("replace status file: %w", err)
}
committedWarning = fmt.Errorf("clean up replaced status file: %w", err)
}
committed = true
if syncParent == nil {
syncParent = syncStatusRoot
}
if err := syncParent(root); err != nil {
committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err))
}
if committedWarning != nil {
return committed, committedWarning
}
return committed, nil
}
func validateStatusRoot(root *os.Root) error {
info, err := root.Stat(".")
if err != nil {
return fmt.Errorf("inspect status directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("status directory is not a directory")
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("status directory permissions are %04o, want no group/other write access", info.Mode().Perm())
}
if err := checkStatusDirOwner(root, info); err != nil {
return err
}
return nil
}
func createStatusTemp(root *os.Root, perm os.FileMode) (*os.File, string, error) {
for range 100 {
var suffix [16]byte
if _, err := rand.Read(suffix[:]); err != nil {
return nil, "", fmt.Errorf("generate temporary status file name: %w", err)
}
name := statusTempPrefix + hex.EncodeToString(suffix[:])
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
if err == nil {
return file, name, nil
}
if errors.Is(err, os.ErrExist) {
continue
}
return nil, "", fmt.Errorf("create temporary status file: %w", err)
}
return nil, "", fmt.Errorf("create temporary status file: exhausted unique names")
}
// syncStatusRoot makes the replacement directory entry durable through the
// same bound root used for creation and replacement. Windows directory sync is
// best-effort because Go cannot fsync a directory there.
func syncStatusRoot(root *os.Root) error {
if runtime.GOOS == "windows" {
return nil
}
directory, err := root.Open(".")
if err != nil {
return err
}
syncErr := directory.Sync()
closeErr := directory.Close()
if syncErr != nil {
return syncErr
}
return closeErr
}
-476
View File
@@ -1,476 +0,0 @@
package daemon
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/fsutil"
"github.com/Gitlawb/zero/internal/privatedir"
)
func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
previous := []byte(`{"pid":7,"socket":"old.sock","version":1,"startedAt":"2026-08-01T00:00:00Z"}`)
if err := os.WriteFile(path, previous, 0o600); err != nil {
t.Fatal(err)
}
replaceErr := errors.New("injected replacement failure")
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Status: path},
Version: 2,
replaceStatusFile: func(root *os.Root, src, dst string) error {
staged, err := root.ReadFile(src)
if err != nil {
t.Fatalf("read staged status: %v", err)
}
var decoded StatusFile
if err := json.Unmarshal(staged, &decoded); err != nil {
t.Fatalf("staged status is incomplete JSON: %v", err)
}
current, err := root.ReadFile(dst)
if err != nil {
t.Fatalf("read previous status at commit boundary: %v", err)
}
if string(current) != string(previous) {
t.Fatalf("previous status changed before commit: %q", current)
}
return replaceErr
},
},
}
err := server.writeStatusFile()
if !errors.Is(err, replaceErr) {
t.Fatalf("writeStatusFile error = %v, want injected replacement failure", err)
}
current, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read preserved status: %v", err)
}
if string(current) != string(previous) {
t.Fatalf("status after failed publication = %q, want previous document", current)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFilePublishesCompleteRestrictedDocument(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o644); err != nil {
t.Fatal(err)
}
startedAt := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
parentSynced := false
server := &Server{
startedAt: startedAt,
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 3,
syncStatusParent: func(root *os.Root) error {
if _, err := root.Stat("."); err != nil {
t.Fatalf("stat bound status parent: %v", err)
}
parentSynced = true
return nil
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var status StatusFile
if err := json.Unmarshal(data, &status); err != nil {
t.Fatalf("published status is invalid JSON: %v", err)
}
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 3 || !status.StartedAt.Equal(startedAt) {
t.Fatalf("published status = %+v", status)
}
if !parentSynced {
t.Fatal("status parent directory was not synced")
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("status mode = %04o, want 0600", got)
}
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
previous := StatusFile{
PID: 7,
Socket: filepath.Join(dir, "old.sock"),
Version: 1,
StartedAt: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
}
previousData, err := json.Marshal(previous)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, previousData, 0o600); err != nil {
t.Fatal(err)
}
replacementReady := make(chan struct{})
publish := make(chan struct{})
writeDone := make(chan error, 1)
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "new.sock"), Status: path},
Version: 2,
replaceStatusFile: func(root *os.Root, src, dst string) error {
close(replacementReady)
<-publish
return fsutil.RenameWithRetry(src, dst, root.Rename)
},
},
}
go func() {
writeDone <- server.writeStatusFile()
}()
<-replacementReady
for range 100 {
status := readStatusDocument(t, path)
if status != previous {
t.Fatalf("status before commit = %+v, want previous document %+v", status, previous)
}
}
close(publish)
if err := <-writeDone; err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
status := readStatusDocument(t, path)
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 2 || !status.StartedAt.Equal(server.startedAt) {
t.Fatalf("status after commit = %+v", status)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
syncErr := errors.New("injected directory sync failure")
var logs []string
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 4,
Log: func(message string) { logs = append(logs, message) },
syncStatusParent: func(*os.Root) error { return syncErr },
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
}
status := readStatusDocument(t, path)
if status.Version != 4 {
t.Fatalf("published status = %+v, want version 4", status)
}
if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) {
t.Fatalf("logs = %q, want directory sync warning", logs)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
cleanupErr := errors.New("injected backup cleanup failure")
parentSynced := false
var logs []string
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 5,
Log: func(message string) { logs = append(logs, message) },
replaceStatusFile: func(root *os.Root, src, dst string) error {
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
return err
}
return &fsutil.CommittedReplacementCleanupError{
BackupPath: filepath.Join(dir, ".zero-replace-backup"),
Cause: cleanupErr,
}
},
syncStatusParent: func(*os.Root) error {
parentSynced = true
return nil
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
}
if !parentSynced {
t.Fatal("status directory was not synced after committed replacement warning")
}
status := readStatusDocument(t, path)
if status.Version != 5 {
t.Fatalf("published status = %+v, want version 5", status)
}
if len(logs) != 1 || !strings.Contains(logs[0], cleanupErr.Error()) {
t.Fatalf("logs = %q, want committed cleanup warning", logs)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileContinuesAfterCommittedTempCleanupWarning(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
var tempName string
var logs []string
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 6,
Log: func(message string) { logs = append(logs, message) },
replaceStatusFile: func(root *os.Root, src, dst string) error {
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
return err
}
tempName = src
if err := root.Mkdir(src, 0o700); err != nil {
t.Fatalf("recreate staged path as directory: %v", err)
}
keep, err := root.OpenFile(filepath.Join(src, "keep"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatalf("make staged directory non-empty: %v", err)
}
if err := keep.Close(); err != nil {
t.Fatalf("close staged directory marker: %v", err)
}
return nil
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
}
status := readStatusDocument(t, path)
if status.Version != 6 {
t.Fatalf("published status = %+v, want version 6", status)
}
if len(logs) != 1 || !strings.Contains(logs[0], "remove temporary status file") {
t.Fatalf("logs = %q, want temporary cleanup warning", logs)
}
if err := os.Remove(filepath.Join(dir, tempName, "keep")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(dir, tempName)); err != nil {
t.Fatal(err)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
var swapErr error
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 6,
beforeStatusReplace: func() {
swapErr = os.Rename(dir, movedDir)
if swapErr != nil {
if runtime.GOOS == "windows" {
return
}
t.Fatalf("move bound status directory: %v", swapErr)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute status directory: %v", err)
}
if err := os.WriteFile(path, substitute, 0o600); err != nil {
t.Fatalf("write substitute status: %v", err)
}
},
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
if swapErr != nil {
if runtime.GOOS != "windows" {
t.Fatalf("unexpected directory-swap error: %v", swapErr)
}
status := readStatusDocument(t, path)
if status.Version != 6 {
t.Fatalf("status after blocked swap = %+v, want version 6", status)
}
if _, err := os.Lstat(movedDir); !os.IsNotExist(err) {
t.Fatalf("moved directory exists after blocked Windows swap: %v", err)
}
assertNoStatusTemps(t, dir)
return
}
if got, err := os.ReadFile(path); err != nil {
t.Fatal(err)
} else if string(got) != string(substitute) {
t.Fatalf("substitute destination changed: %q", got)
}
status := readStatusDocument(t, filepath.Join(movedDir, "daemon.status"))
if status.Version != 6 {
t.Fatalf("bound status = %+v, want version 6", status)
}
assertNoStatusTemps(t, dir)
assertNoStatusTemps(t, movedDir)
}
func TestWriteStatusFileAllowsReadOnlyCustomStatusDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "daemon.status")
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Status: path},
Version: 6,
},
}
err := server.writeStatusFile()
if err != nil {
t.Fatalf("writeStatusFile in 0755 directory: %v", err)
}
if _, err := os.Lstat(path); err != nil {
t.Fatalf("status file not created in read-only custom directory: %v", err)
}
assertNoStatusTemps(t, dir)
}
func TestWriteStatusFileRejectsWritableStatusDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "daemon.status")
server := &Server{
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
opts: ServerOptions{Paths: Paths{Status: path}, Version: 6},
}
err := server.writeStatusFile()
if err == nil || !strings.Contains(err.Error(), "no group/other write access") {
t.Fatalf("writeStatusFile error = %v, want writable-directory rejection", err)
}
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("status file created in writable directory: %v", err)
}
assertNoStatusTemps(t, dir)
}
func TestPrivateDirHardensBroadCurrentUserStatusDirectory(t *testing.T) {
dir := t.TempDir()
broadenStatusTestDirPlatform(t, dir)
if err := privatedir.Ensure(dir); err != nil {
t.Fatalf("privatedir.Ensure: %v", err)
}
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := validateStatusRoot(root); err != nil {
t.Fatalf("validate hardened status root: %v", err)
}
}
func readStatusDocument(t *testing.T, path string) StatusFile {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read status: %v", err)
}
var status StatusFile
if err := json.Unmarshal(data, &status); err != nil {
t.Fatalf("status is incomplete JSON: %v (content %q)", err, data)
}
return status
}
func assertNoStatusTemps(t *testing.T, dir string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, statusTempPattern))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary status files remain: %v", matches)
}
}
func secureStatusTestDir(t *testing.T, dir string) {
t.Helper()
secureStatusTestDirPlatform(t, dir)
}
@@ -1,63 +0,0 @@
//go:build windows
package daemon
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestWriteStatusFilePreservesExistingProtectedDACL(t *testing.T) {
dir := t.TempDir()
secureStatusTestDir(t, dir)
path := filepath.Join(dir, "daemon.status")
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
t.Fatal(err)
}
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
if err != nil {
t.Skipf("cannot build restrictive DACL: %v", err)
}
dacl, _, err := restricted.DACL()
if err != nil {
t.Skipf("cannot read restrictive DACL: %v", err)
}
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil, nil, dacl, nil); err != nil {
t.Skipf("cannot apply restrictive DACL: %v", err)
}
want := statusDACL(t, path)
server := &Server{
startedAt: time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC),
opts: ServerOptions{
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
Version: 2,
},
}
if err := server.writeStatusFile(); err != nil {
t.Fatalf("writeStatusFile: %v", err)
}
if got := statusDACL(t, path); got != want {
t.Fatalf("status DACL after replacement = %q, want %q", got, want)
}
}
func statusDACL(t *testing.T, path string) string {
t.Helper()
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
t.Skipf("cannot read status DACL: %v", err)
}
text := descriptor.String()
if index := strings.Index(text, "D:"); index >= 0 {
return text[index:]
}
return text
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package daemon
import "os"
func prepareStatusReplacement(*os.Root, string, string) error { return nil }
-136
View File
@@ -1,136 +0,0 @@
//go:build windows
package daemon
import (
"errors"
"fmt"
"os"
"path/filepath"
"unsafe"
"golang.org/x/sys/windows"
)
// prepareStatusReplacement copies the existing status document's DACL to the
// complete staging file before Root.Rename replaces it. Both files are opened
// relative to the already-bound Root directory handle, so preserving Windows
// security metadata does not reintroduce a pathname/ancestor-swap boundary.
func prepareStatusReplacement(root *os.Root, src, dst string) (returnErr error) {
if filepath.Base(src) != src || filepath.Base(dst) != dst {
return fmt.Errorf("status replacement names must be root-relative base names")
}
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open bound status directory for DACL preservation: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close bound status directory for DACL preservation: %w", err))
}
}()
raw, err := directory.SyscallConn()
if err != nil {
return fmt.Errorf("access bound status directory handle: %w", err)
}
var destination, source windows.Handle
var openErr error
if err := raw.Control(func(rootHandle uintptr) {
destination, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), dst, windows.READ_CONTROL|windows.FILE_READ_ATTRIBUTES)
if isMissingStatusObject(openErr) {
openErr = nil // no destination descriptor exists to preserve
return
}
if openErr != nil {
return
}
source, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), src, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES)
}); err != nil {
return fmt.Errorf("open status files through bound directory: %w", err)
}
if destination != 0 {
defer windows.CloseHandle(destination)
}
if source != 0 {
defer windows.CloseHandle(source)
}
if openErr != nil {
return fmt.Errorf("open status files for DACL preservation: %w", openErr)
}
if destination == 0 {
return nil
}
descriptor, err := windows.GetSecurityInfo(destination, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("read existing status DACL: %w", err)
}
dacl, _, err := descriptor.DACL()
if err != nil {
return fmt.Errorf("decode existing status DACL: %w", err)
}
if dacl == nil {
return fmt.Errorf("existing status file has an unrestricted Windows DACL")
}
control, _, err := descriptor.Control()
if err != nil {
return fmt.Errorf("read existing status DACL control: %w", err)
}
securityInfo := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION)
if control&windows.SE_DACL_PROTECTED != 0 {
securityInfo |= windows.SECURITY_INFORMATION(windows.PROTECTED_DACL_SECURITY_INFORMATION)
} else {
securityInfo |= windows.SECURITY_INFORMATION(windows.UNPROTECTED_DACL_SECURITY_INFORMATION)
}
if err := windows.SetSecurityInfo(source, windows.SE_FILE_OBJECT, securityInfo, nil, nil, dacl, nil); err != nil {
return fmt.Errorf("preserve existing status DACL on replacement: %w", err)
}
return nil
}
func openStatusSecurityHandle(root windows.Handle, name string, access uint32) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return 0, err
}
attributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: root,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var handle windows.Handle
var status windows.IO_STATUS_BLOCK
err = windows.NtCreateFile(
&handle,
access,
attributes,
&status,
nil,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if err != nil {
return 0, err
}
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
windows.CloseHandle(handle)
return 0, err
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
windows.CloseHandle(handle)
return 0, fmt.Errorf("refusing status DACL operation on reparse point %q", name)
}
return handle, nil
}
func isMissingStatusObject(err error) bool {
return errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
}
-43
View File
@@ -224,49 +224,6 @@ func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContin
return result, nil
}
// WriteInput writes bytes to a retained interactive process's stdin without
// collecting output. Unlike Continue it never drains the pending output
// buffer, so a concurrent write_stdin poll still sees everything the process
// emitted; callers that only need the rolling tail use Snapshot instead.
func (manager *ProcessManager) WriteInput(id int, data []byte) error {
process, ok := manager.get(id)
if !ok {
return ErrProcessNotFound
}
process.touch()
if len(data) == 0 {
return nil
}
if !process.tty || process.stdin == nil {
return ErrProcessStdinDisabled
}
if _, err := process.stdin.Write(data); err != nil && !process.doneClosed() {
return err
}
return nil
}
// ResizeInput updates the PTY window size of a retained interactive process
// so an attached terminal can fill its viewport. Non-positive dimensions are
// a no-op; platforms without PTY support report the transport's error.
func (manager *ProcessManager) ResizeInput(id int, cols, rows int) error {
if cols <= 0 || rows <= 0 {
return nil
}
process, ok := manager.get(id)
if !ok {
return ErrProcessNotFound
}
process.touch()
if !process.tty || process.stdin == nil {
return ErrProcessStdinDisabled
}
if err := resizePTY(process.stdin, cols, rows); err != nil && !process.doneClosed() {
return err
}
return nil
}
func clampInitialProcessWait(wait time.Duration) time.Duration {
return min(wait, maxInteractiveYield)
}
-121
View File
@@ -2,7 +2,6 @@ package execution
import (
"context"
"errors"
"io"
"os"
"os/exec"
@@ -86,126 +85,6 @@ func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
}
}
func TestProcessManagerWriteInputUnknownProcess(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
if err := manager.WriteInput(4242, []byte("x")); !errors.Is(err, ErrProcessNotFound) {
t.Fatalf("WriteInput unknown id = %v, want ErrProcessNotFound", err)
}
}
func TestProcessManagerWriteInputRejectsPipeProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer manager.Stop(started.ProcessID)
if started.TTY {
t.Fatal("pipe process reported a TTY")
}
if err := manager.WriteInput(started.ProcessID, []byte("x")); !errors.Is(err, ErrProcessStdinDisabled) {
t.Fatalf("WriteInput pipe process = %v, want ErrProcessStdinDisabled", err)
}
if err := manager.WriteInput(started.ProcessID, nil); err != nil {
t.Fatalf("WriteInput empty data = %v, want nil", err)
}
}
func TestProcessManagerWriteInputDoesNotDrainPendingOutput(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("PTY sessions are only available on Linux")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.CommandContext(context.Background(), "cat")
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
CommandText: "cat", TTY: true,
}, 50*time.Millisecond)
if err != nil {
t.Skipf("PTY transport unavailable: %v", err)
}
if !started.TTY {
t.Skip("PTY transport fell back to pipes")
}
defer manager.Stop(started.ProcessID)
if err := manager.WriteInput(started.ProcessID, []byte("hello\r")); err != nil {
t.Fatalf("WriteInput: %v", err)
}
// The PTY echoes input back into the output stream, so the rolling recent
// tail shows what was typed without anything being drained.
deadline := time.Now().Add(5 * time.Second)
for {
snapshot, ok := manager.Snapshot(started.ProcessID)
if ok && strings.Contains(snapshot.RecentOutput, "hello") {
break
}
if time.Now().After(deadline) {
t.Fatalf("recent output never echoed the input: %q", snapshot.RecentOutput)
}
time.Sleep(10 * time.Millisecond)
}
// WriteInput must not have consumed the pending buffer: a write_stdin-style
// Continue still collects the echoed bytes.
continued, err := manager.Continue(context.Background(), ProcessContinue{
ProcessID: started.ProcessID, Wait: 200 * time.Millisecond,
})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if !strings.Contains(continued.Output, "hello") {
t.Fatalf("WriteInput drained the pending output: continued output = %q", continued.Output)
}
}
func TestProcessManagerResizeInputUnknownProcess(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
if err := manager.ResizeInput(4242, 100, 40); !errors.Is(err, ErrProcessNotFound) {
t.Fatalf("ResizeInput unknown id = %v, want ErrProcessNotFound", err)
}
}
func TestProcessManagerResizeInputUpdatesWindowSize(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("PTY sessions are only available on Linux")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.CommandContext(context.Background(), "/bin/sh", "-c", "sleep 0.3; stty size")
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
CommandText: "stty size", TTY: true,
}, time.Millisecond)
if err != nil {
t.Skipf("PTY transport unavailable: %v", err)
}
if !started.TTY {
t.Skip("PTY transport fell back to pipes")
}
defer manager.Stop(started.ProcessID)
if err := manager.ResizeInput(started.ProcessID, 100, 40); err != nil {
t.Fatalf("ResizeInput: %v", err)
}
continued, err := manager.Continue(context.Background(), ProcessContinue{
ProcessID: started.ProcessID, Wait: 2 * time.Second,
})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if combined := started.Output + continued.Output; !strings.Contains(combined, "40 100") {
t.Fatalf("stty size output = %q, want %q", combined, "40 100")
}
}
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
reaped := make(chan struct{})
close(reaped)
-4
View File
@@ -11,7 +11,3 @@ import (
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty transport is unavailable on this platform")
}
func resizePTY(_ io.Writer, _, _ int) error {
return errors.New("pty transport is unavailable on this platform")
}
-16
View File
@@ -48,9 +48,6 @@ func openPTY() (*os.File, *os.File, error) {
_ = master.Close()
return nil, nil, err
}
// A fresh PTY defaults to a 0x0 window; seed a sane size so the process
// never sees zero dimensions before an attached client reports its own.
_ = unix.IoctlSetWinsize(masterFD, unix.TIOCSWINSZ, &unix.Winsize{Row: 24, Col: 80})
pts, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN)
if err != nil {
_ = master.Close()
@@ -83,16 +80,3 @@ func hardenPTYProcess(command *exec.Cmd) {
return nil
}
}
// resizePTY reports a new window size to the PTY behind an interactive
// process's stdin writer, so full-screen attached terminals drive the
// session's own line wrapping.
func resizePTY(w io.Writer, cols, rows int) error {
file, ok := w.(interface{ Fd() uintptr })
if !ok {
return errors.New("pty transport is unavailable on this platform")
}
return unix.IoctlSetWinsize(int(file.Fd()), unix.TIOCSWINSZ, &unix.Winsize{
Row: uint16(rows), Col: uint16(cols),
})
}
-57
View File
@@ -64,28 +64,6 @@ func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
if err != nil {
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
}
return acquireOpenedFileLock(file)
}
// TryAcquireFileLockRoot attempts to lock name relative to an already-bound
// directory root. It is intended for lifecycle owners that must not resolve the
// root pathname again between validation and lock acquisition.
func TryAcquireFileLockRoot(root *os.Root, name, displayPath string) (*FileLock, error) {
if root == nil {
return nil, errors.New("lockutil: lock root is nil")
}
name = filepath.Clean(name)
if name == "." || name == ".." || filepath.IsAbs(name) || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("lockutil: lock name %q escapes root", name)
}
file, err := openLockFileRoot(root, name, displayPath)
if err != nil {
return nil, fmt.Errorf("lockutil: open rooted lock file: %w", err)
}
return acquireOpenedFileLock(file)
}
func acquireOpenedFileLock(file *os.File) (*FileLock, error) {
state, contended, err := tryLockFile(file)
if err != nil {
_ = file.Close()
@@ -104,41 +82,6 @@ func acquireOpenedFileLock(file *os.File) (*FileLock, error) {
return lock, nil
}
func openLockFileRoot(root *os.Root, name, displayPath string) (*os.File, error) {
info, err := root.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
file, createErr := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
if createErr != nil {
return nil, createErr
}
if err := validateRootLockFile(file); err != nil {
return nil, errors.Join(err, file.Close())
}
return file, nil
}
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("refusing non-regular rooted lock file")
}
file, err := root.OpenFile(name, os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
opened, err := file.Stat()
if err != nil {
return nil, errors.Join(err, file.Close())
}
if !os.SameFile(info, opened) {
return nil, errors.Join(errors.New("rooted lock file changed while opening"), file.Close())
}
if err := validateRootLockFile(file); err != nil {
return nil, errors.Join(err, file.Close())
}
return file, nil
}
// WriteMetadata replaces the diagnostic contents of the lock file while the
// caller holds it. Metadata does not establish ownership; the kernel lock does.
func (lock *FileLock) WriteMetadata(data []byte) error {
-14
View File
@@ -76,20 +76,6 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
return file, nil
}
func validateRootLockFile(file *os.File) error {
var stat unix.Stat_t
if err := unix.Fstat(int(file.Fd()), &stat); err != nil {
return fmt.Errorf("inspect rooted lock file: %w", err)
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
return errors.New("refusing non-regular rooted lock file")
}
if stat.Nlink != 1 {
return errors.New("refusing multiply-linked rooted lock file")
}
return nil
}
func tryLockFile(file *os.File) (platformLockState, bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
-4
View File
@@ -73,10 +73,6 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
return file, nil
}
func validateRootLockFile(file *os.File) error {
return validateWindowsLockHandle(windows.Handle(file.Fd()), false)
}
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
handle, err := openLockPathAt(
parent,
-3
View File
@@ -216,9 +216,6 @@ func (client *networkClient) request(ctx context.Context, method string, params
if err != nil {
return err
}
if message.isRequestOrNotification() {
return fmt.Errorf("MCP %s expected a response from server %s, got method %q instead", method, client.server.Name, message.Method)
}
if !rpcIDMatches(message.ID, id) {
return fmt.Errorf("MCP %s response id mismatch for server %s", method, client.server.Name)
}
-44
View File
@@ -316,50 +316,6 @@ func TestNonOAuthServerIsUnaffected(t *testing.T) {
}
}
func TestNetworkClientRejectsServerInitiatedRequestAsResponse(t *testing.T) {
// A streamable-HTTP server that answers a tools/call POST with a
// method-bearing frame (a server-initiated request) must surface a protocol
// error rather than an empty, error-free result.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
message := readHTTPRPCMessage(t, r)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, w, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/call":
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(mustRaw(message.ID)) + `,"method":"roots/list","params":{}}`)); err != nil {
t.Errorf("write server request: %v", err)
}
default:
writeHTTPRPCResponse(t, w, message.ID, map[string]any{})
}
}))
defer upstream.Close()
client, err := Connect(ctx, Server{Name: "plain", Type: ServerTypeHTTP, URL: upstream.URL})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer func() {
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}()
result, err := client.CallTool(ctx, "lookup", map[string]any{"query": "zero"})
if err == nil {
t.Fatalf("CallTool() error = nil, result = %#v, want protocol error", result)
}
if !strings.Contains(err.Error(), `"roots/list"`) {
t.Fatalf("CallTool() error = %q, want it to name the unexpected method", err)
}
}
func TestDecodeSSERPCMessageSkipsNotifications(t *testing.T) {
// A leading server notification (has a method) on the POST's event stream must
// be skipped so the actual response (no method) is returned, instead of the
+4 -30
View File
@@ -20,7 +20,6 @@ type LoopbackListener struct {
state string
result chan callbackResult
server *http.Server
done chan struct{}
}
type callbackResult struct {
@@ -39,14 +38,6 @@ func NewLoopbackListener(state string) (*LoopbackListener, error) {
// port (0 = OS-assigned). Used by ChatGPT OAuth which requires a fixed
// redirect_uri of http://localhost:1455/auth/callback.
func NewLoopbackListenerOnPort(state string, port int) (*LoopbackListener, error) {
return newLoopbackListener(state, port, nil)
}
// newLoopbackListener is NewLoopbackListenerOnPort with an optional ConnState
// hook, set before the Serve goroutine starts so tests can synchronize on
// connection lifecycle events (e.g. a stalled request header being accepted)
// without racing the running server.
func newLoopbackListener(state string, port int, connState func(net.Conn, http.ConnState)) (*LoopbackListener, error) {
if strings.TrimSpace(state) == "" {
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
}
@@ -58,20 +49,9 @@ func newLoopbackListener(state string, port int, connState func(net.Conn, http.C
listener: ln,
state: state,
result: make(chan callbackResult, 1),
done: make(chan struct{}),
}
l.server = &http.Server{
Handler: http.HandlerFunc(l.handle),
// This is a single-use, loopback-only callback server: a client that
// never finishes sending its request header must not be able to hold
// the accepted connection (and Close, below) open indefinitely.
ReadHeaderTimeout: 5 * time.Second,
ConnState: connState,
}
go func() {
_ = l.server.Serve(ln)
close(l.done)
}()
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
go func() { _ = l.server.Serve(ln) }()
return l, nil
}
@@ -119,17 +99,11 @@ func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
}
}
// Close shuts the listener down (bounded), idempotent. If a client leaves a
// connection open past the shutdown deadline (e.g. a stalled request header),
// Close forcibly closes it rather than leaking it past the end of the flow,
// and waits for the Serve goroutine to finish before returning.
// Close shuts the listener down (bounded), idempotent.
func (l *LoopbackListener) Close() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := l.server.Shutdown(shutdownCtx); err != nil {
_ = l.server.Close()
}
<-l.done
_ = l.server.Shutdown(shutdownCtx)
}
// parseCallback validates the redirect query and returns the authorization code,
-99
View File
@@ -3,9 +3,6 @@ package oauth
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"testing"
@@ -125,99 +122,3 @@ func TestLoopbackProviderError(t *testing.T) {
t.Fatalf("Wait err = %v, want provider error", err)
}
}
func TestLoopbackCloseForceClosesSlowHeaderConnection(t *testing.T) {
accepted := make(chan struct{}, 1)
l, err := newLoopbackListener("audit-state", 0, func(_ net.Conn, state http.ConnState) {
// StateNew fires as soon as Serve accepts the connection, before it
// blocks reading the (never-completed) header. That's the earliest
// point at which net/http's Shutdown will treat this connection as
// non-idle, which is what this test needs to exercise.
if state == http.StateNew {
select {
case accepted <- struct{}{}:
default:
}
}
})
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
t.Cleanup(l.Close)
addr := l.listener.Addr().String()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
// Wait for Serve to accept the connection before sending a request line
// and a header key with no terminating CRLF, so Serve is guaranteed to
// be blocked reading a partial header (rather than a fixed sleep guessing
// that Accept has run).
select {
case <-accepted:
case <-time.After(3 * time.Second):
t.Fatal("server never accepted the connection")
}
fmt.Fprintf(conn, "GET /callback?code=audit-code&state=audit-state HTTP/1.1\r\nHost: %s\r\nX-Stall:", addr)
started := time.Now()
l.Close()
if elapsed := time.Since(started); elapsed < 900*time.Millisecond {
t.Fatalf("Close returned after %s; expected it to wait out its one-second shutdown deadline first", elapsed)
}
conn.SetReadDeadline(time.Now().Add(time.Second))
var one [1]byte
_, readErr := conn.Read(one[:])
if readErr == nil {
t.Fatal("read after Close succeeded; wanted the stalled connection to be closed")
}
if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() {
t.Fatalf("read after Close timed out (%v); wanted the stalled connection closed rather than left open", readErr)
}
}
func TestLoopbackCloseIsIdempotent(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
l.Close()
l.Close() // must not panic or hang
}
func TestLoopbackCloseReturnsPromptlyAfterSuccessfulCallback(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
t.Cleanup(l.Close)
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
resp, err := http.Get(l.RedirectURI() + "?code=x&state=s")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := l.Wait(ctx); err != nil {
t.Fatalf("Wait: %v", err)
}
// Join the client goroutine so the response is fully read (and its
// connection returned to idle) before timing Close, instead of racing it.
select {
case <-requestDone:
case <-time.After(3 * time.Second):
t.Fatal("http.Get goroutine did not complete")
}
started := time.Now()
l.Close()
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("Close took %s after a completed, idle callback; want a prompt return", elapsed)
}
}
+5 -225
View File
@@ -7,50 +7,17 @@
package observability
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
"time"
"github.com/Gitlawb/zero/internal/privatedir"
)
// crashExitCode is returned when a top-level panic is recovered.
const crashExitCode = 1
const crashTempPrefix = ".crash-report-"
// ErrCrashReportCommitted marks an error that happened after a complete crash
// report was published. Callers may still use the returned path and should
// surface the error as a cleanup warning rather than as a failed publication.
var ErrCrashReportCommitted = errors.New("crash report publication committed")
type crashReportCommittedError struct {
cause error
}
func (err *crashReportCommittedError) Error() string {
return fmt.Sprintf("%v with warning: %v", ErrCrashReportCommitted, err.cause)
}
func (err *crashReportCommittedError) Unwrap() []error {
return []error{ErrCrashReportCommitted, err.cause}
}
type crashReportHooks struct {
beforePublish func()
write func(*os.File, []byte) (int, error)
link func(*os.Root, string, string) error
renameNoReplace func(*os.Root, string, string) error
remove func(*os.Root, string) error
}
// FormatCrashReport renders a human-readable crash report.
func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time) string {
return fmt.Sprintf("zero crash report\ntime: %s\nlabel: %s\npanic: %v\n\nstack:\n%s\n",
@@ -59,189 +26,16 @@ func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time)
// WriteCrashReport writes a crash report file into dir and returns its path.
func WriteCrashReport(dir, label string, recovered any, stack []byte, ts time.Time) (string, error) {
return writeCrashReport(dir, label, recovered, stack, ts, crashReportHooks{})
}
func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Time, hooks crashReportHooks) (path string, returnErr error) {
absoluteDir, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("resolve crash report directory: %w", err)
}
root, err := openCrashDirectory(dir)
if err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
committed := false
defer func() {
if err := root.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close crash report directory: %w", err))
}
if committed && returnErr != nil && !errors.Is(returnErr, ErrCrashReportCommitted) {
returnErr = &crashReportCommittedError{cause: returnErr}
}
if !committed && returnErr != nil {
path = ""
}
}()
name := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
path = filepath.Join(dir, name)
report, tempName, err := createCrashTemp(root)
if err != nil {
path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log")
if err := os.WriteFile(path, []byte(FormatCrashReport(label, recovered, stack, ts)), 0o600); err != nil {
return "", err
}
defer func() {
if tempName == "" {
return
}
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary crash report: %w", err))
}
}()
data := []byte(FormatCrashReport(label, recovered, stack, ts))
writeReport := hooks.write
if writeReport == nil {
writeReport = func(file *os.File, data []byte) (int, error) { return file.Write(data) }
}
written, err := writeReport(report, data)
if err == nil && written != len(data) {
err = io.ErrShortWrite
}
if err != nil {
closeErr := report.Close()
if closeErr != nil {
return "", errors.Join(fmt.Errorf("write crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
}
return "", fmt.Errorf("write crash report: %w", err)
}
if err := report.Sync(); err != nil {
closeErr := report.Close()
if closeErr != nil {
return "", errors.Join(fmt.Errorf("sync crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
}
return "", fmt.Errorf("sync crash report: %w", err)
}
if err := report.Close(); err != nil {
return "", fmt.Errorf("close crash report: %w", err)
}
if hooks.beforePublish != nil {
hooks.beforePublish()
}
publishName := name
link := root.Link
if hooks.link != nil {
link = func(oldname, newname string) error { return hooks.link(root, oldname, newname) }
}
if linkErr := link(tempName, name); linkErr != nil {
publishName, err = publishCrashFallback(root, name, tempName, hooks.renameNoReplace)
if err != nil {
return "", errors.Join(fmt.Errorf("publish crash report with hard link: %w", linkErr), err)
}
committed = true
tempName = ""
} else {
committed = true
}
path = filepath.Join(dir, publishName)
if !crashPathUsesRoot(root, absoluteDir) {
path = ""
}
if tempName != "" {
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
tempName = ""
return path, fmt.Errorf("remove temporary crash report: %w", err)
}
tempName = ""
}
return path, nil
}
// publishCrashFallback atomically renames the complete staging file to an
// unpredictable unused name. The no-replace operation is the collision check;
// there is no check-then-rename window in which another report can be replaced.
func publishCrashFallback(root *os.Root, timestampName, tempName string, rename func(*os.Root, string, string) error) (string, error) {
if rename == nil {
rename = renameNoReplace
}
base := strings.TrimSuffix(timestampName, filepath.Ext(timestampName))
if suffix := strings.TrimPrefix(tempName, crashTempPrefix); suffix != "" && suffix != tempName {
candidate := base + "-" + suffix + filepath.Ext(timestampName)
if err := rename(root, tempName, candidate); err == nil {
return candidate, nil
} else if !errors.Is(err, os.ErrExist) {
return "", fmt.Errorf("publish fallback crash report: %w", err)
}
}
for range 100 {
var suffix [16]byte
if _, err := rand.Read(suffix[:]); err != nil {
return "", fmt.Errorf("generate fallback crash report name: %w", err)
}
candidate := base + "-" + hex.EncodeToString(suffix[:]) + filepath.Ext(timestampName)
if err := rename(root, tempName, candidate); err == nil {
return candidate, nil
} else if !errors.Is(err, os.ErrExist) {
return "", fmt.Errorf("publish fallback crash report: %w", err)
}
}
return "", fmt.Errorf("generate fallback crash report name: exhausted unique names")
}
func removeCrashTemp(hooks crashReportHooks, root *os.Root, name string) error {
if hooks.remove != nil {
return hooks.remove(root, name)
}
return root.Remove(name)
}
func createCrashTemp(root *os.Root) (*os.File, string, error) {
for range 100 {
var suffix [16]byte
if _, err := rand.Read(suffix[:]); err != nil {
return nil, "", fmt.Errorf("generate temporary crash report name: %w", err)
}
name := crashTempPrefix + hex.EncodeToString(suffix[:])
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
return file, name, nil
}
if errors.Is(err, os.ErrExist) {
continue
}
return nil, "", fmt.Errorf("create temporary crash report: %w", err)
}
return nil, "", fmt.Errorf("create temporary crash report: exhausted unique names")
}
func crashPathUsesRoot(root *os.Root, dir string) bool {
bound, err := root.Stat(".")
if err != nil {
return false
}
current, err := os.Stat(filepath.Clean(dir))
return err == nil && os.SameFile(bound, current)
}
func openCrashDirectory(dir string) (*os.Root, error) {
clean := filepath.Clean(dir)
defaultDir := filepath.Clean(DefaultCrashDir())
parent := filepath.Dir(clean)
// The default layout shares ~/.zero with the daemon runtime fallback. Keep
// both the shared parent and the crash-report child private. Custom crash
// destinations are hardened at the caller-supplied boundary only.
if clean == defaultDir && filepath.Base(clean) == "crashes" && filepath.Base(parent) == ".zero" {
if err := privatedir.Ensure(parent); err != nil {
return nil, fmt.Errorf("secure crash report parent: %w", err)
}
}
root, err := privatedir.Open(clean)
if err != nil {
return nil, fmt.Errorf("secure crash report directory: %w", err)
}
return root, nil
}
// DefaultCrashDir is where crash reports are written by default.
func DefaultCrashDir() string {
if home, err := os.UserHomeDir(); err == nil && home != "" {
@@ -260,22 +54,8 @@ func Recover(dir, label string, stderr io.Writer, code *int) {
return
}
stack := debug.Stack()
reportRecoveredCrash(stderr, code, recovered, stack, func() (string, error) {
return WriteCrashReport(dir, label, recovered, stack, time.Now())
})
}
func reportRecoveredCrash(stderr io.Writer, code *int, recovered any, stack []byte, write func() (string, error)) {
path, err := write()
if err == nil || errors.Is(err, ErrCrashReportCommitted) {
if path != "" {
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
} else {
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved, but its current path could not be determined\n", recovered)
}
if err != nil {
fmt.Fprintf(stderr, "Warning: %v\n", err)
}
if path, err := WriteCrashReport(dir, label, recovered, stack, time.Now()); err == nil {
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
} else {
fmt.Fprintf(stderr, "zero crashed: %v\n%s\n", recovered, stack)
}
-419
View File
@@ -2,10 +2,7 @@ package observability
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -30,400 +27,6 @@ func TestWriteAndFormatCrashReport(t *testing.T) {
}
}
func TestWriteCrashReportKeepsTwoReportsFromTheSameSecond(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC)
first, err := WriteCrashReport(dir, "cli", "first panic", []byte("first stack"), ts)
if err != nil {
t.Fatal(err)
}
second, err := WriteCrashReport(dir, "cli", "second panic", []byte("second stack"), ts)
if err != nil {
t.Fatalf("second report in the same second: %v", err)
}
if first == second {
t.Fatalf("same-second reports shared path %q", first)
}
firstData, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
secondData, err := os.ReadFile(second)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(firstData), "first panic") || strings.Contains(string(firstData), "second panic") {
t.Fatalf("first report was replaced: %q", firstData)
}
if !strings.Contains(string(secondData), "second panic") {
t.Fatalf("second report was not persisted: %q", secondData)
}
}
func TestWriteCrashReportCreatesPrivateDefaultDirectories(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
dir := DefaultCrashDir()
if dir != filepath.Join(home, ".zero", "crashes") {
t.Fatalf("DefaultCrashDir = %q, want path beneath temporary home", dir)
}
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
if runtime.GOOS == "windows" {
return
}
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got&0o077 != 0 {
t.Fatalf("directory %s permissions = %04o, want owner-only", path, got)
}
}
}
func TestWriteCrashReportHardensPreexistingDefaultDirectories(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows DACL migration is covered by the daemon integration test")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
dir := DefaultCrashDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
if err := os.Chmod(path, 0o755); err != nil {
t.Fatal(err)
}
}
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
t.Fatalf("WriteCrashReport: %v", err)
}
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got&0o077 != 0 {
t.Fatalf("directory %s permissions = %04o after migration, want owner-only", path, got)
}
}
}
func TestWriteCrashReportBindsDirectoryDuringSwap(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
var swapErr error
path, err := writeCrashReport(dir, "cli", "secret panic", []byte("secret stack"), ts, crashReportHooks{
beforePublish: func() {
swapErr = os.Rename(dir, movedDir)
if swapErr != nil {
return
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute crash directory: %v", err)
}
},
})
if err != nil {
t.Fatalf("writeCrashReport: %v", err)
}
if swapErr != nil {
if runtime.GOOS != "windows" {
t.Fatalf("swap crash directory: %v", swapErr)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("report missing after Windows blocked directory swap: %v", err)
}
return
}
if path != "" {
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
}
reportName := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
data, err := os.ReadFile(filepath.Join(movedDir, reportName))
if err != nil {
t.Fatalf("read report through originally bound directory: %v", err)
}
if !strings.Contains(string(data), "secret panic") {
t.Fatalf("report in bound directory missing panic: %q", data)
}
if _, err := os.Stat(filepath.Join(dir, reportName)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("substitute directory received crash report: %v", err)
}
}
func TestWriteCrashReportPublishesOnlyCompleteContent(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
finalPath := filepath.Join(dir, "crash-20260824-120000.log")
staged := make(chan struct{})
release := make(chan struct{})
result := make(chan crashWriteResult, 1)
go func() {
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
beforePublish: func() {
close(staged)
<-release
},
})
result <- crashWriteResult{path: path, err: err}
}()
select {
case <-staged:
case written := <-result:
t.Fatalf("writeCrashReport returned before publication hook: %v", written.err)
case <-time.After(time.Second):
t.Fatal("writeCrashReport did not reach publication hook")
}
defer func() {
select {
case <-release:
default:
close(release)
}
}()
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final report visible before complete publication: %v", err)
}
close(release)
written := <-result
if written.err != nil {
t.Fatalf("writeCrashReport: %v", written.err)
}
data, err := os.ReadFile(written.path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("published report is incomplete: %q", data)
}
}
func TestWriteCrashReportRemovesTempAfterWriteFailure(t *testing.T) {
dir := t.TempDir()
injected := errors.New("injected write failure")
_, err := writeCrashReport(dir, "cli", "boom", []byte("stack"), time.Now(), crashReportHooks{
write: func(*os.File, []byte) (int, error) { return 0, injected },
})
if !errors.Is(err, injected) {
t.Fatalf("writeCrashReport error = %v, want injected failure", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("crash directory contains partial files after failure: %v", entries)
}
}
func TestWriteCrashReportDoesNotOverwriteExistingReport(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
path := filepath.Join(dir, "crash-20260824-120000.log")
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
published, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), ts)
if err != nil {
t.Fatalf("WriteCrashReport with existing timestamp: %v", err)
}
if published == path {
t.Fatal("new report reused the occupied timestamp path")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "existing" {
t.Fatalf("existing report overwritten: %q", data)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("crash directory should contain existing and uniquely published reports: %v", entries)
}
}
func TestWriteCrashReportPreservesPathAfterCommittedCleanupWarning(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
injected := errors.New("injected cleanup failure")
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
remove: func(*os.Root, string) error { return injected },
})
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
}
wantPath := filepath.Join(dir, "crash-20260824-120000.log")
if path != wantPath {
t.Fatalf("writeCrashReport path = %q, want %q", path, wantPath)
}
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("read committed crash report: %v", readErr)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("committed crash report is incomplete: %q", data)
}
}
func TestWriteCrashReportDoesNotReturnStalePathWithCleanupWarning(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "live")
movedDir := filepath.Join(parent, "moved")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
injected := errors.New("injected cleanup failure")
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
var swapErr error
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
beforePublish: func() {
swapErr = os.Rename(dir, movedDir)
if swapErr == nil {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create substitute crash directory: %v", err)
}
}
},
remove: func(*os.Root, string) error { return injected },
})
if swapErr != nil {
if runtime.GOOS == "windows" {
t.Skipf("Windows kept the open crash directory from being renamed: %v", swapErr)
}
t.Fatalf("swap crash directory: %v", swapErr)
}
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
}
if path != "" {
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
}
report := filepath.Join(movedDir, "crash-20260824-120000.log")
data, readErr := os.ReadFile(report)
if readErr != nil {
t.Fatalf("read committed report through moved directory: %v", readErr)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("committed report is incomplete: %q", data)
}
}
func TestWriteCrashReportFallsBackWhenHardLinksAreUnavailable(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
unsupported := errors.New("hard links unavailable")
var collisionName string
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
link: func(root *os.Root, oldname, _ string) error {
suffix := strings.TrimPrefix(oldname, crashTempPrefix)
collisionName = "crash-20260824-120000-" + suffix + ".log"
if err := root.WriteFile(collisionName, []byte("existing"), 0o600); err != nil {
t.Fatalf("create fallback-name collision: %v", err)
}
return unsupported
},
})
if err != nil {
t.Fatalf("writeCrashReport fallback: %v", err)
}
if path == filepath.Join(dir, collisionName) {
t.Fatalf("fallback replaced the existing report %q", collisionName)
}
if !strings.HasPrefix(filepath.Base(path), "crash-20260824-120000-") {
t.Fatalf("fallback path = %q, want timestamp and random suffix", path)
}
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("read fallback crash report: %v", readErr)
}
if !strings.Contains(string(data), "complete stack") {
t.Fatalf("fallback crash report is incomplete: %q", data)
}
existing, readErr := os.ReadFile(filepath.Join(dir, collisionName))
if readErr != nil {
t.Fatalf("read existing collision: %v", readErr)
}
if string(existing) != "existing" {
t.Fatalf("existing fallback collision was overwritten: %q", existing)
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 2 {
t.Fatalf("crash directory contains partial files after fallback: %v", entries)
}
}
func TestWriteCrashReportFallbackDoesNotReplaceCommitTimeCollision(t *testing.T) {
dir := t.TempDir()
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
unsupported := errors.New("hard links unavailable")
var collisionName string
attempts := 0
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
link: func(*os.Root, string, string) error { return unsupported },
renameNoReplace: func(root *os.Root, oldname, newname string) error {
attempts++
if attempts == 1 {
collisionName = newname
if err := root.WriteFile(newname, []byte("concurrent report"), 0o600); err != nil {
t.Fatalf("create commit-time collision: %v", err)
}
}
return renameNoReplace(root, oldname, newname)
},
})
if err != nil {
t.Fatalf("writeCrashReport fallback: %v", err)
}
if attempts < 2 {
t.Fatalf("fallback rename attempts = %d, want collision retry", attempts)
}
if filepath.Base(path) == collisionName {
t.Fatalf("fallback replaced the concurrent report %q", collisionName)
}
existing, err := os.ReadFile(filepath.Join(dir, collisionName))
if err != nil {
t.Fatal(err)
}
if string(existing) != "concurrent report" {
t.Fatalf("concurrent report overwritten: %q", existing)
}
published, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(published), "complete stack") {
t.Fatalf("published fallback is incomplete: %q", published)
}
}
type crashWriteResult struct {
path string
err error
}
func TestRecoverCapturesPanic(t *testing.T) {
dir := t.TempDir()
var stderr bytes.Buffer
@@ -460,25 +63,3 @@ func TestRecoverNoPanicIsNoop(t *testing.T) {
t.Fatalf("unexpected output without a panic: %q", stderr.String())
}
}
func TestReportRecoveredCrashReportsCommittedCleanupWarning(t *testing.T) {
var stderr bytes.Buffer
code := 0
injected := errors.New("injected cleanup failure")
reportRecoveredCrash(&stderr, &code, "kaboom", []byte("secret stack"), func() (string, error) {
return "/tmp/crash.log", &crashReportCommittedError{cause: injected}
})
if code != crashExitCode {
t.Fatalf("exit code = %d, want %d", code, crashExitCode)
}
output := stderr.String()
for _, want := range []string{"kaboom", "saved to /tmp/crash.log", "Warning:", injected.Error()} {
if !strings.Contains(output, want) {
t.Fatalf("crash notice missing %q: %q", want, output)
}
}
if strings.Contains(output, "secret stack") {
t.Fatalf("committed report warning fell back to inline stack: %q", output)
}
}
@@ -1,19 +0,0 @@
//go:build darwin
package observability
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open crash directory for publication: %w", err)
}
defer directory.Close()
return unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL)
}
@@ -1,19 +0,0 @@
//go:build linux
package observability
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open crash directory for publication: %w", err)
}
defer directory.Close()
return unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE)
}
@@ -1,12 +0,0 @@
//go:build !darwin && !linux && !windows
package observability
import (
"errors"
"os"
)
func renameNoReplace(*os.Root, string, string) error {
return errors.ErrUnsupported
}
@@ -1,84 +0,0 @@
//go:build windows
package observability
import (
"errors"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
type fileRenameInformation struct {
replaceIfExists uint32
rootDirectory windows.Handle
fileNameLength uint32
fileName [1]uint16
}
func renameNoReplace(root *os.Root, oldname, newname string) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return err
}
defer directory.Close()
raw, err := directory.SyscallConn()
if err != nil {
return err
}
if err := raw.Control(func(rawHandle uintptr) {
rootHandle := windows.Handle(rawHandle)
oldObjectName, nameErr := windows.NewNTUnicodeString(oldname)
if nameErr != nil {
returnErr = nameErr
return
}
attributes := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: rootHandle,
ObjectName: oldObjectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
}
var source windows.Handle
var status windows.IO_STATUS_BLOCK
returnErr = windows.NtCreateFile(
&source,
windows.DELETE|windows.SYNCHRONIZE,
attributes,
&status,
nil,
0,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
0,
0,
)
if returnErr != nil {
return
}
defer windows.CloseHandle(source)
newName, nameErr := windows.UTF16FromString(newname)
if nameErr != nil {
returnErr = nameErr
return
}
nameBytes := (len(newName) - 1) * 2
var layout fileRenameInformation
bufferSize := int(unsafe.Offsetof(layout.fileName)) + nameBytes
buffer := make([]byte, bufferSize)
info := (*fileRenameInformation)(unsafe.Pointer(&buffer[0]))
info.rootDirectory = rootHandle
info.fileNameLength = uint32(nameBytes)
copy((*[windows.MAX_LONG_PATH]uint16)(unsafe.Pointer(&info.fileName[0]))[:nameBytes/2:nameBytes/2], newName)
returnErr = windows.NtSetInformationFile(source, &status, &buffer[0], uint32(bufferSize), windows.FileRenameInformation)
}); err != nil {
return err
}
if errors.Is(returnErr, windows.STATUS_OBJECT_NAME_COLLISION) || errors.Is(returnErr, windows.STATUS_OBJECT_NAME_EXISTS) {
return os.ErrExist
}
return returnErr
}
-85
View File
@@ -1,85 +0,0 @@
// Package privatedir creates and safely hardens current-user-owned state
// directories without applying permission changes through a path-only check.
package privatedir
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// Ensure creates path when needed and enforces owner-only access. Existing
// directories are hardened only after ownership is verified through a bound
// directory handle; foreign-owned paths fail closed.
func Ensure(path string) error {
root, err := Open(path)
if err != nil {
return err
}
if err := root.Close(); err != nil {
return fmt.Errorf("close private directory: %w", err)
}
return nil
}
// Open creates path when needed, enforces owner-only access, and returns a
// traversal-resistant handle that callers can retain across subsequent I/O.
func Open(path string) (*os.Root, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve private directory: %w", err)
}
if err := os.MkdirAll(absolute, 0o700); err != nil {
return nil, fmt.Errorf("create private directory: %w", err)
}
before, err := os.Lstat(absolute)
if err != nil {
return nil, fmt.Errorf("inspect private directory entry: %w", err)
}
if before.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("private directory entry is a symbolic link")
}
if !before.IsDir() {
return nil, fmt.Errorf("private directory path is not a directory")
}
root, err := os.OpenRoot(absolute)
if err != nil {
return nil, fmt.Errorf("open private directory: %w", err)
}
opened, err := root.Stat(".")
if err != nil {
_ = root.Close()
return nil, fmt.Errorf("inspect opened private directory: %w", err)
}
after, err := os.Lstat(absolute)
if err != nil {
_ = root.Close()
return nil, fmt.Errorf("reinspect private directory entry: %w", err)
}
if after.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, after) {
_ = root.Close()
return nil, fmt.Errorf("private directory entry changed while opening")
}
if err := harden(root); err != nil {
closeErr := root.Close()
if closeErr != nil {
return nil, errors.Join(err, fmt.Errorf("close private directory: %w", closeErr))
}
return nil, err
}
current, err := os.Lstat(absolute)
if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
closeErr := root.Close()
if err == nil {
err = fmt.Errorf("private directory entry changed while hardening")
} else {
err = fmt.Errorf("verify private directory entry after hardening: %w", err)
}
if closeErr != nil {
err = errors.Join(err, fmt.Errorf("close private directory: %w", closeErr))
}
return nil, err
}
return root, nil
}
-35
View File
@@ -1,35 +0,0 @@
package privatedir
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestOpenRejectsFinalDirectorySymlinkWithoutHardeningTarget(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating directory symlinks requires privileges on some Windows runners")
}
parent := t.TempDir()
target := filepath.Join(parent, "target")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(parent, "private")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := Open(link); err == nil || !strings.Contains(err.Error(), "symbolic link") {
t.Fatalf("Open symlink error = %v, want symbolic-link rejection", err)
}
info, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o755 {
t.Fatalf("symlink target permissions = %04o, want unchanged 0755", got)
}
}
-47
View File
@@ -1,47 +0,0 @@
//go:build !windows
package privatedir
import (
"errors"
"fmt"
"os"
"syscall"
)
func harden(root *os.Root) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open private directory hardening handle: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory hardening handle: %w", err))
}
}()
info, err := directory.Stat()
if err != nil {
return fmt.Errorf("inspect private directory before hardening: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("private directory path is not a directory")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("private directory ownership metadata is unavailable")
}
if int(stat.Uid) != os.Geteuid() {
return fmt.Errorf("private directory is owned by uid %d, not the current user", stat.Uid)
}
if err := directory.Chmod(0o700); err != nil {
return fmt.Errorf("harden private directory permissions: %w", err)
}
info, err = directory.Stat()
if err != nil {
return fmt.Errorf("verify private directory permissions: %w", err)
}
if info.Mode().Perm()&0o077 != 0 {
return fmt.Errorf("private directory permissions are %04o, want owner-only", info.Mode().Perm())
}
return nil
}
-135
View File
@@ -1,135 +0,0 @@
//go:build windows
package privatedir
import (
"errors"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
func harden(root *os.Root) (returnErr error) {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open private directory hardening handle: %w", err)
}
defer func() {
if err := directory.Close(); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory hardening handle: %w", err))
}
}()
raw, err := directory.SyscallConn()
if err != nil {
return fmt.Errorf("access private directory hardening handle: %w", err)
}
// Root.Open uses NtCreateFile, so ReOpenFile cannot accept its handle. An
// empty relative NT object name reopens the bound directory itself with the
// security rights needed for the DACL update and no pathname re-resolution.
var securityHandle windows.Handle
var openErr error
if err := raw.Control(func(rawHandle uintptr) {
objectName, nameErr := windows.NewNTUnicodeString("")
if nameErr != nil {
openErr = nameErr
return
}
openErr = windows.NtCreateFile(
&securityHandle,
windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
&windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: windows.Handle(rawHandle),
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
},
&windows.IO_STATUS_BLOCK{},
nil,
windows.FILE_ATTRIBUTE_DIRECTORY,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
windows.FILE_OPEN,
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT,
0,
0,
)
}); err != nil {
return fmt.Errorf("open private directory security handle: %w", err)
}
if openErr != nil {
return fmt.Errorf("open private directory with security access: %w", openErr)
}
if securityHandle == 0 || securityHandle == windows.InvalidHandle {
return fmt.Errorf("open private directory with security access: invalid handle")
}
defer func() {
if err := windows.CloseHandle(securityHandle); err != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory security handle: %w", err))
}
}()
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return fmt.Errorf("resolve current Windows user: %w", err)
}
tokenOwner, err := windowsTokenOwner(token)
if err != nil {
return fmt.Errorf("resolve current Windows token owner: %w", err)
}
current, err := windows.GetSecurityInfo(securityHandle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("read private directory owner before hardening: %w", err)
}
owner, _, err := current.Owner()
if err != nil {
return fmt.Errorf("read private directory owner before hardening: %w", err)
}
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
return fmt.Errorf("private directory is not owned by the current Windows token")
}
desired, err := windows.SecurityDescriptorFromString(
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
)
if err != nil {
return fmt.Errorf("build private Windows directory DACL: %w", err)
}
dacl, _, err := desired.DACL()
if err != nil {
return fmt.Errorf("read private Windows directory DACL: %w", err)
}
if err := windows.SetSecurityInfo(
securityHandle,
windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
nil,
nil,
dacl,
nil,
); err != nil {
return fmt.Errorf("harden private Windows directory DACL: %w", err)
}
return nil
}
type tokenOwnerInfo struct {
owner *windows.SID
}
func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
var size uint32
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
if err != windows.ERROR_INSUFFICIENT_BUFFER {
return nil, err
}
buffer := make([]byte, size)
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
return nil, err
}
owner := (*tokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
+2 -6
View File
@@ -90,7 +90,6 @@ type Options struct {
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
StreamIdleTimeout time.Duration
SessionHeader string
}
// Provider streams completions from Anthropic's Messages API.
@@ -109,7 +108,6 @@ type Provider struct {
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
sessionHeader providerio.SessionHeader
}
// New creates an Anthropic provider.
@@ -145,7 +143,6 @@ func New(options Options) (*Provider, error) {
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
}, nil
}
@@ -166,12 +163,12 @@ func (provider *Provider) StreamCompletion(
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
provider.stream(ctx, body, request.PromptCacheKey, events)
provider.stream(ctx, body, events)
}()
return events, nil
}
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
// the request, which unblocks the SSE reader goroutine.
streamCtx, cancelStream := context.WithCancel(ctx)
@@ -188,7 +185,6 @@ func (provider *Provider) stream(ctx context.Context, body []byte, sessionID str
},
provider.oauthResolver,
func(request *http.Request) {
provider.sessionHeader.Apply(request, sessionID)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("anthropic-version", provider.version)
if provider.beta != "" {
-20
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/Gitlawb/zero/internal/config"
@@ -54,11 +53,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
return newCodexProvider(profile, resolved, options)
}
sessionHeader := sessionHeaderForProfile(profile, resolved)
if sessionHeader != "" && strings.TrimSpace(options.UserAgent) == "" {
options.UserAgent = "zero"
}
switch resolved.providerKind {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
// prompt_cache_key is an OpenAI-only chat-completions field. Strict
@@ -80,7 +74,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
UserAgent: options.UserAgent,
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
DisablePromptCacheKey: resolved.providerKind == config.ProviderKindOpenAICompatible,
SessionHeader: sessionHeader,
})
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return anthropic.New(anthropic.Options{
@@ -95,7 +88,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
SessionHeader: sessionHeader,
})
case config.ProviderKindGoogle:
return gemini.New(gemini.Options{
@@ -353,18 +345,6 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
return providercatalog.NormalizeID(profile.CatalogID) == "chatgpt"
}
func sessionHeaderForProfile(profile config.ProviderProfile, resolved resolvedProfile) string {
switch providercatalog.NormalizeID(profile.CatalogID) {
case "opencode", "opencode-go", "opencode-go-anthropic-compatible":
return "x-opencode-session"
}
endpoint, err := url.Parse(resolved.baseURL)
if err == nil && strings.EqualFold(endpoint.Hostname(), "opencode.ai") && (endpoint.Path == "/zen" || strings.HasPrefix(endpoint.Path, "/zen/")) {
return "x-opencode-session"
}
return ""
}
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
-567
View File
@@ -3,17 +3,13 @@ package providers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -798,566 +794,3 @@ func TestResolveRuntimeMetadataRejectsModelOutsideProviderAllowlist(t *testing.T
t.Fatalf("error = %q, want it to name the rejected model", err.Error())
}
}
func TestOpenCodeSessionHeader(t *testing.T) {
const sessionHeader = "x-opencode-session"
tests := []struct {
name string
profile config.ProviderProfile
response string
wantHeader bool
wantAuth map[string]string
}{
{
name: "catalog opencode-go default URL",
profile: config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc-go",
Model: "deepseek-v4-pro",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-oc-go"},
},
{
name: "catalog opencode-go-anthropic-compatible",
profile: config.ProviderProfile{
Name: "opencode-go-anthropic",
CatalogID: "opencode-go-anthropic-compatible",
ProviderKind: config.ProviderKindAnthropicCompat,
APIKey: "sk-oc-ant",
Model: "minimax-m3",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
wantHeader: true,
wantAuth: map[string]string{"x-api-key": "sk-oc-ant"},
},
{
name: "catalog opencode Zen",
profile: config.ProviderProfile{
Name: "opencode-zen",
CatalogID: "opencode",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-zen",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-zen"},
},
{
name: "renamed catalog profile with custom proxy URL",
profile: config.ProviderProfile{
Name: "my-opencode",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://proxy.example/v1",
APIKey: "sk-proxy",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-proxy"},
},
{
name: "catalog-less OpenAI-compatible OpenCode Go URL",
profile: config.ProviderProfile{
Name: "manual-go",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opencode.ai/zen/go/v1",
APIKey: "sk-manual",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: true,
wantAuth: map[string]string{"Authorization": "Bearer sk-manual"},
},
{
name: "catalog-less Anthropic-compatible OpenCode Go URL",
profile: config.ProviderProfile{
Name: "manual-go-anthropic",
ProviderKind: config.ProviderKindAnthropicCompat,
BaseURL: "https://opencode.ai/zen/go",
APIKey: "sk-manual-ant",
Model: "test-model",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
wantHeader: true,
wantAuth: map[string]string{"x-api-key": "sk-manual-ant"},
},
{
name: "official OpenAI",
profile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
APIKey: "sk-openai",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-openai"},
},
{
name: "generic OpenAI-compatible",
profile: config.ProviderProfile{
Name: "generic",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://provider.example/v1",
APIKey: "sk-generic",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-generic"},
},
{
name: "official Anthropic",
profile: config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
APIKey: "sk-ant",
Model: "test-model",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
wantHeader: false,
wantAuth: map[string]string{"x-api-key": "sk-ant"},
},
{
name: "lookalike subdomain is not OpenCode",
profile: config.ProviderProfile{
Name: "deceptive-host",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opencode.ai.example/zen/go/v1",
APIKey: "sk-fake",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
},
{
name: "lookalike path is not OpenCode",
profile: config.ProviderProfile{
Name: "deceptive-path",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://example.com/opencode.ai/zen/go",
APIKey: "sk-fake",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
},
{
name: "zenith path is not Zen",
profile: config.ProviderProfile{
Name: "zenith",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opencode.ai/zenith",
APIKey: "sk-zenith",
Model: "test-model",
},
response: "data: [DONE]\n\n",
wantHeader: false,
wantAuth: map[string]string{"Authorization": "Bearer sk-zenith"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
transport := &captureTransport{responseBody: test.response}
provider, err := New(test.profile, Options{
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
got := transport.request.Header.Get(sessionHeader)
if test.wantHeader && got != "conversation-a" {
t.Fatalf("%s = %q, want %q", sessionHeader, got, "conversation-a")
}
if !test.wantHeader && got != "" {
t.Fatalf("%s = %q, want no session header", sessionHeader, got)
}
for header, want := range test.wantAuth {
if got := transport.request.Header.Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
if got := transport.request.Header.Get("User-Agent"); got != "zero-test" {
t.Fatalf("User-Agent = %q, want zero-test", got)
}
if test.profile.ProviderKind == config.ProviderKindOpenAICompatible {
var body map[string]any
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if _, ok := body["prompt_cache_key"]; ok {
t.Fatalf("openai-compatible body carried prompt_cache_key: %#v", body["prompt_cache_key"])
}
}
})
}
}
func TestOpenCodeSessionHeaderFollowsConversationKey(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
profile := config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}
options := Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"}
provider, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
for _, key := range []string{"conversation-a", "conversation-a", "conversation-b"} {
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: key,
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
}
got := transport.sessionHeaders()
want := []string{"conversation-a", "conversation-a", "conversation-b"}
if len(got) != len(want) {
t.Fatalf("captured %d requests, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("request %d x-opencode-session = %q, want %q", i, got[i], want[i])
}
}
reconstructed, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := reconstructed.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
got = transport.sessionHeaders()
if last := got[len(got)-1]; last != "conversation-a" {
t.Fatalf("reconstructed provider sent %s = %q, want conversation-a", "x-opencode-session", last)
}
}
func TestOpenCodeSessionHeaderCustomOverride(t *testing.T) {
custom := map[string]string{"X-Opencode-Session": "explicit-session"}
profile := config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
CustomHeaders: custom,
}
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(profile, Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
if got := transport.request.Header.Get("x-opencode-session"); got != "explicit-session" {
t.Fatalf("x-opencode-session = %q, want explicit custom value", got)
}
if len(custom) != 1 || custom["X-Opencode-Session"] != "explicit-session" {
t.Fatalf("profile CustomHeaders mutated: %#v", custom)
}
}
func TestOpenCodeSessionHeaderFallback(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
profile := config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}
options := Options{HTTPClient: &http.Client{Transport: transport}}
first, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
second, err := New(profile, options)
if err != nil {
t.Fatalf("New() error = %v", err)
}
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
}
for _, provider := range []zeroruntime.Provider{first, first, second} {
stream, err := provider.StreamCompletion(context.Background(), request)
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
}
headers := transport.sessionHeaders()
if len(headers) != 3 {
t.Fatalf("captured %d requests, want 3", len(headers))
}
if headers[0] == "" || headers[1] != headers[0] {
t.Fatalf("fallback headers = %q, %q — want one stable nonempty value per provider", headers[0], headers[1])
}
if headers[2] == headers[0] {
t.Fatalf("distinct provider instances shared fallback %q", headers[0])
}
}
func TestOpenCodeSessionHeaderDefaultsUserAgent(t *testing.T) {
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
if got := transport.request.Header.Get("User-Agent"); got != "zero" {
t.Fatalf("User-Agent = %q, want zero", got)
}
}
func TestOpenCodeSessionHeaderConcurrent(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
var wg sync.WaitGroup
var errsMu sync.Mutex
var errs []string
for _, session := range []string{"session-a", "session-b"} {
wg.Add(1)
go func(session string) {
defer wg.Done()
for i := 0; i < 5; i++ {
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("req-%s-%d", session, i)}},
PromptCacheKey: session,
})
if err != nil {
errsMu.Lock()
errs = append(errs, err.Error())
errsMu.Unlock()
return
}
for event := range stream {
if event.Type == zeroruntime.StreamEventError {
errsMu.Lock()
errs = append(errs, event.Error)
errsMu.Unlock()
}
}
}
}(session)
}
wg.Wait()
for _, errText := range errs {
t.Errorf("concurrent stream error: %s", errText)
}
requests := transport.recorded()
if len(requests) != 10 {
t.Fatalf("captured %d requests, want 10", len(requests))
}
for _, request := range requests {
var payload struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal([]byte(request.body), &payload); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(payload.Messages) == 0 {
t.Fatal("request carried no user message")
}
content := payload.Messages[0].Content
want := "session-a"
if strings.Contains(content, "session-b") {
want = "session-b"
}
if got := request.header.Get("x-opencode-session"); got != want {
t.Fatalf("request for %q carried x-opencode-session %q, want %q", content, got, want)
}
}
}
func TestOpenCodeSessionHeaderAuthRetry(t *testing.T) {
for _, test := range []struct {
name string
profile config.ProviderProfile
response string
}{
{
name: "openai-compatible",
profile: config.ProviderProfile{
Name: "opencode-go",
CatalogID: "opencode-go",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "sk-oc",
Model: "test-model",
},
response: "data: [DONE]\n\n",
},
{
name: "anthropic-compatible",
profile: config.ProviderProfile{
Name: "opencode-go-anthropic",
CatalogID: "opencode-go-anthropic-compatible",
ProviderKind: config.ProviderKindAnthropicCompat,
APIKey: "sk-oc-ant",
Model: "minimax-m3",
},
response: "data: {\"type\":\"message_stop\"}\n\n",
},
} {
t.Run(test.name, func(t *testing.T) {
transport := &recordingTransport{status: http.StatusOK, body: test.response, unauthorized: 1}
var calls atomic.Int32
resolver := providerio.TokenResolver(func(context.Context, bool) (string, string, bool, error) {
n := calls.Add(1)
return "Authorization", fmt.Sprintf("Bearer tok-%d", n), true, nil
})
provider, err := New(test.profile, Options{
HTTPClient: &http.Client{Transport: transport},
OAuthResolver: resolver,
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
PromptCacheKey: "conversation-a",
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
drainStream(t, stream)
requests := transport.recorded()
if len(requests) != 2 {
t.Fatalf("captured %d requests, want 2 (401 + retry)", len(requests))
}
for i, request := range requests {
if got := request.header.Get("x-opencode-session"); got != "conversation-a" {
t.Fatalf("attempt %d x-opencode-session = %q, want conversation-a", i, got)
}
}
if first, second := requests[0].header.Get("Authorization"), requests[1].header.Get("Authorization"); first == second {
t.Fatalf("Authorization not refreshed across retry: %q", first)
}
})
}
}
func drainStream(t *testing.T, stream <-chan zeroruntime.StreamEvent) {
t.Helper()
for event := range stream {
if event.Type == zeroruntime.StreamEventError {
t.Fatalf("stream error: %s", event.Error)
}
}
}
type recordedRequest struct {
header http.Header
body string
}
type recordingTransport struct {
mu sync.Mutex
requests []recordedRequest
status int
body string
unauthorized int
}
func (transport *recordingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
var body string
if request.Body != nil {
data, _ := io.ReadAll(request.Body)
body = string(data)
}
transport.mu.Lock()
transport.requests = append(transport.requests, recordedRequest{header: request.Header.Clone(), body: body})
attempt := len(transport.requests)
transport.mu.Unlock()
status := transport.status
if attempt <= transport.unauthorized {
status = http.StatusUnauthorized
}
return &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d", status),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(transport.body)),
Request: request,
}, nil
}
func (transport *recordingTransport) recorded() []recordedRequest {
transport.mu.Lock()
defer transport.mu.Unlock()
return append([]recordedRequest(nil), transport.requests...)
}
func (transport *recordingTransport) sessionHeaders() []string {
requests := transport.recorded()
headers := make([]string, 0, len(requests))
for _, request := range requests {
headers = append(headers, request.header.Get("x-opencode-session"))
}
return headers
}
+2 -6
View File
@@ -65,7 +65,6 @@ type Options struct {
// enabled; the ZERO_DISABLE_PROMPT_CACHE_KEY env kill switch still applies
// on top for any endpoint.
DisablePromptCacheKey bool
SessionHeader string
}
// Provider streams completions from an OpenAI-compatible chat completions API.
@@ -86,7 +85,6 @@ type Provider struct {
parseThinkTags bool
setRequestExtra func(*http.Request)
disablePromptCacheKey bool
sessionHeader providerio.SessionHeader
}
// New creates an OpenAI-compatible provider.
@@ -142,7 +140,6 @@ func New(options Options) (*Provider, error) {
parseThinkTags: options.ParseThinkTags,
setRequestExtra: options.SetRequestExtra,
disablePromptCacheKey: options.DisablePromptCacheKey,
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
}, nil
}
@@ -159,13 +156,13 @@ func (provider *Provider) StreamCompletion(
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
provider.stream(ctx, body, request.PromptCacheKey, events)
provider.stream(ctx, body, events)
}()
return events, nil
}
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
endpoint := provider.endpoint
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
@@ -189,7 +186,6 @@ func (provider *Provider) stream(ctx context.Context, body []byte, sessionID str
},
provider.oauthResolver,
func(request *http.Request) {
provider.sessionHeader.Apply(request, sessionID)
request.Header.Set("Content-Type", "application/json")
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
-23
View File
@@ -1,7 +1,6 @@
package providerio
import (
"crypto/rand"
"net/http"
"strings"
)
@@ -53,28 +52,6 @@ func ApplyAuthHeaders(request *http.Request, options AuthHeaders) {
request.Header.Set(header, value)
}
type SessionHeader struct {
name string
fallback string
}
func NewSessionHeader(name string) SessionHeader {
if name == "" {
return SessionHeader{}
}
return SessionHeader{name: name, fallback: rand.Text()}
}
func (header SessionHeader) Apply(request *http.Request, sessionID string) {
if header.name == "" {
return
}
if sessionID = strings.TrimSpace(sessionID); sessionID == "" {
sessionID = header.fallback
}
request.Header.Set(header.name, sessionID)
}
func CopyHeaders(headers map[string]string) map[string]string {
if headers == nil {
return nil
@@ -169,53 +169,10 @@ var sharedHTTPClient = func() *http.Client {
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
// Periodically close idle connections to prevent stale HTTP/2
// connections from causing PROTOCOL_ERROR on the next request.
// OpenRouter (and other HTTP/2 backends) can silently RST or
// GOAWAY a connection that has been idle in the pool; the next
// request over that connection gets a PROTOCOL_ERROR. Closing
// idle connections every 30s forces a fresh dial so the error
// window is bounded to ~30s while reuse still benefits healthy
// connections. No-op on darwin where DisableKeepAlives already
// prevents pooling.
if runtime.GOOS != "darwin" {
startIdleConnCloser(transport, defaultIdleConnCloseInterval)
}
transport.DisableKeepAlives = runtime.GOOS == "darwin"
return &http.Client{Transport: transport}
}()
const defaultIdleConnCloseInterval = 30 * time.Second
// startIdleConnCloser launches a background goroutine that periodically closes
// idle keep-alive connections on transport until the returned stop function is
// invoked.
func startIdleConnCloser(transport *http.Transport, interval time.Duration) func() {
if interval <= 0 || transport == nil {
return func() {}
}
ticker := time.NewTicker(interval)
done := make(chan struct{})
go func() {
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
transport.CloseIdleConnections()
}
}
}()
return func() {
select {
case <-done:
default:
close(done)
}
}
}
// HTTPClient returns the configured client or the shared, stall-hardened default.
func HTTPClient(client *http.Client) *http.Client {
if client != nil {
@@ -6,8 +6,6 @@ import (
"io"
"math"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"strings"
"testing"
@@ -373,97 +371,3 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
t.Fatal("an explicit client must be returned unchanged")
}
}
// startIdleConnCloser periodically closes idle pooled connections so stale HTTP/2
// connections are not reused across long idle periods.
func TestStartIdleConnCloserClosesIdleConnections(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DisableKeepAlives = false
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr}
// First request creates a connection in the pool.
resp1, err := client.Get(server.URL)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
_, _ = io.Copy(io.Discard, resp1.Body)
_ = resp1.Body.Close()
// Immediate second request reuses the pooled connection.
var reusedFirst bool
trace1 := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
reusedFirst = info.Reused
},
}
req2, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace1), http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("new request failed: %v", err)
}
resp2, err := client.Do(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
_, _ = io.Copy(io.Discard, resp2.Body)
_ = resp2.Body.Close()
if !reusedFirst {
t.Fatal("expected immediate second request to reuse keep-alive connection")
}
// Start closer with a short interval and wait for it to fire.
stop := startIdleConnCloser(tr, 20*time.Millisecond)
defer stop()
// Poll until the idle closer evicts the connection and a fresh dial occurs.
deadline := time.Now().Add(2 * time.Second)
var closed bool
for time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
var reused bool
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
reused = info.Reused
},
}
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("new request failed: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if !reused {
closed = true
break
}
}
if !closed {
t.Fatal("expected connection to be closed by idle closer and not reused")
}
}
func TestStartIdleConnCloserEdgeCases(t *testing.T) {
// Zero or negative interval returns a no-op stop func without starting goroutine.
stopZero := startIdleConnCloser(nil, 0)
stopZero()
tr := http.DefaultTransport.(*http.Transport).Clone()
stopNil := startIdleConnCloser(tr, -1*time.Second)
stopNil()
// Calling stop multiple times is idempotent.
stop := startIdleConnCloser(tr, 100*time.Millisecond)
stop()
stop()
}
+2 -20
View File
@@ -33,14 +33,8 @@ type WindowsACLEntry struct {
// down onto the target's EXISTING descendants (not just new ones it
// creates going forward), which is why direct-only denies must set this
// flag rather than rely on inheritance.
NoInherit bool `json:"noInherit,omitempty"`
// Anchor is the write root Path was DERIVED from, for the paths this
// package constructs rather than the operator naming. The apply requires
// the object it finally opens to still live under it, so a reparse point
// planted on the derived tail cannot walk an elevated ACL write out of the
// sandbox. Empty for an operator-named path, which has no owned tail.
Anchor string `json:"anchor,omitempty"`
Materialize bool `json:"materialize,omitempty"`
NoInherit bool `json:"noInherit,omitempty"`
Materialize bool `json:"materialize,omitempty"`
}
type WindowsACLPlan struct {
@@ -63,22 +57,10 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er
Capability: capability.SID,
})
for _, path := range capability.ProtectedWriteDenyPaths {
// ANCHORED ONLY WHERE THE ANCHOR IS TRUE. Most of these are derived
// from the root and sit under it, and holding those inside it is the
// point. But ReadOnlySubpaths is a profile field an operator can set to
// any path, and one deliberately placed outside the root is a
// configuration that works today; anchoring it would turn that into a
// containment refusal. A path that is not under the root gets no
// anchor and keeps the final-component guard it always had.
anchor := ""
if pathWithinRoot(capability.Root, path) {
anchor = capability.Root
}
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: path,
Capability: capability.SID,
Anchor: anchor,
})
}
}
+1 -26
View File
@@ -23,10 +23,7 @@ const (
)
type windowsACLPathGroup struct {
Path string
// Anchor is the write root Path was derived from, empty when the operator
// named the path. See verifyWindowsACLHandleUnderAnchor.
Anchor string
Path string
Entries []WindowsACLEntry
Materialize bool
}
@@ -72,12 +69,6 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup {
}
group.Entries = append(group.Entries, entry)
group.Materialize = group.Materialize || entry.Materialize
// One path is derived from at most one root, so the first anchor seen is
// the anchor. Taking it rather than overwriting keeps a later
// operator-named duplicate of the same path from clearing it.
if group.Anchor == "" {
group.Anchor = entry.Anchor
}
}
out := make([]windowsACLPathGroup, 0, len(byPath))
for _, group := range byPath {
@@ -113,13 +104,6 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
}
return windowsACLSnapshot{}, false, nil
}
// Before creating anything: os.MkdirAll walks a pathname and follows every
// reparse point on it, so a junction on the derived tail would have this
// elevated setup create the directory outside the write root and only the
// containment check below would notice, after the fact.
if err := verifyWindowsACLPathUnderAnchor(group.Anchor, path); err != nil {
return windowsACLSnapshot{}, false, err
}
if err := os.MkdirAll(path, 0o700); err != nil {
return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err)
}
@@ -139,15 +123,6 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo
}
return windowsACLSnapshot{}, false, err
}
// THE OBJECT HAS TO BE WHERE THE PLAN SAID IT WOULD BE. The open above
// refuses a reparse point at the final component and resolves every one
// above it, which is right for a path the operator named and not enough for
// one this package derived from a write root: a junction on the derived tail
// redirects the handle out of the sandbox with nothing about the final
// object looking wrong.
if err := verifyWindowsACLHandleUnderAnchor(handle, group.Anchor, path); err != nil {
return fail(err)
}
descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
if err != nil {
return fail(fmt.Errorf("read windows ACL for %s: %w", path, err))
@@ -1,228 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/windows"
)
// Keeping an elevated ACL write inside the tree it was planned for.
//
// FILE_FLAG_OPEN_REPARSE_POINT GUARDS ONE COMPONENT, AND A PATH HAS MANY. The
// apply opens its target with that flag, which stops the FINAL component from
// being followed and refuses the handle when it is a reparse point. Every
// component above it is resolved normally, as it must be for any absolute path
// to work at all.
//
// That is enough for a target the operator named, and not enough for one the
// sandbox derived. The write-root carveouts are derived: <root>/.git/hooks and
// <root>/.git/config are constructed from a root, and <root>/.git is a name an
// unprivileged workspace writer can create. `mklink /J` needs no privilege, so
// a junction there makes the apply open <junction-target>/hooks instead, and
// `zero sandbox setup` writes a deny-write ACE, as Administrator, on an object
// outside the workspace. The final-component flag never fires: the object at
// the end of the walk is an ordinary directory.
//
// So the derived targets carry the root they were derived FROM, and the object
// finally opened has to still be inside it. The check is on the handle, not on
// the name: GetFinalPathNameByHandle answers where the object this process is
// holding actually lives, and both sides of the comparison go through it so the
// spelling is normalized the same way (\\?\ prefix, long names, drive letter).
// A junction anywhere along the derived tail moves the answer out of the root
// and the apply refuses.
//
// The rule is deliberately not "no reparse point anywhere in the path". Above
// the write root the path belongs to the operator, who may legitimately keep a
// workspace under a junction or a mapped directory, and refusing that would
// break setups this has nothing to say about. Only the tail the sandbox itself
// appended is held strict.
// windowsFinalPathByHandle reports where an open handle's object actually lives.
func windowsFinalPathByHandle(handle windows.Handle) (string, error) {
buffer := make([]uint16, windows.MAX_LONG_PATH)
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0)
if err != nil {
return "", err
}
if int(n) > len(buffer) {
buffer = make([]uint16, n)
n, err = windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0)
if err != nil {
return "", err
}
}
return windows.UTF16ToString(buffer[:n]), nil
}
// windowsFinalPathOf opens path without following a final-component reparse
// point and reports where the object lives. A missing path is surfaced as
// os.ErrNotExist so callers can walk up to a parent that exists.
func windowsFinalPathOf(path string) (string, error) {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return "", fmt.Errorf("encode windows path %s: %w", path, err)
}
handle, err := windows.CreateFile(
utf16Path,
windows.READ_CONTROL,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return "", fmt.Errorf("open windows path %s: %w", path, err)
}
defer func() { _ = windows.CloseHandle(handle) }()
final, err := windowsFinalPathByHandle(handle)
if err != nil {
return "", fmt.Errorf("resolve windows path %s: %w", path, err)
}
return final, nil
}
// windowsACLTailUnderAnchor returns the components target adds to anchor, and
// whether target is under anchor lexically at all.
func windowsACLTailUnderAnchor(anchor, target string) (string, bool) {
relative, err := filepath.Rel(filepath.Clean(anchor), filepath.Clean(target))
if err != nil {
return "", false
}
if relative == "." {
return "", true
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", false
}
return relative, true
}
// windowsACLContainmentError reports a derived target that resolved outside the
// root it was derived from.
type windowsACLContainmentError struct {
Anchor string
Target string
Actual string
}
func (err windowsACLContainmentError) Error() string {
return fmt.Sprintf(
"refusing to apply ACL to %s: it resolves to %s, outside the write root %s; "+
"a reparse point on the way down redirects an elevated ACL write out of the sandbox",
err.Target, err.Actual, err.Anchor)
}
// verifyWindowsACLHandleUnderAnchor refuses a handle whose object does not live
// where the plan said it would.
//
// anchor empty means the target was named rather than derived, and there is no
// owned tail to hold strict; the final-component check the caller already did is
// the whole guard for those.
func verifyWindowsACLHandleUnderAnchor(handle windows.Handle, anchor, target string) error {
if strings.TrimSpace(anchor) == "" {
return nil
}
tail, ok := windowsACLTailUnderAnchor(anchor, target)
if !ok {
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: target}
}
anchorFinal, err := windowsFinalPathOf(anchor)
if err != nil {
return fmt.Errorf("resolve write root %s: %w", anchor, err)
}
targetFinal, err := windowsFinalPathByHandle(handle)
if err != nil {
return fmt.Errorf("resolve windows ACL target %s: %w", target, err)
}
expected := anchorFinal
if tail != "" {
expected = filepath.Join(anchorFinal, tail)
}
if !strings.EqualFold(filepath.Clean(targetFinal), filepath.Clean(expected)) {
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: targetFinal}
}
return nil
}
// verifyWindowsACLPathUnderAnchor makes the same check against the deepest
// component of target that exists, before anything is created.
//
// Materialization creates a missing target with os.MkdirAll, which walks a
// pathname and follows every reparse point on it, so without this an elevated
// setup creates the directory on the far side of a junction and only the
// containment check afterwards notices. The window between this check and that
// create is not closed here; closing it needs the components created relative to
// retained handles, which is the rooted descent tracked in #808.
func verifyWindowsACLPathUnderAnchor(anchor, target string) error {
if strings.TrimSpace(anchor) == "" {
return nil
}
if _, ok := windowsACLTailUnderAnchor(anchor, target); !ok {
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: target}
}
existing := filepath.Clean(target)
for {
if _, err := os.Lstat(existing); err == nil {
break
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect windows ACL target %s: %w", existing, err)
}
parent := filepath.Dir(existing)
if parent == existing {
return nil
}
existing = parent
}
tail, ok := windowsACLTailUnderAnchor(anchor, existing)
if !ok || tail == "" {
// Above or at the write root, which is the operator's to arrange.
return nil
}
handle, err := windowsACLOpenForContainment(existing)
if err != nil {
return err
}
defer func() { _ = windows.CloseHandle(handle) }()
// THE ANCESTOR ITSELF IS THE REDIRECTION, WHICH IS WHY ITS NAME LOOKS RIGHT.
//
// Asking only where this object lives answers "exactly where you asked": the
// open above does not follow a final-component reparse point, so a junction
// at <root>/.git comes back as <root>/.git. os.MkdirAll does follow it, and
// creates the missing components on the far side. So what disqualifies this
// ancestor is that it IS a reparse point, not where it reports living.
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return fmt.Errorf("inspect windows ACL target %s: %w", existing, err)
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return windowsACLContainmentError{Anchor: anchor, Target: target, Actual: existing + " (a reparse point)"}
}
return verifyWindowsACLHandleUnderAnchor(handle, anchor, existing)
}
func windowsACLOpenForContainment(path string) (windows.Handle, error) {
utf16Path, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, fmt.Errorf("encode windows ACL target %s: %w", path, err)
}
handle, err := windows.CreateFile(
utf16Path,
windows.READ_CONTROL,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return 0, fmt.Errorf("open windows ACL target %s: %w", path, err)
}
return handle, nil
}
@@ -1,231 +0,0 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// windowsACLTestDenySID is a well-known group this process is not a member of.
//
// A deny ACE is what the carveouts actually are, and denying a group the test
// runs as revokes its own WRITE_DAC: the apply succeeds and then nothing can
// put the DACL back, so the temporary tree cannot be removed. Guests keeps the
// deny semantics without locking the test out of its own fixture.
const windowsACLTestDenySID = "S-1-5-32-546"
// makeACLJunction plants a directory junction at link pointing at target.
//
// A junction rather than a symlink on purpose: mklink /J needs no privilege, so
// it is reachable by exactly the unprivileged workspace writer this guards
// against, and the test runs on an ordinary developer or CI account.
func makeACLJunction(t *testing.T, link, target string) {
t.Helper()
output, err := exec.Command("cmd.exe", "/c", "mklink", "/J", link, target).CombinedOutput()
if err != nil {
t.Skipf("mklink /J unavailable here: %v (%s)", err, output)
}
}
// AN ELEVATED ACL WRITE MUST NOT LEAVE THE WRITE ROOT THROUGH A JUNCTION.
//
// The apply opens its target with FILE_FLAG_OPEN_REPARSE_POINT, which refuses a
// reparse point at the FINAL component and resolves every component above it.
// The write-root carveouts are derived rather than named: <root>/.git/hooks and
// <root>/.git/config are constructed from the root, and <root>/.git is a name an
// unprivileged workspace writer can create before setup runs. A junction there
// makes the apply open <junction-target>/hooks, an ordinary directory with
// nothing wrong about its final component, and `zero sandbox setup` writes a
// deny-write ACE on it as Administrator, outside the workspace.
func TestApplyWindowsACLRefusesACarveoutRedirectedByAJunction(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "elsewhere")
if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil {
t.Fatal(err)
}
makeACLJunction(t, filepath.Join(root, ".git"), outside)
carveout := filepath.Join(root, ".git", "hooks")
group := windowsACLPathGroup{
Path: carveout,
Anchor: root,
Entries: []WindowsACLEntry{{
Action: WindowsACLDenyWrite,
Path: carveout,
Capability: windowsACLTestDenySID,
Anchor: root,
}},
}
_, applied, err := applyWindowsACLPathGroup(group)
if applied {
t.Error("applied a deny-write ACE through a junction, so an elevated setup rewrote the DACL of an object outside the workspace")
}
var containment windowsACLContainmentError
if !errors.As(err, &containment) {
t.Fatalf("err = %v, want a containment refusal", err)
}
if !strings.Contains(containment.Actual, "elsewhere") {
t.Errorf("the refusal does not name where the target actually resolved: %v", containment)
}
}
// The same carveout inside an ordinary workspace still gets its ACE, or the
// refusal above would prove only that the apply fails.
func TestApplyWindowsACLStillAppliesAnOrdinaryCarveout(t *testing.T) {
root := t.TempDir()
carveout := filepath.Join(root, ".git", "hooks")
if err := os.MkdirAll(carveout, 0o700); err != nil {
t.Fatal(err)
}
group := windowsACLPathGroup{
Path: carveout,
Anchor: root,
Entries: []WindowsACLEntry{{
Action: WindowsACLDenyWrite,
Path: carveout,
Capability: windowsACLTestDenySID,
Anchor: root,
}},
}
snapshot, applied, err := applyWindowsACLPathGroup(group)
if err != nil {
t.Fatalf("apply: %v", err)
}
// PUT IT BACK, OR THE TEMP TREE CANNOT BE REMOVED. The ACE just written
// denies write to the group this test runs as, and t.TempDir removes the
// tree when the test ends.
if applied {
t.Cleanup(func() {
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
t.Errorf("rollback: %v", err)
}
})
}
if !applied {
t.Fatal("an ordinary carveout inside the write root was not applied")
}
if snapshot.Path != carveout {
t.Fatalf("snapshot path = %q, want %q", snapshot.Path, carveout)
}
}
// MATERIALIZATION MUST NOT CREATE THROUGH THE JUNCTION EITHER. os.MkdirAll
// walks a pathname and follows every reparse point on it, so without the
// pre-create check an elevated setup makes the directory on the far side and
// only notices afterwards.
func TestApplyWindowsACLRefusesToMaterializeThroughAJunction(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "elsewhere")
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatal(err)
}
makeACLJunction(t, filepath.Join(root, ".git"), outside)
carveout := filepath.Join(root, ".git", "hooks")
if _, err := os.Lstat(filepath.Join(outside, "hooks")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("SETUP INVALID: the target already holds hooks: %v", err)
}
group := windowsACLPathGroup{
Path: carveout,
Anchor: root,
Materialize: true,
Entries: []WindowsACLEntry{{
Action: WindowsACLDenyRead,
Path: carveout,
Capability: windowsACLTestDenySID,
Anchor: root,
Materialize: true,
}},
}
if _, applied, err := applyWindowsACLPathGroup(group); applied || err == nil {
t.Errorf("materialized through a junction: applied=%v err=%v", applied, err)
}
if _, err := os.Lstat(filepath.Join(outside, "hooks")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("an elevated create landed outside the write root: %v", err)
}
}
// An operator-named path carries no anchor and keeps the behaviour it had: the
// final component is still refused when it is a reparse point, and nothing above
// it is second-guessed, because above the write root the path belongs to the
// operator, who may keep a workspace under a junction.
func TestApplyWindowsACLLeavesAnUnanchoredPathAlone(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(t.TempDir(), "elsewhere")
if err := os.MkdirAll(filepath.Join(outside, "leaf"), 0o700); err != nil {
t.Fatal(err)
}
makeACLJunction(t, filepath.Join(base, "link"), outside)
named := filepath.Join(base, "link", "leaf")
group := windowsACLPathGroup{
Path: named,
Entries: []WindowsACLEntry{{
Action: WindowsACLDenyWrite,
Path: named,
Capability: windowsACLTestDenySID,
}},
}
snapshot, applied, err := applyWindowsACLPathGroup(group)
if err != nil {
t.Fatalf("an operator-named path below a junction was refused: %v", err)
}
if applied {
t.Cleanup(func() {
if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil {
t.Errorf("rollback: %v", err)
}
})
}
if !applied {
t.Fatal("an operator-named path below a junction was not applied")
}
}
// AND THE GUARD ON THE CREATE HAS TO BE PINNED WHERE IT CAN BE SEEN.
//
// Through the whole apply, dropping the pre-create check is invisible: the
// directory is made on the far side of the junction, the containment check on
// the handle then refuses, and the failure path removes what it made, so the
// filesystem afterwards looks the same either way. The transient elevated create
// outside the write root is the thing being prevented, so it is checked here,
// against the function that prevents it.
func TestVerifyWindowsACLPathUnderAnchorRefusesAMissingTargetBehindAJunction(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "elsewhere")
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatal(err)
}
makeACLJunction(t, filepath.Join(root, ".git"), outside)
carveout := filepath.Join(root, ".git", "hooks")
if _, err := os.Lstat(carveout); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("SETUP INVALID: the carveout already exists, so nothing would be created: %v", err)
}
err := verifyWindowsACLPathUnderAnchor(root, carveout)
var containment windowsACLContainmentError
if !errors.As(err, &containment) {
t.Fatalf("err = %v, want a containment refusal before anything is created", err)
}
// The ordinary case still says yes, or the refusal proves only that the
// function rejects things.
ordinary := t.TempDir()
if err := verifyWindowsACLPathUnderAnchor(ordinary, filepath.Join(ordinary, ".git", "hooks")); err != nil {
t.Errorf("an ordinary missing carveout was refused: %v", err)
}
// And an unanchored path is not this function's business.
if err := verifyWindowsACLPathUnderAnchor("", carveout); err != nil {
t.Errorf("an unanchored path was refused: %v", err)
}
}
-114
View File
@@ -240,117 +240,3 @@ func TestDedupeWindowsACLEntriesKeepsInheritanceVariants(t *testing.T) {
t.Fatalf("dedupe order/shape = %#v, want first NoInherit then inheritable", out)
}
}
// THE PLAN HAS TO CARRY THE ANCHOR, OR THE APPLY HAS NOTHING TO ENFORCE.
//
// The apply-side tests hand applyWindowsACLPathGroup an anchor directly, so they
// pass whether or not anything ever sets one. This drives the real builder and
// checks which entries come out anchored: the carveouts derived from a write
// root, and not the paths the operator named, which have no owned tail and whose
// intermediates are the operator's own business.
//
// NATIVE PATHS, NOT WINDOWS LITERALS. The builder is cross-platform and so is
// this test, and the anchoring decision runs through pathWithinRoot, which is
// filepath.Rel underneath. On Linux a backslash is an ordinary character, so
// `C:\workspace\.git\hooks` is one component and is not under `C:\workspace`;
// the neighbouring tests get away with such literals because they only compare
// strings they built the same way and never ask whether one contains another.
func TestBuildWindowsACLPlanAnchorsDerivedCarveouts(t *testing.T) {
home := t.TempDir()
workspace := t.TempDir()
carveout := filepath.Join(workspace, ".git", "hooks")
metadata := filepath.Join(workspace, ".zero")
named := filepath.Join(t.TempDir(), "named-by-the-operator")
config := WindowsSandboxCommandConfig{
SandboxHome: home,
WorkspaceRoots: []string{workspace},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
WriteRoots: []WritableRoot{{
Root: workspace,
ReadOnlySubpaths: []string{carveout},
ProtectedMetadataNames: []string{".zero"},
}},
DenyWrite: []string{named},
},
Network: NetworkPolicy{Mode: NetworkDeny},
},
}
plan, err := BuildWindowsACLPlan(config)
if err != nil {
t.Fatalf("BuildWindowsACLPlan: %v", err)
}
anchors := map[string]string{}
for _, entry := range plan.Entries {
anchors[strings.ToLower(entry.Path)] = entry.Anchor
}
for _, derived := range []string{carveout, metadata} {
anchor, present := anchors[strings.ToLower(derived)]
if !present {
t.Fatalf("SETUP INVALID: the plan has no entry for the derived carveout %s", derived)
}
if anchor != workspace {
t.Errorf("derived carveout %s carries anchor %q, want the write root it came from", derived, anchor)
}
}
operatorAnchor, present := anchors[strings.ToLower(named)]
if !present {
t.Fatal("SETUP INVALID: the plan has no entry for the operator-named deny path")
}
if operatorAnchor != "" {
t.Errorf("operator-named path carries anchor %q, want none: its intermediates are not the sandbox's to police", operatorAnchor)
}
}
// AND AN OUT-OF-ROOT READ-ONLY SUBPATH IS NOT ANCHORED AT ALL.
//
// ReadOnlySubpaths is a profile field an operator can set to any path. One
// placed outside the write root is a configuration that works today, and
// anchoring it to that root would turn it into a containment refusal: the
// object is not under the root and never was. Only the paths that are actually
// under it are held to it.
func TestBuildWindowsACLPlanLeavesAnOutOfRootSubpathUnanchored(t *testing.T) {
home := t.TempDir()
workspace := t.TempDir()
inside := filepath.Join(workspace, "vendor")
outside := filepath.Join(t.TempDir(), "shared")
plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{
SandboxHome: home,
WorkspaceRoots: []string{workspace},
PermissionProfile: PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
WriteRoots: []WritableRoot{{
Root: workspace,
ReadOnlySubpaths: []string{inside, outside},
}},
},
Network: NetworkPolicy{Mode: NetworkDeny},
},
})
if err != nil {
t.Fatalf("BuildWindowsACLPlan: %v", err)
}
seen := map[string]string{}
for _, entry := range plan.Entries {
seen[strings.ToLower(entry.Path)] = entry.Anchor
}
insideAnchor, present := seen[strings.ToLower(inside)]
if !present {
t.Fatal("SETUP INVALID: the plan has no entry for the in-root subpath")
}
if insideAnchor != workspace {
t.Errorf("in-root subpath carries anchor %q, want the write root", insideAnchor)
}
outsideAnchor, present := seen[strings.ToLower(outside)]
if !present {
t.Fatal("SETUP INVALID: the plan has no entry for the out-of-root subpath")
}
if outsideAnchor != "" {
t.Errorf("out-of-root subpath carries anchor %q, want none: the apply would refuse a config that works today", outsideAnchor)
}
}
+37 -27
View File
@@ -8,8 +8,6 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
// controllableLauncher records every launched spec and lets a test control each
@@ -87,6 +85,18 @@ func newSwarmForWithSize(t *testing.T, l MemberLauncher, maxTeamSize int) *Swarm
return sw
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func okFor(spec MemberSpec, _ int) (MemberResult, error) {
return MemberResult{Result: "ok:" + spec.Task, SessionID: "sess-" + spec.ID}, nil
}
@@ -98,7 +108,7 @@ func TestSpawnCompletes(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "task done", func() bool {
waitFor(t, "task done", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
@@ -115,7 +125,7 @@ func TestSpawnInheritsPolicy(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
spec := l.recorded()[0]
if spec.Model != "orch-model" {
t.Fatalf("member model = %q, want inherited orch-model", spec.Model)
@@ -148,7 +158,7 @@ func TestConcurrencyCapAndQueueDrains(t *testing.T) {
}
// Release everyone; the queue should drain one-per-slot until all are done.
close(gate)
testutil.WaitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
waitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
if team.Running() != 0 || team.QueueDepth() != 0 {
t.Fatalf("after drain running=%d queue=%d, want 0/0", team.Running(), team.QueueDepth())
}
@@ -166,7 +176,7 @@ func TestRetryOnTemporaryError(t *testing.T) {
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
testutil.WaitFor(t, "task recovered", func() bool {
waitFor(t, "task recovered", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
@@ -185,7 +195,7 @@ func TestRetryExhaustionFails(t *testing.T) {
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
testutil.WaitFor(t, "task failed", func() bool {
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
@@ -200,7 +210,7 @@ func TestPermanentErrorNoRetry(t *testing.T) {
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
testutil.WaitFor(t, "task failed", func() bool {
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
@@ -286,7 +296,7 @@ func TestClosePreventsMemberRetry(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -315,7 +325,7 @@ func TestCloseWaitsForMemberWatchers(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "task running", func() bool {
waitFor(t, "task running", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusRunning
})
@@ -355,7 +365,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
sw := newSwarmFor(t, l)
pol := Policy{Model: "m"}
origID, _ := sw.Spawn(pol, "team", "teammate", "original task", "/w")
testutil.WaitFor(t, "original running", func() bool {
waitFor(t, "original running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
@@ -379,7 +389,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
}
// The new member carries the handoff note in its task and preserves cwd.
close(gate)
testutil.WaitFor(t, "spec for new member", func() bool {
waitFor(t, "spec for new member", func() bool {
for _, s := range l.recorded() {
if s.ID == newID {
return true
@@ -395,7 +405,7 @@ func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
}
}
// A handoff of an already-terminal task is rejected.
testutil.WaitFor(t, "new task done", func() bool {
waitFor(t, "new task done", func() bool {
task, ok := sw.Coordinator().Get(newID)
return ok && task.Status == StatusDone
})
@@ -460,7 +470,7 @@ func TestHandoffMailboxFailureRestoresSourceCompletion(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "original running", func() bool {
waitFor(t, "original running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
@@ -475,7 +485,7 @@ func TestHandoffMailboxFailureRestoresSourceCompletion(t *testing.T) {
}
close(gate)
testutil.WaitFor(t, "source completion after aborted handoff", func() bool {
waitFor(t, "source completion after aborted handoff", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusDone
})
@@ -522,7 +532,7 @@ func TestHandoffSuccessorCollisionDoesNotStopSource(t *testing.T) {
if err != nil {
t.Fatalf("Spawn: %v", err)
}
testutil.WaitFor(t, "original running", func() bool {
waitFor(t, "original running", func() bool {
task, ok := coord.Get(origID)
return ok && task.Status == StatusRunning
})
@@ -547,7 +557,7 @@ func TestHandoffSuccessorCollisionDoesNotStopSource(t *testing.T) {
t.Fatalf("collision misdelivered %d handoff notes to the existing task", len(msgs))
}
close(gate)
testutil.WaitFor(t, "source completion after rejected collision", func() bool {
waitFor(t, "source completion after rejected collision", func() bool {
task, ok := coord.Get(origID)
return ok && task.Status == StatusDone
})
@@ -633,7 +643,7 @@ func TestCloseReleasesBlockedHandoffBeforeMemberExit(t *testing.T) {
case <-time.After(3 * time.Second):
t.Fatal("Close did not return after the source member exited")
}
testutil.WaitFor(t, "source completion after handoff cancellation", func() bool {
waitFor(t, "source completion after handoff cancellation", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusDone
})
@@ -694,7 +704,7 @@ func TestHandoffTimesOutWithoutStartingSuccessor(t *testing.T) {
}
close(releaseOriginal)
testutil.WaitFor(t, "source completion after handoff timeout", func() bool {
waitFor(t, "source completion after handoff timeout", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusDone
})
@@ -866,7 +876,7 @@ func TestHandoffRemovesQueuedOriginalBeforeDispatchingSuccessor(t *testing.T) {
if _, err := sw.Spawn(pol, "team", "teammate", "slot blocker", "/w"); err != nil {
t.Fatalf("Spawn blocker: %v", err)
}
testutil.WaitFor(t, "blocker running", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "blocker running", func() bool { return len(l.recorded()) == 1 })
origID, err := sw.Spawn(pol, "team", "teammate", "queued original", "/w")
if err != nil {
t.Fatalf("Spawn queued original: %v", err)
@@ -880,7 +890,7 @@ func TestHandoffRemovesQueuedOriginalBeforeDispatchingSuccessor(t *testing.T) {
t.Fatalf("Handoff: %v", err)
}
close(gate)
testutil.WaitFor(t, "successor launch", func() bool {
waitFor(t, "successor launch", func() bool {
for _, spec := range l.recorded() {
if spec.ID == newID {
return true
@@ -913,7 +923,7 @@ func TestAdoptOrphans(t *testing.T) {
t.Fatalf("adopted = %v, want [orphan-1]", adopted)
}
// The orphan is relaunched under a fresh agent and completes.
testutil.WaitFor(t, "orphan completed", func() bool {
waitFor(t, "orphan completed", func() bool {
task, ok := sw.Coordinator().Get("orphan-1")
return ok && task.Status == StatusDone
})
@@ -954,7 +964,7 @@ func TestCollectScopesToTeam(t *testing.T) {
sw := newSwarmFor(t, l)
a, _ := sw.Spawn(Policy{}, "alpha", "teammate", "ta", "")
_, _ = sw.Spawn(Policy{}, "beta", "teammate", "tb", "")
testutil.WaitFor(t, "alpha done", func() bool {
waitFor(t, "alpha done", func() bool {
task, ok := sw.Coordinator().Get(a)
return ok && task.Status == StatusDone
})
@@ -1100,7 +1110,7 @@ func TestAdmittedDispatchDoesNotLaunchAfterClose(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -1160,7 +1170,7 @@ func TestCloseBetweenLaunchPrecheckAndReturn(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -1258,7 +1268,7 @@ func TestCloseBetweenRetryLaunchPrecheckAndReturn(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
@@ -1450,7 +1460,7 @@ func TestCloseFailsQueuedSpecOnLateCreatedTeam(t *testing.T) {
sw.Close()
close(closed)
}()
testutil.WaitFor(t, "swarm closed state", func() bool {
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
+8 -10
View File
@@ -4,8 +4,6 @@ import (
"math"
"testing"
"time"
"github.com/Gitlawb/zero/internal/testutil"
)
// testTicker returns a ticker factory backed by a single unbounded-handshake
@@ -41,11 +39,11 @@ func TestSchedulerFiresAndCountsRuns(t *testing.T) {
for i := 0; i < 3; i++ {
ticks <- time.Time{}
want := i + 1
testutil.WaitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == want })
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == want })
}
// After MaxRuns the job retires itself.
testutil.WaitFor(t, "job retired", func() bool { _, ok := findJob(sched.List(), id); return !ok })
waitFor(t, "job retired", func() bool { _, ok := findJob(sched.List(), id); return !ok })
if got := len(l.recorded()); got != 3 {
t.Fatalf("spawned %d members, want 3", got)
}
@@ -70,11 +68,11 @@ func TestSchedulerSkipsWhilePreviousRuns(t *testing.T) {
// Fire 1: spawns and the member stays running (gated).
ticks <- time.Time{}
testutil.WaitFor(t, "first spawn", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "first spawn", func() bool { return len(l.recorded()) == 1 })
// Fire 2: previous still running => skipped, no new spawn.
ticks <- time.Time{}
testutil.WaitFor(t, "skip recorded", func() bool {
waitFor(t, "skip recorded", func() bool {
j, ok := findJob(sched.List(), id)
return ok && j.Skipped == 1
})
@@ -84,14 +82,14 @@ func TestSchedulerSkipsWhilePreviousRuns(t *testing.T) {
// Release the first member, then fire 3: previous terminal => spawns again.
close(gate)
testutil.WaitFor(t, "first done", func() bool { return sw.Coordinator().Summarize().Done == 1 })
waitFor(t, "first done", func() bool { return sw.Coordinator().Summarize().Done == 1 })
ticks <- time.Time{}
testutil.WaitFor(t, "second spawn", func() bool { return len(l.recorded()) == 2 })
waitFor(t, "second spawn", func() bool { return len(l.recorded()) == 2 })
// fireIfIdle's spawn (what "second spawn" observes via the launcher) and
// run's subsequent job.incRuns() are sequential but distinct steps in the
// scheduler's goroutine; wait for Runs itself rather than assuming the
// launcher recording it means the job's counter is updated too.
testutil.WaitFor(t, "second run recorded", func() bool {
waitFor(t, "second run recorded", func() bool {
j, ok := findJob(sched.List(), id)
return ok && j.Runs == 2
})
@@ -217,7 +215,7 @@ func TestSchedulerDailyRecomputesNextDelay(t *testing.T) {
}
for i := 0; i < 2; i++ {
ticks <- time.Time{}
testutil.WaitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == i+1 })
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == i+1 })
}
if len(delays) < 2 {
t.Fatalf("expected at least two requested delays, got %v", delays)
+6 -7
View File
@@ -8,7 +8,6 @@ import (
"time"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -95,7 +94,7 @@ func TestSpawnToolThroughRegistry(t *testing.T) {
if id == "" {
t.Fatal("spawn must return a task_id in Meta")
}
testutil.WaitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
spec := l.recorded()[0]
if spec.Model != "m1" || spec.Cwd != "/work" {
t.Fatalf("policy/cwd not threaded into member: model=%q cwd=%q", spec.Model, spec.Cwd)
@@ -166,7 +165,7 @@ func TestStatusAndCollectTools(t *testing.T) {
"agent_type": "teammate", "task": "compute", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
id := spawn.Meta["task_id"]
testutil.WaitFor(t, "task done", func() bool {
waitFor(t, "task done", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
@@ -214,7 +213,7 @@ func TestCollectBlocksUntilMembersFinish(t *testing.T) {
"agent_type": "teammate", "task": "compute", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
id := spawn.Meta["task_id"]
testutil.WaitFor(t, "member running", func() bool {
waitFor(t, "member running", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusRunning
})
@@ -259,7 +258,7 @@ func TestCollectReturnsPartialOnTimeout(t *testing.T) {
"agent_type": "teammate", "task": "compute", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
id := spawn.Meta["task_id"]
testutil.WaitFor(t, "member running", func() bool {
waitFor(t, "member running", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusRunning
})
@@ -325,7 +324,7 @@ func TestNewWithUserDefinitions(t *testing.T) {
if _, err := sw.Spawn(Policy{Model: "m"}, "team", "researcher", "find things", ""); err != nil {
t.Fatalf("Spawn user-defined agent: %v", err)
}
testutil.WaitFor(t, "researcher used", func() bool { return used })
waitFor(t, "researcher used", func() bool { return used })
// A bad definition makes New fail closed.
if _, err := New(Options{BaseDir: t.TempDir(), Launcher: newLauncher(okFor), Definitions: []Definition{{AgentType: " "}}}); err == nil {
@@ -342,7 +341,7 @@ func TestHandoffToolThroughRegistry(t *testing.T) {
"agent_type": "teammate", "task": "long", "team": "alpha",
}, tools.RunOptions{PermissionGranted: true, Model: "m"})
origID := spawn.Meta["task_id"]
testutil.WaitFor(t, "running", func() bool {
waitFor(t, "running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
-23
View File
@@ -1,23 +0,0 @@
// Package testutil provides small, shared helpers for tests across the
// codebase.
package testutil
import (
"testing"
"time"
)
// WaitFor polls cond until it returns true, or fails the test if it does not
// become true within a generous deadline. what names the condition being
// waited for so a timeout failure message is actionable.
func WaitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
+4 -160
View File
@@ -2,10 +2,8 @@ package tools
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
@@ -365,7 +363,7 @@ func TestUnifiedPatchCopyOperation(t *testing.T) {
if content, _ := os.ReadFile(filepath.Join(root, "dst.txt")); string(content) != "hello\nnew\n" {
t.Fatalf("copy destination = %q", string(content))
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "dst.txt" {
if len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "src.txt" || result.ChangedFiles[1] != "dst.txt" {
t.Fatalf("changed files = %v", result.ChangedFiles)
}
destination, err := filepath.EvalSymlinks(filepath.Join(root, "dst.txt"))
@@ -573,9 +571,9 @@ func TestApplyStructuredPatchChangeRefusesSourceChangedAfterPlanning(t *testing.
"copy": {kind: structuredPatchCopy, from: target, to: destination, before: "planned\n", after: "planned\n", mode: 0o644},
} {
writeTestFile(t, path, "changed after planning\n")
outcome, err := applyStructuredPatchChange(workspace, change)
if err == nil || len(outcome.completed) != 0 || len(outcome.incompletePaths) != 0 || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
t.Fatalf("%s: expected a refusal, got outcome=%#v err=%v", name, outcome, err)
committed, err := applyStructuredPatchChange(workspace, change)
if err == nil || committed || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
t.Fatalf("%s: expected a refusal, got committed=%v err=%v", name, committed, err)
}
if content, _ := os.ReadFile(path); string(content) != "changed after planning\n" {
t.Fatalf("%s: file must be untouched, got %q", name, string(content))
@@ -711,158 +709,4 @@ func TestApplyPatchOperationsReportsCommittedPrefixOnFailure(t *testing.T) {
if content, _ := os.ReadFile(filepath.Join(root, "third.txt")); string(content) != "three\n" {
t.Fatalf("third.txt must be untouched, got %q", string(content))
}
if got := result.ChangedFiles; len(got) != 1 || got[0] != "first.txt" {
t.Fatalf("partial patch ChangedFiles = %#v, want committed prefix only", got)
}
resolvedFirst, err := filepath.EvalSymlinks(filepath.Join(root, "first.txt"))
if err != nil {
t.Fatal(err)
}
if got := result.FileDiffs; len(got) != 1 || got[0] != (FileDiff{Path: resolvedFirst, OldExists: true, NewExists: true, OldText: "one\n", NewText: "ONE\n"}) {
t.Fatalf("partial patch FileDiffs = %#v", got)
}
}
func TestApplyPatchOperationsReportsWorkspaceRelativeCommittedPrefixUnderCwd(t *testing.T) {
root := t.TempDir()
applyRoot := filepath.Join(root, "sub", "dir")
if err := os.MkdirAll(applyRoot, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(applyRoot, "first.txt"), "one\n")
writeTestFile(t, filepath.Join(applyRoot, "second.txt"), "two\n")
structuredPatchBeforeCommit = func(change structuredPatchChange) {
if change.to.relative == "second.txt" {
writeTestFile(t, filepath.Join(applyRoot, "second.txt"), "changed\n")
}
}
t.Cleanup(func() { structuredPatchBeforeCommit = nil })
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: first.txt", "@@", "-one", "+ONE",
"*** Update File: second.txt", "@@", "-two", "+TWO",
"*** End Patch", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{
"cwd": "sub/dir", "patch": patch,
})
if result.Status != StatusError || !strings.Contains(result.Output, "already committed: sub/dir/first.txt") {
t.Fatalf("nested partial failure = status=%s output=%q", result.Status, result.Output)
}
if got := result.ChangedFiles; len(got) != 1 || got[0] != "sub/dir/first.txt" {
t.Fatalf("nested partial ChangedFiles = %#v", got)
}
}
func TestApplyPatchSeparatesVerifiedAndUnverifiedPartialPublications(t *testing.T) {
root := t.TempDir()
priorPublish := structuredPatchPublishNoReplace
structuredPatchPublishNoReplace = func(workspace *os.Root, source, target string, mode os.FileMode) (bool, error) {
if target != "uncertain.txt" {
return priorPublish(workspace, source, target, mode)
}
file, err := workspace.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm())
if err != nil {
return false, err
}
if _, err := file.WriteString("partial"); err != nil {
_ = file.Close()
return true, err
}
if err := file.Close(); err != nil {
return true, err
}
return true, errors.New("injected failure after partial publication")
}
t.Cleanup(func() { structuredPatchPublishNoReplace = priorPublish })
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: verified.txt", "+verified",
"*** Add File: uncertain.txt", "+intended complete content",
"*** Add File: untouched.txt", "+untouched",
"*** End Patch", "",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("partial publication status = %s, want error", result.Status)
}
for _, want := range []string{"already committed: verified.txt", "published but unverified: uncertain.txt"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("partial publication error must contain %q: %s", want, result.Output)
}
}
committedFragment := strings.SplitN(result.Output, "; published but unverified:", 2)[0]
if strings.Contains(committedFragment, "uncertain.txt") {
t.Fatalf("unverified path appears in committed fragment: %s", result.Output)
}
if got, want := result.ChangedFiles, []string{"verified.txt"}; !slices.Equal(got, want) {
t.Fatalf("ChangedFiles = %#v, want verified publications only %#v", got, want)
}
resolvedVerified, err := filepath.EvalSymlinks(filepath.Join(root, "verified.txt"))
if err != nil {
t.Fatal(err)
}
if got := result.FileDiffs; len(got) != 1 || got[0].Path != resolvedVerified {
t.Fatalf("FileDiffs = %#v, want verified.txt only", got)
}
if got := mustReadTestFile(t, filepath.Join(root, "verified.txt")); got != "verified\n" {
t.Fatalf("verified publication = %q", got)
}
if got := mustReadTestFile(t, filepath.Join(root, "uncertain.txt")); got != "partial" {
t.Fatalf("unverified publication = %q", got)
}
if _, err := os.Stat(filepath.Join(root, "untouched.txt")); !os.IsNotExist(err) {
t.Fatalf("later publication must remain untouched: %v", err)
}
}
func TestApplyPatchMoveReportsPublishedDestinationWhenSourceRemovalFails(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "source.txt"), "before\n")
priorRemove := structuredPatchRemove
structuredPatchRemove = func(workspace *os.Root, name string) error {
if name == "source.txt" {
return os.ErrPermission
}
return priorRemove(workspace, name)
}
t.Cleanup(func() { structuredPatchRemove = priorRemove })
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: source.txt",
"*** Move to: destination.txt",
"@@",
"-before",
"+after",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("move status = %s, want error", result.Status)
}
if got := mustReadTestFile(t, filepath.Join(root, "source.txt")); got != "before\n" {
t.Fatalf("source content = %q", got)
}
if got := mustReadTestFile(t, filepath.Join(root, "destination.txt")); got != "after\n" {
t.Fatalf("destination content = %q", got)
}
if got, want := result.ChangedFiles, []string{"destination.txt"}; !slices.Equal(got, want) {
t.Fatalf("ChangedFiles = %#v, want %#v", got, want)
}
resolvedDestination, err := filepath.EvalSymlinks(filepath.Join(root, "destination.txt"))
if err != nil {
t.Fatal(err)
}
wantDiff := FileDiff{Path: resolvedDestination, OldExists: false, NewExists: true, NewText: "after\n"}
if got := result.FileDiffs; len(got) != 1 || got[0] != wantDiff {
t.Fatalf("FileDiffs = %#v, want %#v", got, []FileDiff{wantDiff})
}
if !strings.Contains(result.Display.Preview, "destination.txt") || strings.Contains(result.Display.Preview, "source.txt") {
t.Fatalf("preview must show only destination creation: %q", result.Display.Preview)
}
}
+3 -106
View File
@@ -1,122 +1,19 @@
package tools
import (
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/redaction"
udiff "github.com/aymanbagabas/go-udiff"
)
import udiff "github.com/aymanbagabas/go-udiff"
// maxToolPreviewBytes caps the inline diff a write tool appends to its result, so
// a large generated file can't flood the transcript or balloon the persisted
// session events. Past this the tool falls back to its summary line alone.
const maxToolPreviewBytes = 48 * 1024
// A structured replacement contains two complete file sides, so its transport
// budget is intentionally separate from the single rendered-preview budget.
// Each side may be as large as a normal preview; aggregate producers apply the
// two-sided result cap in file order.
const maxToolResultFileDiffBytes = 2 * maxToolPreviewBytes
// FileDiff is a human-facing before/after file change. Registry-boundary
// redaction applies to both sides before any caller receives it.
type FileDiff struct {
// Path is the canonical absolute path required by ACP diff content. The
// separate ChangedFiles result remains workspace-relative for local UI use.
Path string
// OldExists and NewExists distinguish an empty file from a missing side of a
// create/delete/move. Empty strings alone cannot encode that difference.
OldExists bool
NewExists bool
OldText string
NewText string
}
// boundedFileDiff declines rather than truncating: a truncated side would look
// like an exact file replacement. Callers keep ChangedFiles as the safe
// fallback for large, unsafe, or unchanged content. Each complete side gets the
// same bound as a rendered preview; aggregate result producers apply their own
// cap. Control bytes are rejected. Ordinary Unicode format/space characters are
// retained unless removing them reveals a credential shape that the normal
// redactor could not see in the original text.
func boundedFileDiff(path, oldText, newText string, oldExists, newExists bool) (FileDiff, bool) {
if !filepath.IsAbs(path) || (!oldExists && !newExists) ||
(oldExists == newExists && oldText == newText) ||
!utf8.ValidString(oldText) || !utf8.ValidString(newText) ||
unsafeDiffText(oldText) || unsafeDiffText(newText) ||
len(oldText) > maxToolPreviewBytes || len(newText) > maxToolPreviewBytes {
return FileDiff{}, false
}
return FileDiff{Path: path, OldExists: oldExists, NewExists: newExists, OldText: oldText, NewText: newText}, true
}
func unsafeDiffText(text string) bool {
if !utf8.ValidString(text) {
return true
}
hasCanonicalizableSeparator := false
for _, r := range text {
switch r {
case '\n', '\r', '\t', ' ':
continue
}
if unicode.IsControl(r) {
return true
}
if isDiffCredentialIgnorable(r) {
hasCanonicalizableSeparator = true
}
}
if !hasCanonicalizableSeparator {
return false
}
return diffTextRevealsObfuscatedSecret(text)
}
func diffTextRevealsObfuscatedSecret(text string) bool {
if !utf8.ValidString(text) {
return false
}
canonical := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', '\t', ' ':
return r
}
if isDiffCredentialIgnorable(r) {
return -1
}
return r
}, text)
return canonical != text && redaction.RedactString(canonical, redaction.Options{}) != canonical
}
// isDiffCredentialIgnorable mirrors Unicode's Default_Ignorable_Code_Point
// derived property using the tables exposed by the Go standard library. Cf
// catches format controls, while the other two tables cover non-Cf default
// ignorables such as U+034F COMBINING GRAPHEME JOINER and variation selectors.
// Unicode whitespace is also removable because it can split a credential that
// the boundary redactor would otherwise recognize. The caller preserves normal
// ASCII layout whitespace before consulting this helper.
func isDiffCredentialIgnorable(r rune) bool {
return unicode.Is(unicode.Cf, r) ||
unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r) ||
unicode.Is(unicode.Variation_Selector, r) ||
unicode.IsSpace(r)
}
// boundedUnifiedDiff returns a unified diff of oldContent -> newContent labelled
// with path, suitable for the TUI's diff card renderer. A create (oldContent "")
// yields an all-additions (green) preview; an overwrite/edit yields red/green.
// Returns "" when there is no change, the rendered diff is unsafe text, or the
// diff exceeds maxToolPreviewBytes. This applies the same unsafe-text gate as
// FileDiff to what reaches Display.Preview, without discarding a safe hunk only
// because an unrelated part of the source file contains an unsafe byte.
// Returns "" when there is no change or the diff exceeds maxToolPreviewBytes.
func boundedUnifiedDiff(path, oldContent, newContent string) string {
diff := udiff.Unified(path, path, oldContent, newContent)
if diff == "" || !utf8.ValidString(diff) || unsafeDiffText(diff) || len(diff) > maxToolPreviewBytes {
if diff == "" || len(diff) > maxToolPreviewBytes {
return ""
}
return diff
-186
View File
@@ -1,186 +0,0 @@
package tools
import (
"path/filepath"
"strings"
"testing"
)
func TestBoundedFileDiffRefusesPartialOrBinaryContent(t *testing.T) {
path := filepath.Join(t.TempDir(), "a.txt")
if diff, ok := boundedFileDiff(path, "old", "new", true, true); !ok || diff.Path != path || !diff.OldExists || !diff.NewExists || diff.OldText != "old" || diff.NewText != "new" {
t.Fatalf("small text diff = %#v, %t", diff, ok)
}
for _, tc := range []struct {
name string
old string
new string
}{
{"unchanged", "same", "same"},
{"binary old", string([]byte{0xff}), "text"},
{"binary new", "text", string([]byte{0xff})},
{"nul old", "token=sk-proj-abc\x00def", "text"},
{"escape new", "text", "token=sk-proj-abc\x1bdef"},
{"c1 old", "token=sk-proj-abc\u0085def", "text"},
{"zero width space", "token=sk-proj-abc\u200bdef", "text"},
{"zero width joiner", "token=sk-proj-abc\u200ddef", "text"},
{"byte order mark", "token=sk-proj-abc\ufeffdef", "text"},
{"soft hyphen", "token=sk-proj-abc\u00addef", "text"},
{"non breaking space", "token=sk-proj-abc\u00a0def", "text"},
{"too large", strings.Repeat("a", maxToolPreviewBytes+1), "b"},
} {
t.Run(tc.name, func(t *testing.T) {
if diff, ok := boundedFileDiff(path, tc.old, tc.new, true, true); ok || diff != (FileDiff{}) {
t.Fatalf("unexpected diff = %#v, %t", diff, ok)
}
})
}
}
func TestBoundedFileDiffPreservesEmptyFileOperations(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
for _, tc := range []struct {
name string
oldExists, newExists bool
}{
{name: "create", newExists: true},
{name: "delete", oldExists: true},
} {
t.Run(tc.name, func(t *testing.T) {
diff, ok := boundedFileDiff(path, "", "", tc.oldExists, tc.newExists)
if !ok || diff.OldExists != tc.oldExists || diff.NewExists != tc.newExists {
t.Fatalf("empty %s = %#v, %t", tc.name, diff, ok)
}
})
}
}
func TestBoundedUnifiedDiffRejectsUnsafeRichText(t *testing.T) {
if got := boundedUnifiedDiff("safe.txt", "before\n", "after\n"); got == "" {
t.Fatal("safe unified diff was unexpectedly omitted")
}
for _, content := range []string{
"token=sk-proj-abc\x00def",
"token=sk-proj-abc\x1bdef",
"token=sk-proj-abc\u200bdef",
string([]byte{0xff}),
} {
if got := boundedUnifiedDiff("secret.txt", content, "safe\n"); got != "" {
t.Fatalf("unsafe rich diff = %q", got)
}
}
old := strings.Repeat("unchanged\n", 12) + "form\ffeed\n" + strings.Repeat("unchanged\n", 12)
updated := strings.Replace(old, "unchanged\n", "changed\n", 1)
if got := boundedUnifiedDiff("safe-hunk.txt", old, updated); got == "" || strings.Contains(got, "\f") {
t.Fatalf("safe hunk near unrelated unsafe text = %q", got)
}
}
func TestStructuredPatchFileDiffsPreserveEmptyOperationsAndResultBudget(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
changes := []structuredPatchChange{
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "created"), relative: "created"}},
{kind: structuredPatchDelete, from: structuredPatchTarget{absolute: filepath.Join(root, "deleted"), relative: "deleted"}},
{kind: structuredPatchUpdate, from: structuredPatchTarget{absolute: filepath.Join(root, "from"), relative: "from"}, to: structuredPatchTarget{absolute: filepath.Join(root, "to"), relative: "to"}},
}
diffs := fileDiffsFromStructuredPatch(".", changes)
if len(diffs) != 4 {
t.Fatalf("empty create/delete/move diffs = %#v", diffs)
}
for _, diff := range diffs {
if diff.Path == "" || (!diff.OldExists && !diff.NewExists) {
t.Fatalf("invalid diff = %#v", diff)
}
}
large := strings.Repeat("x", maxToolResultFileDiffBytes/3+1)
budgeted := fileDiffsFromStructuredPatch(".", []structuredPatchChange{
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "one")}, after: large},
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "two")}, after: "tiny"},
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "three")}, after: large},
{kind: structuredPatchAdd, to: structuredPatchTarget{absolute: filepath.Join(root, "four")}, after: large},
})
if len(budgeted) != 3 || filepath.Base(budgeted[0].Path) != "one" || filepath.Base(budgeted[1].Path) != "two" || filepath.Base(budgeted[2].Path) != "three" {
t.Fatalf("ordered aggregate file-diff budget = %#v", budgeted)
}
}
func TestStructuredPatchFileDiffsKeepEligibleSameBasenameSibling(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
diffs := fileDiffsFromStructuredPatch(".", []structuredPatchChange{
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: filepath.Join(root, "a.go"), relative: "a.go"},
after: strings.Repeat("x", maxToolPreviewBytes+1),
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: filepath.Join(root, "sub", "a.go"), relative: filepath.Join("sub", "a.go")},
after: "package sub\n",
},
})
if len(diffs) != 1 || diffs[0].Path != filepath.Join(root, "sub", "a.go") {
t.Fatalf("same-basename rich diffs = %#v", diffs)
}
}
func TestBoundedDiffPreservesOrdinaryUnicodeButRejectsObfuscatedSecrets(t *testing.T) {
path := filepath.Join(t.TempDir(), "unicode.txt")
for name, content := range map[string]string{
"family emoji": "family: 👨‍👩‍👧‍👦\n",
"emoji variation selector": "heart: ❤️\n",
"combining grapheme joiner": "ordinary\u034fprose\n",
"nonbreaking space": "ordinary\u00a0prose\n",
"byte order mark": "\ufeffdocument\n",
"soft hyphen": "co\u00adoperate\n",
} {
t.Run(name, func(t *testing.T) {
if _, ok := boundedFileDiff(path, "before\n", content, true, true); !ok {
t.Fatalf("ordinary Unicode content was rejected: %q", content)
}
if preview := boundedUnifiedDiff("unicode.txt", "before\n", content); preview == "" {
t.Fatal("ordinary Unicode preview was omitted")
}
})
}
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
for name, separator := range map[string]string{
"zero width space": "\u200b",
"zero width joiner": "\u200d",
"byte order mark": "\ufeff",
"soft hyphen": "\u00ad",
"nonbreaking space": "\u00a0",
"combining grapheme joiner": "\u034f",
"variation selector": "\ufe0f",
} {
t.Run("split "+name, func(t *testing.T) {
obfuscated := secret[:20] + separator + secret[20:]
if _, ok := boundedFileDiff(path, "before\n", obfuscated, true, true); ok {
t.Fatalf("obfuscated credential produced a rich diff: %q", obfuscated)
}
if preview := boundedUnifiedDiff("secret.txt", "before\n", obfuscated); preview != "" {
t.Fatalf("obfuscated credential produced preview: %q", preview)
}
})
}
}
func TestWriteFileMarksSuppressedObfuscatedSecretAsRedacted(t *testing.T) {
root := t.TempDir()
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
obfuscated := secret[:20] + "\u200b" + secret[20:]
result := NewScopedWriteFileTool(root, nil).Run(t.Context(), map[string]any{
"path": "secret.txt", "content": obfuscated,
})
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || !result.Redacted {
t.Fatalf("obfuscated-secret result = status=%s changed=%#v diffs=%#v redacted=%t", result.Status, result.ChangedFiles, result.FileDiffs, result.Redacted)
}
}
+6 -29
View File
@@ -80,10 +80,6 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
}
}
content := string(contentBytes)
priorInfo, err := os.Stat(absolutePath)
if err != nil {
return errorResult("Error reading " + relativePath + ": " + err.Error())
}
occurrences := strings.Count(content, oldString)
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
@@ -157,25 +153,19 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
if err := commitFileContents(absolutePath, priorInfo, &content, updated); err != nil {
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
modelKnownContent := updated
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
// FileTracker re-baseline: recording pre-format content would make the very
// next edit look like an external modification and trip the conflict guard.
formatting := maybeFormatWrittenFileScoped(ctx, tool.workspaceRoot, tool.scope, absolutePath, updated, priorInfo.Mode().Perm())
formatting := maybeFormatWrittenFile(ctx, absolutePath, updated)
updated = formatting.Content
finalContentKnown := formatting.ContentKnown
// Re-baseline to the content we just wrote so subsequent edits in this session
// compare against the current on-disk state, not the pre-edit version.
newInfo := formatting.Info
if newInfo == nil {
newInfo, _ = os.Stat(absolutePath)
}
if !finalContentKnown {
options.FileTracker.Forget(absolutePath)
} else if updated == modelKnownContent {
newInfo, _ := os.Stat(absolutePath)
if updated == modelKnownContent {
// OUR edit, so we know precisely which lines moved: RecordEdit carries
// across the reads this edit did not disturb instead of dropping them.
//
@@ -205,25 +195,12 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
}
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
summary += formatting.notice(relativePath)
if finalContentKnown {
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
}
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
if finalContentKnown {
if diff, ok := boundedFileDiff(absolutePath, content, updated, true, true); ok {
result.FileDiffs = []FileDiff{diff}
} else if diffTextRevealsObfuscatedSecret(content) || diffTextRevealsObfuscatedSecret(updated) {
result.Redacted = true
}
}
// Card-only preview (Display.Preview): the model's Output stays the one-line
// summary, so the red/green diff costs zero model tokens.
preview := ""
if finalContentKnown {
preview = boundedUnifiedDiff(relativePath, content, updated)
}
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: preview}
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: boundedUnifiedDiff(relativePath, content, updated)}
return result
}
+5 -32
View File
@@ -47,11 +47,8 @@ type ExecSessionSnapshot = execution.ProcessSnapshot
type ExecSessionController interface {
ExecSessions() []ExecSessionSnapshot
ExecSession(id int) (ExecSessionSnapshot, bool)
StopExecSession(id int) bool
StopAllExecSessions() []int
WriteExecSessionInput(id int, data []byte) error
ResizeExecSession(id int, cols, rows int) error
}
type execCommandTool struct {
@@ -98,7 +95,7 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
},
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
"prefix_rule": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Reusable approval prefix for this command, only with `sandbox_permissions: \"require_escalated\"`; keep it narrow, for example [\"git\", \"pull\"]."},
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes. Use true for commands that may prompt for input (sudo, ssh, interactive installers): the user can then type into the session directly. Setuid tools such as sudo also need sandbox_permissions \"require_escalated\", since the sandbox blocks privilege escalation.", Default: false},
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes.", Default: false},
},
Required: []string{"cmd"},
AdditionalProperties: false,
@@ -134,10 +131,6 @@ func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
return tool.manager.List()
}
func (tool execCommandTool) ExecSession(id int) (ExecSessionSnapshot, bool) {
return tool.manager.Snapshot(id)
}
func (tool execCommandTool) StopExecSession(id int) bool {
return tool.manager.Stop(id)
}
@@ -146,14 +139,6 @@ func (tool execCommandTool) StopAllExecSessions() []int {
return tool.manager.StopAll()
}
func (tool execCommandTool) WriteExecSessionInput(id int, data []byte) error {
return tool.manager.WriteInput(id, data)
}
func (tool execCommandTool) ResizeExecSession(id int, cols, rows int) error {
return tool.manager.ResizeInput(id, cols, rows)
}
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
commandText, err := execCommandArg(args)
if err != nil {
@@ -187,14 +172,8 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
// tty:true exists exactly to run prompting commands, so the
// non-interactive guard steps aside and the PTY path gets the session.
if !ttyRequested {
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
result := interactiveBlockResult(interactive)
result.Output += "\nRerun with tty:true if the user should interact with it."
return result
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
return interactiveBlockResult(interactive)
}
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
if err != nil {
@@ -474,7 +453,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
if input.exited && input.exitCode != 0 && !input.interrupted {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted, input.tty)
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
meta["shell_issue"] = issue.Kind
@@ -596,7 +575,7 @@ func executionChangeSummaries(changes []execution.Change) []execution.Change {
return summaries
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool, tty bool) string {
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
if output != "" {
@@ -614,18 +593,12 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
parts = append(parts, "interrupted: true")
}
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
if exitCode != 0 && strings.Contains(output, "no new privileges") {
parts = append(parts, `Hint: this command needs sandbox_permissions "require_escalated" (the sandbox blocks setuid); retry with it and tty:true if it prompts.`)
}
} else {
if output == "" {
parts = append(parts, "Command is still running.")
}
parts = append(parts, fmt.Sprintf("session_id: %d", sessionID))
parts = append(parts, fmt.Sprintf("Use write_stdin with session_id %d and empty chars to poll; send chars \"\\u0003\" to interrupt/stop it.", sessionID))
if tty {
parts = append(parts, fmt.Sprintf("This session has a terminal and the user can already type into it (it opened in their TUI; /attach %d reopens it). If it is waiting on a password or confirmation, say so in one line, then keep polling with write_stdin (empty chars, yield_time_ms 60000) until it exits. Do not end your turn while it is running, and never ask for the password in chat.", sessionID))
}
}
return strings.Join(parts, "\n")
}
-53
View File
@@ -894,56 +894,3 @@ func TestTruncateExecOutputPreservesUTF8(t *testing.T) {
t.Fatalf("truncated output is not valid UTF-8: %q", truncated)
}
}
func TestFormatExecCommandOutputTTYAttachHint(t *testing.T) {
running := formatExecCommandOutput("", 1007, false, 0, false, true)
if !strings.Contains(running, "/attach 1007 reopens it") || !strings.Contains(running, "Do not end your turn while it is running") {
t.Fatalf("tty running session should point at /attach and keep-poll guidance: %q", running)
}
exited := formatExecCommandOutput("", 1007, true, 0, false, true)
if strings.Contains(exited, "/attach") {
t.Fatalf("exited session should not mention /attach: %q", exited)
}
pipes := formatExecCommandOutput("", 1007, false, 0, false, false)
if strings.Contains(pipes, "/attach") {
t.Fatalf("non-tty session should not mention /attach: %q", pipes)
}
}
func TestExecCommandInteractiveBlockSuggestsTTY(t *testing.T) {
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
result := tool.Run(context.Background(), map[string]any{"cmd": "ssh host.example.com"})
if result.Status != StatusError || result.Meta["safety_block"] != "interactive_command" {
t.Fatalf("expected interactive safety block, got meta=%#v output=%q", result.Meta, result.Output)
}
if !strings.Contains(result.Output, "tty:true") {
t.Fatalf("block output should suggest tty:true: %q", result.Output)
}
}
func TestExecCommandTTYSkipsInteractiveBlock(t *testing.T) {
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
// ssh to a bogus host fails fast on its own; the point is that the
// interactive guard must not fire ahead of the tty path.
result := tool.Run(context.Background(), map[string]any{
"cmd": "ssh host.example.com", "tty": true, "yield_time_ms": 250,
})
if result.Meta["safety_block"] == "interactive_command" {
t.Fatalf("tty:true should not hit the interactive block: %#v", result.Meta)
}
}
func TestFormatExecCommandOutputNoNewPrivilegesHint(t *testing.T) {
blocked := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 1, false, true)
if !strings.Contains(blocked, `sandbox_permissions "require_escalated"`) {
t.Fatalf("no_new_privs failure should hint at require_escalated: %q", blocked)
}
normal := formatExecCommandOutput("some other error", 1007, true, 1, false, true)
if strings.Contains(normal, "require_escalated") {
t.Fatalf("unrelated failure should not hint at require_escalated: %q", normal)
}
blockedOK := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 0, false, true)
if strings.Contains(blockedOK, "require_escalated") {
t.Fatalf("zero-exit output should not hint at require_escalated: %q", blockedOK)
}
}
-108
View File
@@ -1,108 +0,0 @@
package tools
import (
"errors"
"fmt"
"io"
"os"
)
var errFileChangedDuringWrite = errors.New("file changed on disk before the write committed")
// fileWriteBeforeCommit is a deterministic test hook. Production leaves it
// nil; tests use it to replace a path after observation but before opening the
// object that will actually be mutated.
var fileWriteBeforeCommit func(path string)
// fileWriteStat is a deterministic test seam for proving that the opened-file
// identity is captured before the final preimage comparison. Production uses
// the file descriptor directly.
var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat() }
// commitFileContents binds an overwrite to the file identity and bytes that
// the caller observed. A create uses exclusive creation. An overwrite opens the
// observed object without truncation, verifies identity/content through that
// handle, then truncates and writes the same handle. A path replacement before
// or during commit therefore fails instead of publishing stale rich evidence.
//
// expectedInfo nil means the caller observed a missing path. expectedContent
// may be nil for an existing but unreadable file; that path may still be
// overwritten, but callers must omit rich before/after evidence.
func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent *string, content string) error {
if fileWriteBeforeCommit != nil {
fileWriteBeforeCommit(path)
}
if expectedInfo == nil {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
openedInfo, err := fileWriteStat(file)
if err != nil {
_ = file.Close()
return err
}
return writeAndVerifyFileIdentity(path, file, openedInfo, content, false)
}
flags := os.O_WRONLY
if expectedContent != nil {
flags = os.O_RDWR
}
file, err := os.OpenFile(path, flags, 0)
if err != nil {
return err
}
openedInfo, err := fileWriteStat(file)
if err != nil {
_ = file.Close()
return err
}
if !os.SameFile(expectedInfo, openedInfo) {
_ = file.Close()
return errFileChangedDuringWrite
}
pathInfo, err := os.Stat(path)
if err != nil || !os.SameFile(openedInfo, pathInfo) {
_ = file.Close()
return errFileChangedDuringWrite
}
if expectedContent != nil {
current, readErr := io.ReadAll(file)
if readErr != nil {
_ = file.Close()
return readErr
}
if string(current) != *expectedContent {
_ = file.Close()
return errFileChangedDuringWrite
}
}
return writeAndVerifyFileIdentity(path, file, openedInfo, content, true)
}
func writeAndVerifyFileIdentity(path string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error {
if truncate {
if err := file.Truncate(0); err != nil {
_ = file.Close()
return err
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
_ = file.Close()
return err
}
}
if _, err := io.WriteString(file, content); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
pathInfo, err := os.Stat(path)
if err != nil || !os.SameFile(openedInfo, pathInfo) {
return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
}
return nil
}
-131
View File
@@ -1,131 +0,0 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func installFileWriteRace(t *testing.T, mutate func(string)) {
t.Helper()
prior := fileWriteBeforeCommit
fileWriteBeforeCommit = mutate
t.Cleanup(func() { fileWriteBeforeCommit = prior })
}
func installFileWriteStat(t *testing.T, stat func(*os.File) (os.FileInfo, error)) {
t.Helper()
prior := fileWriteStat
fileWriteStat = stat
t.Cleanup(func() { fileWriteStat = prior })
}
func TestWriteFileRefusesCreateAndOverwriteRaces(t *testing.T) {
t.Run("create", func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "created.txt")
installFileWriteRace(t, func(path string) {
if err := os.WriteFile(path, []byte("other writer\n"), 0o644); err != nil {
t.Fatal(err)
}
})
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "created.txt", "content": "zero\n",
})
if result.Status != StatusError {
t.Fatalf("raced create status = %s, want error", result.Status)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "other writer\n" {
t.Fatalf("raced create content = %q, err=%v", got, err)
}
})
t.Run("overwrite", func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "existing.txt")
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
t.Fatal(err)
}
installFileWriteRace(t, func(path string) {
if err := os.WriteFile(path, []byte("other writer\n"), 0o644); err != nil {
t.Fatal(err)
}
})
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "existing.txt", "content": "zero\n", "overwrite": true,
})
if result.Status != StatusError || !strings.Contains(result.Output, errFileChangedDuringWrite.Error()) {
t.Fatalf("raced overwrite = %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "other writer\n" {
t.Fatalf("raced overwrite content = %q, err=%v", got, err)
}
})
}
func TestEditFileRefusesPreimageRace(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "existing.txt")
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
t.Fatal(err)
}
installFileWriteRace(t, func(path string) {
if err := os.WriteFile(path, []byte("other writer\n"), 0o644); err != nil {
t.Fatal(err)
}
})
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "existing.txt", "old_string": "observed", "new_string": "zero",
})
if result.Status != StatusError || !strings.Contains(result.Output, errFileChangedDuringWrite.Error()) {
t.Fatalf("raced edit = %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(target); err != nil || string(got) != "other writer\n" {
t.Fatalf("raced edit content = %q, err=%v", got, err)
}
}
func TestOverwriteDoesNotStatOpenedFileAfterFinalPreimageComparison(t *testing.T) {
for name, run := range map[string]func(string) Result{
"write overwrite": func(root string) Result {
return NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "existing.txt", "content": "zero\n", "overwrite": true,
})
},
"edit": func(root string) Result {
return NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "existing.txt", "old_string": "observed", "new_string": "zero",
})
},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "existing.txt")
if err := os.WriteFile(target, []byte("observed\n"), 0o644); err != nil {
t.Fatal(err)
}
statCalls := 0
installFileWriteStat(t, func(file *os.File) (os.FileInfo, error) {
statCalls++
if statCalls == 2 {
// This preserves the inode, so identity-only checks cannot detect it.
if err := os.WriteFile(target, []byte("other writer\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return file.Stat()
})
result := run(root)
if result.Status != StatusOK {
t.Fatalf("overwrite status = %s: %s", result.Status, result.Output)
}
if statCalls != 1 {
t.Fatalf("opened file was statted %d times; the final byte comparison must be followed directly by mutation", statCalls)
}
})
}
}
+32 -104
View File
@@ -3,15 +3,11 @@ package tools
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/sandbox"
)
// Format-on-write for the mutating file tools. When enabled, a successful
@@ -48,9 +44,7 @@ var formatOnWriteTimeout = 10 * time.Second
// and finds out from a CI format check it cannot see, which is the thing this
// feature exists to prevent.
type formatOnWriteResult struct {
Content string
ContentKnown bool
Info os.FileInfo
Content string
// Formatter is the binary that was run, named in the notice so the user can
// tell a slow gofmt from a slow prettier.
Formatter string
@@ -123,32 +117,14 @@ func formatOnWriteEnabled() bool {
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
var runFormatOnWriteCommand = func(ctx context.Context, binaryPath string, arguments []string, directory string) error {
formatter := exec.CommandContext(ctx, binaryPath, arguments...)
formatter.Dir = directory
formatter.Stdin = strings.NewReader("")
return formatter.Run()
}
var readFormattedFile = readRootedFile
// maybeFormatWrittenFile is the unscoped test-facing wrapper. Production
// callers use maybeFormatWrittenFileScoped so a formatter cannot redirect the
// final read or recovery write outside the configured write roots.
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
// enabled and on PATH) and returns the file's content afterwards. Best-effort
// throughout: any failure — no formatter, formatter error, timeout, unreadable
// result — returns writtenContent so the caller's state matches the last write
// it performed itself. Only the timeout is reported back, for the reason on
// formatOnWriteResult.
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) formatOnWriteResult {
restoreMode := os.FileMode(0o644)
if info, err := os.Stat(absolutePath); err == nil {
restoreMode = info.Mode().Perm()
}
return maybeFormatWrittenFileScoped(ctx, filepath.Dir(absolutePath), nil, absolutePath, writtenContent, restoreMode)
}
// maybeFormatWrittenFileScoped runs the configured formatter and returns only
// content verified through a descriptor-bound root. Formatter failures restore
// writtenContent through that same root; if restoration or the final read
// fails, ContentKnown is false and callers omit exact diff evidence.
func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, scope PathScope, absolutePath string, writtenContent string, restoreMode os.FileMode) formatOnWriteResult {
unformatted := formatOnWriteResult{Content: writtenContent, ContentKnown: true}
unformatted := formatOnWriteResult{Content: writtenContent}
if !formatOnWriteEnabled() {
return unformatted
}
@@ -160,90 +136,42 @@ func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, sco
if err != nil {
return unformatted
}
root, relativePath, err := openFormattedFileRoot(workspaceRoot, scope, absolutePath)
if err != nil {
unformatted.ContentKnown = false
return unformatted
}
defer root.Close()
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
if err := runFormatOnWriteCommand(formatCtx, binaryPath, arguments, filepath.Dir(absolutePath)); err != nil {
formatter := exec.CommandContext(formatCtx, binaryPath, arguments...)
formatter.Dir = filepath.Dir(absolutePath)
formatter.Stdin = strings.NewReader("")
if err := formatter.Run(); err != nil {
unformatted.Formatter = command[0]
if restoreErr := restoreFormattedFile(root, relativePath, writtenContent, restoreMode); restoreErr != nil {
// THE FORMATTER EDITS IN PLACE, SO A FAILED RUN CAN LEAVE THE FILE
// NEITHER FORMATTED NOR AS WRITTEN. Killed by the deadline or by the
// caller, or exiting partway through its own rewrite, the target can hold
// a truncation. Returning the written bytes on top of that would leave the
// tracker baseline and the diff preview describing a file that is not on
// disk, which is a worse failure than the missing formatting: the next
// edit compares against content the file does not have.
//
// Written back unconditionally on this path rather than only when the
// bytes differ. Comparing first means reading the file to find out, and a
// read that fails leaves the same ambiguity this exists to remove.
if restoreErr := os.WriteFile(absolutePath, []byte(writtenContent), 0o644); restoreErr != nil {
unformatted.RestoreFailed = true
unformatted.ContentKnown = false
} else if restored, info, readErr := readFormattedFile(root, relativePath); readErr != nil {
unformatted.ContentKnown = false
} else {
unformatted.Content = string(restored)
unformatted.Info = info
}
// OUR deadline, not the caller's cancellation and not the formatter's own
// exit status. A cancelled tool call is already being reported as
// cancelled, and a formatter that ran and refused the file usually means
// content it could not parse, which the write itself does not promise to
// fix. Neither is this notice's business; the restore above is, for all
// three.
if errors.Is(formatCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
unformatted.TimedOut = true
}
return unformatted
}
formatted, info, err := readFormattedFile(root, relativePath)
formatted, err := os.ReadFile(absolutePath)
if err != nil {
unformatted.ContentKnown = false
return unformatted
}
return formatOnWriteResult{Content: string(formatted), ContentKnown: true, Info: info, Formatter: command[0]}
}
func restoreFormattedFile(root *os.Root, relativePath string, content string, mode os.FileMode) error {
file, err := root.OpenFile(relativePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode)
if err != nil {
return err
}
if _, err := io.WriteString(file, content); err != nil {
_ = file.Close()
return err
}
return file.Close()
}
// openFormattedFileRoot opens the write root before the formatter runs and
// computes the target relative to that descriptor-bound root. Atomic in-root
// replacement remains valid; a formatter that swaps the target to an escaping
// symlink is rejected when readFormattedFile opens it through the root.
func openFormattedFileRoot(workspaceRoot string, scope PathScope, absolutePath string) (*os.Root, string, error) {
roots, err := scopedRoots(workspaceRoot, scope)
if err != nil {
return nil, "", err
}
var firstErr error
for _, configuredRoot := range roots {
resolvedRoot, err := filepath.Abs(configuredRoot)
if err == nil {
resolvedRoot, err = filepath.EvalSymlinks(resolvedRoot)
}
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
candidate := sandbox.NormalizePrefixForRoot(absolutePath, resolvedRoot)
relativePath, err := filepath.Rel(resolvedRoot, candidate)
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
continue
}
root, err := os.OpenRoot(resolvedRoot)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
return root, relativePath, nil
}
if firstErr != nil {
return nil, "", firstErr
}
return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath)
return formatOnWriteResult{Content: string(formatted), Formatter: command[0]}
}
+2 -411
View File
@@ -5,7 +5,6 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -99,7 +98,7 @@ func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
formatting := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text")
if formatting.Content != "raw text" || !formatting.ContentKnown {
if formatting.Content != "raw text" {
t.Fatalf("unknown extension must pass through: %q", formatting.Content)
}
if notice := formatting.notice("notes.xyz"); notice != "" {
@@ -116,418 +115,10 @@ func TestFormatOnWriteFormatterLookupFailure(t *testing.T) {
t.Fatal(err)
}
formatting := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent)
if formatting.Content != uglyContent || !formatting.ContentKnown {
if formatting.Content != uglyContent {
t.Fatalf("missing formatter must return written content, got %q", formatting.Content)
}
if notice := formatting.notice("a.go"); notice != "" {
t.Fatalf("an uninstalled formatter is a standing fact, not a miss worth reporting, got %q", notice)
}
}
func TestFormatOnWriteReadsMutatedFileAfterFormatterFailure(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
targetPath := filepath.Join(t.TempDir(), "a.go")
if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil {
t.Fatal(err)
}
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
if err := os.WriteFile(targetPath, []byte("formatter-mutated"), 0o644); err != nil {
t.Fatal(err)
}
return exec.ErrNotFound
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested")
if !formatting.ContentKnown || formatting.Content != "requested" {
t.Fatalf("formatter failure content = %q, known=%t", formatting.Content, formatting.ContentKnown)
}
}
func TestFormatOnWriteReportsWhenFormatterFailureCannotBeRestored(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
targetPath := filepath.Join(t.TempDir(), "a.go")
if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil {
t.Fatal(err)
}
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
if err := os.Remove(targetPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(targetPath, 0o755); err != nil {
t.Fatal(err)
}
return exec.ErrNotFound
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested")
if !formatting.RestoreFailed {
t.Fatal("failed recovery was not reported")
}
if formatting.ContentKnown {
t.Fatalf("failed recovery reported known content %q", formatting.Content)
}
if notice := formatting.notice("a.go"); !strings.Contains(notice, "WARNING: a.go may not hold what was written") {
t.Fatalf("failed recovery notice = %q", notice)
}
}
func TestWriteFileRecreatesFormatterDeletedTargetWithOriginalMode(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
for _, tc := range []struct {
name string
existing bool
wantMode os.FileMode
overwrite bool
}{
{name: "create", wantMode: 0o644},
{name: "overwrite", existing: true, wantMode: 0o751, overwrite: true},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
if tc.existing {
if err := os.WriteFile(targetPath, []byte("before\n"), tc.wantMode); err != nil {
t.Fatal(err)
}
}
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
if err := os.Remove(targetPath); err != nil {
t.Fatal(err)
}
return exec.ErrNotFound
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "a.go", "content": "requested\n", "overwrite": tc.overwrite,
})
if result.Status != StatusOK {
t.Fatalf("write status = %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(targetPath)
if err != nil || string(content) != "requested\n" {
t.Fatalf("recreated content = %q, err=%v", content, err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(targetPath)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != tc.wantMode {
t.Fatalf("recreated mode = %o, want %o", got, tc.wantMode)
}
}
})
}
}
func TestFormatOnWriteMarksUnreadableFinalStateUnknown(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
targetPath := filepath.Join(t.TempDir(), "a.go")
if err := os.WriteFile(targetPath, []byte("requested"), 0o644); err != nil {
t.Fatal(err)
}
priorRunner := runFormatOnWriteCommand
priorReader := readFormattedFile
runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil }
readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission }
t.Cleanup(func() {
runFormatOnWriteCommand = priorRunner
readFormattedFile = priorReader
})
formatting := maybeFormatWrittenFile(context.Background(), targetPath, "requested")
if formatting.ContentKnown || formatting.Content != "requested" {
t.Fatalf("unreadable formatter result = %q, known=%t", formatting.Content, formatting.ContentKnown)
}
}
func TestWriteFileUsesVerifiedBytesAfterFormatterFailure(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
if err := os.WriteFile(targetPath, []byte("formatter-mutated\n"), 0o644); err != nil {
t.Fatal(err)
}
return exec.ErrNotFound
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "a.go", "content": "requested\n",
})
if result.Status != StatusOK {
t.Fatalf("write status = %s: %s", result.Status, result.Output)
}
if got := result.FileDiffs; len(got) != 1 || got[0].NewText != "requested\n" {
t.Fatalf("formatter-failure FileDiff = %#v", got)
}
}
func TestEditFileUsesVerifiedBytesAfterFormatterFailure(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
if err := os.WriteFile(targetPath, []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(_ context.Context, _ string, _ []string, _ string) error {
if err := os.WriteFile(targetPath, []byte("formatter-mutated\n"), 0o644); err != nil {
t.Fatal(err)
}
return exec.ErrNotFound
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "a.go", "old_string": "before", "new_string": "requested",
})
if result.Status != StatusOK {
t.Fatalf("edit status = %s: %s", result.Status, result.Output)
}
if got := result.FileDiffs; len(got) != 1 || got[0].OldText != "before\n" || got[0].NewText != "requested\n" {
t.Fatalf("formatter-failure edit FileDiff = %#v", got)
}
}
func TestWriteFileOmitsRichDiffWhenFormatterFinalReadFails(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
priorRunner := runFormatOnWriteCommand
priorReader := readFormattedFile
runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil }
readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission }
t.Cleanup(func() {
runFormatOnWriteCommand = priorRunner
readFormattedFile = priorReader
})
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "a.go", "content": "requested\n",
})
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 {
t.Fatalf("unverified formatter result = status=%s changed=%#v diffs=%#v", result.Status, result.ChangedFiles, result.FileDiffs)
}
if result.Display.Preview != "" {
t.Fatalf("unverified formatter result exposed stale preview: %q", result.Display.Preview)
}
}
func TestEditFileOmitsPreviewWhenFormatterFinalReadFails(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
if err := os.WriteFile(targetPath, []byte("before\n"), 0o644); err != nil {
t.Fatal(err)
}
priorRunner := runFormatOnWriteCommand
priorReader := readFormattedFile
runFormatOnWriteCommand = func(context.Context, string, []string, string) error { return nil }
readFormattedFile = func(*os.Root, string) ([]byte, os.FileInfo, error) { return nil, nil, os.ErrPermission }
t.Cleanup(func() {
runFormatOnWriteCommand = priorRunner
readFormattedFile = priorReader
})
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "a.go", "old_string": "before", "new_string": "requested",
})
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 {
t.Fatalf("unverified formatter result = status=%s changed=%#v diffs=%#v", result.Status, result.ChangedFiles, result.FileDiffs)
}
if result.Display.Preview != "" {
t.Fatalf("unverified formatter result exposed stale preview: %q", result.Display.Preview)
}
}
func TestWriteFileOmitsRichEvidenceWhenFormatterReplacesTargetWithOutOfRootSymlink(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
trackedPath := filepath.Join(resolvedRoot, "a.go")
outsidePath := filepath.Join(t.TempDir(), "outside.go")
outsideContent := "package external\n\nconst Secret = \"outside\"\n"
if err := os.WriteFile(outsidePath, []byte(outsideContent), 0o644); err != nil {
t.Fatal(err)
}
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
if err := os.Remove(targetPath); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsidePath, targetPath); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
return nil
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
tracker := NewFileTracker()
diagnosticsCalled := false
result := NewScopedWriteFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go", "content": "package requested\n",
}, RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string {
diagnosticsCalled = true
return "must not run"
}})
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || result.Display.Preview != "" {
t.Fatalf("out-of-root formatter result = status=%s changed=%#v diffs=%#v preview=%q output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Display.Preview, result.Output)
}
if diagnosticsCalled {
t.Fatal("diagnostics must not inspect an unverified formatter target")
}
if _, tracked := tracker.Version(trackedPath); tracked {
t.Fatal("out-of-root formatter target must not be recorded in the tracker")
}
}
func TestEditFileOmitsRichEvidenceWhenFormatterReplacesTargetWithOutOfRootSymlink(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
trackedPath := filepath.Join(resolvedRoot, "a.go")
if err := os.WriteFile(targetPath, []byte("package before\n"), 0o644); err != nil {
t.Fatal(err)
}
outsidePath := filepath.Join(t.TempDir(), "outside.go")
outsideContent := "package external\n\nconst Secret = \"outside\"\n"
if err := os.WriteFile(outsidePath, []byte(outsideContent), 0o644); err != nil {
t.Fatal(err)
}
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
if err := os.Remove(targetPath); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsidePath, targetPath); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
return nil
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
tracker := NewFileTracker()
read := NewScopedReadFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
}, RunOptions{FileTracker: tracker})
if read.Status != StatusOK {
t.Fatalf("read before edit failed: %s", read.Output)
}
diagnosticsCalled := false
result := NewScopedEditFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go", "old_string": "before", "new_string": "requested",
}, RunOptions{FileTracker: tracker, Diagnostics: func(context.Context, string) string {
diagnosticsCalled = true
return "must not run"
}})
if result.Status != StatusOK || len(result.ChangedFiles) != 1 || len(result.FileDiffs) != 0 || result.Display.Preview != "" {
t.Fatalf("out-of-root formatter result = status=%s changed=%#v diffs=%#v preview=%q output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Display.Preview, result.Output)
}
if diagnosticsCalled {
t.Fatal("diagnostics must not inspect an unverified formatter target")
}
if _, tracked := tracker.Version(trackedPath); tracked {
t.Fatal("out-of-root formatter target must not be recorded in the tracker")
}
}
func TestWriteFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
formatted := "package formatted\n"
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
tempPath := filepath.Join(root, "formatter.tmp")
if err := os.WriteFile(tempPath, []byte(formatted), 0o644); err != nil {
t.Fatal(err)
}
return os.Rename(tempPath, targetPath)
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
tracker := NewFileTracker()
result := NewScopedWriteFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go", "content": "package requested\n",
}, RunOptions{FileTracker: tracker})
if result.Status != StatusOK || len(result.FileDiffs) != 1 || result.FileDiffs[0].NewText != formatted {
t.Fatalf("atomic formatter write = status=%s diffs=%#v output=%q", result.Status, result.FileDiffs, result.Output)
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
version, tracked := tracker.Version(filepath.Join(resolvedRoot, "a.go"))
if !tracked || version.Hash != HashContent([]byte(formatted)) {
t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked)
}
}
func TestEditFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
root := t.TempDir()
targetPath := filepath.Join(root, "a.go")
if err := os.WriteFile(targetPath, []byte("package before\n"), 0o644); err != nil {
t.Fatal(err)
}
formatted := "package formatted\n"
priorRunner := runFormatOnWriteCommand
runFormatOnWriteCommand = func(context.Context, string, []string, string) error {
tempPath := filepath.Join(root, "formatter.tmp")
if err := os.WriteFile(tempPath, []byte(formatted), 0o644); err != nil {
t.Fatal(err)
}
return os.Rename(tempPath, targetPath)
}
t.Cleanup(func() { runFormatOnWriteCommand = priorRunner })
tracker := NewFileTracker()
read := NewScopedReadFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
}, RunOptions{FileTracker: tracker})
if read.Status != StatusOK {
t.Fatalf("read before edit failed: %s", read.Output)
}
result := NewScopedEditFileTool(root, nil).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go", "old_string": "before", "new_string": "requested",
}, RunOptions{FileTracker: tracker})
if result.Status != StatusOK || len(result.FileDiffs) != 1 || result.FileDiffs[0].NewText != formatted {
t.Fatalf("atomic formatter edit = status=%s diffs=%#v output=%q", result.Status, result.FileDiffs, result.Output)
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
version, tracked := tracker.Version(filepath.Join(resolvedRoot, "a.go"))
if !tracked || version.Hash != HashContent([]byte(formatted)) {
t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked)
}
}
-34
View File
@@ -343,33 +343,6 @@ func scrubResultSecrets(res Result) Result {
res.Display.Preview = scrubbed
res.Redacted = true
}
fileDiffs := make([]FileDiff, 0, len(res.FileDiffs))
for _, diff := range res.FileDiffs {
// Never normalize control bytes in a diff: normalizing after redaction can
// reassemble a split credential. Decline unsafe rich content entirely and
// leave ChangedFiles as the safe fallback.
if unsafeDiffText(diff.OldText) || unsafeDiffText(diff.NewText) {
res.Redacted = true
continue
}
if scrubbed := redaction.RedactString(diff.OldText, redaction.Options{}); scrubbed != diff.OldText {
diff.OldText = scrubbed
res.Redacted = true
}
if scrubbed := redaction.RedactString(diff.NewText, redaction.Options{}); scrubbed != diff.NewText {
diff.NewText = scrubbed
res.Redacted = true
}
// Redaction can collapse two distinct credentials to the same token. At
// this final outbound boundary, retain rich evidence only when the
// transformed sides still describe a real transition; ChangedFiles
// remains the safe path-only fallback.
if diff.OldExists == diff.NewExists && diff.OldText == diff.NewText {
continue
}
fileDiffs = append(fileDiffs, diff)
}
res.FileDiffs = fileDiffs
// Meta values carry model-controlled strings (e.g. glob pattern, bash cwd) and
// are forwarded into the transcript, so they are part of the boundary too.
for key, value := range res.Meta {
@@ -381,13 +354,6 @@ func scrubResultSecrets(res Result) Result {
return res
}
// ScrubResultSecrets applies the registry's transcript boundary to a result
// that was produced before Registry.RunWithOptions could own it, such as a
// local pre-permission rejection in the agent loop.
func ScrubResultSecrets(res Result) Result {
return scrubResultSecrets(res)
}
func CoreReadOnlyToolsScoped(workspaceRoot string, scope PathScope) []Tool {
return []Tool{
NewScopedReadMinifiedFileTool(workspaceRoot, scope),
+1 -108
View File
@@ -445,120 +445,13 @@ func (t denyTool) Run(context.Context, map[string]any) Result { return Result{St
// must be scrubbed too, not just the tool-execution paths.
func TestScrubResultSecretsRedactsPreview(t *testing.T) {
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
res := scrubResultSecrets(Result{
Display: Display{Preview: "+++ b/x\n+token := \"" + secret + "\""},
FileDiffs: []FileDiff{{Path: filepath.Join(t.TempDir(), "x"), OldExists: true, NewExists: true, OldText: "before " + secret, NewText: "after " + secret}},
})
res := scrubResultSecrets(Result{Display: Display{Preview: "+++ b/x\n+token := \"" + secret + "\""}})
if strings.Contains(res.Display.Preview, secret) {
t.Errorf("Display.Preview (the card-only code preview) must be redacted, leaked: %q", res.Display.Preview)
}
if !res.Redacted {
t.Error("scrubbing a secret from the preview should set Redacted")
}
if len(res.FileDiffs) != 1 || strings.Contains(res.FileDiffs[0].OldText, secret) || strings.Contains(res.FileDiffs[0].NewText, secret) {
t.Errorf("FileDiff must be redacted: %#v", res.FileDiffs)
}
}
func TestScrubResultSecretsDropsControlSplitFileDiff(t *testing.T) {
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
res := scrubResultSecrets(Result{FileDiffs: []FileDiff{{
Path: filepath.Join(t.TempDir(), "x"),
OldExists: true,
NewExists: true,
OldText: "token=" + secret[:12] + "\x00" + secret[12:],
NewText: "safe",
}}})
if len(res.FileDiffs) != 0 || !res.Redacted {
t.Fatalf("unsafe FileDiff = %#v, redacted = %t", res.FileDiffs, res.Redacted)
}
}
func TestScrubResultSecretsDropsDefaultIgnorableSplitFileDiffOnEitherSide(t *testing.T) {
secret := "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGG"
for name, separator := range map[string]string{
"combining grapheme joiner": "\u034f",
"variation selector": "\ufe0f",
} {
for _, side := range []string{"old", "new"} {
t.Run(name+" "+side, func(t *testing.T) {
obfuscated := secret[:20] + separator + secret[20:]
diff := FileDiff{
Path: filepath.Join(t.TempDir(), "x"), OldExists: true, NewExists: true,
OldText: "safe old", NewText: "safe new",
}
if side == "old" {
diff.OldText = obfuscated
} else {
diff.NewText = obfuscated
}
res := scrubResultSecrets(Result{FileDiffs: []FileDiff{diff}})
if len(res.FileDiffs) != 0 || !res.Redacted {
t.Fatalf("unsafe FileDiff = %#v, redacted = %t", res.FileDiffs, res.Redacted)
}
})
}
}
}
func TestScrubResultSecretsDoesNotMutateCallerFileDiffSlice(t *testing.T) {
path := filepath.Join(t.TempDir(), "x")
secret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
retainedOld := "before token=" + secret
retainedNew := "after token=" + secret
original := []FileDiff{
{Path: path, OldExists: true, NewExists: true, OldText: "token=sk-proj-abc\x00def", NewText: "unsafe"},
{Path: path, OldExists: true, NewExists: true, OldText: retainedOld, NewText: retainedNew},
}
result := scrubResultSecrets(Result{FileDiffs: original})
if len(result.FileDiffs) != 1 || strings.Contains(result.FileDiffs[0].OldText, secret) || strings.Contains(result.FileDiffs[0].NewText, secret) {
t.Fatalf("filtered FileDiffs = %#v", result.FileDiffs)
}
if original[0].NewText != "unsafe" || original[1].OldText != retainedOld || original[1].NewText != retainedNew {
t.Fatalf("caller slice was mutated: %#v", original)
}
}
func TestScrubResultSecretsDropsSemanticallyUnchangedRedactedDiff(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.txt")
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
result := scrubResultSecrets(Result{
ChangedFiles: []string{"credentials.txt"},
FileDiffs: []FileDiff{{
Path: path, OldExists: true, NewExists: true,
OldText: "token=" + oldSecret, NewText: "token=" + newSecret,
}},
})
if len(result.FileDiffs) != 0 {
t.Fatalf("semantically unchanged redacted diff = %#v", result.FileDiffs)
}
if got, want := result.ChangedFiles, []string{"credentials.txt"}; !slices.Equal(got, want) {
t.Fatalf("ChangedFiles = %#v, want %#v", got, want)
}
if !result.Redacted {
t.Fatal("credential rotation must set Redacted")
}
}
func TestScrubResultSecretsKeepsRealTransitionAroundRedactedSecrets(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.txt")
oldSecret := "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
newSecret := "ghp_9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA"
result := scrubResultSecrets(Result{FileDiffs: []FileDiff{{
Path: path, OldExists: true, NewExists: true,
OldText: "environment=staging token=" + oldSecret,
NewText: "environment=production token=" + newSecret,
}}})
if len(result.FileDiffs) != 1 {
t.Fatalf("redacted real transition = %#v", result.FileDiffs)
}
if result.FileDiffs[0].OldText == result.FileDiffs[0].NewText {
t.Fatalf("redacted transition became unchanged: %#v", result.FileDiffs[0])
}
if strings.Contains(result.FileDiffs[0].OldText, oldSecret) || strings.Contains(result.FileDiffs[0].NewText, newSecret) {
t.Fatalf("redacted transition leaked a secret: %#v", result.FileDiffs[0])
}
}
func TestRunWithOptionsScrubsSecretsOnDenialPaths(t *testing.T) {
-43
View File
@@ -1,43 +0,0 @@
package tools
import (
"fmt"
"io"
"os"
)
// readRootedFile returns bytes and identity from the same opened object, and
// verifies that the rooted path still names that object after the read. Root.Open
// binds symlink containment to use rather than relying on a pathname pre-check.
func readRootedFile(root *os.Root, relativePath string) ([]byte, os.FileInfo, error) {
file, err := root.Open(relativePath)
if err != nil {
return nil, nil, err
}
closed := false
defer func() {
if !closed {
_ = file.Close()
}
}()
openedInfo, err := file.Stat()
if err != nil {
return nil, nil, err
}
if !openedInfo.Mode().IsRegular() {
return nil, nil, fmt.Errorf("%s is not a regular file", relativePath)
}
content, err := io.ReadAll(file)
if err != nil {
return nil, nil, err
}
pathInfo, err := root.Stat(relativePath)
if err != nil || !os.SameFile(openedInfo, pathInfo) {
return nil, nil, fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
}
if err := file.Close(); err != nil {
return nil, nil, err
}
closed = true
return content, openedInfo, nil
}
+55 -255
View File
@@ -86,13 +86,12 @@ type structuredPatchTarget struct {
}
type structuredPatchChange struct {
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
beforeInfo os.FileInfo
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
}
// unifiedHunkRangePattern recognises a unified-diff range header ("-12,4 +12,6",
@@ -157,14 +156,8 @@ func applyPatchOperations(applyRoot, relativeRoot string, operations []structure
}
}
}
applyOutcome, err := applyStructuredPatchChanges(workspace, relativeRoot, changes, options.FileTracker)
if err != nil {
result := errorResult("Error applying patch: " + err.Error())
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, applyOutcome.committed)
result.FileDiffs = fileDiffsFromStructuredPatch(relativeRoot, applyOutcome.committed)
result.Redacted = structuredPatchContainsObfuscatedSecret(applyOutcome.committed)
result.Display = Display{Summary: result.Output, Kind: "diff", Preview: structuredPatchPreview(applyOutcome.committed)}
return result
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
for _, change := range changes {
@@ -190,78 +183,10 @@ func applyPatchOperations(applyRoot, relativeRoot string, operations []structure
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
result.FileDiffs = fileDiffsFromStructuredPatch(relativeRoot, changes)
result.Redacted = structuredPatchContainsObfuscatedSecret(changes)
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
return result
}
func structuredPatchContainsObfuscatedSecret(changes []structuredPatchChange) bool {
for _, change := range changes {
if diffTextRevealsObfuscatedSecret(change.before) || diffTextRevealsObfuscatedSecret(change.after) {
return true
}
}
return false
}
func fileDiffsFromStructuredPatch(_ string, changes []structuredPatchChange) []FileDiff {
const maxToolResultFileDiffs = 64
diffs := make([]FileDiff, 0, len(changes)*2)
usedBytes := 0
appendGroup := func(group ...FileDiff) bool {
if len(group) == 0 || len(diffs)+len(group) > maxToolResultFileDiffs {
return false
}
groupBytes := 0
for _, diff := range group {
groupBytes += len(diff.OldText) + len(diff.NewText)
}
if usedBytes+groupBytes > maxToolResultFileDiffBytes {
return false
}
diffs = append(diffs, group...)
usedBytes += groupBytes
return true
}
makeDiff := func(path, before, after string, oldExists, newExists bool) (FileDiff, bool) {
return boundedFileDiff(path, before, after, oldExists, newExists)
}
for _, change := range changes {
var group []FileDiff
switch {
case change.kind == structuredPatchDelete:
if diff, ok := makeDiff(change.from.absolute, change.before, "", true, false); ok {
group = append(group, diff)
}
case change.kind == structuredPatchAdd:
if diff, ok := makeDiff(change.to.absolute, "", change.after, false, true); ok {
group = append(group, diff)
}
case change.kind == structuredPatchCopy && change.from.absolute != change.to.absolute:
// A copy leaves its source unchanged; the destination is a create.
if diff, ok := makeDiff(change.to.absolute, "", change.after, false, true); ok {
group = append(group, diff)
}
case change.kind == structuredPatchUpdate && change.from.absolute != change.to.absolute:
// A move is two filesystem changes, not a destination overwrite.
from, fromOK := makeDiff(change.from.absolute, change.before, "", true, false)
to, toOK := makeDiff(change.to.absolute, "", change.after, false, true)
if fromOK && toOK {
group = append(group, from, to)
}
default:
if diff, ok := makeDiff(change.to.absolute, change.before, change.after, true, true); ok {
group = append(group, diff)
}
}
if len(group) > 0 && !appendGroup(group...) {
break
}
}
return diffs
}
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
if normalized == "" {
@@ -469,12 +394,15 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate, structuredPatchCopy:
content, info, err := readRootedFile(root, from.relative)
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
}
change.mode = info.Mode()
content, err := root.ReadFile(from.relative)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
}
change.mode = info.Mode()
change.beforeInfo = info
if err := tracker.CheckConflict(from.absolute, content); err != nil {
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
@@ -744,112 +672,38 @@ func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bo
return -1, false
}
type structuredPatchApplyOutcome struct {
committed []structuredPatchChange
incompletePaths []string
}
type structuredPatchChangeOutcome struct {
completed []structuredPatchChange
incompletePaths []string
}
func applyStructuredPatchChanges(root *os.Root, relativeRoot string, changes []structuredPatchChange, tracker *FileTracker) (structuredPatchApplyOutcome, error) {
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
// committed lists, in order, the paths whose change reached disk before a
// later change failed, so the caller (and the model) knows exactly which
// files now hold the patched content and which were never touched.
var outcome structuredPatchApplyOutcome
var committed []string
for _, change := range changes {
changeOutcome, err := applyStructuredPatchChange(root, change)
outcome.committed = append(outcome.committed, changeOutcome.completed...)
outcome.incompletePaths = append(outcome.incompletePaths, changeOutcome.incompletePaths...)
done, err := applyStructuredPatchChange(root, change)
if done {
committed = append(committed, structuredPatchChangePaths(change)...)
}
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
committedPaths := changedFilesFromStructuredPatch(relativeRoot, outcome.committed)
unverifiedPaths := appendUniqueStructuredPatchPaths(nil, relativeRoot, outcome.incompletePaths)
if len(committedPaths) > 0 || len(unverifiedPaths) > 0 {
parts := []string{"patch was partially applied"}
if len(committedPaths) > 0 {
parts = append(parts, "already committed: "+strings.Join(committedPaths, ", "))
}
if len(unverifiedPaths) > 0 {
parts = append(parts, "published but unverified: "+strings.Join(unverifiedPaths, ", "))
}
parts = append(parts, "the remaining files are unchanged")
if len(unverifiedPaths) > 0 {
parts = append(parts, "re-read the listed files before retrying")
} else {
parts = append(parts, "re-read the committed files before retrying")
}
return outcome, fmt.Errorf("%w; %s", err, strings.Join(parts, "; "))
if len(committed) > 0 {
return fmt.Errorf("%w; patch was partially applied — already committed: %s; the remaining files are unchanged; re-read the committed files before retrying", err, strings.Join(committed, ", "))
}
return outcome, err
return err
}
}
return outcome, nil
return nil
}
// completedStructuredPatchEffect turns a partially completed compound change
// into the exact filesystem sub-effect that is still verifiable after the
// error. In particular, a failed move may have published its destination while
// leaving the source in place; that is a destination creation, not a move.
func completedStructuredPatchEffect(root *os.Root, change structuredPatchChange) (structuredPatchChange, bool) {
if change.to.relative == "" {
return structuredPatchChange{}, false
// structuredPatchChangePaths names the workspace-relative paths a committed
// change touched: the destination, plus the source of a move or copy.
func structuredPatchChangePaths(change structuredPatchChange) []string {
if change.kind == structuredPatchDelete {
return []string{change.from.relative}
}
content, _, err := readRootedFile(root, change.to.relative)
if err != nil || string(content) != change.after {
return structuredPatchChange{}, false
paths := []string{change.to.relative}
if change.from.absolute != change.to.absolute && change.from.relative != "" {
paths = append([]string{change.from.relative}, paths...)
}
switch change.kind {
case structuredPatchAdd, structuredPatchCopy:
return structuredPatchChange{
kind: structuredPatchAdd,
to: change.to,
after: change.after,
mode: change.mode,
}, true
case structuredPatchUpdate:
if change.from.absolute != change.to.absolute {
return structuredPatchChange{
kind: structuredPatchAdd,
to: change.to,
after: change.after,
mode: change.mode,
}, true
}
}
return structuredPatchChange{}, false
}
func incompleteStructuredPatchWrite(root *os.Root, change structuredPatchChange, published bool) structuredPatchChangeOutcome {
if !published {
return structuredPatchChangeOutcome{}
}
if completed, ok := completedStructuredPatchEffect(root, change); ok {
return structuredPatchChangeOutcome{completed: []structuredPatchChange{completed}}
}
if change.to.relative != "" {
return structuredPatchChangeOutcome{incompletePaths: []string{change.to.relative}}
}
return structuredPatchChangeOutcome{}
}
func appendUniqueStructuredPatchPaths(existing []string, relativeRoot string, paths []string) []string {
seen := make(map[string]bool, len(existing)+len(paths))
for _, path := range existing {
seen[path] = true
}
for _, path := range paths {
if relativeRoot != "" && relativeRoot != "." {
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if path != "" && !seen[path] {
seen[path] = true
existing = append(existing, path)
}
}
return existing
return paths
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
@@ -869,17 +723,7 @@ func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchC
// deterministically; it is nil in production.
var structuredPatchBeforeCommit func(change structuredPatchChange)
// structuredPatchBeforeRename runs after a same-path update has staged and
// closed its replacement but before the final preimage recheck and rename.
// Tests use it to reproduce a competing writer deterministically.
var structuredPatchBeforeRename func(change structuredPatchChange)
// structuredPatchRemove is a deterministic test seam for failures after a
// move destination has been published. Production removes through the opened
// workspace root.
var structuredPatchRemove = func(root *os.Root, name string) error { return root.Remove(name) }
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (structuredPatchChangeOutcome, error) {
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
if structuredPatchBeforeCommit != nil {
structuredPatchBeforeCommit(change)
}
@@ -888,63 +732,39 @@ func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (st
// by another process in between is refused rather than overwritten or
// removed.
if change.kind != structuredPatchAdd {
if err := recheckStructuredPatchPreimage(root, change); err != nil {
return structuredPatchChangeOutcome{}, err
current, err := root.ReadFile(change.from.relative)
if err != nil {
return false, fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
}
if string(current) != change.before {
return false, fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
}
}
switch change.kind {
case structuredPatchDelete:
if err := structuredPatchRemove(root, change.from.relative); err != nil {
return structuredPatchChangeOutcome{}, fmt.Errorf("deleting %s: %w", change.from.relative, err)
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return structuredPatchChangeOutcome{completed: []structuredPatchChange{change}}, nil
return true, nil
case structuredPatchAdd, structuredPatchCopy:
var beforePublish func() error
if change.kind == structuredPatchCopy {
beforePublish = structuredPatchPrePublishGuard(root, change)
}
published, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, true, beforePublish)
if err != nil {
return incompleteStructuredPatchWrite(root, change, published), err
}
return structuredPatchChangeOutcome{completed: []structuredPatchChange{change}}, nil
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
published, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving, structuredPatchPrePublishGuard(root, change))
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
if err != nil {
return incompleteStructuredPatchWrite(root, change, published), err
return committed, err
}
if moving {
if err := structuredPatchRemove(root, change.from.relative); err != nil {
return incompleteStructuredPatchWrite(root, change, true), fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
if err := root.Remove(change.from.relative); err != nil {
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
}
}
return structuredPatchChangeOutcome{completed: []structuredPatchChange{change}}, nil
return true, nil
}
return structuredPatchChangeOutcome{}, fmt.Errorf("unsupported structured patch operation")
return false, fmt.Errorf("unsupported structured patch operation")
}
func recheckStructuredPatchPreimage(root *os.Root, change structuredPatchChange) error {
current, info, err := readRootedFile(root, change.from.relative)
if err != nil {
return fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
}
if (change.beforeInfo != nil && !os.SameFile(change.beforeInfo, info)) || string(current) != change.before {
return fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
}
return nil
}
func structuredPatchPrePublishGuard(root *os.Root, change structuredPatchChange) func() error {
return func() error {
if change.kind == structuredPatchUpdate && change.from.absolute == change.to.absolute && structuredPatchBeforeRename != nil {
structuredPatchBeforeRename(change)
}
return recheckStructuredPatchPreimage(root, change)
}
}
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool, beforePublish func() error) (bool, error) {
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
parent := filepath.Dir(target.relative)
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
@@ -965,13 +785,8 @@ func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, conte
if err := temp.Close(); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if beforePublish != nil {
if err := beforePublish(); err != nil {
return false, err
}
}
if createOnly {
return structuredPatchPublishNoReplace(root, tempName, target.relative, mode)
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
}
if err := root.Rename(tempName, target.relative); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
@@ -983,11 +798,6 @@ func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
}
// structuredPatchPublishNoReplace is a deterministic test seam for failures
// after a create-only target has become visible. Production uses the atomic
// no-replace publisher above.
var structuredPatchPublishNoReplace = publishStructuredPatchNoReplace
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
if linkErr := link(source, target); linkErr == nil {
cleanupErr := removeStructuredPatchTemp(root, source)
@@ -1104,19 +914,9 @@ func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPa
seen := make(map[string]bool)
var paths []string
for _, change := range changes {
var targets []structuredPatchTarget
switch change.kind {
case structuredPatchDelete:
targets = []structuredPatchTarget{change.from}
case structuredPatchUpdate:
targets = []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
default:
// Adds and copies mutate only their destination; the copy source is
// evidence for the operation, not a changed file.
targets = []structuredPatchTarget{change.to}
targets := []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
for _, target := range targets {
path := target.relative
-4
View File
@@ -120,10 +120,6 @@ type Result struct {
// entries under a granted extra write root are absolute, since
// workspace-relative would be ambiguous there.
ChangedFiles []string
// FileDiffs carries exact, bounded before/after text for built-in file
// mutations. A missing entry means the client must fall back to ChangedFiles
// rather than inventing a partial diff.
FileDiffs []FileDiff
// ChangeSummaries contains bounded generated-tree changes. These are shown
// in session evidence and the Files panel but are never treated as files to
// open or diagnose individually.
+10 -47
View File
@@ -12,7 +12,6 @@ type writeFileTool struct {
baseTool
workspaceRoot string
scope PathScope
readFile func(string) ([]byte, error)
}
func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
@@ -35,7 +34,6 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
readFile: os.ReadFile,
}
}
@@ -63,10 +61,8 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
}
existed := false
var priorInfo os.FileInfo
if info, err := os.Stat(absolutePath); err == nil {
if _, err := os.Stat(absolutePath); err == nil {
existed = true
priorInfo = info
if !overwrite {
return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.")
}
@@ -86,7 +82,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
// the overwrite rather than clobbering a file whose current state is
// unknown (it may have been replaced or removed out from under us).
current, rerr := tool.readFile(absolutePath)
current, rerr := os.ReadFile(absolutePath)
if rerr != nil {
return errorResult(fileConflictMessage(relativePath))
}
@@ -99,11 +95,9 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
// Capture the prior content (before we replace it) so an overwrite can show a
// real diff; a fresh create stays "" and previews as all-additions.
priorContent := ""
priorContentKnown := !existed
if existed {
if prev, rerr := tool.readFile(absolutePath); rerr == nil {
if prev, rerr := os.ReadFile(absolutePath); rerr == nil {
priorContent = string(prev)
priorContentKnown = true
}
}
@@ -113,36 +107,20 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
var expectedContent *string
if priorContentKnown {
expectedContent = &priorContent
}
if err := commitFileContents(absolutePath, priorInfo, expectedContent, content); err != nil {
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
modelKnownContent := content
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
// FileTracker baseline: recording pre-format content would make the very
// next edit look like an external modification and trip the conflict guard.
restoreMode := os.FileMode(0o644)
if priorInfo != nil {
restoreMode = priorInfo.Mode().Perm()
}
formatting := maybeFormatWrittenFileScoped(ctx, tool.workspaceRoot, tool.scope, absolutePath, content, restoreMode)
formatting := maybeFormatWrittenFile(ctx, absolutePath, content)
content = formatting.Content
finalContentKnown := formatting.ContentKnown
// Baseline the freshly written content so a later edit/overwrite in this
// session compares against what is now on disk.
newInfo := formatting.Info
if newInfo == nil {
newInfo, _ = os.Stat(absolutePath)
}
if finalContentKnown {
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
} else {
options.FileTracker.Forget(absolutePath)
}
if finalContentKnown && content == modelKnownContent {
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
if content == modelKnownContent {
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content))
}
if !existed {
@@ -161,28 +139,13 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
}
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
summary += formatting.notice(relativePath)
if finalContentKnown {
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
}
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
// Do not pretend an unreadable overwrite was a creation. The write may be
// valid, but ACP only receives an exact before/after pair we actually saw.
if priorContentKnown && finalContentKnown {
if diff, ok := boundedFileDiff(absolutePath, priorContent, content, existed, true); ok {
result.FileDiffs = []FileDiff{diff}
} else if diffTextRevealsObfuscatedSecret(priorContent) || diffTextRevealsObfuscatedSecret(content) {
result.Redacted = true
}
}
// Card-only preview: a real unified diff (all-green for a create, red/green for
// an overwrite) on Display.Preview. Output stays the summary, so the model never
// re-reads the file — the rich preview costs zero model tokens.
preview := ""
if priorContentKnown && finalContentKnown {
preview = boundedUnifiedDiff(relativePath, priorContent, content)
}
result.Display = Display{Summary: summary, Kind: "file", Preview: preview}
result.Display = Display{Summary: summary, Kind: "file", Preview: boundedUnifiedDiff(relativePath, priorContent, content)}
return result
}
+5 -151
View File
@@ -6,8 +6,6 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
)
@@ -325,10 +323,6 @@ func TestEditFileToolReplacesExactStrings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
path, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
result := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "code.go",
@@ -373,12 +367,7 @@ func TestEditFileToolReplacesCRLF(t *testing.T) {
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
path, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
res := NewScopedEditFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "code.go", "old_string": "const a = 1", "new_string": "const a = 42",
})
@@ -398,9 +387,6 @@ func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
t.Fatalf("edit preview missing diff marker %q: %q", want, res.Display.Preview)
}
}
if got := res.FileDiffs; len(got) != 1 || got[0].Path != path || !got[0].OldExists || !got[0].NewExists || got[0].OldText != "const a = 1\nconst b = 2\n" || got[0].NewText != "const a = 42\nconst b = 2\n" {
t.Fatalf("file diffs = %#v", got)
}
}
func TestWriteFileToolEmitsAdditionsDiff(t *testing.T) {
@@ -422,13 +408,6 @@ func TestWriteFileToolEmitsAdditionsDiff(t *testing.T) {
if strings.Contains(res.Display.Preview, "\n-line") {
t.Fatalf("a fresh-create diff must have no removed lines: %q", res.Display.Preview)
}
path, err := filepath.EvalSymlinks(filepath.Join(root, "new.txt"))
if err != nil {
t.Fatal(err)
}
if got := res.FileDiffs; len(got) != 1 || got[0].Path != path || got[0].OldExists || !got[0].NewExists || got[0].OldText != "" || got[0].NewText != "line one\nline two\n" {
t.Fatalf("file diffs = %#v", got)
}
}
func TestWriteFileToolOverwriteEmitsRedGreenDiff(t *testing.T) {
@@ -450,28 +429,6 @@ func TestWriteFileToolOverwriteEmitsRedGreenDiff(t *testing.T) {
}
}
func TestWriteFileToolOmitsDiffWhenOverwritePreimageCannotBeRead(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "private.txt")
writeTestFile(t, path, "before\n")
tool := NewScopedWriteFileTool(root, nil).(writeFileTool)
tool.readFile = func(string) ([]byte, error) { return nil, os.ErrPermission }
registry := NewRegistry()
registry.Register(tool)
result := registry.RunWithOptions(context.Background(), tool.Name(), map[string]any{
"path": "private.txt", "content": "after\n", "overwrite": true,
}, RunOptions{PermissionGranted: true})
if result.Status != StatusOK {
t.Fatalf("write = %s", result.Output)
}
if len(result.FileDiffs) != 0 {
t.Fatalf("unreadable preimage must not produce a create-like diff: %#v", result.FileDiffs)
}
if got, err := os.ReadFile(path); err != nil || string(got) != "after\n" {
t.Fatalf("written content = %q, err = %v", got, err)
}
}
func TestEditFileToolAllowsDeletingRegions(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "notes.txt")
@@ -627,17 +584,6 @@ func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
if got, want := result.FileDiffs, []FileDiff{
{Path: filepath.Join(resolvedRoot, "nested", "new.txt"), OldExists: false, NewExists: true, OldText: "", NewText: "created\n"},
{Path: filepath.Join(resolvedRoot, "old.txt"), OldExists: true, NewExists: false, OldText: "old\n", NewText: ""},
{Path: filepath.Join(resolvedRoot, "moved.txt"), OldExists: false, NewExists: true, OldText: "", NewText: "moved\n"},
}; !reflect.DeepEqual(got, want) {
t.Fatalf("FileDiffs = %#v, want %#v", got, want)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
@@ -885,7 +831,7 @@ func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
after: "patch content\n", mode: 0o644,
}
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
@@ -911,7 +857,7 @@ func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
before: "removed by another writer\n", mode: 0o644,
}
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
@@ -948,7 +894,7 @@ func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testin
}
defer func() { structuredPatchBeforeCommit = nil }()
_, err = applyStructuredPatchChanges(workspace, ".", []structuredPatchChange{change}, nil)
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if !removed {
t.Fatal("pre-commit hook did not run")
}
@@ -963,59 +909,6 @@ func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testin
}
}
func TestStructuredPatchUpdatePreservesCompetingWriteBeforeRename(t *testing.T) {
for _, tc := range []struct {
name string
competing string
replaceIdentity bool
}{
{name: "content changed", competing: "competing writer\n"},
{name: "identity changed with same content", competing: "planned\n", replaceIdentity: true},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "file.txt")
writeTestFile(t, targetPath, "planned\n")
patch := "*** Begin Patch\n*** Update File: file.txt\n@@\n-planned\n+patched\n*** End Patch\n"
hookRan := false
var competingInfo os.FileInfo
structuredPatchBeforeRename = func(change structuredPatchChange) {
if tc.replaceIdentity {
tempPath := filepath.Join(root, "competing.tmp")
writeTestFile(t, tempPath, tc.competing)
if err := os.Rename(tempPath, targetPath); err != nil {
t.Fatal(err)
}
} else if err := os.WriteFile(targetPath, []byte(tc.competing), 0o644); err != nil {
t.Fatal(err)
}
var err error
competingInfo, err = os.Stat(targetPath)
if err != nil {
t.Fatal(err)
}
hookRan = true
}
t.Cleanup(func() { structuredPatchBeforeRename = nil })
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if !hookRan {
t.Fatal("pre-rename hook did not run")
}
if result.Status != StatusError || len(result.ChangedFiles) != 0 || len(result.FileDiffs) != 0 {
t.Fatalf("raced update = status=%s changed=%#v diffs=%#v output=%q", result.Status, result.ChangedFiles, result.FileDiffs, result.Output)
}
if got := mustReadTestFile(t, targetPath); got != tc.competing {
t.Fatalf("raced update overwrote competing content: %q", got)
}
finalInfo, err := os.Stat(targetPath)
if err != nil || !os.SameFile(competingInfo, finalInfo) {
t.Fatalf("raced update replaced the competing file identity: %v", err)
}
})
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
@@ -1085,45 +978,6 @@ func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
}
}
func TestIncompleteStructuredPatchNoReplaceWriteReportsOnlyVerifiedTarget(t *testing.T) {
for name, content := range map[string]string{
"complete publication": "complete content\n",
"partial publication": "partial",
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
targetPath := filepath.Join(root, "target.txt")
writeTestFile(t, targetPath, content)
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{
requested: "target.txt",
relative: "target.txt",
absolute: targetPath,
},
after: "complete content\n",
mode: 0o644,
}
outcome := incompleteStructuredPatchWrite(workspace, change, true)
if content == change.after {
if len(outcome.completed) != 1 || outcome.completed[0].kind != structuredPatchAdd || len(outcome.incompletePaths) != 0 {
t.Fatalf("verified publication outcome = %#v", outcome)
}
return
}
if len(outcome.completed) != 0 || !slices.Equal(outcome.incompletePaths, []string{"target.txt"}) {
t.Fatalf("partial publication outcome = %#v", outcome)
}
})
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
@@ -1163,7 +1017,7 @@ func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState
},
}
_, err = applyStructuredPatchChanges(workspace, ".", changes, tracker)
err = applyStructuredPatchChanges(workspace, changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
+1 -1
View File
@@ -24,7 +24,7 @@ func (m model) attachmentThumbnailVisible(width int) bool {
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
+4 -94
View File
@@ -6,8 +6,6 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -32,21 +30,15 @@ func (m model) backgroundTerminalSessions() []tools.ExecSessionSnapshot {
}
func (m model) backgroundTerminalSummary() string {
sessions := m.backgroundTerminalSessions()
if len(sessions) == 0 {
count := len(m.backgroundTerminalSessions())
if count == 0 {
return ""
}
plural := ""
if len(sessions) != 1 {
if count != 1 {
plural = "s"
}
summary := fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", len(sessions), plural)
for _, session := range sessions {
if session.TTY {
return summary + " · /attach to type into it"
}
}
return summary
return fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", count, plural)
}
func (m model) stopAllBackgroundTerminalSessions() []int {
@@ -79,12 +71,6 @@ func (m model) backgroundTerminalsText() string {
Rows: rows,
}}
card.Actions = []string{"/stop <session_id>", "/stop"}
for _, session := range sessions {
if session.TTY {
card.Actions = append([]string{"/attach <session_id>"}, card.Actions...)
break
}
}
return renderCommandCardTranscript(card)
}
@@ -126,82 +112,6 @@ func (m model) stopBackgroundTerminalsText(input string) string {
return renderStopBackgroundTerminalsCard([]int{id}, "")
}
// attachTerminalCommand implements /attach: with an explicit session id it
// validates and opens the attach overlay; bare, it picks the one interactive
// session or lists the choices.
func (m model) attachTerminalCommand(input string) (model, tea.Cmd) {
controller, ok := m.execSessionController()
if !ok {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
Title: "Background Terminals",
Summary: []string{"unavailable"},
Sections: []commandCardSection{{
Rows: []commandRow{{Text: "exec_command is not registered."}},
}},
})})
return m, nil
}
// The overlay composites into the main transcript viewport; in a view that
// replaces it (subchat, detailed transcript, another overlay) it would
// capture keystrokes while invisible, so refuse to open there.
if !m.terminalAttachCanOpen() {
return m.showTransientNoticeInline("Leave the current view first", transientNoticeInfo), nil
}
input = strings.TrimSpace(input)
if input != "" {
id, err := strconv.Atoi(input)
if err != nil || id <= 0 {
return m.appendAttachCard("invalid session id", "Usage: /attach [session_id]"), nil
}
snapshot, found := controller.ExecSession(id)
if !found {
return m.appendAttachCard("not found", fmt.Sprintf("No running terminal session %d.", id)), nil
}
if !snapshot.TTY {
return m.appendAttachCard("not interactive", fmt.Sprintf("Session %d has no terminal — it was started without tty:true (or this platform has no PTY), so it can't take input. Use /stop %d to end it.", id, id)), nil
}
return m.openTerminalAttach(id)
}
var interactive []tools.ExecSessionSnapshot
for _, session := range controller.ExecSessions() {
if session.TTY {
interactive = append(interactive, session)
}
}
switch len(interactive) {
case 0:
return m.showTransientNoticeInline("No interactive terminal sessions running.", transientNoticeInfo), nil
case 1:
return m.openTerminalAttach(interactive[0].ID)
}
rows := make([]commandRow, 0, len(interactive))
now := m.now()
for _, session := range interactive {
rows = append(rows, commandRow{Text: formatBackgroundTerminalRow(session, now)})
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
Title: "Background Terminals",
Summary: []string{fmt.Sprintf("%d interactive sessions", len(interactive))},
Sections: []commandCardSection{{
Title: "running",
Rows: rows,
}},
Actions: []string{"/attach <session_id>"},
})})
return m, nil
}
func (m model) appendAttachCard(summary string, text string) model {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
Title: "Background Terminals",
Summary: []string{summary},
Sections: []commandCardSection{{
Rows: []commandRow{{Text: text}},
}},
})})
return m
}
func renderStopBackgroundTerminalsCard(stopped []int, note string) string {
card := commandCard{Title: "Background Terminals"}
if len(stopped) == 0 {
-141
View File
@@ -6,7 +6,6 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -14,11 +13,6 @@ type fakeExecSessionTool struct {
sessions []tools.ExecSessionSnapshot
stopped []int
stopAll bool
writes [][]byte
resizeCalls int
resizeCols int
resizeRows int
}
func (tool *fakeExecSessionTool) Name() string { return tools.ExecCommandToolName }
@@ -41,15 +35,6 @@ func (tool *fakeExecSessionTool) ExecSessions() []tools.ExecSessionSnapshot {
return append([]tools.ExecSessionSnapshot(nil), tool.sessions...)
}
func (tool *fakeExecSessionTool) ExecSession(id int) (tools.ExecSessionSnapshot, bool) {
for _, session := range tool.sessions {
if session.ID == id {
return session, true
}
}
return tools.ExecSessionSnapshot{}, false
}
func (tool *fakeExecSessionTool) StopExecSession(id int) bool {
for _, session := range tool.sessions {
if session.ID == id {
@@ -69,35 +54,6 @@ func (tool *fakeExecSessionTool) StopAllExecSessions() []int {
return ids
}
func (tool *fakeExecSessionTool) WriteExecSessionInput(id int, data []byte) error {
for _, session := range tool.sessions {
if session.ID != id {
continue
}
if !session.TTY {
return execution.ErrProcessStdinDisabled
}
tool.writes = append(tool.writes, append([]byte(nil), data...))
return nil
}
return execution.ErrProcessNotFound
}
func (tool *fakeExecSessionTool) ResizeExecSession(id int, cols, rows int) error {
for _, session := range tool.sessions {
if session.ID != id {
continue
}
if !session.TTY {
return execution.ErrProcessStdinDisabled
}
tool.resizeCalls++
tool.resizeCols, tool.resizeRows = cols, rows
return nil
}
return execution.ErrProcessNotFound
}
func modelWithFakeExecSessions(tool *fakeExecSessionTool, now time.Time) model {
registry := tools.NewRegistry()
registry.Register(tool)
@@ -171,103 +127,6 @@ func TestStopBackgroundTerminalsTextRejectsInvalidSessionID(t *testing.T) {
}
}
func TestBackgroundTerminalsTextAdvertisesAttachForTTY(t *testing.T) {
now := time.Unix(200, 0)
m := modelWithFakeExecSessions(&fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{{
ID: 1000, Command: "sudo apt install x", StartedAt: now, Status: "running", TTY: true,
}},
}, now)
text := m.backgroundTerminalsText()
if !strings.Contains(text, "/attach <session_id>") {
t.Fatalf("/ps card should offer /attach for a tty session:\n%s", text)
}
if summary := m.backgroundTerminalSummary(); !strings.HasSuffix(summary, " · /attach to type into it") {
t.Fatalf("summary = %q, want /attach hint", summary)
}
}
func TestAttachCommandNeedsController(t *testing.T) {
m := model{now: func() time.Time { return time.Unix(100, 0) }}
next, _ := m.attachTerminalCommand("7")
if !transcriptContains(next.transcript, "exec_command is not registered.") {
t.Fatalf("transcript = %#v", next.transcript)
}
}
func TestAttachCommandExplicitID(t *testing.T) {
now := time.Unix(200, 0)
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
},
}
m := modelWithFakeExecSessions(tool, now)
next, cmd := m.attachTerminalCommand("7")
if cmd == nil || next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
t.Fatalf("expected attach to session 7, state=%#v cmd=%v", next.terminalAttach, cmd)
}
next, _ = m.attachTerminalCommand("8")
if !transcriptContains(next.transcript, "Session 8 has no terminal") {
t.Fatalf("non-tty session should be refused: %#v", next.transcript)
}
next, _ = m.attachTerminalCommand("9")
if !transcriptContains(next.transcript, "No running terminal session 9.") {
t.Fatalf("missing session should be reported: %#v", next.transcript)
}
next, _ = m.attachTerminalCommand("abc")
if !transcriptContains(next.transcript, "Usage: /attach [session_id]") {
t.Fatalf("invalid id should show usage: %#v", next.transcript)
}
}
func TestAttachCommandBarePicksSingleTTY(t *testing.T) {
now := time.Unix(200, 0)
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
},
}
m := modelWithFakeExecSessions(tool, now)
next, _ := m.attachTerminalCommand("")
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
t.Fatalf("bare /attach should pick the only tty session, state=%#v", next.terminalAttach)
}
}
func TestAttachCommandBareNoneOrSeveral(t *testing.T) {
now := time.Unix(200, 0)
m := modelWithFakeExecSessions(&fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
},
}, now)
next, _ := m.attachTerminalCommand("")
if next.terminalAttach != nil || !strings.Contains(next.transientNotice.text, "No interactive terminal sessions running.") {
t.Fatalf("bare /attach with no tty sessions: notice=%q state=%#v", next.transientNotice.text, next.terminalAttach)
}
m = modelWithFakeExecSessions(&fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
{ID: 9, TTY: true, Status: "running", Command: "top", StartedAt: now},
},
}, now)
next, _ = m.attachTerminalCommand("")
if next.terminalAttach != nil {
t.Fatal("bare /attach with several tty sessions should list, not attach")
}
if !transcriptContains(next.transcript, "/attach <session_id>") || !transcriptContains(next.transcript, "cat") || !transcriptContains(next.transcript, "top") {
t.Fatalf("expected a choice card listing both sessions: %#v", next.transcript)
}
}
func TestQuitStopsBackgroundTerminals(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{{ID: 1000, StartedAt: time.Unix(100, 0), Status: "running"}},
-4
View File
@@ -306,10 +306,6 @@ func btwMessageRunID(msg tea.Msg) (int, bool) {
return typed.runID, true
case toolCallStreamStartMsg:
return typed.runID, true
case interactiveExecStartMsg:
return typed.runID, true
case terminalAutoAttachTickMsg:
return typed.runID, true
case toolCallStreamDeltaMsg:
return typed.runID, true
case planUpdateMsg:
+10 -29
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
tea "charm.land/bubbletea/v2"
@@ -366,30 +365,17 @@ func (defaultModelSwitchCompactionPolicy) BeforeModelSwitch(request modelSwitchC
var modelSwitchCompactionGuard modelSwitchCompactionPolicy = defaultModelSwitchCompactionPolicy{}
// sanitizeCardField strips the card protocol's separator byte and every
// control rune from user-controlled values (titles can legally contain
// anything --session-title was given, and a directory name can legally
// contain an ESC byte on Unix). Line separators and tabs become spaces so
// words stay apart; the remaining controls — ESC/CSI/OSC initiators, BEL, BS,
// VT, NEL, NUL — are dropped outright, since any of them can repaint the
// terminal or shift cells when the value lands in a rendered row.
// sanitizeCardField strips the card protocol's separator bytes from
// user-controlled values (titles can legally contain anything --session-title
// was given), so a hostile or accidental \x1f / newline cannot shift fields
// or leak control characters into the transcript.
func sanitizeCardField(value string) string {
var out strings.Builder
for _, r := range value {
switch {
case r == '\x1f' || r == '\n' || r == '\r' || r == '\t':
out.WriteRune(' ')
case unicode.IsControl(r):
default:
out.WriteRune(r)
}
}
return out.String()
value = strings.ReplaceAll(value, sessionsCardFieldSep, " ")
value = strings.ReplaceAll(value, "\n", " ")
return strings.ReplaceAll(value, "\x00", "")
}
// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"), falling
// back to the month/day this year and the bare date for sessions older than a
// month, so deep history reads as a calendar date rather than "400d ago"; ""
// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"); ""
// when the timestamp does not parse, so the card simply omits it.
func relativeAge(timestamp string, now time.Time) string {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
@@ -399,19 +385,14 @@ func relativeAge(timestamp string, now time.Time) string {
age := now.Sub(parsed)
switch {
case age < time.Minute:
return "now"
return "just now"
case age < time.Hour:
return fmt.Sprintf("%dm ago", int(age.Minutes()))
case age < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(age.Hours()))
case age <= 30*24*time.Hour:
default:
return fmt.Sprintf("%dd ago", int(age.Hours()/24))
}
parsed, now = parsed.Local(), now.Local()
if parsed.Year() == now.Year() {
return parsed.Format("Jan _2")
}
return parsed.Format("2006-01-02")
}
// handleModelCommand applies a model switch against the ACTIVE provider (the
-8
View File
@@ -17,7 +17,6 @@ const (
commandPermissions
commandPS
commandStop
commandAttach
commandSandboxSetup
commandProvider
commandModel
@@ -141,13 +140,6 @@ var commandDefinitions = []commandDefinition{
description: "Stop running background terminal sessions.",
kind: commandStop,
},
{
name: "/attach",
usage: "/attach [session_id]",
group: commandGroupRuntime,
description: "Type directly into a running interactive terminal session.",
kind: commandAttach,
},
{
name: "/sandbox-setup",
usage: "/sandbox-setup",
-2
View File
@@ -977,8 +977,6 @@ func TestParseBackgroundTerminalCommands(t *testing.T) {
{input: "/ps", kind: commandPS, text: ""},
{input: "/stop", kind: commandStop, text: ""},
{input: "/stop 1000", kind: commandStop, text: "1000"},
{input: "/attach", kind: commandAttach, text: ""},
{input: "/attach 7", kind: commandAttach, text: "7"},
}
for _, tc := range cases {
got := parseCommand(tc.input)
+1 -63
View File
@@ -253,9 +253,6 @@ type model struct {
composerSelection composerSelectionState
dictation dictationController
sttKeyPrompt *sttKeyPromptState
terminalAttach *terminalAttachState
terminalAutoAttach *terminalAutoAttachState
terminalAttachSeen map[int]bool
// plan holds the sticky plan panel state (steps, expansion, timings)
// synced from the update_plan tool. See plan_panel.go.
plan planPanelState
@@ -1248,7 +1245,7 @@ func (m *model) stopPRWatcher() {
func (m model) noBlockingModal() bool {
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
m.sttKeyPrompt == nil && m.renamePrompt == nil && m.terminalAttach == nil
m.sttKeyPrompt == nil && m.renamePrompt == nil
}
func (m model) quit() (tea.Model, tea.Cmd) {
@@ -1450,11 +1447,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.MouseMsg:
// Attached to a live terminal: the overlay owns the viewport, so mouse
// events go nowhere rather than hitting transcript selection below.
if m.terminalAttach != nil {
return m, nil
}
if m.setup.visible {
return m.handleSetupMouse(msg)
}
@@ -1541,11 +1533,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.attachClipboardImage(msg.data, msg.mediaType), nil
case tea.PasteMsg:
// While attached to a live terminal, a paste forwards verbatim to the
// PTY stdin — never into the composer or transcript.
if m.terminalAttach != nil {
return m.handleTerminalAttachPaste(msg.Content)
}
// A paste into the cloud-STT key prompt fills the key (the common way to
// enter an API key), not the composer.
if m.sttKeyPrompt != nil {
@@ -1578,20 +1565,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleVoiceCaptureRelease()
}
return m, nil
case terminalAttachTickMsg:
return m.refreshTerminalAttach()
case interactiveExecStartMsg:
if msg.runID != m.activeRunID {
return m, nil
}
m.terminalAutoAttach = &terminalAutoAttachState{
runID: msg.runID,
known: msg.known,
deadline: m.now().Add(terminalAutoAttachTimeout),
}
return m, terminalAutoAttachTickCmd(msg.runID)
case terminalAutoAttachTickMsg:
return m.pollTerminalAutoAttach(msg)
case tea.KeyPressMsg:
if m.petDragActive {
pixelDrag := m.petPixelDrag
@@ -1631,11 +1604,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.sttKeyPrompt != nil {
return m.handleSTTKeyPromptKey(msg)
}
// Attached to a live terminal session: keystrokes (including Ctrl+C,
// which maps to 0x03 for the process) go to the PTY until Esc detaches.
if m.terminalAttach != nil {
return m.handleTerminalAttachKey(msg)
}
if m.renamePrompt != nil {
return m.handleSessionRenameKey(msg)
}
@@ -2489,9 +2457,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// Size the composer so long input scrolls horizontally with the cursor
// visible instead of being clipped invisibly past the right edge.
m.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
// An attached terminal fills the viewport, so the PTY tracks the
// live size instead of a fixed default.
m = m.resizeAttachedTerminalPTY()
// The title bar prints once into native scrollback when the inline
// renderer is active. In alt-screen mode it stays pinned inside View.
if !m.altScreen && !m.headerPrinted && msg.Width > 0 {
@@ -2667,7 +2632,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.runCancel = nil
m.activeRunID = 0
m.terminalAutoAttach = nil
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
// A fully successful turn means the task is done. Weaker models often
// forget the final update_plan, leaving the panel stuck mid-progress;
@@ -3171,11 +3135,8 @@ func (m model) transcriptView() string {
mcpOverlay := m.mcpManagerOverlay(width)
pickerOverlay := m.pickerOverlay(width)
sttKeyOverlay := m.sttKeyPromptOverlay(width)
terminalAttachOverlay := m.terminalAttachOverlay(width)
viewportOverlay := ""
switch {
case terminalAttachOverlay != "":
viewportOverlay = terminalAttachOverlay
case sttKeyOverlay != "":
viewportOverlay = sttKeyOverlay
case helpOverlayContent != "":
@@ -3243,13 +3204,6 @@ func (m model) pinnedTitleBar(width int) string {
func (m model) footerView(width int) string {
var footer strings.Builder
// An attached terminal owns the keyboard and the viewport; the composer is
// inert, so like the ask_user and permission modals only the status line
// renders. Frame math routes through footerView, so this also shrinks the
// footer rect the overlay's row budget is derived from.
if m.terminalAttach != nil {
return m.footerStatusLine(width)
}
if m.renamePrompt != nil {
footer.WriteString(m.sessionRenamePromptView(width))
footer.WriteString("\n")
@@ -4785,8 +4739,6 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
case commandStop:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.stopBackgroundTerminalsText(command.text)})
return m, nil
case commandAttach:
return m.attachTerminalCommand(command.text)
case commandSandboxSetup:
return m.startSandboxSetupCommand(command.text)
case commandProvider:
@@ -5288,7 +5240,6 @@ func (m model) beginRun(cancel context.CancelFunc) model {
}
m.runID++
m.activeRunID = m.runID
m.terminalAutoAttach = nil
m.runCancel = cancel
m.pending = true
// Clear per-run tracking state so stale specialists and plans from the
@@ -5446,7 +5397,6 @@ func (m *model) cancelRun() {
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.terminalAutoAttach = nil
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
@@ -5810,18 +5760,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
// live status. The child session ID is not known yet (it's created
// inside the executor), so we use the tool call ID as a temporary
// key and reconcile on the result.
// A tty exec_command wants the attach overlay: the session registers
// with the process manager inside the tool's Run, so the update loop
// polls for it on a tick.
if call.Name == tools.ExecCommandToolName && execCallWantsTTY(call.Arguments) && m.runtimeMessageSink != nil {
known := map[int]bool{}
if controller, ok := m.execSessionController(); ok {
for _, session := range controller.ExecSessions() {
known[session.ID] = true
}
}
m.runtimeMessageSink(interactiveExecStartMsg{runID: runID, known: known})
}
if call.Name == "Task" {
name, desc := parseTaskCallArgs(call.Arguments)
if m.runtimeMessageSink != nil {
+7 -7
View File
@@ -1087,16 +1087,16 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
}
func TestSessionPickerLabelAlignsTitles(t *testing.T) {
recent := sessionPickerLabel("2h ago", "Recent title")
older := sessionPickerLabel("2006-01-02", "Older title")
today := sessionPickerLabel("20:47:50", "Today title")
older := sessionPickerLabel("Jul 24 10:47", "Older title")
recentColumn := strings.Index(recent, "Recent title")
todayColumn := strings.Index(today, "Today title")
olderColumn := strings.Index(older, "Older title")
if recentColumn < 0 || olderColumn < 0 {
t.Fatalf("sessionPickerLabel omitted a title: recent=%q older=%q", recent, older)
if todayColumn < 0 || olderColumn < 0 {
t.Fatalf("sessionPickerLabel omitted a title: today=%q older=%q", today, older)
}
if recentColumn != olderColumn {
t.Fatalf("title columns differ: recent=%d (%q), older=%d (%q)", recentColumn, recent, olderColumn, older)
if todayColumn != olderColumn {
t.Fatalf("title columns differ: today=%d (%q), older=%d (%q)", todayColumn, today, olderColumn, older)
}
}
+4 -11
View File
@@ -299,7 +299,7 @@ func (m model) wantsMouseCapture() bool {
if m.mouseReleased {
return false // user released the mouse for native text selection/copy
}
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.terminalAttach != nil || m.suggestionsActive())
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive())
}
func (m model) setupWantsMouseCapture() bool {
@@ -513,11 +513,10 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
return mouseSelectionTarget{}, false
}
}
maxVisible := pickerMaxVisible(m.picker, width)
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
start := selectableListStart(len(m.picker.items), maxVisible, selected)
visible := m.picker.items[start : start+maxVisible]
details := pickerShowsDetails(m.picker, width)
// y=0 titled top border, y=1 search line, y=2 separator; rows begin at y=3.
line := 3
lastGroup := ""
@@ -529,18 +528,12 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
line++
lastGroup = item.Group
}
// A rendered detail line belongs to its item: a click anywhere on the
// two-line block selects the same row.
rowLines := 1
if details && item.Detail != "" {
rowLines = 2
}
if hit.y >= line && hit.y < line+rowLines {
if hit.y == line {
index := start + offset
m.picker.selected = index
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line += rowLines
line++
}
return mouseSelectionTarget{}, false
}
+1 -1
View File
@@ -411,7 +411,7 @@ func (m model) petLayoutActive() bool {
func (m model) petObscuringModalActive() bool {
return m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil ||
m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil
m.sttKeyPrompt != nil || m.renamePrompt != nil
}
func (m model) petComposerReservedColumns(width int) int {
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more