Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 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
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
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 @@ -1555,10 +1555,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 @@ -1587,10 +1591,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 @@ -1710,10 +1718,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 @@ -1863,10 +1875,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 @@ -1906,10 +1922,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 @@ -1951,10 +1971,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 @@ -2188,10 +2212,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 @@ -344,7 +344,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 @@ -112,6 +112,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 { NativeToolCallParser } from "../assistant-message/NativeToolCallParser"
Expand Down Expand Up @@ -292,6 +293,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 @@ -1409,6 +1411,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 @@ -3445,6 +3448,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.
// Stop the idle telemetry check and report any unflushed activity as a
// shutdown installment, so a task torn down mid-work (panel closed, task
// switched, extension deactivated) isn't invisible to telemetry.
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 @@ -1398,6 +1398,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