Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
7505c8e
split unit U1 of PR 1833 (issue 1375)
Oct 5, 2026
1b07409
fix(file-safety): close the pre-merge findings on the publish primiti…
Oct 5, 2026
8735c49
fix(file-safety): propagate a non-ENOENT lstat failure in resolvePubl…
Oct 5, 2026
1fca597
fix(file-safety): keep the rollback pair typed and the mock stand-ins…
Oct 5, 2026
4e74e82
rebuild unit u2 on the fixed chain
Oct 5, 2026
815b5e0
chore(lint): prune the safeWriteJson suppression this unit earns
Oct 5, 2026
ffa10b1
rebuild unit u3 on the fixed chain
Oct 5, 2026
4653b34
fix(task): declare the observation registry on Task in this unit
Oct 5, 2026
dfac2c3
rebuild unit u4 on the fixed chain
Oct 5, 2026
fed1242
rebuild unit u5 on the fixed chain
Oct 5, 2026
27d98af
rebuild unit U8 on U5 (corrected merge order)
Oct 5, 2026
44dc7e1
chore(ci): rerun the e2e-mock lane
Oct 5, 2026
91bd6c7
fix(file-safety): inherit unit 1 committed guard
Oct 5, 2026
0a7fcff
test: rerun mocked e2e - no source change, previous run failed in the…
Oct 5, 2026
716b5d9
fix(file-safety): keep the publish error message in RollbackFailureError
Oct 5, 2026
c478fdc
test(file-safety): assert the exact staging directory removed after a…
Oct 5, 2026
02ce2e6
test: re-trigger required checks - the queued runs were cancelled by …
Oct 5, 2026
0aaf125
test: re-run mocked e2e and the ubuntu lane - no source change since …
Oct 5, 2026
930c88d
test: re-run mocked e2e - the suite passed on the previous head of th…
Oct 5, 2026
f6b3e3d
fix(file-safety): give the Windows DACL dump a per-write name
Oct 5, 2026
f4e03eb
test: third mocked-e2e attempt on this unit - the suite is green on t…
Oct 5, 2026
a5eaa7a
chore(ci): re-run mocked E2E (apply_diff timeouts on the previous run…
Oct 6, 2026
eb93e30
chore(ci): second re-run of the mocked E2E suite. The apply_diff fixt…
Oct 6, 2026
1140140
fix(tools): apply_diff must publish with edit-guard semantics, not cr…
Oct 6, 2026
0d3697d
fix(file-safety): durable-copy publish and confined writes for the di…
Oct 7, 2026
bb15ea9
fix(file-safety): do not read a failed target lstat as a missing target
Oct 7, 2026
8a86a91
fix(file-safety): compare staging and target identity with bigint stats
Oct 7, 2026
732bfff
test(file-safety): pin the bigint options in the staging-identity tests
Oct 7, 2026
ebfc7fb
fix(file-safety): report a Windows replacement whose DACL was not pre…
Oct 7, 2026
5b8fa7b
fix(diff-view): undo a partial open() instead of leaving an unapprove…
Oct 7, 2026
d87e1d4
fix(file-safety): route the DACL restore failure through onWarning too
Oct 7, 2026
90e4b22
fix(file-safety): keep DACL warning delivery from failing the save
Oct 7, 2026
6f69ff6
fix(utils): check confinement before taking the advisory lock
Oct 7, 2026
0028a1b
fix(integrations): the preview observation must not authorize an appr…
Oct 7, 2026
da01ba8
fix(file-safety): handle async warning sinks and confine before mkdir
Oct 7, 2026
1c6e5d3
fix(tools): ApplyDiffTool must observe the version its diff was built on
Oct 7, 2026
51137bf
test(tools): type the apply_diff observation doubles for check-types
Oct 7, 2026
26be948
fix(integrations): never adopt an autosaved match for an unauthorized…
Oct 7, 2026
2316165
fix(tools): never refresh a model observation the tool read on a diff…
Oct 7, 2026
c508da8
fix(editor): attribute an adopted publish to the version the preview saw
Oct 8, 2026
0f31b2d
fix(u8): track placeholder ownership apart from the observation token
Oct 8, 2026
c868022
fix(file-safety): one lock key whether the parent directory exists yet
Oct 8, 2026
abf03c5
test(file-safety): a backup copy that fails part-way must not survive…
Oct 8, 2026
9d2be1e
fix(diff-view): put the save's post-publish cleanup inside the same t…
Oct 8, 2026
3a3473e
fix(diff-view): let only the teardown that started a pass finalize th…
Oct 9, 2026
c075439
test(file-safety): pin that an uncanonicalizable confined scope stops…
Oct 9, 2026
56fb9cc
fix(diff-view): restore preview tabs for the pass that owns a rejecte…
Oct 9, 2026
8a29285
test(apply-diff): cover the two bracketing stats that can fail on the…
Oct 9, 2026
a8df293
fix(read-file): stop indenting the read content inside the notice tem…
Oct 9, 2026
d1ca165
test(safe-write): a warning sink that throws must not fail the commit…
Oct 9, 2026
d564f61
fix(diff-view): finalize the session when a cancellation lands during…
Oct 9, 2026
db5ef0a
test(diff-view): put the rejected-publish comment back above the test…
Oct 9, 2026
fdc1ace
fix(file-safety): never abandon the backup seed descriptor when its c…
Oct 9, 2026
34a88e4
fix(diff-view): one caller owns the session's finalization, by claim …
Oct 9, 2026
bc8aeee
fix(safe-write): a write that declares no scope replaces the link ins…
Oct 9, 2026
facbc4c
fix(diff-view): the rejected-save pass owns the session through previ…
Oct 9, 2026
f34215c
fix(safe-write): an existing Windows target is not published over und…
Oct 9, 2026
7b1a20d
fix(test): teach main's unicode-reader spec about hasClippedLines
Oct 9, 2026
0b12594
fix(safe-write): every descriptor this call owns is released through …
Oct 9, 2026
e0fbde1
fix(safe-write): a failed Windows ACL step never publishes content, a…
Oct 9, 2026
95480b7
fix(safe-write-json): an unscoped write locks and reads the file it a…
Oct 10, 2026
dc4a78a
fix(safe-write): match a verified ACE against the whole principal, no…
Oct 10, 2026
0942fd1
fix(diffview): put the finalization guard on the public reset(), not …
Oct 10, 2026
87ac7b1
fix(safe-write-json): keep the referent lock beside the link-path loc…
Oct 10, 2026
51257de
style: format the ten files the formatting gate flags on this PR
Oct 10, 2026
516496a
test: scope the icacls assertion to the platform that issues the command
Oct 10, 2026
fc6ef60
fix: fold a lock key from the deepest existing ancestor, not the way …
Oct 10, 2026
625d412
fix(safe-write-json): take the lock on the canonical spelling of the …
Oct 10, 2026
56c45d6
style: reflow the lock-key spec to the repo's prettier output
Oct 10, 2026
d2ce125
fix(u8): refuse adoption when the rejection was about completeness, a…
Oct 10, 2026
05a5e1d
fix(editor): reconcile the observation when a guarded publish lands w…
Oct 11, 2026
413fa01
fix(utils): decide confinement once and reject a declared-empty root
Oct 11, 2026
81d16f3
fix: order cancellation against the guarded publish and session restart
easonliang28 Oct 12, 2026
2ba2936
test(diffview): keep the open() serialization test inside the lint su…
easonliang28 Oct 12, 2026
716bff8
fix(diffview): gate the rollback against the rejected save's full lif…
easonLiangWorldedtech Oct 14, 2026
0a76af3
fix(u8): align the unit with main's lint re-enable and format gate
easonLiangWorldedtech Oct 14, 2026
5a04426
fix(integrations,file-safety): clear the re-review threads on typed t…
easonLiangWorldedtech Oct 14, 2026
9980ac7
chore: retrigger CI (windows timing flake in main's TaskHistoryStore.…
easonLiangWorldedtech Oct 14, 2026
3624e4f
fix(file-safety): refuse a captured-DACL publish over a symbolic link…
easonLiangWorldedtech Oct 14, 2026
f23e49f
test(utils): follow the DACL-over-link refusal in the lock-key integr…
easonLiangWorldedtech Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools"
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
import { restoreTodoListForTask } from "../tools/UpdateTodoListTool"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { ObservationRegistry } from "./observationRegistry"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { RooProtectedController } from "../protect/RooProtectedController"
import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message"
Expand Down Expand Up @@ -282,6 +283,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
readonly instanceId: string
readonly metadata: TaskMetadata

// The observed on-disk version of each file this task has read. Declared here so the
// read tools can record it; a write guard later compares a token against this registry.
readonly observationRegistry = new ObservationRegistry()

todoList?: TodoItem[]

readonly rootTask: Task | undefined = undefined
Expand Down
135 changes: 135 additions & 0 deletions src/core/task/__tests__/observationRegistry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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)
})
})

it("forget() removes exactly the given path and reports it", () => {
// DiffViewProvider revokes the observation for the preview path it published. Removing
// a neighbour's entry would make that neighbour's later write look untouched.
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1")
reg.observe("/a/b/d.ts", "v2")

expect(reg.forget("/a/b/c.ts")).toBe(true)

expect(reg.get("/a/b/c.ts")).toBeUndefined()
expect(reg.get("/a/b/d.ts")?.version).toBe("v2")
expect(reg.size).toBe(1)
})

it("forget() reports false and changes nothing for a path that was never observed", () => {
// A cancellation can forget a path whose observation was already dropped. The caller
// distinguishes the two cases through this return value, so it must not throw and must
// leave the other entries alone.
const reg = new ObservationRegistry()
reg.observe("/a/b/c.ts", "v1")

expect(reg.forget("/a/b/absent.ts")).toBe(false)

expect(reg.get("/a/b/c.ts")?.version).toBe("v1")
expect(reg.size).toBe(1)
})
})
69 changes: 69 additions & 0 deletions src/core/task/observationRegistry.ts
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
}
}
32 changes: 30 additions & 2 deletions src/core/tools/ApplyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"

import { getReadablePath } from "../../utils/path"
import { versionTokenOfStat } from "../../utils/versionToken"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { fileExistsAtPath } from "../../utils/fs"
Expand Down Expand Up @@ -68,7 +69,32 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
return
}

// The diff below is built from this exact read, so the save that follows must be
// authorized against the version captured here - not against whatever version the
// preview happens to stat afterwards. Same contract as ApplyPatchTool's hunk read:
// stat around the read and observe only when the file did not change underneath it.
const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined)
const originalContent: string = await fs.readFile(absolutePath, "utf-8")
const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined)
if (preReadStats && postReadStats) {
const preReadToken = versionTokenOfStat(preReadStats)
if (preReadToken === versionTokenOfStat(postReadStats)) {
// A tool read is not a model read. With no prior observation this stays a
// partial observation of the version the diff was computed against - the only
// authorization the save can have, since apply_diff computes its hunks from
// this read. When the model already observed the file, keep the completeness it
// earned, but only on the version it was earned on: refreshing an OLDER
// observation to the current version would let content the model built from a
// stale read pass the compare-and-swap, so an out-of-date observation is left
// alone and the save fails with the re-read remediation.
const prior = task.observationRegistry.get(absolutePath)
if (prior === undefined) {
task.observationRegistry.observe(absolutePath, preReadToken, false)
} else if (prior.version === preReadToken) {
task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true)
}
}
}

// Apply the diff to the original content
const diffResult = (await task.diffStrategy?.applyDiff(
Expand Down Expand Up @@ -173,7 +199,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
return
}

// Save directly without showing diff view or opening the file
// Save directly without showing diff view or opening the file. The diff is
// applied to an existing file, so edit-guard semantics require a prior read.
task.diffViewProvider.editType = "modify"
task.diffViewProvider.originalContent = originalContent
await task.diffViewProvider.saveDirectly(
Expand All @@ -182,6 +209,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
false,
diagnosticsEnabled,
writeDelayMs,
"edit",
)
} else {
// Original behavior with diff view
Expand Down Expand Up @@ -221,7 +249,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
}

// Call saveChanges to update the DiffViewProvider properties
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit")
}

// Track file edit operation
Expand Down
Loading
Loading