Repository navigation
feat(tools): publish apply_patch through the guard (U6, #1375) #1915
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
easonLiangWorldedtech
wants to merge
73
commits into
Zoo-Code-Org:main
Choose a base branch
from
easonLiangWorldedtech:fws/u6-apply-patch-wiring
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
73 commits
Select commit
Hold shift + click to select a range
c4a9ae2
split unit U1 of PR 1833 (issue 1375)
9003730
fix(file-safety): close the pre-merge findings on the publish primiti…
6c331da
fix(file-safety): propagate a non-ENOENT lstat failure in resolvePubl…
ed7274f
fix(file-safety): keep the rollback pair typed and the mock stand-ins…
88c4afa
rebuild unit u2 on the fixed chain
166073f
chore(lint): prune the safeWriteJson suppression this unit earns
f491af8
rebuild unit u3 on the fixed chain
188b2f3
fix(task): declare the observation registry on Task in this unit
91efcb1
rebuild unit u4 on the fixed chain
3920f65
rebuild unit u5 on the fixed chain
05b6aa2
rebuild unit U8 on U5 (corrected merge order)
427e141
rebuild unit u6 in the corrected order
82eb3f8
fix(apply-patch): a full hunk read is a complete observation
fae26bc
chore(ci): rerun the e2e-mock lane
ba493f4
fix(file-safety): inherit unit 1 committed guard
aaa12cb
test: rerun mocked e2e - no source change, previous run failed in the…
ea76274
fix(file-safety): keep the publish error message in RollbackFailureError
ef18c7c
test(file-safety): assert the exact staging directory removed after a…
84c95ba
fix(tools): the tool's own hunk read must not grant full-file authority
31d08da
test: re-run mocked e2e and the ubuntu lane - no source change since …
89624ce
test(tools): the move test must follow the partial observation the hu…
e8cffea
test: re-run mocked e2e - the same suite passed on the neighbouring u…
a3be4a6
fix(file-safety): give the Windows DACL dump a per-write name
59176fb
test: third mocked-e2e attempt on this unit - the suite is green on t…
22179e4
chore(ci): re-run mocked E2E (apply_diff timeouts on the previous run…
8459cba
chore(ci): second re-run of the mocked E2E suite. The apply_diff fixt…
2c61f0c
fix(tools): apply_diff must publish with edit-guard semantics, not cr…
ff80d49
fix(file-safety): durable-copy publish and confined writes for the ap…
3ebd9ce
fix(file-safety): do not read a failed target lstat as a missing target
da74317
fix(file-safety): compare staging and target identity with bigint stats
c6c6abf
test(file-safety): pin the bigint options in the staging-identity tests
fdb0977
fix(file-safety): report a Windows replacement whose DACL was not pre…
f56e02a
fix(file-safety): keep DACL warning delivery non-fatal and complete
f241311
fix(utils): check confinement before taking the advisory lock
f29b11b
fix(integrations): the preview observation must not authorize an appr…
5fa2179
fix(file-safety): handle async warning sinks and confine before mkdir
32d1fa6
fix(tools): ApplyDiffTool must observe the version its diff was built on
11848e6
test(tools): type the apply_diff observation doubles for check-types
9075fdd
fix(integrations): never adopt an autosaved match for an unauthorized…
cc8d413
fix(tools): never refresh a model observation the tool read on a diff…
8c55b29
test(tools),docs(utils): drop the void casts, describe the backup as …
6551af7
fix(editor): roll back created directories when the guarded publish i…
5529fbc
fix(diff-view,file-safety): keep one task's teardown in one task, and…
5218aea
fix(u6): pin the confinement decision onto the publish, and stop leak…
4d2bfb4
fix(u6): pin the path the publish re-resolves, not a second spelling …
758e74c
test(file-safety): a backup copy that fails part-way must not survive…
55a6598
test(tools): pin which bracketing stat fails in the diff-read observa…
d10f0f2
test(file-safety): cover the non-ENOENT scope-resolution failures the…
22ee292
fix(diff-view): put the save's post-publish cleanup inside the same t…
c1d3021
fix(diff-view): let only the teardown that started a pass finalize th…
b8a5cfd
fix(diff-view): restore preview tabs for the pass that owns a rejecte…
8795844
fix(diff-view): finalize the session when a cancellation lands during…
e0ca1db
fix(file-safety): refuse to replace an existing target whose access r…
52b7cc6
fix(diff-view): one caller owns the session's finalization, by claim …
2b82937
fix(safe-write): a write that declares no scope replaces the link ins…
4fbf99a
fix(safe-write): cleanup is gated on the commit, not on the staging name
957b1b1
fix(diff-view): a rejected publish joins a cancellation's teardown in…
f4edf2c
fix(test): teach main's unicode-reader spec about hasClippedLines
23f0e8e
fix(safe-write): staging files are created and owned by this write, n…
567bf8f
fix(safe-write): a fresh target keeps its documented mode and the sta…
cc9bb4a
test(observation-registry): cover forget()'s boolean result and its b…
688053b
fix(safe-write-json): serialize an unconfined symlink publish under b…
9610329
style: format the nine files the formatting gate flags on this PR
fd25bba
fix: compare the two lock identities the way the filesystem does
9f5c801
style: reflow the lock identity comparison the previous commit added
4b6cf13
fix: fold lock keys the way the lock is placed, not the way strings c…
8d8901b
fix: fold a lock key from the deepest existing ancestor, not the imme…
77070a2
fix: pin the directories a confined write creates before it commits
f1371d8
fix(safe-write-json): fail closed on unknown ancestor identities and …
433d15b
fix(safe-write): the fresh-target default mode respects the process u…
98be920
fix(safe-write-json): decide confinement presence once so an empty sc…
e153075
fix(u6): align the unit with main's lint re-enable and format gate
easonLiangWorldedtech c411f97
fix(integrations): drop the Stryker directives from the guarded save …
easonLiangWorldedtech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| 2909d201-4884-42a8-ad5f-7908190cd7d6 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import { describe, it, expect, vi } from "vitest" | ||
|
|
||
| import { ObservationRegistry } from "../observationRegistry" | ||
|
|
||
| describe("ObservationRegistry", () => { | ||
| it("observe → get returns the recorded version and observedAt", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") | ||
|
|
||
| const obs = reg.get("/a/b/c.ts") | ||
| expect(obs).toBeDefined() | ||
| expect(obs!.version).toBe("1:2:300:4000000000:5000000000") | ||
| expect(typeof obs!.observedAt).toBe("number") | ||
| }) | ||
|
|
||
| it("re-observe replaces the entry with a fresh observedAt", () => { | ||
| vi.useFakeTimers() | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| const first = reg.get("/a/b/c.ts")! | ||
| expect(first.version).toBe("v1") | ||
|
|
||
| vi.advanceTimersByTime(50) | ||
| reg.observe("/a/b/c.ts", "v2") | ||
| const second = reg.get("/a/b/c.ts")! | ||
| expect(second.version).toBe("v2") | ||
| expect(second.observedAt).toBeGreaterThan(first.observedAt) | ||
|
|
||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| it("has returns true for observed paths, false otherwise", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/x.ts", "t1") | ||
| expect(reg.has("/x.ts")).toBe(true) | ||
| expect(reg.has("/y.ts")).toBe(false) | ||
| }) | ||
|
|
||
| it("size reflects the number of observed entries", () => { | ||
| const reg = new ObservationRegistry() | ||
| expect(reg.size).toBe(0) | ||
| reg.observe("/a.ts", "t1") | ||
| reg.observe("/b.ts", "t2") | ||
| expect(reg.size).toBe(2) | ||
| }) | ||
|
|
||
| it("clear removes all entries and resets size to 0", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a.ts", "t1") | ||
| reg.observe("/b.ts", "t2") | ||
| reg.clear() | ||
| expect(reg.size).toBe(0) | ||
| expect(reg.get("/a.ts")).toBeUndefined() | ||
| expect(reg.has("/b.ts")).toBe(false) | ||
| }) | ||
|
|
||
| it("get on empty registry returns undefined", () => { | ||
| const reg = new ObservationRegistry() | ||
| expect(reg.get("/any.ts")).toBeUndefined() | ||
| }) | ||
|
|
||
| it("separate instances are independent — observing in one does not appear in the other", () => { | ||
| const regA = new ObservationRegistry() | ||
| const regB = new ObservationRegistry() | ||
| regA.observe("/shared.ts", "v1") | ||
| expect(regA.get("/shared.ts")).toBeDefined() | ||
| expect(regB.get("/shared.ts")).toBeUndefined() | ||
| regB.observe("/shared.ts", "v2") | ||
| expect(regA.get("/shared.ts")!.version).toBe("v1") | ||
| expect(regB.get("/shared.ts")!.version).toBe("v2") | ||
| }) | ||
|
|
||
| describe("completeness scope (S4b follow-up #46)", () => { | ||
| it("defaults to a complete observation when the read scope is not given", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
|
|
||
| expect(reg.get("/a/b/c.ts")!.complete).toBe(true) | ||
| }) | ||
|
|
||
| it("records a partial observation when the read only returned a view of the file", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1", false) | ||
|
|
||
| expect(reg.get("/a/b/c.ts")!.complete).toBe(false) | ||
| }) | ||
|
|
||
| it("re-observing replaces the entry's completeness with the new read's scope", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1", false) | ||
| reg.observe("/a/b/c.ts", "v2") | ||
|
|
||
| const obs = reg.get("/a/b/c.ts")! | ||
| expect(obs.version).toBe("v2") | ||
| expect(obs.complete).toBe(true) | ||
| }) | ||
|
|
||
| it("re-observing with a partial scope downgrades a previously complete entry", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| reg.observe("/a/b/c.ts", "v2", false) | ||
|
|
||
| const obs = reg.get("/a/b/c.ts")! | ||
| expect(obs.version).toBe("v2") | ||
| expect(obs.complete).toBe(false) | ||
| }) | ||
| }) | ||
| describe("forget", () => { | ||
| it("drops the observation for one path and reports that it did", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| reg.observe("/a/b/d.ts", "v2") | ||
| const sizeBefore = reg.size | ||
|
|
||
| expect(reg.forget("/a/b/c.ts")).toBe(true) | ||
|
|
||
| // The three effects the row names: gone from the registry, one smaller, and a true | ||
| // result. Asserted together because a delete that returned the wrong boolean, or that | ||
| // cleared more than it was asked to, would pass a size-only check. | ||
| expect(reg.get("/a/b/c.ts")).toBeUndefined() | ||
| expect(reg.has("/a/b/c.ts")).toBe(false) | ||
| expect(reg.size).toBe(sizeBefore - 1) | ||
| // The reason forget() exists rather than clear(): the other authorization survives. | ||
| expect(reg.get("/a/b/d.ts")?.version).toBe("v2") | ||
| }) | ||
|
|
||
| it("reports false when the path was never observed", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
|
|
||
| expect(reg.forget("/absent.ts")).toBe(false) | ||
| expect(reg.size).toBe(1) | ||
| expect(reg.get("/a/b/c.ts")?.version).toBe("v1") | ||
| }) | ||
|
|
||
| it("reports false when the same path is forgotten twice", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| expect(reg.forget("/a/b/c.ts")).toBe(true) | ||
|
|
||
| // A second revoke must not report a success it did not earn: the caller uses the | ||
| // boolean to tell "I dropped the authorization" from "there was nothing to drop". | ||
| expect(reg.forget("/a/b/c.ts")).toBe(false) | ||
| expect(reg.size).toBe(0) | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| /** | ||
| * Per-task file observation registry (upstream epic #1375, phase A2). | ||
| * | ||
| * Each Task owns its own instance so parent and subtask observations are | ||
| * independent. The S4 guarded-write will compare these versions against the | ||
| * token recomputed pre-write to detect stale reads or file replacement. | ||
| * | ||
| * Pure in-memory — zero I/O, no dependencies. The S4 guarded-write consults | ||
| * these observations for the version check and for the completeness check that | ||
| * gates a full-file replacement. | ||
| */ | ||
|
|
||
| export interface FileObservation { | ||
| /** Version token derived from on-disk fs.stat (bigint mode). */ | ||
| version: string | ||
| /** Millisecond timestamp when the observation was recorded. */ | ||
| observedAt: number | ||
| /** | ||
| * Whether the read that produced this observation returned the complete | ||
| * file. A slice, line-range, truncated, or indentation-block read returns | ||
| * only a view of the file; such an observation authorizes targeted edits | ||
| * on the view the model saw, but never a full-file replacement. | ||
| */ | ||
| complete: boolean | ||
| } | ||
|
|
||
| export class ObservationRegistry { | ||
| private readonly entries = new Map<string, FileObservation>() | ||
|
|
||
| /** | ||
| * Record an observation for a file at its absolute path. | ||
| * | ||
| * Re-observing replaces the entry with a fresh observedAt timestamp, the | ||
| * new version token, and the read's completeness. `complete` defaults to | ||
| * true for callers that read the whole file themselves (spec doubles, | ||
| * WriteToFileTool). A caller whose read is internal to a targeted edit must | ||
| * carry the model's prior completeness instead, so the tool's own read cannot | ||
| * upgrade a partial read into authority for a full-file replacement. | ||
| */ | ||
| observe(absolutePath: string, version: string, complete: boolean = true): void { | ||
| this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) | ||
| } | ||
|
|
||
| get(absolutePath: string): FileObservation | undefined { | ||
| return this.entries.get(absolutePath) | ||
| } | ||
|
|
||
| has(absolutePath: string): boolean { | ||
| return this.entries.has(absolutePath) | ||
| } | ||
|
|
||
| /** | ||
| * Drop the observation for one path. A caller that must revoke an | ||
| * authorization it did not earn - a preview that observed a version the model | ||
| * never read - needs this instead of clear(), which would also discard the | ||
| * observations other reads of the same task still rely on. | ||
| */ | ||
| forget(absolutePath: string): boolean { | ||
| return this.entries.delete(absolutePath) | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.entries.clear() | ||
| } | ||
|
|
||
| get size(): number { | ||
| return this.entries.size | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Remove this local device ID from the PR.
Line 1 adds GitHub CLI’s persisted per-user device ID. GitHub CLI includes this identifier in telemetry, which GitHub describes as pseudonymous. Committing it exposes a persistent identifier in repository history. (raw.githubusercontent.com)
🤖 Prompt for AI Agents