From 6f70be8c0f06b7cf98372099bafa8ff393b2d56f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 12 Oct 2026 20:02:36 +0200 Subject: [PATCH 1/2] fix(mcp): refuse stdio edits to a project open in the app's editor The stdio server runs in a process of its own and could not see which project the app's editor holds, so it reported it closed and wrote to its file, which the editor's next autosave overwrote. The app now records the project its editor holds in a pid-stamped marker beside the projects; listProjects and every external save read it, and a marker whose process is gone is ignored. Refs #1138. --- electron/ai-edition/document-service.test.ts | 65 +++++++++++++- electron/ai-edition/document-service.ts | 85 +++++++++++++++++-- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 23 +++-- electron/mcp/mcp-controller.test.ts | 1 + electron/mcp/openscreen-mcp-server.test.ts | 73 ++++++++++++++++ electron/mcp/openscreen-mcp-server.ts | 24 +++++- electron/preload.ts | 5 ++ .../ai-edition/store/mcpDocumentHost.test.ts | 23 ++++- src/lib/ai-edition/store/mcpDocumentHost.ts | 5 ++ src/native/contracts.ts | 2 + .../testing/manual-e2e-checklist.md | 2 +- 12 files changed, 291 insertions(+), 19 deletions(-) diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 526a5a12d..577bf690e 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -14,7 +15,13 @@ import { stylePresetAppearanceFromSettings, } from "../../src/lib/ai-edition/stylePresetsEditor"; import { registerMediaLinks } from "../media/mediaLinksRegistry"; -import { DocumentNotFoundError, DocumentService, ProjectFileError } from "./document-service"; +import { + DocumentNotFoundError, + DocumentService, + isProcessAlive, + ProjectFileError, + ProjectOpenInEditorError, +} from "./document-service"; async function makeTempDir(): Promise { const base = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-ai-edition-")); @@ -877,6 +884,62 @@ describe("DocumentService", () => { expect(await service.saveProjectIfUnchanged(renamed(document, "Edited"), version)).toBeNull(); await expect(service.getProject(created.project.id)).rejects.toThrow(DocumentNotFoundError); }); + + it("writes nothing over a project another process's editor holds (#1138)", async () => { + const created = await service.createProject("Held"); + const { document, version } = await service.getProjectForUpdate(created.project.id); + // The app, on the same folder, opens it after the read: only its marker says so. + new DocumentService(tempDir, mediaDir).markOpenInEditor(created.project.id); + await expect( + service.saveProjectIfUnchanged(renamed(document, "Edited"), version), + ).rejects.toThrow(ProjectOpenInEditorError); + expect((await service.getProject(created.project.id)).project.title).toBe("Held"); + }); + }); + + describe("the project open in an editor", () => { + const marker = () => path.join(tempDir, ".open-in-editor.json"); + /** A pid that was just alive and is not any more. */ + const deadPid = () => spawnSync(process.execPath, ["-e", ""]).pid; + + it("is none until an editor says so", async () => { + expect(await service.projectOpenInEditor()).toBeNull(); + }); + + it("is what an editor in any process sharing the folder last said, until it lets go", async () => { + const app = new DocumentService(tempDir, mediaDir); + app.markOpenInEditor("proj_a"); + expect(await service.projectOpenInEditor()).toBe("proj_a"); + app.markOpenInEditor("proj_b"); + expect(await service.projectOpenInEditor()).toBe("proj_b"); + app.markOpenInEditor(null); + expect(await service.projectOpenInEditor()).toBeNull(); + }); + + it("ignores the marker of a process that is gone, as after a crash", async () => { + await fs.writeFile(marker(), JSON.stringify({ pid: deadPid(), projectId: "proj_a" })); + expect(await service.projectOpenInEditor()).toBeNull(); + }); + + it("ignores a marker it cannot read", async () => { + await fs.writeFile(marker(), "{not json"); + expect(await service.projectOpenInEditor()).toBeNull(); + await fs.writeFile(marker(), JSON.stringify({ projectId: "proj_a" })); + expect(await service.projectOpenInEditor()).toBeNull(); + }); + + it("never shows up as a project", async () => { + await service.createProject("Listed"); + service.markOpenInEditor("proj_a"); + expect((await service.listProjects()).map((p) => p.title)).toEqual(["Listed"]); + }); + }); + + describe("isProcessAlive", () => { + it("tells a running process from one that exited", () => { + expect(isProcessAlive(process.pid)).toBe(true); + expect(isProcessAlive(spawnSync(process.execPath, ["-e", ""]).pid)).toBe(false); + }); }); describe("storedProjectId", () => { diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index c1e0ea20b..9c14c8c87 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -10,6 +10,7 @@ // ponytail: Phase 1 surface area is intentionally narrow (list / get / create // / save / addAsset / removeAsset). ops/history/agent runtime land in Phase 6. +import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import fs, { type FileHandle } from "node:fs/promises"; import path from "node:path"; import { createId } from "../../src/lib/ai-edition/document/ids"; @@ -33,6 +34,9 @@ const PROJECT_FILE_EXTENSION = ".openscreen"; // Older builds stored these same v3/v4 AxcutDocuments under `.axcut`. We read // them for back-compat and rename them to PROJECT_FILE_EXTENSION on access. const LEGACY_PROJECT_FILE_EXTENSION = ".axcut"; +// Which project the app's editor holds, for the OpenScreen processes beside it +// (`openscreen mcp`): see markOpenInEditor. Not a `.openscreen`, so never listed. +const OPEN_IN_EDITOR_FILE = ".open-in-editor.json"; export interface ProjectSummary { id: string; @@ -67,6 +71,26 @@ export class DocumentNotFoundError extends Error { } } +/** A write refused because the project is open in an editor, which would save over it. */ +export class ProjectOpenInEditorError extends Error { + constructor(public readonly projectId: string) { + super( + "The edit was NOT applied: this project is open in the OpenScreen editor, which would overwrite it with its own copy on its next save. Ask the user to close the project in OpenScreen and retry, or to turn on the MCP server in OpenScreen's AI settings and connect to that one, which edits the open project through the editor.", + ); + this.name = "ProjectOpenInEditorError"; + } +} + +/** Whether a process with this pid is running. EPERM: it is, just not ours to signal. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + export class ProjectFileError extends Error { constructor( message: string, @@ -253,6 +277,51 @@ export class DocumentService { return path.join(this.projectsRoot, `${safe}${LEGACY_PROJECT_FILE_EXTENSION}`); } + /** + * Records which project this process's editor holds (`null`: none), for the other + * OpenScreen processes that write projects. `openscreen mcp` runs beside the app + * with no editor of its own to ask, and a file edit to a project the editor holds + * is lost on the editor's next save (#1138). + * + * Synchronous so the removal lands even as the app quits. A crash leaves the file + * behind, which `projectOpenInEditor` ignores once its pid is gone. + */ + markOpenInEditor(projectId: string | null): void { + const marker = path.join(this.projectsRoot, OPEN_IN_EDITOR_FILE); + try { + if (projectId === null) { + rmSync(marker, { force: true }); + return; + } + mkdirSync(this.projectsRoot, { recursive: true }); + // Temp + rename: a reader never sees a half-written marker. + const temp = `${marker}.tmp-${process.pid}`; + writeFileSync(temp, JSON.stringify({ pid: process.pid, projectId }), "utf8"); + renameSync(temp, marker); + } catch (error) { + console.warn("[document-service] could not record the project open in the editor:", error); + } + } + + /** + * The project an OpenScreen editor holds, in this process or another, or `null`. + * ponytail: liveness is by pid alone, so a pid reused after the app crashed keeps + * its project "open" until the app's editor runs again. That refuses, never loses. + */ + async projectOpenInEditor(): Promise { + const raw = await fs + .readFile(path.join(this.projectsRoot, OPEN_IN_EDITOR_FILE), "utf8") + .catch(() => null); + if (raw === null) return null; + try { + const { pid, projectId } = JSON.parse(raw) as { pid?: unknown; projectId?: unknown }; + if (typeof pid !== "number" || typeof projectId !== "string") return null; + return isProcessAlive(pid) ? projectId : null; + } catch { + return null; + } + } + async listProjects(): Promise { const summaries = (await this.readAllProjects()).map((parsed) => ({ id: parsed.project.id, @@ -421,7 +490,8 @@ export class DocumentService { * `version` holds a copy without this save, and its own next save would drop * it without a word. The check runs inside the project's write queue, and reads * wait behind that queue, so nothing in this process reads or saves the project - * between the check and the write. + * between the check and the write. An editor in another process is seen through + * `projectOpenInEditor` instead: then this rejects with `ProjectOpenInEditorError`. */ async saveProjectIfUnchanged( document: AxcutDocument, @@ -429,12 +499,15 @@ export class DocumentService { ): Promise { const stamped = stampForSave(document); const projectId = stamped.project.id; - const written = await this.writeProject( - stamped, - async () => + const written = await this.writeProject(stamped, async () => { + if ((await this.projectOpenInEditor()) === projectId) { + throw new ProjectOpenInEditorError(projectId); + } + return ( this.generations.get(projectId) === version.generation && - (await this.readProjectFile(projectId).catch(() => null)) === version.raw, - ); + (await this.readProjectFile(projectId).catch(() => null)) === version.raw + ); + }); if (!written) return null; await ensureDocumentExtensions(stamped); return stamped; diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6ad0694cd..7a524d219 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -88,6 +88,8 @@ interface Window { request: import("../src/native/contracts").AiEditionMcpHostRequest, ) => Promise, ) => () => void; + /** Optional for the same reason. */ + setAiEditionOpenProject?: (projectId: string | null) => void; requestCameraAccess: () => Promise<{ success: boolean; granted: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index c71996c1c..501e2be16 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5,7 +5,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import type { DesktopCapturerSource, Rectangle } from "electron"; +import type { DesktopCapturerSource, Rectangle, WebContents } from "electron"; import { app, BrowserWindow, @@ -43,11 +43,12 @@ import { type RecordingSession, type StoreRecordedSessionInput, } from "../../src/lib/recordingSession"; -import type { - CursorRecordingData, - CursorRecordingSample, - ProjectFileResult, - ProjectPathResult, +import { + AI_EDITION_OPEN_PROJECT_CHANNEL, + type CursorRecordingData, + type CursorRecordingSample, + type ProjectFileResult, + type ProjectPathResult, } from "../../src/native/contracts"; import { PRODUCT_NAME } from "../about"; import { @@ -5040,6 +5041,16 @@ export function registerIpcHandlers( }; const mcpController = new McpController(mcpSettings, mcpToolDeps); + // The editor says which project it holds, so `openscreen mcp`, a process of its own + // that cannot ask it, refuses to edit that project behind its back (#1138). + const editorsHoldingProjects = new WeakSet(); + ipcMain.on(AI_EDITION_OPEN_PROJECT_CHANNEL, (event, projectId: unknown) => { + aiEditionDocuments.markOpenInEditor(typeof projectId === "string" ? projectId : null); + if (editorsHoldingProjects.has(event.sender)) return; + editorsHoldingProjects.add(event.sender); + event.sender.once("destroyed", () => aiEditionDocuments.markOpenInEditor(null)); + }); + registerNativeBridgeHandlers({ getPlatform: () => process.platform, getCurrentProjectPath: () => currentProjectPath, diff --git a/electron/mcp/mcp-controller.test.ts b/electron/mcp/mcp-controller.test.ts index 290dfe14b..fd8b7ec97 100644 --- a/electron/mcp/mcp-controller.test.ts +++ b/electron/mcp/mcp-controller.test.ts @@ -22,6 +22,7 @@ const deps: McpToolDeps = { getProjectForUpdate: async (id) => { throw new DocumentNotFoundError(id); }, + projectOpenInEditor: async () => null, saveProjectIfUnchanged: async (document) => document, }, editsAllowed: () => true, diff --git a/electron/mcp/openscreen-mcp-server.test.ts b/electron/mcp/openscreen-mcp-server.test.ts index b3d75df5e..485a95826 100644 --- a/electron/mcp/openscreen-mcp-server.test.ts +++ b/electron/mcp/openscreen-mcp-server.test.ts @@ -2,6 +2,7 @@ // ephemeral port, with an in-memory stand-in for the editor window and a real // DocumentService on a temp directory for the projects that are not open. +import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import fsPromises from "node:fs/promises"; import os from "node:os"; @@ -648,6 +649,78 @@ describe("projects other than the open one", () => { }); }); +// `openscreen mcp` beside the app (#1138): this server's own editor holds nothing, +// the app's editor holds proj_2, and only the marker the app leaves says so. +describe("a project open in another process's editor", () => { + const addTrimArgs = { assetId: "asset_1", startSec: 5, endSec: 6, projectId: "proj_2" }; + + async function connectBesideTheApp(): Promise { + await projects.saveProject(fixtureDocument("proj_2")); + const editor = new FakeEditor(); + editor.document = null; + const mcp = await connect(editor); + const result = await mcp.callTool({ + name: "createCheckpoint", + arguments: { projectId: "proj_2" }, + }); + expect(result.isError).toBeFalsy(); + return mcp; + } + + /** The app's own DocumentService, on the same projects folder. */ + const theApp = () => new DocumentService(path.join(dir, "projects"), dir); + + it("lists it as open", async () => { + const mcp = await connectBesideTheApp(); + theApp().markOpenInEditor("proj_2"); + const result = await mcp.callTool({ name: LIST_PROJECTS_TOOL, arguments: {} }); + const listed = JSON.parse(resultText(result)).projects as Array<{ id: string; open: boolean }>; + expect(listed).toEqual([expect.objectContaining({ id: "proj_2", open: true })]); + }); + + it("refuses an edit to it, saying how to make one, and leaves its file alone", async () => { + const mcp = await connectBesideTheApp(); + theApp().markOpenInEditor("proj_2"); + const result = await mcp.callTool({ name: "addTrim", arguments: addTrimArgs }); + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("open in the OpenScreen editor"); + expect(resultText(result)).toContain("MCP server"); + expect((await projects.getProject("proj_2")).timeline.trimRanges).toHaveLength(0); + }); + + it("still reads it", async () => { + const mcp = await connectBesideTheApp(); + theApp().markOpenInEditor("proj_2"); + const result = await mcp.callTool({ + name: "getCurrentDocument", + arguments: { projectId: "proj_2" }, + }); + expect(result.isError).toBeFalsy(); + }); + + it("edits it again once the editor lets go of it", async () => { + const mcp = await connectBesideTheApp(); + const app = theApp(); + app.markOpenInEditor("proj_2"); + app.markOpenInEditor(null); + const result = await mcp.callTool({ name: "addTrim", arguments: addTrimArgs }); + expect(result.isError).toBeFalsy(); + expect((await projects.getProject("proj_2")).timeline.trimRanges).toHaveLength(1); + }); + + it("is not blocked by the marker of an app that crashed", async () => { + const mcp = await connectBesideTheApp(); + const deadPid = spawnSync(process.execPath, ["-e", ""]).pid; + writeFileSync( + path.join(dir, "projects", ".open-in-editor.json"), + JSON.stringify({ pid: deadPid, projectId: "proj_2" }), + ); + const result = await mcp.callTool({ name: "addTrim", arguments: addTrimArgs }); + expect(result.isError).toBeFalsy(); + expect((await projects.getProject("proj_2")).timeline.trimRanges).toHaveLength(1); + }); +}); + describe("the HTTP guard", () => { async function post(headers: Record, path = MCP_ENDPOINT_PATH) { running = await startMcpHttpServer({ diff --git a/electron/mcp/openscreen-mcp-server.ts b/electron/mcp/openscreen-mcp-server.ts index acba66f52..a353b6301 100644 --- a/electron/mcp/openscreen-mcp-server.ts +++ b/electron/mcp/openscreen-mcp-server.ts @@ -15,7 +15,9 @@ // A call can name another project with `projectId` (see `listProjects`). The // project open in the editor is still only ever edited through the editor; any // other one is read from and saved to its file, refused if anything read or saved -// that project while the call ran (`saveProjectIfUnchanged`). +// that project while the call ran (`saveProjectIfUnchanged`), or if the editor of +// another OpenScreen process holds it (`projectOpenInEditor`: the app, seen from +// `openscreen mcp`). // // Two tools exist only here: `createCheckpoint` / `restoreCheckpoint`. A client // chains several edits per turn, and undo is per call, so they give it one step @@ -46,6 +48,7 @@ import { } from "../ai-edition/deep-agent/service"; import { DocumentNotFoundError, + ProjectOpenInEditorError, type ProjectSummary, type ProjectVersion, } from "../ai-edition/document-service"; @@ -83,7 +86,11 @@ export interface McpProjectStore { getProjectForUpdate( projectId: string, ): Promise<{ document: AxcutDocument; version: ProjectVersion }>; - /** `null`, and nothing written, when the project was read or saved since `version`. */ + /** The project an OpenScreen editor holds, this process's or another's (`openscreen mcp` + * runs beside the app), or `null`. */ + projectOpenInEditor(): Promise; + /** `null`, and nothing written, when the project was read or saved since `version`. + * Rejects with `ProjectOpenInEditorError` when an editor holds it. */ saveProjectIfUnchanged( document: AxcutDocument, version: ProjectVersion, @@ -248,12 +255,16 @@ export function createToolRunner(deps: McpToolDeps) { } async function listProjects(): Promise { - const [projects, snapshot] = await Promise.all([ + const [projects, snapshot, openElsewhereId] = await Promise.all([ deps.projects.listProjects(), deps.host.snapshot(), + deps.projects.projectOpenInEditor(), ]); const openId = openProjectId(snapshot); - const listed = projects.map((project) => ({ ...project, open: project.id === openId })); + const listed = projects.map((project) => ({ + ...project, + open: project.id === openId || project.id === openElsewhereId, + })); return textResult(JSON.stringify({ projects: listed }), false); } @@ -284,6 +295,11 @@ export function createToolRunner(deps: McpToolDeps) { ): Promise { const execution = await execute(read.document, name, args); if (execution.document) { + // An editor this server cannot reach (the app, beside `openscreen mcp`) holds + // it and would save over this edit. Checked again just before the write. + if ((await deps.projects.projectOpenInEditor()) === projectId) { + return textResult(new ProjectOpenInEditorError(projectId).message, true); + } // A restore IS the way back, so it needs no checkpoint of its own. if (name !== "restoreCheckpoint" && !hasCheckpoint(projectId)) { return textResult(CHECKPOINT_REQUIRED_MESSAGE, true); diff --git a/electron/preload.ts b/electron/preload.ts index 98b2a90e8..d704fd75f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -14,6 +14,7 @@ import { AI_EDITION_MCP_HOST_CHANNEL, AI_EDITION_MCP_REQUEST_CHANNEL, AI_EDITION_MCP_RESPONSE_CHANNEL, + AI_EDITION_OPEN_PROJECT_CHANNEL, NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest, } from "../src/native/contracts"; @@ -612,6 +613,10 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.send(AI_EDITION_MCP_HOST_CHANNEL, false); }; }, + // The project the editor holds, for `openscreen mcp` in another process (#1138). + setAiEditionOpenProject: (projectId: string | null) => { + ipcRenderer.send(AI_EDITION_OPEN_PROJECT_CHANNEL, projectId); + }, stt: { transcribe: (request: SttTranscribeRequest): Promise => { return ipcRenderer.invoke("stt:transcribe", request) as Promise; diff --git a/src/lib/ai-edition/store/mcpDocumentHost.test.ts b/src/lib/ai-edition/store/mcpDocumentHost.test.ts index 7b3a6d628..b66aa5da7 100644 --- a/src/lib/ai-edition/store/mcpDocumentHost.test.ts +++ b/src/lib/ai-edition/store/mcpDocumentHost.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyDocument } from "../schema"; -import { answerMcpHostRequest } from "./mcpDocumentHost"; +import { answerMcpHostRequest, useMcpDocumentHost } from "./mcpDocumentHost"; import { useProjectStore } from "./projectStore"; import { clearHistory, undo } from "./undo"; @@ -75,3 +76,23 @@ describe("answerMcpHostRequest", () => { ).resolves.toBe("no-live-document"); }); }); + +describe("useMcpDocumentHost", () => { + it("tells the main process which project the editor holds, and when it holds none (#1138)", () => { + useProjectStore.getState().clear(); + const setAiEditionOpenProject = vi.fn(); + vi.stubGlobal("electronAPI", { setAiEditionOpenProject }); + try { + const { unmount } = renderHook(() => useMcpDocumentHost()); + expect(setAiEditionOpenProject).toHaveBeenLastCalledWith(null); + act(() => { + openProject(0); + }); + expect(setAiEditionOpenProject).toHaveBeenLastCalledWith("project_1"); + unmount(); + expect(setAiEditionOpenProject).toHaveBeenLastCalledWith(null); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/lib/ai-edition/store/mcpDocumentHost.ts b/src/lib/ai-edition/store/mcpDocumentHost.ts index d4874354d..d1c82ff16 100644 --- a/src/lib/ai-edition/store/mcpDocumentHost.ts +++ b/src/lib/ai-edition/store/mcpDocumentHost.ts @@ -32,4 +32,9 @@ export async function answerMcpHostRequest( /** Makes this editor window the one the MCP server reads and writes, while mounted. */ export function useMcpDocumentHost(): void { useEffect(() => window.electronAPI?.onAiEditionMcpRequest?.(answerMcpHostRequest), []); + // `openscreen mcp` runs in a process of its own and cannot ask this window, so the + // main process records the open project where it can read it (#1138). + const openProjectId = useProjectStore((state) => state.document?.project.id ?? null); + useEffect(() => window.electronAPI?.setAiEditionOpenProject?.(openProjectId), [openProjectId]); + useEffect(() => () => window.electronAPI?.setAiEditionOpenProject?.(null), []); } diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 9dcb031e3..24b193cf2 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -357,6 +357,8 @@ export interface AiEditionMcpHostResponse { export const AI_EDITION_MCP_HOST_CHANNEL = "ai-edition.mcp-host"; export const AI_EDITION_MCP_REQUEST_CHANNEL = "ai-edition.mcp-request"; export const AI_EDITION_MCP_RESPONSE_CHANNEL = "ai-edition.mcp-response"; +/** Editor → main: the id of the project the editor holds, or `null` when it holds none. */ +export const AI_EDITION_OPEN_PROJECT_CHANNEL = "ai-edition.open-project"; /** One executed agent tool call, rendered as a compact "applied: …" line in * the chat panel (P1.7). */ diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 14c63ebd6..12cb9432a 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -577,7 +577,7 @@ Two servers. The **in-app** one is HTTP, in *AI settings* → *MCP server*, off - [ ] Confirm `"" help` lists `mcp`, register the stdio server in an MCP client, and confirm `listSources` lists displays, windows and microphones. - [ ] Run `startRecording` on a window, click inside it a few times, `stopRecording`, and confirm the returned project is in the editor's project list with one clip and a zoom per click. On macOS 15.2+, confirm Apple's picker never appears; on Linux, confirm the portal asks. - [ ] Edit that project while it is closed, `restoreCheckpoint` with the returned checkpoint, and confirm the project is back to the raw take. Confirm an edit to a closed project with no checkpoint is refused. -- [ ] Open the project in the editor, then send an MCP edit to it as a closed project, and confirm it is refused rather than overwriting the editor's copy. +- [ ] Open the project in the editor, then send an MCP edit to it as a closed project, and confirm it is refused rather than overwriting the editor's copy. Do it through the **stdio** server, a process apart from the app (#1138): `listProjects` reports the project `open: true`, and an edit, even after `createCheckpoint`, is refused with a message naming the in-app MCP server. Open another project in the editor, or quit OpenScreen, and confirm the same edit then lands. - [ ] `exportVideo`, then `getFrames` at a few ruler seconds after a trim, and confirm the frames show the moments asked for; edit the project again and confirm `getFrames` warns that the export is stale. - [ ] Drive a take from a script instead of the mouse, hand its action log to `setPointerTrack`, and confirm the export draws a cursor and zooms on the logged clicks. From 8aaff460dddb09bffe069b1ec5e8704ddaccdf3a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 12 Oct 2026 20:14:55 +0200 Subject: [PATCH 2/2] fix(mcp): let only the editor that set the open-project marker clear it --- electron/ipc/handlers.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 501e2be16..ca1bc88b3 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5043,12 +5043,22 @@ export function registerIpcHandlers( // The editor says which project it holds, so `openscreen mcp`, a process of its own // that cannot ask it, refuses to edit that project behind its back (#1138). - const editorsHoldingProjects = new WeakSet(); + // Only the window that set the marker clears it, so an older window going away + // never unmarks the project a newer one holds. + let markedBy: WebContents | null = null; + const watchedEditors = new WeakSet(); ipcMain.on(AI_EDITION_OPEN_PROJECT_CHANNEL, (event, projectId: unknown) => { - aiEditionDocuments.markOpenInEditor(typeof projectId === "string" ? projectId : null); - if (editorsHoldingProjects.has(event.sender)) return; - editorsHoldingProjects.add(event.sender); - event.sender.once("destroyed", () => aiEditionDocuments.markOpenInEditor(null)); + const id = typeof projectId === "string" ? projectId : null; + if (id === null && markedBy !== null && markedBy !== event.sender) return; + markedBy = id === null ? null : event.sender; + aiEditionDocuments.markOpenInEditor(id); + if (watchedEditors.has(event.sender)) return; + watchedEditors.add(event.sender); + event.sender.once("destroyed", () => { + if (markedBy !== event.sender) return; + markedBy = null; + aiEditionDocuments.markOpenInEditor(null); + }); }); registerNativeBridgeHandlers({