Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
a37dd24
feat(file-safety): atomic text publish primitive + safeWriteJson refa…
easonliang28 Aug 27, 2026
3dd8700
feat(file-safety): add version token for the guarded-write path (A1, …
easonliang28 Aug 27, 2026
ba1332e
docs(file-safety): correct ino precision bounds in version token (A1,…
easonliang28 Aug 27, 2026
6813013
fix(file-safety): derive the version token from exact BigInt stats (A…
easonliang28 Aug 27, 2026
588b95f
feat(task): per-task file observation registry (A2, #1375)
easonliang28 Aug 27, 2026
7a25fc0
feat(tools): guarded write CAS core with per-path FIFO chain (S4a, #1…
easonliang28 Aug 27, 2026
56ce4bf
fix(fws): use doUnmock + resetModules in safeWriteJson test cleanup
easonliang28 Aug 28, 2026
be894d9
chore(ci): empty commit — re-trigger CI and the CodeRabbit current-he…
easonliang28 Aug 30, 2026
58bb5ce
fix(tools): address CodeRabbit review feedback (PR 1405)
easonliang28 Aug 30, 2026
bb93d62
merge(upstream): take main into feat/guarded-write-s4a
Oct 6, 2026
4d4d511
fix(file-safety): run verifyBeforeCommit before the backup rename, an…
Oct 6, 2026
1ce0421
test(file-safety): cover a staging-file fsync failure
Oct 7, 2026
968078d
chore: trigger a fresh review pass at this head
Oct 7, 2026
a2c364d
docs(task): state the cross-process limit of the observation check
Oct 7, 2026
9f8a857
fix(utils): refuse to publish a settings export through a symlink
Oct 7, 2026
cb72ac7
test(config): expect the export write to refuse symlink targets
Oct 7, 2026
8cb5920
fix(file-safety): keep the target mode, clean the staging dir, correc…
Oct 7, 2026
2810c8f
fix(services): honour the creation mask for new targets and keep the …
Oct 7, 2026
006389d
fix(services): stop safeWriteText from resolving an already-guarded p…
Oct 7, 2026
2dd792c
fix(file-safety): publish credential writes without following a plant…
Oct 8, 2026
51f0cea
test(file-safety): pin that a partial DACL dump is unlinked after a f…
Oct 8, 2026
8e5061e
fix(task): clear the per-task observation registry on disposal
Oct 8, 2026
bacb9d3
fix(mcp): refuse a symlinked target for project-scoped settings writes
Oct 8, 2026
fc94f62
fix(mcp,task): derive the symlink policy from the selected source; ma…
Oct 8, 2026
171a26e
fix(utils): refuse a symlinked ancestor for credential-bearing safeWr…
Oct 8, 2026
17c736e
fix(utils): keep safeWriteJson publishing no-follow by default, and s…
Oct 8, 2026
f1b2ae4
test(tools): cover the omitted kind argument in guardedWrite, and say…
Oct 8, 2026
17c53e0
fix(tools): stop a queued guarded write once its task has been aborte…
Oct 8, 2026
509208e
test(file-safety): cover parent-directory creation and its failure paths
Oct 9, 2026
df0ce3a
Merge org main into the guarded-write CAS core
Oct 10, 2026
55d69c1
style: format the MCP hub spec the way the repository formatter wants it
Oct 10, 2026
a37f284
test(mcp,task): make two test names say what their assertions check, …
Oct 10, 2026
5e66c2f
test(read-file): check that both stats around a read ask for the bigi…
Oct 10, 2026
ca77e38
test(guarded-write,safe-write): stop names and describe blocks claimi…
Oct 10, 2026
a54e21a
style: format the safe-write JSON module the way the repository forma…
Oct 10, 2026
1d205cd
docs(safe-write-json): say the lock and publication decision once, an…
Oct 10, 2026
777df3c
docs(safe-write-json): state the default symlink contract as it is im…
Oct 10, 2026
3ee59e5
fix(tools): re-observe the published version and capture observations…
Oct 10, 2026
e56d189
style: format the four remaining files this PR touches
Oct 10, 2026
ea730f0
test(guarded-write): drop orphaned comment fragment in the FIFO chain…
Oct 10, 2026
97f2f4d
fix(safe-write-json): re-derive the symlink refusal at the commit rename
easonliang28 Oct 11, 2026
2439c80
style: format the safe-write JSON spec the way the repository formatt…
easonliang28 Oct 12, 2026
95cb6df
chore(merge): refresh onto main 4f1dee18e
easonLiangWorldedtech Oct 16, 2026
6b6f97c
style(tools): drop imports unused after the main refresh (lint gate)
easonLiangWorldedtech Oct 16, 2026
e3799c3
style(file-safety): drop SafeWriteTextOptions type import unused afte…
easonLiangWorldedtech Oct 16, 2026
f9a7746
chore(ci): retrigger checks (knip self-installer transient failure)
easonLiangWorldedtech Oct 16, 2026
3aa08be
test(file-safety,mcp): pin the DACL fallback outcome and the deleteSe…
easonLiangWorldedtech Oct 16, 2026
9d7ae92
refactor(editor): keep DiffViewProvider out of the guarded-write unit
easonLiangWorldedtech Oct 16, 2026
34ac5ff
test(safety): cover the RollbackFailedError recovery contract
easonLiangWorldedtech Oct 22, 2026
50e5ac6
chore(knip): declare the guarded-write core an entry until S4b wires it
easonLiangWorldedtech Oct 22, 2026
7878fed
chore: retrigger CI (Code QA run was not created for the knip entry c…
easonLiangWorldedtech Oct 22, 2026
348112b
chore: retrigger CI attempt 2
easonLiangWorldedtech Oct 22, 2026
4133527
Merge upstream/main into feat/guarded-write-s4a
easonLiangWorldedtech Oct 22, 2026
a56c410
fix(tools): use the exact #1399 remediation texts and create on obser…
easonLiangWorldedtech Oct 22, 2026
37cd82d
style: apply prettier formatting to the guarded write spec
easonLiangWorldedtech Oct 22, 2026
462874b
test(safety): pin the DACL restore-failure commit rename and icacls c…
easonLiangWorldedtech Oct 22, 2026
6c9236c
test(tools): cover the pre-commit non-ENOENT token failure
easonLiangWorldedtech Oct 22, 2026
7e46bd2
test(safety): cover the ancestor-disappearance and non-Error rollback…
easonLiangWorldedtech Oct 22, 2026
4517148
fix(safety): fail closed when an ancestor vanishes during the authori…
easonLiangWorldedtech Oct 22, 2026
1f9c8f7
test(safety): drop the unused asFile double
easonLiangWorldedtech Oct 22, 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
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"!utils/WorkspacePathResolver.ts!",
"!test-utils/**!",
"!utils/versionToken.ts!",
"!core/tools/guardedWrite.ts!",
"!core/tools/helpers/toolResultFormatting.ts!"
],
"ignoreDependencies": [
Expand Down
84 changes: 56 additions & 28 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,10 +1557,14 @@ describe("importExport", () => {
expect(mockContextProxy.export).toHaveBeenCalled()
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })

expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", {
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
})
expect(safeWriteJson).toHaveBeenCalledWith(
"/mock/path/zoo-code-settings.json",
{
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
},
{ refuseSymlinkTarget: true },
)
})

it("should include globalSettings when allowedMaxRequests is null", async () => {
Expand Down Expand Up @@ -1589,10 +1593,14 @@ describe("importExport", () => {
contextProxy: mockContextProxy,
})

expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", {
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
})
expect(safeWriteJson).toHaveBeenCalledWith(
"/mock/path/zoo-code-settings.json",
{
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
},
{ refuseSymlinkTarget: true },
)
})

it("should handle errors during the export process", async () => {
Expand Down Expand Up @@ -1712,10 +1720,14 @@ describe("importExport", () => {
contextProxy: mockContextProxy,
})

expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", {
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
})
expect(safeWriteJson).toHaveBeenCalledWith(
"/mock/path/zoo-code-settings.json",
{
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
},
{ refuseSymlinkTarget: true },
)
})

it("should export model dimension for OpenAI Compatible provider", async () => {
Expand Down Expand Up @@ -1865,10 +1877,14 @@ describe("importExport", () => {
})

// Should not throw an error and should preserve original settings
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", {
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings, // Should remain unchanged
})
expect(safeWriteJson).toHaveBeenCalledWith(
"/mock/path/zoo-code-settings.json",
{
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings, // Should remain unchanged
},
{ refuseSymlinkTarget: true },
)
})

it("should maintain backward compatibility with existing exports", async () => {
Expand Down Expand Up @@ -1908,10 +1924,14 @@ describe("importExport", () => {
})

// Should not modify settings for non-openai-compatible providers
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", {
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings, // Should remain unchanged
})
expect(safeWriteJson).toHaveBeenCalledWith(
"/mock/path/zoo-code-settings.json",
{
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings, // Should remain unchanged
},
{ refuseSymlinkTarget: true },
)
})

it("should handle missing current provider gracefully", async () => {
Expand Down Expand Up @@ -1953,10 +1973,14 @@ describe("importExport", () => {
})

// Should not throw an error and should preserve original settings
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", {
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings, // Should remain unchanged
})
expect(safeWriteJson).toHaveBeenCalledWith(
"/mock/path/zoo-code-settings.json",
{
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings, // Should remain unchanged
},
{ refuseSymlinkTarget: true },
)
})
})

Expand Down Expand Up @@ -2190,10 +2214,14 @@ describe("importExport", () => {
})

// Step 4: Verify the exported data includes the model dimension
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/test-settings.json", {
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
})
expect(safeWriteJson).toHaveBeenCalledWith(
"/mock/path/test-settings.json",
{
providerProfiles: mockProviderProfiles,
globalSettings: mockGlobalSettings,
},
{ refuseSymlinkTarget: true },
)

// Step 5: Get the exported data for import test
const exportedData = (safeWriteJson as Mock).mock.calls[0][1]
Expand Down
2 changes: 1 addition & 1 deletion src/core/config/importExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }:

const dirname = path.dirname(uri.fsPath)
await fs.mkdir(dirname, { recursive: true })
await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings })
await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings }, { refuseSymlinkTarget: true })
} catch (e) {
console.error("Failed to export settings:", e)
// Don't re-throw - the UI will handle showing error messages
Expand Down
9 changes: 9 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
import { restoreTodoListForTask } from "../tools/UpdateTodoListTool"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { ObservationRegistry } from "./observationRegistry"
import { RooProtectedController } from "../protect/RooProtectedController"
import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message"
import { type RequestPolicySnapshot } from "../prompts/tools/effective-tool-policy"
Expand Down Expand Up @@ -298,6 +299,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
readonly parentTask: Task | undefined = undefined
readonly taskNumber: number
readonly workspacePath: string
readonly observationRegistry = new ObservationRegistry()

/**
* The mode associated with this task. Persisted across sessions
Expand Down Expand Up @@ -1450,6 +1452,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
/** Cancels the current persistence generation before creating the next assistant-turn boundary. */
private resetAssistantMessagePersistence(): void {
this.cancelAssistantMessagePersistence()

this.assistantMessagePersistencePromise = new Promise<AssistantMessagePersistenceResult>((resolve) => {
this.resolveAssistantMessagePersistence = resolve
})
Expand Down Expand Up @@ -3631,6 +3634,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`)
this.cancelAssistantMessagePersistence()

// Drop the per-task file observations. The registry holds an entry per absolute
// path the task read or wrote (version token + timestamp); nothing can consume them
// after disposal, and a long-lived extension host would otherwise keep every path a
// finished task touched alive.
this.observationRegistry.close()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Release handler-owned host resources (e.g. VS Code configuration listeners).
// This is the fallback for leases that never settle; later lease releases
// find nothing retired and so cannot dispose twice.
Expand Down
65 changes: 65 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,71 @@ describe("Cline", () => {
})
})

describe("observation registry lifecycle (S4a, epic #1375)", () => {
it("gives each Task its own observation registry", () => {
const firstTask = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "first observation task",
startTask: false,
})
const secondTask = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "second observation task",
startTask: false,
})

// The guarded-write contract assumes an observation in one task never validates
// a write issued by another task.
expect(firstTask.observationRegistry).not.toBe(secondTask.observationRegistry)
firstTask.observationRegistry.observe("/workspace/a.ts", "v-a")
expect(firstTask.observationRegistry.get("/workspace/a.ts")?.version).toBe("v-a")
expect(secondTask.observationRegistry.get("/workspace/a.ts")).toBeUndefined()
})

it("clears the observation registry when the task is disposed", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "disposed observation task",
startTask: false,
})
task.observationRegistry.observe("/workspace/a.ts", "v-a")
task.observationRegistry.observe("/workspace/b.ts", "v-b")

await task.dispose()

// A disposed task cannot serve another guarded write, so its observed paths
// (version token + timestamp each) must not stay reachable for the host lifetime.
expect(task.observationRegistry.get("/workspace/a.ts")).toBeUndefined()
expect(task.observationRegistry.get("/workspace/b.ts")).toBeUndefined()
// Closing is what makes a later observation refuse, so it is asserted rather than
// left to the test name: clearing the map alone would look identical from here.
expect(task.observationRegistry.isClosed).toBe(true)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("refuses an observation recorded after the task was disposed", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "late observation task",
startTask: false,
})

await task.dispose()

// A read that was already in flight when the task was disposed can finish late and
// call observe(). Recording then would repopulate a registry no task owns and hand a
// version token to a guarded write that will never happen.
task.observationRegistry.observe("/workspace/late.ts", "v-late")

expect(task.observationRegistry.get("/workspace/late.ts")).toBeUndefined()
expect(task.observationRegistry.size).toBe(0)
expect(task.observationRegistry.isClosed).toBe(true)
})
})

describe("constructor", () => {
it.each([{ apiConfigName: "parent-local-profile" }, { apiConfigName: undefined }])(
"uses an explicit delegated-child context without shared state or startup persistence",
Expand Down
90 changes: 90 additions & 0 deletions src/core/task/__tests__/observationRegistry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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("close() - disposal is terminal", () => {
it("drops every observation and refuses later ones", () => {
const registry = new ObservationRegistry()
registry.observe("/workspace/a.ts", "v-a")
expect(registry.size).toBe(1)

registry.close()

expect(registry.size).toBe(0)
expect(registry.isClosed).toBe(true)

// A read that was in flight when the task was disposed must not repopulate it.
registry.observe("/workspace/late.ts", "v-late")
expect(registry.get("/workspace/late.ts")).toBeUndefined()
expect(registry.size).toBe(0)
})
})
Loading
Loading