authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-17 16:41:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 16:41:05-07:00
logb4620d595554654c259ec7c0afb3d3d5532959a8
tree9f414faad9c31fcd853040d3f1e89aca65addfed
parentb17cc2ab5d5029f898121798ca19db3288cba616
signature Signed by PGP key B5690EEEBB952194

fix(server): reject file rewind in shared workspaces (#12306)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

4 files changed, 186 insertions(+), 20 deletions(-)

apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+103-9
......@@ -297,6 +297,7 @@ describe("CheckpointReactor", () => {
297297 readonly threadWorktreePath?: string | null;
298298 readonly threadBranch?: string | null;
299299 readonly secondThreadSharingWorktree?: boolean;
300 readonly secondThreadWorktreePath?: (cwd: string) => string;
300301 readonly localStatusRefName?: string | null;
301302 readonly providerSessionCwd?: string;
302303 readonly providerName?: ProviderDriverKind;
......@@ -436,7 +437,8 @@ describe("CheckpointReactor", () => {
436437 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
437438 runtimeMode: "approval-required",
438439 branch: options?.threadBranch ?? null,
439 worktreePath: options?.threadWorktreePath ?? cwd,
440 worktreePath:
441 options?.threadWorktreePath !== undefined ? options.threadWorktreePath : cwd,
440442 createdAt,
441443 })
442444 .pipe(
......@@ -455,7 +457,8 @@ describe("CheckpointReactor", () => {
455457 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
456458 runtimeMode: "approval-required",
457459 branch: null,
458 worktreePath: options?.threadWorktreePath ?? cwd,
460 worktreePath:
461 options?.secondThreadWorktreePath?.(cwd) ?? options?.threadWorktreePath ?? cwd,
459462 createdAt,
460463 }),
461464 )
......@@ -497,6 +500,81 @@ describe("CheckpointReactor", () => {
497500 };
498501 }
499502
503 effectIt.effect.each([
504 "active",
505 "archived",
506 "alias",
507 "nested",
508 "ancestor",
509 "project-root",
510 "conversation",
511 ] as const)("preserves sibling files when reverting a shared workspace, owner=%s", (owner) =>
512 Effect.gen(function* () {
513 const harness = yield* Effect.promise(() =>
514 createHarness({
515 secondThreadSharingWorktree: true,
516 ...(owner === "alias" || owner === "nested" || owner === "ancestor"
517 ? {
518 secondThreadWorktreePath: (cwd: string) => {
519 if (owner === "ancestor") return NodePath.dirname(cwd);
520 if (owner === "nested") {
521 const nested = NodePath.join(cwd, "nested-owner");
522 NodeFS.mkdirSync(nested);
523 return nested;
524 }
525 const alias = `${cwd}-alias`;
526 NodeFS.symlinkSync(cwd, alias, "junction");
527 tempDirs.push(alias);
528 return alias;
529 },
530 }
531 : {}),
532 }),
533 );
534 const createdAt = "2026-01-01T00:00:02.000Z";
535 if (owner === "archived")
536 yield* harness.engine.dispatch({
537 type: "thread.archive",
538 commandId: CommandId.make("cmd-archive-owner"),
539 threadId: ThreadId.make("thread-2"),
540 });
541 if (owner === "project-root")
542 yield* harness.engine.dispatch({
543 type: "thread.meta.update",
544 commandId: CommandId.make("cmd-root-owner"),
545 threadId: ThreadId.make("thread-2"),
546 worktreePath: null,
547 });
548 const siblingFile = NodePath.join(
549 harness.cwd,
550 ...(owner === "nested" ? ["nested-owner"] : []),
551 "sibling-work.txt",
552 );
553 NodeFS.writeFileSync(siblingFile, "sibling work\n");
554 yield* harness.engine.dispatch({
555 type: owner === "conversation" ? "thread.conversation.revert" : "thread.checkpoint.revert",
556 commandId: CommandId.make("cmd-shared-revert"),
557 threadId: ThreadId.make("thread-1"),
558 turnCount: 0,
559 createdAt,
560 });
561 yield* Effect.promise(harness.drain);
562 expect(NodeFS.readFileSync(siblingFile, "utf8")).toBe("sibling work\n");
563 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n");
564 const model = yield* Effect.promise(harness.readModel);
565 const failure = model.threads
566 .find((t) => t.id === "thread-1")
567 ?.activities.find((a) => a.kind === "checkpoint.revert.failed");
568 if (owner === "conversation") expect(failure).toBeUndefined();
569 else {
570 expect(failure?.payload).toMatchObject({
571 detail: expect.stringContaining("isolated worktree"),
572 });
573 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
574 }
575 }),
576 );
577
500578 effectIt.effect("captures baseline and large turn summaries before completion receipts", () =>
501579 Effect.gen(function* () {
502580 const harness = yield* Effect.promise(() =>
......@@ -1963,7 +2041,7 @@ describe("CheckpointReactor", () => {
19632041 });
19642042
19652043 it.each([false, true])(
1966 "reverts without an active session using project cwd fallback: %s",
2044 "restores files only in an isolated worktree without an active session, project cwd=%s",
19672045 async (useProjectCwd) => {
19682046 const harness = await createHarness({
19692047 hasSession: false,
......@@ -1995,12 +2073,28 @@ describe("CheckpointReactor", () => {
19952073 }),
19962074 );
19972075
1998 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
1999 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
2000 threadId: ThreadId.make("thread-1"),
2001 numTurns: 1,
2002 });
2003 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n");
2076 await harness.drain();
2077 if (useProjectCwd) {
2078 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
2079 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n");
2080 const model = await harness.readModel();
2081 expect(model.threads[0]?.activities).toEqual(
2082 expect.arrayContaining([
2083 expect.objectContaining({
2084 kind: "checkpoint.revert.failed",
2085 payload: expect.objectContaining({
2086 detail: expect.stringContaining("isolated worktree"),
2087 }),
2088 }),
2089 ]),
2090 );
2091 } else {
2092 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
2093 threadId: ThreadId.make("thread-1"),
2094 numTurns: 1,
2095 });
2096 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n");
2097 }
20042098 },
20052099 );
20062100});
apps/server/src/orchestration/Layers/CheckpointReactor.ts+64
......@@ -15,6 +15,8 @@ import * as Crypto from "effect/Crypto";
1515import * as DateTime from "effect/DateTime";
1616import * as Effect from "effect/Effect";
1717import * as Layer from "effect/Layer";
18import * as FileSystem from "effect/FileSystem";
19import * as Path from "effect/Path";
1820import * as Option from "effect/Option";
1921import type * as PlatformError from "effect/PlatformError";
2022import * as Stream from "effect/Stream";
......@@ -87,6 +89,8 @@ const make = Effect.gen(function* () {
8789 const checkpointStore = yield* CheckpointStore.CheckpointStore;
8890 const receiptBus = yield* RuntimeReceiptBus;
8991 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
92 const fileSystem = yield* FileSystem.FileSystem;
93 const path = yield* Path.Path;
9094 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
9195 const pullRequests = yield* PullRequestService.PullRequestService;
9296 const startedTurns = new Map<ThreadId, TurnId>();
......@@ -687,6 +691,55 @@ const make = Effect.gen(function* () {
687691 });
688692 });
689693
694 // Checkpoints contain the whole checkout, so restoring a shared cwd can erase a sibling's work.
695 const isRestoreWorkspaceIsolated = Effect.fn("isRestoreWorkspaceIsolated")(function* (
696 thread: { readonly id: ThreadId; readonly worktreePath: string | null },
697 cwd: string,
698 ) {
699 if (thread.worktreePath === null) return false;
700 const canonicalCwd = yield* fileSystem.realPath(cwd);
701 if ((yield* fileSystem.realPath(thread.worktreePath)) !== canonicalCwd) return false;
702 const active = yield* projectionSnapshotQuery.getShellSnapshot();
703 const archived = yield* projectionSnapshotQuery.getArchivedShellSnapshot();
704 const projects = [...active.projects, ...archived.projects];
705 const paths = new Set<string>();
706 for (const other of [...active.threads, ...archived.threads]) {
707 if (other.id === thread.id) continue;
708 const candidate =
709 other.worktreePath ??
710 projects.find((project) => project.id === other.projectId)?.workspaceRoot;
711 if (candidate !== undefined) paths.add(candidate);
712 }
713 for (const session of yield* providerService.listSessions()) {
714 if (
715 session.threadId !== thread.id &&
716 session.status !== "closed" &&
717 session.cwd !== undefined
718 )
719 paths.add(session.cwd);
720 }
721 for (const candidate of paths) {
722 const otherCwd = yield* fileSystem
723 .realPath(candidate)
724 .pipe(
725 Effect.catch((error) =>
726 error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error),
727 ),
728 );
729 if (otherCwd === null) continue;
730 const isWithin = (parent: string, child: string) => {
731 const relative = path.relative(parent, child);
732 return (
733 relative === "" ||
734 (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
735 );
736 };
737 // Parent and nested owners can both have files inside the restore target.
738 if (isWithin(canonicalCwd, otherCwd) || isWithin(otherCwd, canonicalCwd)) return false;
739 }
740 return true;
741 });
742
690743 const handleRevertRequested = Effect.fn("handleRevertRequested")(function* (
691744 event: Extract<OrchestrationEvent, { type: "thread.checkpoint-revert-requested" }>,
692745 ) {
......@@ -742,6 +795,17 @@ const make = Effect.gen(function* () {
742795 return;
743796 }
744797
798 if (!(yield* isRestoreWorkspaceIsolated(thread, checkpointCwd))) {
799 yield* appendRevertFailureActivity({
800 threadId: thread.id,
801 turnCount: event.payload.turnCount,
802 detail:
803 "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead.",
804 createdAt: now,
805 }).pipe(Effect.catch(() => Effect.void));
806 return;
807 }
808
745809 const targetCheckpointRef =
746810 event.payload.turnCount === 0
747811 ? checkpointRefForThreadTurn(event.payload.threadId, 0)
apps/web/src/components/ChatView.tsx+15-10
......@@ -10393,20 +10393,25 @@ export default function ChatView(props: ChatViewProps) {
1039310393 <AlertDialogDescription>
1039410394 Rewind chat to before this message. Your prompt and attachments return to the
1039510395 composer.
10396 {activeWorktreePath === null
10397 ? " Files stay as they are because this thread shares the project directory."
10398 : null}
1039610399 </AlertDialogDescription>
1039710400 </AlertDialogHeader>
1039810401 <AlertDialogFooter>
1039910402 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
10400 <Button
10401 variant="destructive"
10402 onClick={() => {
10403 if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
10404 setPendingRevert(null);
10405 void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true);
10406 }}
10407 >
10408 Revert files too
10409 </Button>
10403 {activeWorktreePath !== null ? (
10404 <Button
10405 variant="destructive"
10406 onClick={() => {
10407 if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
10408 setPendingRevert(null);
10409 void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true);
10410 }}
10411 >
10412 Revert files too
10413 </Button>
10414 ) : null}
1041010415 <Button
1041110416 onClick={() => {
1041210417 if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
docs/user/composer.md+4-1
......@@ -104,7 +104,10 @@ into a normal draft.
104104On web and desktop, choose **Edit from here** beneath a sent message to rewind
105105the conversation to before that message. Choose **Revert and keep changes** to
106106leave workspace files as they are, or **Revert files too** to restore them as well.
107The selected prompt and its attachments return to the composer for editing and
107File restore is only offered for threads running in a worktree, and it is
108refused when another thread or agent session also uses that directory, since
109restoring would erase their changes. A thread that works in the project directory
110rewinds the conversation only. The selected prompt and its attachments return to the composer for editing and
108111resending. Any unsent draft stays above the restored prompt.
109112
110113This removes the selected message and later conversation from the active thread