From 8e593294414af1bc8f5498b0f397fdf40813016e Mon Sep 17 00:00:00 2001 From: George Ng Date: Wed, 26 Aug 2026 17:18:09 -0700 Subject: [PATCH 1/7] fix(markdown): bind view server to loopback Restrict the unauthenticated Markdown HTTP and WebSocket service to 127.0.0.1 and advertise the same address to collaboration clients. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 824e625d-be4c-48f5-91c7-88675b55c6e6 --- .../agents/markdown/src/view/route/service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ts/packages/agents/markdown/src/view/route/service.ts b/ts/packages/agents/markdown/src/view/route/service.ts index 7afc45f775..8e8f518e8c 100644 --- a/ts/packages/agents/markdown/src/view/route/service.ts +++ b/ts/packages/agents/markdown/src/view/route/service.ts @@ -28,6 +28,7 @@ import { const debug = registerDebug("typeagent:markdown:service"); const app: Express = express(); +const LOOPBACK_HOST = "127.0.0.1"; const port = parseInt(process.argv[2]); if (isNaN(port)) { throw new Error("Port must be a number"); @@ -833,7 +834,7 @@ app.get("/collaboration/info", (req: Request, res: Response) => { res.json({ ...stats, - websocketServerUrl: `ws://localhost:${port}`, + websocketServerUrl: `ws://${LOOPBACK_HOST}:${port}`, currentDocument: currentDocument, }); }); @@ -2310,14 +2311,15 @@ const server = http.createServer(app); createYjsWSServer(server); debug(`[SIGNAL] Y.js WebSocket server integrated`); -// Start the HTTP server (which includes WebSocket support) -server.listen(port, () => { +// Bind only to loopback. Origin checks are not authentication and requests +// from non-browser clients may legitimately omit the Origin header. +server.listen(port, LOOPBACK_HOST, () => { const boundPort = (server.address() as { port: number }).port; debug( - `Express server with WebSocket support listening on port ${boundPort}`, + `Express server with WebSocket support listening at http://${LOOPBACK_HOST}:${boundPort}`, ); debug( - `Y.js collaboration available at ws://localhost:${boundPort}/`, + `Y.js collaboration available at ws://${LOOPBACK_HOST}:${boundPort}/`, ); // Send success signal to parent process AFTER server is ready to accept WebSocket connections From 99d0885646532d81336e61fb29c0e665ab9cfc2b Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 27 Aug 2026 01:32:02 -0700 Subject: [PATCH 2/7] fix(markdown): avoid model setup for document creation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02e51644-28c8-4b3f-98da-61c5fe172346 --- .../src/agent/markdownActionHandler.ts | 11 ++- .../agents/markdown/src/agent/translator.ts | 2 +- .../test/markdownActionHandler.spec.ts | 73 +++++++++++++++++++ .../agents/markdown/test/tsconfig.json | 2 +- 4 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 ts/packages/agents/markdown/test/markdownActionHandler.spec.ts diff --git a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts index 6882347c46..ec9cf8aef4 100644 --- a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts +++ b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts @@ -379,7 +379,7 @@ async function handleStreamingMarkdownAction( `[AGENT] Starting streaming action: ${action.actionName} (stream: ${streamId})`, ); - const agent = await createMarkdownAgent("GPT_4o"); + const agent = await createMarkdownAgent("GPT_4_O"); const storage = actionContext.sessionContext.sessionStorage; // Get current document content @@ -539,7 +539,6 @@ async function handleMarkdownAction( actionContext: ActionContext, ) { let result: ActionResult | undefined = undefined; - const agent = await createMarkdownAgent("GPT_4o"); // Accumulates the LLM token usage consumed while handling this action so // it can be reported back to the dispatcher as "Action Tokens". The agent @@ -549,7 +548,11 @@ async function handleMarkdownAction( completion_tokens: 0, total_tokens: 0, }; - agent.tokenUsage = tokenUsage; + const createAgent = async () => { + const agent = await createMarkdownAgent("GPT_4_O"); + agent.tokenUsage = tokenUsage; + return agent; + }; const storage = actionContext.sessionContext.sessionStorage; @@ -600,6 +603,7 @@ async function handleMarkdownAction( break; } case "updateDocument": { + const agent = await createAgent(); debug("Starting updateDocument action in agent process"); result = createActionResult("Updating document ..."); @@ -740,6 +744,7 @@ async function handleMarkdownAction( break; } case "streamingUpdateDocument": { + const agent = await createAgent(); // Handle streaming AI commands - now unified with regular updateDocument flow debug( "Starting streamingUpdateDocument action - using standard translator flow", diff --git a/ts/packages/agents/markdown/src/agent/translator.ts b/ts/packages/agents/markdown/src/agent/translator.ts index f1bfe4e709..77bb4beaea 100644 --- a/ts/packages/agents/markdown/src/agent/translator.ts +++ b/ts/packages/agents/markdown/src/agent/translator.ts @@ -21,7 +21,7 @@ import { MarkdownUpdateResult } from "./markdownOperationSchema.js"; const debug = registerDebug("typeagent:markdown:translator"); export async function createMarkdownAgent( - model: "GPT_35_TURBO" | "GPT_4" | "GPT-v" | "GPT_4o", + model: "GPT_35_TURBO" | "GPT_4" | "GPT_V" | "GPT_4_O", ) { const packageRoot = path.join("../../"); const schemaText = await fs.promises.readFile( diff --git a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts new file mode 100644 index 0000000000..a4063ab669 --- /dev/null +++ b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionContext, Storage } from "@typeagent/agent-sdk"; +import { instantiate } from "../src/agent/markdownActionHandler.js"; + +describe("markdown document actions", () => { + test.each(["createDocument", "openDocument"] as const)( + "%s works without model configuration", + async (actionName) => { + const savedModelSettings = Object.entries(process.env).filter( + ([key]) => + key.startsWith("AZURE_OPENAI_") || + key.startsWith("OPENAI_") || + key.startsWith("OLLAMA_") || + key === "MODEL_PROVIDER", + ); + for (const [key] of savedModelSettings) { + delete process.env[key]; + } + + const checkedPaths: string[] = []; + const writes: [string, string][] = []; + const storage = { + exists: async (storagePath: string) => { + checkedPaths.push(storagePath); + return false; + }, + write: async (storagePath: string, data: string) => { + writes.push([storagePath, data]); + }, + } as unknown as Storage; + const context = { + sessionContext: { + agentContext: { localHostPort: 0 }, + sessionStorage: storage, + }, + } as unknown as ActionContext<{ + currentFileName?: string; + localHostPort: number; + }>; + + try { + const result = await instantiate().executeAction!( + { + schemaName: "markdown", + actionName, + parameters: { name: "notes" }, + }, + context, + ); + + if (result === undefined) { + throw new Error("Expected a document creation result"); + } + if ("error" in result) { + throw new Error(result.error); + } + expect(checkedPaths).toEqual(["notes.md"]); + expect(writes).toEqual([["notes.md", ""]]); + expect(result.tokenUsage).toEqual({ + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }); + } finally { + for (const [key, value] of savedModelSettings) { + process.env[key] = value; + } + } + }, + ); +}); diff --git a/ts/packages/agents/markdown/test/tsconfig.json b/ts/packages/agents/markdown/test/tsconfig.json index fb7bb74fdd..7aa38d62cf 100644 --- a/ts/packages/agents/markdown/test/tsconfig.json +++ b/ts/packages/agents/markdown/test/tsconfig.json @@ -7,5 +7,5 @@ "types": ["node", "jest"] }, "include": ["./**/*"], - "references": [{ "path": "../src/view/route" }] + "references": [{ "path": "../src/agent" }, { "path": "../src/view/route" }] } From 2a849772f29e2df2000575b9ee0b8ff7e9a977d1 Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 27 Aug 2026 17:05:01 -0700 Subject: [PATCH 3/7] fix(aiclient): preserve typed Azure config resolution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02e51644-28c8-4b3f-98da-61c5fe172346 --- .../test/markdownActionHandler.spec.ts | 33 +++++++++- ts/packages/aiclient/src/openai.ts | 6 +- ts/packages/aiclient/src/runtimeConfig.ts | 13 ++-- .../aiclient/test/runtimeConfig.spec.ts | 64 +++++++++++++++++++ 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts index a4063ab669..468d0eca8c 100644 --- a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts +++ b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts @@ -2,7 +2,13 @@ // Licensed under the MIT License. import type { ActionContext, Storage } from "@typeagent/agent-sdk"; +import { + configFromEnvRecord, + getRuntimeConfig, + setRuntimeConfig, +} from "@typeagent/aiclient"; import { instantiate } from "../src/agent/markdownActionHandler.js"; +import { createMarkdownAgent } from "../src/agent/translator.js"; describe("markdown document actions", () => { test.each(["createDocument", "openDocument"] as const)( @@ -13,7 +19,7 @@ describe("markdown document actions", () => { key.startsWith("AZURE_OPENAI_") || key.startsWith("OPENAI_") || key.startsWith("OLLAMA_") || - key === "MODEL_PROVIDER", + key === "TYPEAGENT_MODEL_PROVIDER", ); for (const [key] of savedModelSettings) { delete process.env[key]; @@ -70,4 +76,29 @@ describe("markdown document actions", () => { } }, ); + + test("constructs its update model from typed configuration", async () => { + const originalConfig = getRuntimeConfig(); + const openAIKey = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + setRuntimeConfig( + configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: + "https://markdown-model.example", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }), + ); + + try { + const agent = await createMarkdownAgent("GPT_4_O"); + expect(agent.model).toBeDefined(); + } finally { + setRuntimeConfig(originalConfig); + if (openAIKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = openAIKey; + } + } + }); }); diff --git a/ts/packages/aiclient/src/openai.ts b/ts/packages/aiclient/src/openai.ts index 4951c59e7f..fc00ae53cc 100644 --- a/ts/packages/aiclient/src/openai.ts +++ b/ts/packages/aiclient/src/openai.ts @@ -162,9 +162,9 @@ export function apiSettingsFromEnv( return azureApiSettingsFromEnv(modelType, env, target); } - env ??= process.env; - if (EnvVars.OPENAI_API_KEY in env) { - return openAIApiSettingsFromEnv(modelType, env, endpointName); + const resolvedEnv = env ?? process.env; + if (EnvVars.OPENAI_API_KEY in resolvedEnv) { + return openAIApiSettingsFromEnv(modelType, resolvedEnv, endpointName); } return azureApiSettingsFromEnv(modelType, env, endpointName); diff --git a/ts/packages/aiclient/src/runtimeConfig.ts b/ts/packages/aiclient/src/runtimeConfig.ts index 29adc6939b..74a8be7bbc 100644 --- a/ts/packages/aiclient/src/runtimeConfig.ts +++ b/ts/packages/aiclient/src/runtimeConfig.ts @@ -33,9 +33,7 @@ let cached: Config | undefined; */ export function setRuntimeConfig(config: Config): void { cached = config; - if (config.modelProvider !== undefined) { - setActiveModelProvider(config.modelProvider); - } + setActiveModelProvider(config.modelProvider); } /** @@ -47,11 +45,9 @@ export function initRuntimeConfigFromProcessEnv(): Config { for (const [k, v] of Object.entries(process.env)) { if (typeof v === "string") flat[k] = v; } - cached = buildConfig(flat); - if (cached.modelProvider !== undefined) { - setActiveModelProvider(cached.modelProvider); - } - return cached; + const config = buildConfig(flat); + setRuntimeConfig(config); + return config; } /** @@ -71,4 +67,5 @@ export function getRuntimeConfig(): Config { */ export function _resetRuntimeConfigForTests(): void { cached = undefined; + setActiveModelProvider(undefined); } diff --git a/ts/packages/aiclient/test/runtimeConfig.spec.ts b/ts/packages/aiclient/test/runtimeConfig.spec.ts index f55a2b8897..109e4637b8 100644 --- a/ts/packages/aiclient/test/runtimeConfig.spec.ts +++ b/ts/packages/aiclient/test/runtimeConfig.spec.ts @@ -7,6 +7,9 @@ import { initRuntimeConfigFromProcessEnv, setRuntimeConfig, configFromEnvRecord, + getActiveModelProvider, + openai, + setActiveModelProvider, } from "../src/index.js"; describe("runtimeConfig: process-wide singleton", () => { @@ -33,9 +36,70 @@ describe("runtimeConfig: process-wide singleton", () => { expect(got.azureOpenAI.deployments.get("gpt_4_o")).toBeDefined(); }); + test("setRuntimeConfig clears a stale model provider", () => { + setActiveModelProvider("copilot"); + setRuntimeConfig(configFromEnvRecord({})); + expect(getActiveModelProvider()).toBeUndefined(); + }); + test("initRuntimeConfigFromProcessEnv overrides cached value", () => { setRuntimeConfig(configFromEnvRecord({})); const fresh = initRuntimeConfigFromProcessEnv(); expect(getRuntimeConfig()).toBe(fresh); }); + + test("apiSettingsFromEnv uses typed config when env is omitted", () => { + const openAIKey = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + setRuntimeConfig( + configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: + "https://typed-config.example", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }), + ); + + try { + const settings = openai.apiSettingsFromEnv( + openai.ModelType.Chat, + undefined, + "GPT_4_O", + ); + expect(settings.endpoint).toBe("https://typed-config.example"); + } finally { + if (openAIKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = openAIKey; + } + _resetRuntimeConfigForTests(); + } + }); + + test("apiSettingsFromEnv honors an explicit legacy env map", () => { + setRuntimeConfig( + configFromEnvRecord({ + AZURE_OPENAI_ENDPOINT_GPT_4_O_EASTUS: + "https://typed-config.example", + AZURE_OPENAI_API_KEY_GPT_4_O_EASTUS: "identity", + }), + ); + + const settings = openai.apiSettingsFromEnv( + openai.ModelType.Chat, + { + AZURE_OPENAI_ENDPOINT_GPT_4_O: "https://explicit-env.example", + AZURE_OPENAI_API_KEY_GPT_4_O: "explicit-key", + }, + "GPT_4_O", + ); + + expect(settings.endpoint).toBe("https://explicit-env.example"); + if (settings.provider !== "azure") { + throw new Error( + `Expected Azure settings, got ${settings.provider}`, + ); + } + expect(settings.apiKey).toBe("explicit-key"); + }); }); From 8529bec6a6c61c614aee169a4d0f29eaec7de95a Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 27 Aug 2026 17:38:53 -0700 Subject: [PATCH 4/7] fix(markdown): persist headless document updates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02e51644-28c8-4b3f-98da-61c5fe172346 --- .../markdown/src/agent/documentOperations.ts | 167 ++++++++++++++++++ .../src/agent/markdownActionHandler.ts | 41 +++-- .../src/agent/markdownOperationSchema.ts | 2 +- .../src/view/route/collaborationManager.ts | 156 ++-------------- .../agents/markdown/src/view/route/service.ts | 128 +++++++------- .../test/collaborationManager.spec.ts | 74 ++++++++ .../test/markdownActionHandler.spec.ts | 4 + .../agents/markdown/test/viewService.spec.ts | 143 +++++++++++++++ 8 files changed, 496 insertions(+), 219 deletions(-) create mode 100644 ts/packages/agents/markdown/src/agent/documentOperations.ts create mode 100644 ts/packages/agents/markdown/test/collaborationManager.spec.ts create mode 100644 ts/packages/agents/markdown/test/viewService.spec.ts diff --git a/ts/packages/agents/markdown/src/agent/documentOperations.ts b/ts/packages/agents/markdown/src/agent/documentOperations.ts new file mode 100644 index 0000000000..4c2bd2701c --- /dev/null +++ b/ts/packages/agents/markdown/src/agent/documentOperations.ts @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + ContentItem, + DocumentOperation, +} from "./markdownOperationSchema.js"; + +export function applyDocumentOperations( + content: string, + operations: DocumentOperation[], +): string { + return operations.reduce( + (updatedContent, operation) => + applyDocumentOperation(updatedContent, operation), + content, + ); +} + +function applyDocumentOperation( + content: string, + operation: DocumentOperation, +): string { + switch (operation.type) { + case "insert": { + const position = clampPosition(operation.position, content.length); + return ( + content.slice(0, position) + + contentItemsToText(operation.content) + + content.slice(position) + ); + } + case "replace": { + const [from, to] = clampRange( + operation.from, + operation.to, + content.length, + ); + return ( + content.slice(0, from) + + contentItemsToText(operation.content) + + content.slice(to) + ); + } + case "delete": { + const [from, to] = clampRange( + operation.from, + operation.to, + content.length, + ); + return content.slice(0, from) + content.slice(to); + } + case "format": + throw new Error( + "Format operations cannot be applied to markdown text", + ); + } +} + +function contentItemsToText(items: ContentItem[]): string { + return items.map((item) => contentItemToText(item)).join(""); +} + +function contentItemToText(item: ContentItem): string { + const text = getPlainText(item); + switch (item.type) { + case "heading": { + if (/^#{1,6}\s/.test(text)) { + return ensureBlockSeparator(text); + } + const attrs = item.attrs as { level?: number } | undefined; + const requestedLevel = attrs?.level; + const level = + requestedLevel !== undefined && + Number.isInteger(requestedLevel) && + requestedLevel >= 1 && + requestedLevel <= 6 + ? requestedLevel + : 1; + return `${"#".repeat(level)} ${text}\n\n`; + } + case "paragraph": + return ensureBlockSeparator(text); + case "bullet_list": + return serializeList(item, "-"); + case "ordered_list": + return serializeList(item, "1."); + case "code_block": + return `\`\`\`\n${text}\n\`\`\`\n\n`; + case "blockquote": + return `${text + .split("\n") + .map((line) => `> ${line}`) + .join("\n")}\n\n`; + case "horizontal_rule": + return "---\n\n"; + case "hard_break": + return " \n"; + case "text": + return applyMarks(text, item); + default: + return text; + } +} + +function getPlainText(item: ContentItem): string { + if (item.text !== undefined) { + return item.text; + } + return item.content ? item.content.map(getPlainText).join("") : ""; +} + +function ensureBlockSeparator(text: string): string { + return text.endsWith("\n\n") ? text : `${text}\n\n`; +} + +function serializeList(item: ContentItem, marker: string): string { + const lines = + item.content?.map( + (child) => `${marker} ${getPlainText(child).trim()}`, + ) ?? []; + return `${lines.join("\n")}\n\n`; +} + +function applyMarks(text: string, item: ContentItem): string { + return (item.marks ?? []).reduce((markedText, mark) => { + switch (mark.type) { + case "strong": + return `**${markedText}**`; + case "em": + return `*${markedText}*`; + case "code": + return `\`${markedText}\``; + case "link": { + const attrs = mark.attrs as { href?: string } | undefined; + return attrs?.href + ? `[${markedText}](${attrs.href})` + : markedText; + } + default: + return markedText; + } + }, text); +} + +function clampPosition(position: number, contentLength: number): number { + if (!Number.isInteger(position) || position < 0) { + throw new Error(`Invalid document position: ${position}`); + } + return Math.min(position, contentLength); +} + +function clampRange( + from: number, + to: number, + contentLength: number, +): [number, number] { + if ( + !Number.isInteger(from) || + !Number.isInteger(to) || + from < 0 || + to < from + ) { + throw new Error(`Invalid document range: ${from}-${to}`); + } + return [Math.min(from, contentLength), Math.min(to, contentLength)]; +} diff --git a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts index ec9cf8aef4..3a58246d88 100644 --- a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts +++ b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts @@ -18,6 +18,7 @@ import { ChildProcess, fork } from "child_process"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { UICommandResult } from "./ipcTypes.js"; +import { applyDocumentOperations } from "./documentOperations.js"; import registerDebug from "debug"; const debug = registerDebug("typeagent:markdown:agent"); @@ -381,6 +382,9 @@ async function handleStreamingMarkdownAction( const agent = await createMarkdownAgent("GPT_4_O"); const storage = actionContext.sessionContext.sessionStorage; + if (!storage) { + throw new Error("Markdown actions require session storage"); + } // Get current document content const filePath = `${actionContext.sessionContext.agentContext.currentFileName}`; @@ -555,6 +559,9 @@ async function handleMarkdownAction( }; const storage = actionContext.sessionContext.sessionStorage; + if (!storage) { + throw new Error("Markdown actions require session storage"); + } switch (action.actionName) { case "openDocument": @@ -574,14 +581,14 @@ async function handleMarkdownAction( actionContext.sessionContext.agentContext.currentFileName = newFileName; - if (!(await storage?.exists(newFileName))) { - await storage?.write(newFileName, ""); + if (!(await storage.exists(newFileName))) { + await storage.write(newFileName, ""); } if (actionContext.sessionContext.agentContext.viewProcess) { const fullPath = await getFullMarkdownFilePath( newFileName, - storage!, + storage, ); actionContext.sessionContext.agentContext.viewProcess.send({ @@ -591,6 +598,10 @@ async function handleMarkdownAction( }); } result = createActionResult("Document opened"); + result.resultEntity = { + name: newFileName, + type: ["file", "markdown"], + }; result.activityContext = { activityName: "editingMarkdown", description: "Editing a Markdown document", @@ -633,9 +644,9 @@ async function handleMarkdownAction( } } else { // Fallback if no view process - if (await storage?.exists(filePath)) { + if (await storage.exists(filePath)) { markdownContent = - (await storage?.read(filePath, "utf8")) || ""; + (await storage.read(filePath, "utf8")) || ""; debug( "No view process, read content from storage:", markdownContent?.length, @@ -718,9 +729,12 @@ async function handleMarkdownAction( "Operations applied successfully via view process", ); } else { - console.warn( - "No view process available, operations not applied", + const updatedContent = applyDocumentOperations( + markdownContent, + updateResult.operations, ); + await storage.write(filePath, updatedContent); + debug("Applied operations directly to session storage"); } } else { debug("[AGENT] No operations returned from LLM"); @@ -777,9 +791,9 @@ async function handleMarkdownAction( } } else { // Fallback if no view process - if (await storage?.exists(filePath)) { + if (await storage.exists(filePath)) { markdownContent = - (await storage?.read(filePath, "utf8")) || ""; + (await storage.read(filePath, "utf8")) || ""; debug( "No view process, read content from storage:", markdownContent?.length, @@ -824,8 +838,13 @@ async function handleMarkdownAction( "Operations applied successfully via view process", ); } else { - console.warn( - "No view process available, operations not applied", + const updatedContent = applyDocumentOperations( + markdownContent, + updateResult.operations, + ); + await storage.write(filePath, updatedContent); + debug( + "Applied streaming operations directly to session storage", ); } } diff --git a/ts/packages/agents/markdown/src/agent/markdownOperationSchema.ts b/ts/packages/agents/markdown/src/agent/markdownOperationSchema.ts index 22f4e3e468..f36039fdab 100644 --- a/ts/packages/agents/markdown/src/agent/markdownOperationSchema.ts +++ b/ts/packages/agents/markdown/src/agent/markdownOperationSchema.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. // Document operation types for incremental updates to ProseMirror documents -// Position references should be line numbers (0-based) in the document. +// Position references are character offsets (0-based) in the markdown text. export type DocumentOperation = | InsertOperation | DeleteOperation diff --git a/ts/packages/agents/markdown/src/view/route/collaborationManager.ts b/ts/packages/agents/markdown/src/view/route/collaborationManager.ts index f75cd8e06d..e5f7f66f8f 100644 --- a/ts/packages/agents/markdown/src/view/route/collaborationManager.ts +++ b/ts/packages/agents/markdown/src/view/route/collaborationManager.ts @@ -3,6 +3,8 @@ import * as Y from "yjs"; import registerDebug from "debug"; +import { applyDocumentOperations } from "../../agent/documentOperations.js"; +import type { DocumentOperation } from "../../agent/markdownOperationSchema.js"; const debug = registerDebug("typeagent:markdown:collaboration"); @@ -52,82 +54,30 @@ export class CollaborationManager { }; } - /** - * Apply operation to Yjs document - */ - applyOperation(documentId: string, operation: any): void { + applyOperations( + documentId: string, + operations: DocumentOperation[], + ): string { const ydoc = this.documents.get(documentId); if (!ydoc) { - console.warn( - `No document found for ID: ${documentId}, cannot apply operation`, - ); - return; + throw new Error(`No document found for ID: ${documentId}`); } const ytext = ydoc.getText("content"); - debug( - `Applying operation: ${operation.type} to document: ${documentId}`, + const updatedContent = applyDocumentOperations( + ytext.toString(), + operations, ); - try { - switch (operation.type) { - case "insert": { - const insertText = operation.content - .map((item: any) => this.contentItemToText(item)) - .join(""); - const position = Math.min( - operation.position || 0, - ytext.length, - ); - ytext.insert(position, insertText); - debug( - `Inserted ${insertText.length} chars at position ${position} in document ${documentId}`, - ); - break; - } - case "replace": { - const replaceText = operation.content - .map((item: any) => this.contentItemToText(item)) - .join(""); - const fromPos = Math.min(operation.from || 0, ytext.length); - const toPos = Math.min( - operation.to || fromPos + 1, - ytext.length, - ); - const deleteLength = toPos - fromPos; - - ytext.delete(fromPos, deleteLength); - ytext.insert(fromPos, replaceText); - debug( - `Replaced ${deleteLength} chars with ${replaceText.length} chars at position ${fromPos} in document ${documentId}`, - ); - break; - } - case "delete": { - const fromPos = Math.min(operation.from || 0, ytext.length); - const toPos = Math.min( - operation.to || fromPos + 1, - ytext.length, - ); - const deleteLength = toPos - fromPos; + ydoc.transact(() => { + ytext.delete(0, ytext.length); + ytext.insert(0, updatedContent); + }); - ytext.delete(fromPos, deleteLength); - debug( - `Deleted ${deleteLength} chars at position ${fromPos} in document ${documentId}`, - ); - break; - } - default: - console.warn( - `[COLLAB] Unknown operation type: ${operation.type}`, - ); - } - } catch (error) { - console.error( - `[COLLAB] Failed to apply operation ${operation.type}:`, - error, - ); - } + debug( + `Applied ${operations.length} operations to document ${documentId}`, + ); + return updatedContent; } /** @@ -160,74 +110,4 @@ export class CollaborationManager { ytext.delete(0, ytext.length); ytext.insert(0, content); } - - /** - * Convert content item to text (helper for operation application) - */ - private contentItemToText(item: any): string { - if (item.text) { - return item.text; - } - - if (item.content) { - return item.content - .map((child: any) => this.contentItemToText(child)) - .join(""); - } - - // Handle special node types - switch (item.type) { - case "paragraph": - return ( - "\n" + - (item.content - ? item.content - .map((child: any) => - this.contentItemToText(child), - ) - .join("") - : "") + - "\n" - ); - case "heading": - const level = item.attrs?.level || 1; - const prefix = "#".repeat(level) + " "; - return ( - "\n" + - prefix + - (item.content - ? item.content - .map((child: any) => - this.contentItemToText(child), - ) - .join("") - : "") + - "\n" - ); - case "code_block": - return ( - "\n```\n" + - (item.content - ? item.content - .map((child: any) => - this.contentItemToText(child), - ) - .join("") - : "") + - "\n```\n" - ); - case "mermaid": - return ( - "\n```mermaid\n" + (item.attrs?.content || "") + "\n```\n" - ); - case "math_display": - return "\n$$\n" + (item.attrs?.content || "") + "\n$$\n"; - default: - return item.content - ? item.content - .map((child: any) => this.contentItemToText(child)) - .join("") - : ""; - } - } } diff --git a/ts/packages/agents/markdown/src/view/route/service.ts b/ts/packages/agents/markdown/src/view/route/service.ts index 8e8f518e8c..b8b2c2d3ee 100644 --- a/ts/packages/agents/markdown/src/view/route/service.ts +++ b/ts/packages/agents/markdown/src/view/route/service.ts @@ -690,7 +690,7 @@ app.post("/autosave", express.json(), (req: Request, res: Response) => { sanitizedFilePath += ".md"; } - const resolvedFilePath = resolvePathWithinRoot( + const resolvedFilePath = resolveWritableFileWithinRoot( ROOT_DIR, sanitizedFilePath, ); @@ -758,7 +758,7 @@ app.post("/autosave", express.json(), (req: Request, res: Response) => { ytext.insert(0, content); // Then save to file - // fs.writeFileSync(targetFilePath, content, "utf-8"); + fs.writeFileSync(targetFilePath, content, "utf-8"); debug( `Auto-save completed to both Y.js document and file: ${targetFilePath}, ${content.length} chars`, @@ -1635,98 +1635,88 @@ Start typing to see the editor in action! ); }); } else if (message.type === "applyLLMOperations") { - // PRODUCTION: Send operations to PRIMARY client only via SSE to prevent duplicates try { - debug( - `[VIEW] Forwarding ${message.operations?.length || 0} operations to primary client via SSE`, - ); + if (!Array.isArray(message.operations)) { + throw new Error("Document operations must be an array"); + } - if (clients.length === 0) { - console.warn( - `[SSE] No clients connected to receive operations`, + if (clients.length > 0) { + const operationsEvent = { + type: "llmOperations", + operations: message.operations, + timestamp: message.timestamp || Date.now(), + source: "agent", + clientRole: "primary", + }; + clients[0].write( + `data: ${JSON.stringify(operationsEvent)}\n\n`, ); + + const notificationEvent = { + type: "operationsBeingApplied", + timestamp: Date.now(), + operationCount: message.operations.length, + source: "agent", + }; + clients.slice(1).forEach((client) => { + client.write( + `data: ${JSON.stringify(notificationEvent)}\n\n`, + ); + }); + process.send?.({ type: "operationsApplied", - success: false, - error: "No clients connected", + success: true, + operationCount: message.operations.length, method: "sse-forwarded", + clientsNotified: clients.length, }); return; } - // Send operations to ONLY the first client to prevent duplicates - const primaryClient = clients[0]; - const operationsEvent = { - type: "llmOperations", - operations: message.operations, - timestamp: message.timestamp || Date.now(), - source: "agent", - clientRole: "primary", // Mark this client as the primary applier - }; + const documentId = filePath + ? path.basename(filePath, ".md") + : "default"; + getAuthoritativeDocument(documentId); - try { - primaryClient.write( - `data: ${JSON.stringify(operationsEvent)}\n\n`, - ); - debug( - `[SSE] Sent ${message.operations?.length || 0} operations to PRIMARY client (${clients.indexOf(primaryClient)} of ${clients.length} clients)`, + let writableFilePath: string | undefined; + if (filePath) { + writableFilePath = resolveWritableFileWithinRoot( + ROOT_DIR, + filePath, ); - - debug(`data: ${JSON.stringify(operationsEvent)}\n\n`); - - // Notify other clients that operations are being applied (optional) - if (clients.length > 1) { - const notificationEvent = { - type: "operationsBeingApplied", - timestamp: Date.now(), - operationCount: message.operations?.length || 0, - source: "agent", - }; - - clients.slice(1).forEach((client, index) => { - try { - client.write( - `data: ${JSON.stringify(notificationEvent)}\n\n`, - ); - debug( - `[SSE] Notified secondary client ${index + 1} of pending operations`, - ); - } catch (error) { - console.error( - `[SSE] Failed to notify secondary client ${index + 1}:`, - error, - ); - } - }); + if (writableFilePath === undefined) { + throw new Error("Access to the file is forbidden"); } - } catch (error) { - console.error( - "[SSE] Failed to send operations to primary client:", - error, - ); - throw error; } - // Send success confirmation back to agent + const content = collaborationManager.applyOperations( + documentId, + message.operations, + ); + if (writableFilePath) { + fs.writeFileSync(writableFilePath, content, "utf-8"); + filePath = writableFilePath; + } + + debug( + `[VIEW] Applied ${message.operations.length} operations to ${documentId}`, + ); + process.send?.({ type: "operationsApplied", success: true, - operationCount: message.operations?.length || 0, - method: "sse-forwarded", + operationCount: message.operations.length, + method: "server-applied", clientsNotified: clients.length, }); - - debug(`[VIEW] Operations forwarded to primary client successfully`); } catch (error) { - console.error( - "[VIEW] Failed to forward operations via SSE:", - error, - ); + console.error("[VIEW] Failed to apply operations:", error); process.send?.({ type: "operationsApplied", success: false, error: error instanceof Error ? error.message : "Unknown error", - method: "sse-forwarded", + method: "server-applied", }); } } else if (message.type === "getDocumentContent") { diff --git a/ts/packages/agents/markdown/test/collaborationManager.spec.ts b/ts/packages/agents/markdown/test/collaborationManager.spec.ts new file mode 100644 index 0000000000..3888db6290 --- /dev/null +++ b/ts/packages/agents/markdown/test/collaborationManager.spec.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CollaborationManager } from "../src/view/route/collaborationManager.js"; + +describe("markdown document operations", () => { + test("applies generated markdown without a connected view client", () => { + const manager = new CollaborationManager(); + manager.initializeDocument("cli-document", null); + + const content = manager.applyOperations("cli-document", [ + { + type: "insert", + position: 0, + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [ + { + type: "text", + text: "CLI validation", + }, + ], + }, + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Created headlessly.", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]); + + expect(content).toBe("# CLI validation\n\n- Created headlessly.\n\n"); + expect(manager.getDocumentContent("cli-document")).toBe(content); + }); + + test("applies a batch atomically when an operation is invalid", () => { + const manager = new CollaborationManager(); + manager.initializeDocument("atomic-document", null); + manager.setDocumentContent("atomic-document", "original"); + + expect(() => + manager.applyOperations("atomic-document", [ + { + type: "insert", + position: 8, + content: [{ type: "text", text: " updated" }], + }, + { + type: "delete", + from: 5, + to: 3, + }, + ]), + ).toThrow("Invalid document range"); + expect(manager.getDocumentContent("atomic-document")).toBe("original"); + }); +}); diff --git a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts index 468d0eca8c..2f556e6e50 100644 --- a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts +++ b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts @@ -69,6 +69,10 @@ describe("markdown document actions", () => { completion_tokens: 0, total_tokens: 0, }); + expect(result.resultEntity).toEqual({ + name: "notes.md", + type: ["file", "markdown"], + }); } finally { for (const [key, value] of savedModelSettings) { process.env[key] = value; diff --git a/ts/packages/agents/markdown/test/viewService.spec.ts b/ts/packages/agents/markdown/test/viewService.spec.ts new file mode 100644 index 0000000000..e14f32757a --- /dev/null +++ b/ts/packages/agents/markdown/test/viewService.spec.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ChildProcess, fork } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const servicePath = fileURLToPath( + new URL("../view/route/service.js", import.meta.url), +); + +describe("markdown view service", () => { + let viewProcess: ChildProcess | undefined; + let root: string | undefined; + + afterEach(() => { + viewProcess?.kill(); + viewProcess = undefined; + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = undefined; + } + }); + + test("applies and persists operations without an SSE client", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "headless.md"); + fs.writeFileSync(filePath, "", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + filePath: "headless.md", + }); + viewProcess.send({ + type: "applyLLMOperations", + operations: [ + { + type: "insert", + position: 0, + content: [ + { + type: "text", + text: "# Headless\n\nPersisted by the view service.", + }, + ], + }, + ], + }); + + const response = await waitForMessage( + viewProcess, + (message) => message.type === "operationsApplied", + ); + expect(response).toMatchObject({ + success: true, + operationCount: 1, + method: "server-applied", + clientsNotified: 0, + }); + expect(fs.readFileSync(filePath, "utf-8")).toBe( + "# Headless\n\nPersisted by the view service.", + ); + }); + + test("persists browser autosave content", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "browser.md"); + fs.writeFileSync(filePath, "", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + const response = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId: "browser", + content: "# Browser\n\nPersisted by autosave.", + }), + }, + ); + + expect(response.ok).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe( + "# Browser\n\nPersisted by autosave.", + ); + }); +}); + +function waitForMessage( + child: ChildProcess, + predicate: (message: any) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for view service response")); + }, 10_000); + const onMessage = (message: any) => { + if (predicate(message)) { + cleanup(); + resolve(message); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`View service exited with code ${code}`)); + }; + const cleanup = () => { + clearTimeout(timeout); + child.off("message", onMessage); + child.off("exit", onExit); + }; + + child.on("message", onMessage); + child.on("exit", onExit); + }); +} From 2ee0fcd91ff3cb99863640bee97cee9393ba7970 Mon Sep 17 00:00:00 2001 From: George Ng Date: Thu, 27 Aug 2026 18:54:19 -0700 Subject: [PATCH 5/7] fix(markdown): preserve create request content Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02e51644-28c8-4b3f-98da-61c5fe172346 --- .../src/agent/markdownActionHandler.ts | 46 +++++++++++++------ .../src/agent/markdownActionSchema.ts | 2 + .../test/markdownActionHandler.spec.ts | 41 +++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts index 3a58246d88..934630454e 100644 --- a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts +++ b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts @@ -532,10 +532,8 @@ function sendStreamingCompleteToView( } async function getFullMarkdownFilePath(fileName: string, storage: Storage) { - const paths = await storage?.list("", { fullPath: true }); - const candidates = paths?.filter((item) => item.endsWith(fileName!)); - - return candidates ? candidates[0] : undefined; + const paths = await storage.list("", { fullPath: true }); + return paths.find((item) => path.basename(item) === fileName); } async function handleMarkdownAction( @@ -581,23 +579,45 @@ async function handleMarkdownAction( actionContext.sessionContext.agentContext.currentFileName = newFileName; - if (!(await storage.exists(newFileName))) { - await storage.write(newFileName, ""); + const documentExisted = await storage.exists(newFileName); + const initialContent = + action.actionName === "createDocument" + ? (action.parameters.content ?? "") + : ""; + if (!documentExisted) { + await storage.write(newFileName, initialContent); + } else if (initialContent) { + const existingContent = + (await storage.read(newFileName, "utf8")) ?? ""; + if (existingContent) { + throw new Error( + `Document ${newFileName} already contains content`, + ); + } + await storage.write(newFileName, initialContent); } + const fullPath = await getFullMarkdownFilePath( + newFileName, + storage, + ); if (actionContext.sessionContext.agentContext.viewProcess) { - const fullPath = await getFullMarkdownFilePath( - newFileName, - storage, - ); + if (!fullPath) { + throw new Error( + `Unable to resolve the path for ${newFileName}`, + ); + } actionContext.sessionContext.agentContext.viewProcess.send({ type: "setFile", - filePath: path.basename(fullPath!), - folderPath: path.dirname(fullPath!), + filePath: path.basename(fullPath), + folderPath: path.dirname(fullPath), }); } - result = createActionResult("Document opened"); + const actionLabel = documentExisted ? "opened" : "created"; + result = createActionResult( + `Document ${actionLabel} at ${fullPath ?? newFileName}`, + ); result.resultEntity = { name: newFileName, type: ["file", "markdown"], diff --git a/ts/packages/agents/markdown/src/agent/markdownActionSchema.ts b/ts/packages/agents/markdown/src/agent/markdownActionSchema.ts index b36433f5bc..a6d56f1dc7 100644 --- a/ts/packages/agents/markdown/src/agent/markdownActionSchema.ts +++ b/ts/packages/agents/markdown/src/agent/markdownActionSchema.ts @@ -13,6 +13,8 @@ export type CreateDocumentAction = { parameters: { // the name to use for the document name: string; + // markdown content to write into the new document + content?: string; }; }; diff --git a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts index 2f556e6e50..f539fb4a51 100644 --- a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts +++ b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import type { ActionContext, Storage } from "@typeagent/agent-sdk"; +import path from "node:path"; import { configFromEnvRecord, getRuntimeConfig, @@ -27,6 +28,7 @@ describe("markdown document actions", () => { const checkedPaths: string[] = []; const writes: [string, string][] = []; + const fullPath = path.resolve("storage", "notes.md"); const storage = { exists: async (storagePath: string) => { checkedPaths.push(storagePath); @@ -35,6 +37,7 @@ describe("markdown document actions", () => { write: async (storagePath: string, data: string) => { writes.push([storagePath, data]); }, + list: async () => [fullPath], } as unknown as Storage; const context = { sessionContext: { @@ -73,6 +76,9 @@ describe("markdown document actions", () => { name: "notes.md", type: ["file", "markdown"], }); + expect(result.historyText).toBe( + `Document created at ${fullPath}`, + ); } finally { for (const [key, value] of savedModelSettings) { process.env[key] = value; @@ -105,4 +111,39 @@ describe("markdown document actions", () => { } } }); + + test("creates a document with initial markdown content", async () => { + const fullPath = path.resolve("storage", "filled.md"); + const writes: [string, string][] = []; + const storage = { + exists: async () => false, + write: async (storagePath: string, data: string) => { + writes.push([storagePath, data]); + }, + list: async () => [fullPath], + } as unknown as Storage; + const context = { + sessionContext: { + agentContext: { localHostPort: 0 }, + sessionStorage: storage, + }, + } as unknown as ActionContext<{ + currentFileName?: string; + localHostPort: number; + }>; + + await instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { + name: "filled.md", + content: "# Filled\n\nLorem ipsum.", + }, + }, + context, + ); + + expect(writes).toEqual([["filled.md", "# Filled\n\nLorem ipsum."]]); + }); }); From c1dc9cdee24a9ebe0cc1b676692e220682d6b759 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 28 Aug 2026 10:23:06 -0700 Subject: [PATCH 6/7] fix(markdown): persist documents in workspace Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02e51644-28c8-4b3f-98da-61c5fe172346 --- ts/packages/agentRpc/src/client.ts | 8 +- ts/packages/agentRpc/src/server.ts | 1 + ts/packages/agentRpc/src/types.ts | 4 + .../agentRpc/test/actionContext.spec.ts | 84 + ts/packages/agentSdk/src/agentInterface.ts | 7 + ts/packages/agents/markdown/package.json | 1 + .../markdown/src/agent/boundPathAdoption.ts | 103 + .../markdown/src/agent/contentRevision.ts | 14 + .../markdown/src/agent/documentOperations.ts | 346 ++- .../agents/markdown/src/agent/ipcTypes.ts | 115 +- .../src/agent/markdownActionHandler.ts | 1314 ++++++---- .../agents/markdown/src/agent/pathPolicy.ts | 275 ++ .../src/view/route/collaborationManager.ts | 53 +- .../markdown/src/view/route/pathPolicy.ts | 118 - .../agents/markdown/src/view/route/service.ts | 1436 +++++++++-- .../agents/markdown/src/view/route/urlPath.ts | 62 + .../view/site/core/collaboration-manager.ts | 11 +- .../src/view/site/core/document-manager.ts | 781 +++--- .../agents/markdown/src/view/site/index.ts | 42 +- .../markdown/src/view/site/tsconfig.json | 1 + .../agents/markdown/src/view/site/types.ts | 7 + .../src/view/site/ui/toolbar-manager.ts | 16 +- .../agents/markdown/src/view/site/utils.ts | 17 +- .../test/bindingUpdatedFilter.spec.ts | 225 ++ .../markdown/test/boundPathAdoption.spec.ts | 116 + .../markdown/test/browserPersistence.spec.ts | 287 +++ .../test/collaborationManager.spec.ts | 74 - .../markdown/test/documentOperations.spec.ts | 187 ++ .../test/markdownActionHandler.spec.ts | 388 ++- .../test/markdownUpdatePersistence.spec.ts | 134 + .../agents/markdown/test/pathPolicy.spec.ts | 73 +- .../agents/markdown/test/urlPath.spec.ts | 81 + .../agents/markdown/test/viewService.spec.ts | 2253 ++++++++++++++++- ts/packages/cli/src/commands/run/request.ts | 1 + .../dispatcher/src/execute/actionContext.ts | 1 + .../dispatcher/test/actionContext.spec.ts | 17 +- ts/pnpm-lock.yaml | 3 + 37 files changed, 7224 insertions(+), 1432 deletions(-) create mode 100644 ts/packages/agentRpc/test/actionContext.spec.ts create mode 100644 ts/packages/agents/markdown/src/agent/boundPathAdoption.ts create mode 100644 ts/packages/agents/markdown/src/agent/contentRevision.ts create mode 100644 ts/packages/agents/markdown/src/agent/pathPolicy.ts delete mode 100644 ts/packages/agents/markdown/src/view/route/pathPolicy.ts create mode 100644 ts/packages/agents/markdown/src/view/route/urlPath.ts create mode 100644 ts/packages/agents/markdown/test/bindingUpdatedFilter.spec.ts create mode 100644 ts/packages/agents/markdown/test/boundPathAdoption.spec.ts create mode 100644 ts/packages/agents/markdown/test/browserPersistence.spec.ts delete mode 100644 ts/packages/agents/markdown/test/collaborationManager.spec.ts create mode 100644 ts/packages/agents/markdown/test/documentOperations.spec.ts create mode 100644 ts/packages/agents/markdown/test/markdownUpdatePersistence.spec.ts create mode 100644 ts/packages/agents/markdown/test/urlPath.spec.ts diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index e8b6aa2268..c91f6af1b2 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -256,6 +256,7 @@ export async function createAgentRpcClient( actionContextId: actionContextMap.getId(actionContext), activityContext: actionContext.activityContext, isFromReasoningLoop: actionContext.isFromReasoningLoop, + workingDirectory: actionContext.workingDirectory, ...getContextParam(actionContext.sessionContext), }); } finally { @@ -264,15 +265,14 @@ export async function createAgentRpcClient( } async function withActionContextAsync( actionContext: ActionContext, - fn: (contextParams: { - actionContextId: number; - isFromReasoningLoop: boolean; - }) => Promise, + fn: (contextParams: ActionContextParams) => Promise, ) { try { return await fn({ actionContextId: actionContextMap.getId(actionContext), + activityContext: actionContext.activityContext, isFromReasoningLoop: actionContext.isFromReasoningLoop, + workingDirectory: actionContext.workingDirectory, ...getContextParam(actionContext.sessionContext), }); } finally { diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index 24488daa1f..4b5e8319ea 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -812,6 +812,7 @@ export function createAgentRpcServer( streamingContext: undefined, activityContext: param.activityContext, isFromReasoningLoop: param.isFromReasoningLoop ?? false, + workingDirectory: param.workingDirectory, get abortSignal() { return abortController.signal; }, diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index b2615bcc18..a9daeffb80 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -300,6 +300,10 @@ export type ActionContextParams = ContextParams & { actionContextId: number; activityContext: ActivityContext | undefined; isFromReasoningLoop: boolean; + // Absolute filesystem root the host authorized for this action. Serialized + // across the RPC boundary so out-of-process agents see the same value as + // ActionContext.workingDirectory on the dispatcher side. + workingDirectory: string | undefined; }; export type OptionsFunctionCallBack = { diff --git a/ts/packages/agentRpc/test/actionContext.spec.ts b/ts/packages/agentRpc/test/actionContext.spec.ts new file mode 100644 index 0000000000..85c3349728 --- /dev/null +++ b/ts/packages/agentRpc/test/actionContext.spec.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + ActionContext, + AppAgent, + SessionContext, +} from "@typeagent/agent-sdk"; +import { createAgentRpcClient } from "../src/client.js"; +import { + createChannelProviderAdapter, + type ChannelProviderAdapter, +} from "../src/common.js"; +import { createAgentRpcServer } from "../src/server.js"; + +describe("agent action context RPC", () => { + test("propagates workingDirectory to the out-of-process agent", async () => { + let clientProvider: ChannelProviderAdapter; + let serverProvider: ChannelProviderAdapter; + clientProvider = createChannelProviderAdapter( + "test-client", + (message, callback) => { + queueMicrotask(() => serverProvider.notifyMessage(message)); + callback?.(null); + }, + ); + serverProvider = createChannelProviderAdapter( + "test-server", + (message, callback) => { + queueMicrotask(() => clientProvider.notifyMessage(message)); + callback?.(null); + }, + ); + + let receivedWorkingDirectory: string | undefined; + const serverAgent: AppAgent = { + initializeAgentContext: async () => ({}), + executeAction: async (_action, context) => { + receivedWorkingDirectory = context.workingDirectory; + return undefined; + }, + }; + const server = createAgentRpcServer( + "test", + serverAgent, + serverProvider, + ); + const clientAgent = await createAgentRpcClient( + "test", + clientProvider, + server.agentInterface, + ); + + try { + const agentContext = await clientAgent.initializeAgentContext?.(); + const sessionContext = { + agentContext, + sessionContextId: "rpc-working-directory-test", + } as SessionContext; + const actionContext = { + sessionContext, + workingDirectory: "C:\\host-authorized-workspace", + isFromReasoningLoop: false, + } as ActionContext; + + await clientAgent.executeAction?.( + { + schemaName: "test", + actionName: "test", + parameters: {}, + }, + actionContext, + ); + + expect(receivedWorkingDirectory).toBe( + "C:\\host-authorized-workspace", + ); + } finally { + server.closeFn(); + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + } + }); +}); diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index 051a72922d..a605542e37 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -466,6 +466,13 @@ export interface ActionContext { // to execute immediately or redirect back to the reasoning loop. readonly isFromReasoningLoop: boolean; + // Host-authorized filesystem root for this action, propagated from + // ProcessCommandOptions.workingDirectory. Agents that persist to disk + // (e.g. the markdown editor) should resolve document paths under this + // root instead of session storage. Undefined when the host did not + // supply a working directory (the request is not filesystem-scoped). + readonly workingDirectory?: string | undefined; + // queue up toggle transient agent to be executed at the end of the commands queueToggleTransientAgent( agentName: string, diff --git a/ts/packages/agents/markdown/package.json b/ts/packages/agents/markdown/package.json index cf19f1fb47..923dae5357 100644 --- a/ts/packages/agents/markdown/package.json +++ b/ts/packages/agents/markdown/package.json @@ -70,6 +70,7 @@ "yjs": "^13.6.8" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@milkdown/ctx": "^7.3.6", "@types/debug": "^4.1.12", "@types/express": "^4.17.17", diff --git a/ts/packages/agents/markdown/src/agent/boundPathAdoption.ts b/ts/packages/agents/markdown/src/agent/boundPathAdoption.ts new file mode 100644 index 0000000000..fd9fc17089 --- /dev/null +++ b/ts/packages/agents/markdown/src/agent/boundPathAdoption.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pure decision helper for adoptBoundPathFromView. Given a view-reported +// binding (boundFilePath / boundRoot / boundRelativePath), decide whether +// the agent can adopt it as its current document without widening trust. +// +// The gating rule is deliberately conservative: the reported root must +// either already be authorized in this process (via a prior create/open +// under a trusted ActionContext.workingDirectory) OR canonicalize to the +// same directory as the currently-supplied ActionContext.workingDirectory. +// A UI-synthesized ActionContext with no workingDirectory can never widen +// the trust boundary on its own, and no session/conversation storage is +// consulted. +// +// Kept as a pure function (I/O comes in via deps) so it can be unit +// tested without mocking child processes or leaning on process-global +// state. + +import path from "node:path"; + +export type ViewBoundPathReport = { + boundFilePath: string | null | undefined; + boundRoot: string | null | undefined; + boundRelativePath?: string | null | undefined; +}; + +export type BoundPathAdoptionDeps = { + resolveRealDirectory(absolutePath: string): string | undefined; + resolveExistingFileWithinRoot( + root: string, + requestedPath: string, + ): string | undefined; + isAuthorizedRoot(canonicalRoot: string): boolean; + authorizeRoot(canonicalRoot: string): void; +}; + +export type BoundPathAdoption = { + canonicalRoot: string; + // POSIX-style relative path; nested directory segments preserved so + // the caller can reconstruct the same user-relative name the view + // was already using. + relativePath: string; + resolvedAbsolute: string; +}; + +export function evaluateBoundPathAdoption( + report: ViewBoundPathReport, + actionContextWorkingDirectory: string | undefined, + deps: BoundPathAdoptionDeps, +): BoundPathAdoption | undefined { + const boundFilePath = report.boundFilePath ?? undefined; + const boundRoot = report.boundRoot ?? undefined; + const boundRelativePath = report.boundRelativePath ?? undefined; + if ( + !boundFilePath || + !boundRoot || + !path.isAbsolute(boundFilePath) || + !path.isAbsolute(boundRoot) + ) { + return undefined; + } + const canonicalRoot = deps.resolveRealDirectory(boundRoot); + if (canonicalRoot === undefined) { + return undefined; + } + // Authorize the currently-supplied ActionContext.workingDirectory when + // it canonicalizes to the same root the view reports. Only fires when + // the caller actually supplied a workingDirectory, so a + // UI-synthesized ActionContext cannot promote an unapproved root. + if (typeof actionContextWorkingDirectory === "string") { + const acCanonical = deps.resolveRealDirectory( + actionContextWorkingDirectory, + ); + if (acCanonical === canonicalRoot) { + deps.authorizeRoot(canonicalRoot); + } + } + if (!deps.isAuthorizedRoot(canonicalRoot)) { + return undefined; + } + // Prefer the full user-relative path the view sent (which preserves + // nested directories). Fall back to computing it from the absolute + // bound path only if the view did not supply one. + const relativeCandidate = + boundRelativePath ?? path.relative(canonicalRoot, boundFilePath); + const relativePath = relativeCandidate.split(path.sep).join("/"); + if ( + relativePath === "" || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + return undefined; + } + const resolvedAbsolute = deps.resolveExistingFileWithinRoot( + canonicalRoot, + relativePath, + ); + if (resolvedAbsolute === undefined) { + return undefined; + } + return { canonicalRoot, relativePath, resolvedAbsolute }; +} diff --git a/ts/packages/agents/markdown/src/agent/contentRevision.ts b/ts/packages/agents/markdown/src/agent/contentRevision.ts new file mode 100644 index 0000000000..9bf08a9f13 --- /dev/null +++ b/ts/packages/agents/markdown/src/agent/contentRevision.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Compute a stable revision hash for a Markdown document. Both the agent and +// the view service compare revisions to detect that the browser edited the +// document between the agent's read (getDocumentContent) and its apply +// (applyLLMOperations). Hex-encoded SHA-256 is opaque, collision-resistant +// for this use, and produces short strings suitable for logging. + +import { createHash } from "node:crypto"; + +export function computeContentRevision(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} diff --git a/ts/packages/agents/markdown/src/agent/documentOperations.ts b/ts/packages/agents/markdown/src/agent/documentOperations.ts index 4c2bd2701c..ba6e519ca7 100644 --- a/ts/packages/agents/markdown/src/agent/documentOperations.ts +++ b/ts/packages/agents/markdown/src/agent/documentOperations.ts @@ -1,9 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Applies DocumentOperation values against the raw Markdown *string*, using +// character offsets into that string. This module is the authoritative +// applier for both the headless path (no view process) and for the +// server-authoritative apply in the view process. Callers computed the +// offsets against the same raw Markdown they read via getDocumentContent +// and paired the apply with the SHA-256 of that base content, so the +// service rejects the apply when the current Markdown hashes differently. + import type { ContentItem, DocumentOperation, + MarkItem, } from "./markdownOperationSchema.js"; export function applyDocumentOperations( @@ -50,10 +59,16 @@ function applyDocumentOperation( ); return content.slice(0, from) + content.slice(to); } - case "format": - throw new Error( - "Format operations cannot be applied to markdown text", + case "format": { + const [from, to] = clampRange( + operation.from, + operation.to, + content.length, ); + return operation.add + ? addFormatMarks(content, from, to, operation.marks) + : removeFormatMarks(content, from, to, operation.marks); + } } } @@ -123,24 +138,317 @@ function serializeList(item: ContentItem, marker: string): string { } function applyMarks(text: string, item: ContentItem): string { - return (item.marks ?? []).reduce((markedText, mark) => { - switch (mark.type) { - case "strong": - return `**${markedText}**`; - case "em": - return `*${markedText}*`; - case "code": - return `\`${markedText}\``; - case "link": { - const attrs = mark.attrs as { href?: string } | undefined; - return attrs?.href - ? `[${markedText}](${attrs.href})` - : markedText; + return (item.marks ?? []).reduce( + (markedText, mark) => wrapWithMark(markedText, mark), + text, + ); +} + +// Markdown wrapper for a single MarkItem. `symmetric` marks use identical left +// and right delimiters and, for removal, may also accept a set of alternate +// GFM-valid delimiters (e.g. `_em_` and `__strong__`). `code` marks use a +// backtick run chosen at wrap time so a run inside the selected text can +// never terminate the span, plus code-span padding when the content begins +// or ends with a backtick or spaces. `link` marks emit `[text](href)`; when +// the LLM did not supply `attrs.href` the mark is dropped so we never emit +// a link with an empty target. +type SymmetricMarkWrapper = { + kind: "symmetric"; + delimiter: string; + alternates?: readonly string[]; +}; +type MarkWrapper = + | SymmetricMarkWrapper + | { kind: "code" } + | { kind: "link"; href: string }; + +function markWrapper(mark: MarkItem): MarkWrapper | undefined { + switch (mark.type) { + case "strong": + return { + kind: "symmetric", + delimiter: "**", + alternates: ["__"], + }; + case "em": + return { + kind: "symmetric", + delimiter: "*", + alternates: ["_"], + }; + case "code": + return { kind: "code" }; + case "link": { + const attrs = mark.attrs as { href?: string } | undefined; + if (!attrs?.href) { + return undefined; + } + return { kind: "link", href: attrs.href }; + } + default: + return undefined; + } +} + +// Pick the shortest backtick run strictly longer than any run already in +// `text`. That is the canonical CommonMark rule: no interior run can close +// the span, so a selection containing "`" gets wrapped in "``", "``" in +// "```", and so on. +function codeSpanDelimiter(text: string): string { + const runs = text.match(/`+/g); + let longest = 0; + if (runs) { + for (const run of runs) { + if (run.length > longest) { + longest = run.length; } - default: - return markedText; } - }, text); + } + return "`".repeat(longest + 1); +} + +// CommonMark code-span padding: add a single space on each side when the +// content begins or ends with a backtick, so the delimiter run and the +// interior can be told apart by a reader. Also pad when the content is +// entirely spaces so the span is not read as empty. We deliberately do +// NOT pad on plain leading/trailing spaces alone, because those are +// semantic content the user selected. +function shouldPadCodeSpan(text: string): boolean { + if (text.length === 0) { + return false; + } + if (text.startsWith("`") || text.endsWith("`")) { + return true; + } + if (/^ +$/.test(text)) { + return true; + } + return false; +} + +function wrapCodeSpan(text: string): string { + const delimiter = codeSpanDelimiter(text); + const padded = shouldPadCodeSpan(text) ? ` ${text} ` : text; + return `${delimiter}${padded}${delimiter}`; +} + +function wrapWithMark(text: string, mark: MarkItem): string { + const wrapper = markWrapper(mark); + if (wrapper === undefined) { + return text; + } + switch (wrapper.kind) { + case "symmetric": + return `${wrapper.delimiter}${text}${wrapper.delimiter}`; + case "code": + return wrapCodeSpan(text); + case "link": + return `[${text}](${wrapper.href})`; + } +} + +// Add the requested marks around content[from..to]. Marks are applied +// innermost-first to match applyMarks so `[strong, em]` produces +// `*text*`. An empty range or an empty marks list is a +// no-op so callers don't have to guard. +function addFormatMarks( + content: string, + from: number, + to: number, + marks: MarkItem[], +): string { + if (marks.length === 0 || from === to) { + return content; + } + const wrapped = marks.reduce( + (text, mark) => wrapWithMark(text, mark), + content.slice(from, to), + ); + return content.slice(0, from) + wrapped + content.slice(to); +} + +// Remove the requested marks by peeling matching Markdown delimiters that +// immediately surround content[from..to]. Marks are processed +// innermost-first so `[strong, em]` correctly peels `*` then `**` off +// `*text*`. A mark whose delimiter is not present at the +// current boundary is silently skipped, so remove is idempotent when the +// user asked to strip formatting that was never applied. +type Boundaries = { leftPos: number; rightPos: number }; + +function removeFormatMarks( + content: string, + from: number, + to: number, + marks: MarkItem[], +): string { + if (marks.length === 0 || from === to) { + return content; + } + let leftPos = from; + let rightPos = to; + for (const mark of marks) { + const wrapper = markWrapper(mark); + if (wrapper === undefined) { + continue; + } + const peeled = peelMarkWrapper(content, leftPos, rightPos, wrapper); + if (peeled !== undefined) { + leftPos = peeled.leftPos; + rightPos = peeled.rightPos; + } + } + return ( + content.slice(0, leftPos) + + content.slice(from, to) + + content.slice(rightPos) + ); +} + +function peelMarkWrapper( + content: string, + leftPos: number, + rightPos: number, + wrapper: MarkWrapper, +): Boundaries | undefined { + switch (wrapper.kind) { + case "symmetric": + return peelSymmetricDelimiter(content, leftPos, rightPos, wrapper); + case "code": + return peelCodeSpan(content, leftPos, rightPos); + case "link": + return peelLink(content, leftPos, rightPos); + } +} + +// Peel a symmetric delimiter (or one of its alternates) that surrounds +// content[leftPos..rightPos]. Preferring the canonical delimiter keeps +// existing tests deterministic while still accepting the GFM alternates +// (`__` and `_`) the LLM may emit alongside `**` and `*`. +function peelSymmetricDelimiter( + content: string, + leftPos: number, + rightPos: number, + wrapper: SymmetricMarkWrapper, +): Boundaries | undefined { + const candidates = [wrapper.delimiter, ...(wrapper.alternates ?? [])]; + for (const delimiter of candidates) { + if (isSurroundedBy(content, leftPos, rightPos, delimiter)) { + return { + leftPos: leftPos - delimiter.length, + rightPos: rightPos + delimiter.length, + }; + } + } + return undefined; +} + +function isSurroundedBy( + content: string, + leftPos: number, + rightPos: number, + delimiter: string, +): boolean { + return ( + leftPos >= delimiter.length && + content.slice(leftPos - delimiter.length, leftPos) === delimiter && + rightPos + delimiter.length <= content.length && + content.slice(rightPos, rightPos + delimiter.length) === delimiter + ); +} + +// Peel the outer code-span delimiters and the optional CommonMark +// single-space padding. The delimiter length is discovered from the +// actual backtick run rather than hard-coded, so any pair emitted by +// wrapCodeSpan (`` ` ``, `` `` ``, `` ``` ``, ...) can be undone. +// Padding must be symmetric or absent: wrapCodeSpan only emits both +// spaces together, so an unbalanced pattern is not something we wrote +// and we leave it alone. +function peelCodeSpan( + content: string, + leftPos: number, + rightPos: number, +): Boundaries | undefined { + const left = stripCodeSpanPad(content, leftPos, -1); + const right = stripCodeSpanPad(content, rightPos, 1); + if (left.padded !== right.padded) { + return undefined; + } + const runLength = countRun(content, left.pos, "`", -1); + if (runLength === 0) { + return undefined; + } + if (countRun(content, right.pos, "`", 1) !== runLength) { + return undefined; + } + return { + leftPos: left.pos - runLength, + rightPos: right.pos + runLength, + }; +} + +// Consume a single optional space adjacent to `pos`. `direction` is -1 for +// the left boundary (checking content[pos - 1]) and 1 for the right +// boundary (checking content[pos]). +function stripCodeSpanPad( + content: string, + pos: number, + direction: -1 | 1, +): { pos: number; padded: boolean } { + if (direction === -1) { + if (pos >= 1 && content[pos - 1] === " ") { + return { pos: pos - 1, padded: true }; + } + } else { + if (pos < content.length && content[pos] === " ") { + return { pos: pos + 1, padded: true }; + } + } + return { pos, padded: false }; +} + +// Count consecutive occurrences of `char` starting from `pos`, walking +// left (direction -1) or right (direction 1). Used to discover the actual +// backtick-run length on each side of a code span. +function countRun( + content: string, + pos: number, + char: string, + direction: -1 | 1, +): number { + let count = 0; + if (direction === -1) { + while (pos - (count + 1) >= 0 && content[pos - (count + 1)] === char) { + count += 1; + } + } else { + while (pos + count < content.length && content[pos + count] === char) { + count += 1; + } + } + return count; +} + +// Peel a link wrapper `[text](href)`. Expect `[` immediately before +// leftPos and `](...)` starting at rightPos. Only peel when the full +// pattern is present; malformed links are left alone. +function peelLink( + content: string, + leftPos: number, + rightPos: number, +): Boundaries | undefined { + if ( + leftPos < 1 || + content[leftPos - 1] !== "[" || + content[rightPos] !== "]" || + content[rightPos + 1] !== "(" + ) { + return undefined; + } + const closeParen = content.indexOf(")", rightPos + 2); + if (closeParen === -1) { + return undefined; + } + return { leftPos: leftPos - 1, rightPos: closeParen + 1 }; } function clampPosition(position: number, contentLength: number): number { diff --git a/ts/packages/agents/markdown/src/agent/ipcTypes.ts b/ts/packages/agents/markdown/src/agent/ipcTypes.ts index 34f538ce8b..f5aabd7261 100644 --- a/ts/packages/agents/markdown/src/agent/ipcTypes.ts +++ b/ts/packages/agents/markdown/src/agent/ipcTypes.ts @@ -3,7 +3,34 @@ // IPC Message Types for TypeAgent Communication -// Agent ← View: UI command requests +// Agent to View: rebind the view service to a specific workspace file. The +// message carries the canonical workspace root (as authorized by the host) +// plus a normalized POSIX-style relative path under that root. The service +// re-validates both under pathPolicy, so a compromised agent process cannot +// coerce the view to write outside a host-authorized workspace. `filePath` +// is omitted (or "") to switch to memory-only mode. +export interface SetFileMessage { + type: "setFile"; + // Absolute canonical workspace root. Optional so the caller can leave + // the root unchanged when only the bound file is switching within it. + workspaceRoot?: string; + // Normalized POSIX-style relative path under `workspaceRoot`. When + // omitted (or empty) the view unbinds and goes memory-only. + relativePath?: string; +} + +// View to Agent: emitted from the view service after every successful setFile +// or /api/switch-document binding rotation. Carries the freshly-rotated +// binding token so the agent can attach it to subsequent read/apply IPC. +export interface BindingUpdatedMessage { + type: "bindingUpdated"; + bindingToken: string | null; + boundFilePath: string | null; + boundRoot: string | null; + boundRelativePath: string | null; +} + +// Agent to View: UI command requests export interface UICommandMessage { type: "uiCommand"; requestId: string; @@ -18,7 +45,7 @@ export interface UICommandMessage { timestamp: number; } -// Agent → View: UI command results +// Agent to View: UI command results export interface UICommandResultMessage { type: "uiCommandResult"; requestId: string; @@ -33,40 +60,80 @@ export interface UICommandResult { error?: string; } -// Agent → View: Content requests +// Agent to View: content requests. `requestId` correlates the response. +// `expectedBindingToken` lets the agent detect races where the view was +// rebound (e.g. the browser switched files) since the token was observed. +// `expectedRoot`/`expectedRelativePath` add a second identity check the +// view enforces alongside the token, so a request sent during the brief +// window when no token has been observed yet (right after setFile before +// bindingUpdated returns) still cannot land on an arbitrary browser- +// selected binding. Recovery requests may omit both expectations. export interface GetDocumentContentMessage { type: "getDocumentContent"; + requestId: string; + expectedBindingToken?: string; + expectedRoot?: string; + expectedRelativePath?: string; } +// View to Agent: content responses. `requestId` is echoed for correlation. +// `bindingToken` carries the current binding so recovery can adopt it +// (see BindingUpdatedMessage). `revision` is the SHA-256 of `content` and +// forms the base version for subsequent applyLLMOperations calls. export interface DocumentContentMessage { type: "documentContent"; + requestId: string; content: string; - source?: "client-serializer" | "yjs-fallback" | "error"; + source?: "client-serializer" | "yjs-fallback" | "file-fallback" | "error"; error?: string; timestamp: number; + bindingToken: string | null; + boundFilePath: string | null; + boundRoot: string | null; + boundRelativePath: string | null; + revision: string | null; + identityMismatch?: boolean; } -// Agent → View: LLM operations +// Agent to View: LLM operations. `requestId` correlates the response. +// `expectedBindingToken` protects against races where the view rebound to a +// different file between the agent's content read and its apply. +// `expectedRoot`/`expectedRelativePath` add a second identity check the +// view enforces alongside the token, so a request sent during the brief +// window when no token has been observed yet still cannot land on an +// arbitrary browser-selected binding. `expectedRevision` is the SHA-256 +// the agent observed for the base content it fed to the LLM; the view +// rejects the apply when the current markdown (via the browser when +// present, or the Yjs mirror otherwise) hashes to a different value. export interface LLMOperationsMessage { type: "applyLLMOperations"; + requestId: string; operations: any[]; // DocumentOperation[] timestamp: number; + expectedBindingToken?: string; + expectedRoot?: string; + expectedRelativePath?: string; + expectedRevision?: string; } export interface OperationsAppliedMessage { type: "operationsApplied"; + requestId: string; success: boolean; operationCount?: number; error?: string; + identityMismatch?: boolean; + revisionMismatch?: boolean; + bindingToken?: string | null; + revision?: string | null; } -// View → Frontend: Auto-save notifications +// View to Frontend: notifications and status export interface AutoSaveMessage { type: "autoSave"; timestamp: number; } -// View → Frontend: Notifications and status export interface NotificationEvent { type: "notification"; message: string; @@ -78,14 +145,44 @@ export interface OperationsAppliedEvent { operationCount: number; } -// Client ← View: Markdown content requests +// View to Frontend: post-commit full-Markdown snapshot. Sent after the +// server-authoritative apply succeeds so browsers can adopt the new +// content without applying raw offsets against their ProseMirror-backed +// document. Tied to the binding token: a browser that has since rebound +// (or a browser bound to a different document) discards the snapshot. +export interface DocumentSnapshotEvent { + type: "documentSnapshot"; + bindingToken: string; + markdown: string; + revision: string; + timestamp: number; +} + +// View to Frontend: emitted once to every newly-connected SSE client so +// browsers that missed the last documentChanged (e.g. connected after +// the setFile from parent IPC) still learn the currently-active binding +// token. Without this bootstrap a browser that never observed a +// documentChanged would have no token to compare a documentSnapshot +// against; the browser fails closed and discards snapshots until a +// trusted token is learned. +export interface BindingBootstrapEvent { + type: "bindingBootstrap"; + bindingToken: string | null; + documentId: string | null; + documentName: string | null; + boundRelativePath: string | null; + revision: string | null; + timestamp: number; +} + +// Client to View: Markdown content requests export interface RequestMarkdownMessage { type: "requestMarkdown"; requestId: string; timestamp: number; } -// View ← Client: Markdown content responses +// View to Client: Markdown content responses export interface MarkdownResponseMessage { type: "markdownResponse"; requestId: string; diff --git a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts index 934630454e..dd2969689c 100644 --- a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts +++ b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts @@ -7,18 +7,30 @@ import { AppAgent, SessionContext, ActionResult, - Storage, AppAgentInitSettings, } from "@typeagent/agent-sdk"; -import { createActionResult } from "@typeagent/agent-sdk/helpers/action"; +import { + createActionResult, + createActionResultFromMarkdownDisplay, +} from "@typeagent/agent-sdk/helpers/action"; import { MarkdownAction } from "./markdownActionSchema.js"; import { DocumentOperation } from "./markdownOperationSchema.js"; import { createMarkdownAgent } from "./translator.js"; import { ChildProcess, fork } from "child_process"; import { fileURLToPath } from "node:url"; +import fs from "node:fs"; import path from "node:path"; import { UICommandResult } from "./ipcTypes.js"; import { applyDocumentOperations } from "./documentOperations.js"; +import { computeContentRevision } from "./contentRevision.js"; +import { + isCanonicalDirectory, + normalizeRelativeDocumentPath, + resolveExistingFileWithinRoot, + resolveRealDirectory, + resolveWritableFileWithinRoot, +} from "./pathPolicy.js"; +import { evaluateBoundPathAdoption } from "./boundPathAdoption.js"; import registerDebug from "debug"; const debug = registerDebug("typeagent:markdown:agent"); @@ -45,7 +57,18 @@ async function executeMarkdownAction( } type MarkdownActionContext = { + // Relative name of the active document, used for activity state. currentFileName?: string | undefined; + // Absolute path resolved beneath the host-authorized working directory. + currentFilePath?: string | undefined; + // Canonical workspace root used to revalidate direct file access. + currentWorkspaceRoot?: string | undefined; + // Opaque token the view service rotates on every trusted rebinding + // (setFile from this agent, or /api/switch-document from the browser). + // Attached to every read/apply IPC so a rebound view rejects requests + // pinned to an older binding, including rebinding to the same relative + // path. + currentBindingToken?: string | undefined; viewProcess?: ChildProcess | undefined; localHostPort: number; // Handle returned by sessionContext.registerPort for the markdown @@ -54,6 +77,23 @@ type MarkdownActionContext = { viewPortRegistration?: { release: () => void } | undefined; }; +// In-memory set of canonical workspace roots the host authorized in this +// process (via ActionContext.workingDirectory on create/openDocument). +// Recovery via adoptBoundPathFromView requires the view-reported root to +// exist in this set (or to canonicalize to the same value as the current +// ActionContext.workingDirectory). No persistence: session storage is +// intentionally not consulted, and a UI-synthesized ActionContext without +// a workingDirectory can never widen the trust boundary on its own. +const authorizedWorkspaceRoots = new Set(); + +function authorizeWorkspaceRoot(canonicalRoot: string): void { + authorizedWorkspaceRoots.add(canonicalRoot); +} + +function isAuthorizedWorkspaceRoot(canonicalRoot: string): boolean { + return authorizedWorkspaceRoots.has(canonicalRoot); +} + async function handleUICommand( command: string, parameters: any, @@ -283,78 +323,75 @@ async function updateMarkdownContext( context: SessionContext, ): Promise { if (enable) { - // Store agent context for UI command processing + // Store agent context for UI command processing. Markdown documents + // are persisted under the host-authorized workingDirectory that + // create/openDocument validates, not session storage; this call + // deliberately does not seed a placeholder file so the view is + // never rooted in the conversation sandbox. setCurrentAgentContext(context.agentContext); - if (!context.agentContext.currentFileName) { - context.agentContext.currentFileName = "live.md"; - } - - const storage = context.sessionStorage; - const fileName = context.agentContext.currentFileName; - - if (!(await storage?.exists(fileName))) { - await storage?.write(fileName, ""); - } - debug( - `Agent context updated for: ${fileName}, port: ${context.agentContext.localHostPort}`, + `Agent context enabled, port: ${context.agentContext.localHostPort}`, ); if (!context.agentContext.viewProcess) { - const fullPath = await getFullMarkdownFilePath(fileName, storage!); - if (fullPath) { - process.env.MARKDOWN_FILE = fullPath; - // Fork the express view service in the background instead of - // blocking agent enable (and therefore agent-server startup) - // on it. The view is only needed once the user actually opens - // the markdown view; every action handler guards on - // `viewProcess` presence, so early actions simply skip the - // view until it's ready. This keeps a slow/cold view-service - // fork (up to the 10s timeout) off the launch critical path. - void createViewServiceHost( - fullPath, - context.agentContext.localHostPort, - ) - .then((result) => { - if (!result) { + // Fork the express view service in the background instead of + // blocking agent enable (and therefore agent-server startup) + // on it. The view starts in memory-only mode; create/openDocument + // reroots it via setFile once the user picks a workspace file. + // Every action handler guards on `viewProcess` presence, so + // early actions simply skip the view until it's ready. + void createViewServiceHost(context.agentContext.localHostPort) + .then((result) => { + if (!result) { + return; + } + const viewProcess = result.process; + context.agentContext.viewProcess = viewProcess; + context.agentContext.localHostPort = result.port; + context.agentContext.viewPortRegistration?.release(); + context.agentContext.viewPortRegistration = + context.registerPort("view", result.port); + // If create/openDocument already ran while the fork was + // in flight, replay the setFile so the freshly-forked + // view binds to the file the agent believes is current. + reconcileViewBinding(context.agentContext, viewProcess); + // Watch for binding rotations that originate in the view + // (browser-driven /api/switch-document, or the ack of + // this agent's setFile). This lets a later apply/read + // carry the freshest token as expectedBindingToken so + // the view can reject stale ones. + viewProcess.on("message", (message: any) => { + applyBindingUpdateFromView( + context.agentContext, + message, + ); + }); + // Defensive cleanup if the child crashes mid-session. + // The identity guard prevents a late-firing `exit` + // event on a previously-replaced process from + // clobbering a newer registration; the explicit + // disable path (which also releases) is naturally + // idempotent under `?.release()`. + viewProcess.once("exit", () => { + if (context.agentContext.viewProcess !== viewProcess) { return; } - const viewProcess = result.process; - context.agentContext.viewProcess = viewProcess; - context.agentContext.localHostPort = result.port; context.agentContext.viewPortRegistration?.release(); - context.agentContext.viewPortRegistration = - context.registerPort("view", result.port); - // Defensive cleanup if the child crashes mid-session. - // The identity guard prevents a late-firing `exit` - // event on a previously-replaced process from - // clobbering a newer registration; the explicit - // disable path (which also releases) is naturally - // idempotent under `?.release()`. - viewProcess.once("exit", () => { - if ( - context.agentContext.viewProcess !== viewProcess - ) { - return; - } - context.agentContext.viewPortRegistration?.release(); - context.agentContext.viewPortRegistration = - undefined; - context.agentContext.viewProcess = undefined; - }); - // Re-wire the UI-command message handler now that the - // view process exists (the earlier call below ran - // before it was forked). - setCurrentAgentContext(context.agentContext); - }) - .catch((e) => { - console.warn( - "[AGENT] Markdown view service background start failed:", - e?.message ?? e, - ); + context.agentContext.viewPortRegistration = undefined; + context.agentContext.viewProcess = undefined; }); - } + // Re-wire the UI-command message handler now that the + // view process exists (the earlier call below ran + // before it was forked). + setCurrentAgentContext(context.agentContext); + }) + .catch((e) => { + console.warn( + "[AGENT] Markdown view service background start failed:", + e?.message ?? e, + ); + }); } setCurrentAgentContext(context.agentContext); @@ -368,6 +405,149 @@ async function updateMarkdownContext( } } +// Re-emit setFile to the freshly-forked view when create/openDocument +// finished ahead of the fork. Silently skipped when no file is bound yet. +export function reconcileViewBinding( + agentContext: MarkdownActionContext, + viewProcess: ChildProcess, +): void { + const relativePath = agentContext.currentFileName; + const workspaceRoot = agentContext.currentWorkspaceRoot; + if (!relativePath || !workspaceRoot) { + return; + } + viewProcess.send({ + type: "setFile", + workspaceRoot, + relativePath, + }); + debug( + `[AGENT] Reconciled view binding after fork: ${relativePath} under ${workspaceRoot}`, + ); +} + +// Decision returned by shouldAdoptBindingUpdate. The rejection kinds are +// distinct so tests can assert on the reason without pattern-matching a +// human-readable message. +export type BindingUpdateDecision = + | { kind: "ignore-non-binding" } + | { kind: "ignore-missing-fields" } + | { kind: "reject-path-mismatch" } + | { kind: "reject-file-mismatch" } + | { kind: "clear" } + | { kind: "adopt"; bindingToken: string }; + +// Pure decision function for bindingUpdated messages. A bindingUpdated is +// only trustworthy when the reported bound root and relative path match +// what the agent already believes is current (canonical POSIX form). A +// browser-driven /api/switch-document that rotates the view onto a +// different document must NOT overwrite the agent's currentBindingToken, +// because the agent's currentFileName/currentWorkspaceRoot are still the +// old ones - adopting the new token here would let a subsequent +// applyLLMOperations sail through the identity check and write into the +// browser-selected file. Leaving the old token in place makes that apply +// fail identity closed and forces a fresh read/adopt cycle before any +// write. A rebinding to the same relative path (typical for our own +// setFile ack) still adopts, since the file identity is unchanged. +export function shouldAdoptBindingUpdate( + agentContext: Pick< + MarkdownActionContext, + "currentFileName" | "currentWorkspaceRoot" | "currentFilePath" + >, + message: any, +): BindingUpdateDecision { + if (!message || message.type !== "bindingUpdated") { + return { kind: "ignore-non-binding" }; + } + if ( + typeof message.boundRoot !== "string" || + typeof message.boundRelativePath !== "string" || + typeof message.boundFilePath !== "string" + ) { + // View reported that no file is bound (memory-only mode). Clear + // the agent's cached token so a subsequent read carries no stale + // token; leaving a stale token could match a later same-token + // rebinding to a different file. + if ( + message.bindingToken === null && + message.boundFilePath === null && + message.boundRoot === null && + message.boundRelativePath === null + ) { + return { kind: "clear" }; + } + return { kind: "ignore-missing-fields" }; + } + if (typeof message.bindingToken !== "string") { + return { kind: "ignore-missing-fields" }; + } + const expectedRoot = agentContext.currentWorkspaceRoot; + const expectedRelative = agentContext.currentFileName; + const expectedFilePath = agentContext.currentFilePath; + if (!expectedRoot || !expectedRelative) { + // Agent has no active document yet (e.g. bindingUpdated arrives + // ahead of create/openDocument). Do not adopt: the agent has + // nothing to pair the token with, and the browser-selected + // binding must not silently become the agent's active document. + return { kind: "reject-path-mismatch" }; + } + if ( + message.boundRoot !== expectedRoot || + message.boundRelativePath !== expectedRelative + ) { + return { kind: "reject-path-mismatch" }; + } + if (expectedFilePath && message.boundFilePath !== expectedFilePath) { + return { kind: "reject-file-mismatch" }; + } + return { kind: "adopt", bindingToken: message.bindingToken }; +} + +// Applies a bindingUpdated to the agent context using the pure decision +// function above. Returns the decision so callers/tests can observe it. +export function applyBindingUpdateFromView( + agentContext: MarkdownActionContext, + message: any, +): BindingUpdateDecision { + const decision = shouldAdoptBindingUpdate(agentContext, message); + switch (decision.kind) { + case "adopt": + agentContext.currentBindingToken = decision.bindingToken; + debug( + `[AGENT] Adopted view-reported bindingToken ${decision.bindingToken}`, + ); + break; + case "clear": + agentContext.currentBindingToken = undefined; + debug( + "[AGENT] Cleared bindingToken after view reported memory-only mode", + ); + break; + case "reject-path-mismatch": + debug( + `[AGENT] Ignoring bindingUpdated: view reports ${ + typeof message?.boundRelativePath === "string" + ? message.boundRelativePath + : "" + } under ${ + typeof message?.boundRoot === "string" + ? message.boundRoot + : "" + } but agent expects ${agentContext.currentFileName ?? ""} under ${agentContext.currentWorkspaceRoot ?? ""}`, + ); + break; + case "reject-file-mismatch": + debug( + `[AGENT] Ignoring bindingUpdated: absolute boundFilePath differs from agent's currentFilePath`, + ); + break; + case "ignore-missing-fields": + case "ignore-non-binding": + break; + } + return decision; +} + /** * Handle streaming markdown actions that send content chunks to view process */ @@ -381,37 +561,12 @@ async function handleStreamingMarkdownAction( ); const agent = await createMarkdownAgent("GPT_4_O"); - const storage = actionContext.sessionContext.sessionStorage; - if (!storage) { - throw new Error("Markdown actions require session storage"); - } - // Get current document content - const filePath = `${actionContext.sessionContext.agentContext.currentFileName}`; - let markdownContent = ""; - - if (actionContext.sessionContext.agentContext.viewProcess) { - try { - markdownContent = await getDocumentContentFromView( - actionContext.sessionContext.agentContext.viewProcess, - ); - debug( - `Got content from view process for streaming: ${markdownContent?.length || 0} chars`, - ); - } catch (error) { - console.warn( - "[STREAMING] Failed to get content from view, falling back to storage:", - error, - ); - if (await storage?.exists(filePath)) { - markdownContent = (await storage?.read(filePath, "utf8")) || ""; - } - } - } else { - if (await storage?.exists(filePath)) { - markdownContent = (await storage?.read(filePath, "utf8")) || ""; - } - } + // Read the current document. Prefer the view process (which has the + // authoritative Yjs state) when it exists; otherwise pull directly from + // the on-disk workspace document. Session storage is not a fallback. + const { content: markdownContent } = + await readCurrentDocumentContent(actionContext); try { // Call agent with streaming callback @@ -461,6 +616,21 @@ async function handleStreamingMarkdownAction( actionContext, ); + // Persist directly to the filesystem when the view process is + // not running. Otherwise the view process is responsible for + // applying and autosaving the operations. + if ( + !actionContext.sessionContext.agentContext.viewProcess && + updateResult.operations && + updateResult.operations.length > 0 + ) { + await persistOperationsToFile( + actionContext, + markdownContent, + updateResult.operations as DocumentOperation[], + ); + } + return createActionResult( updateResult.operationSummary || "Streaming content generated successfully", @@ -531,357 +701,475 @@ function sendStreamingCompleteToView( } } -async function getFullMarkdownFilePath(fileName: string, storage: Storage) { - const paths = await storage.list("", { fullPath: true }); - return paths.find((item) => path.basename(item) === fileName); +/** + * Read the current document content the LLM operates on. Prefers the view + * process's live Yjs state; otherwise falls back to the on-disk absolute + * `currentFilePath`. Session storage is intentionally not consulted here: + * documents live under the host-authorized workingDirectory, not the + * session-storage sandbox. + * + * Also carries the expected document identity through the IPC so that if + * the view has been rebound (e.g. the browser switched files) since the + * agent last observed the binding, the read reports the mismatch instead + * of silently returning content from a different file. + */ +async function readCurrentDocumentContent( + actionContext: ActionContext, +): Promise<{ + content: string; + bindingToken: string | undefined; + revision: string; +}> { + const agentContext = actionContext.sessionContext.agentContext; + if (!agentContext.currentFilePath && agentContext.viewProcess) { + // Recovery path: the ActionContext synthesized for editor-originated + // UI commands may lack workingDirectory. Adoption is gated by the + // authorized-roots set so it never widens the trust boundary; when + // no root can be justified the read fails closed below. + await adoptBoundPathFromView(agentContext, actionContext); + } + const { root, relativeName } = getCurrentWorkspaceDocument(agentContext); + const filePath = resolveExistingFileWithinRoot(root, relativeName); + if (filePath === undefined) { + throw new Error( + "The current markdown document is no longer accessible within the authorized workspace", + ); + } + agentContext.currentFilePath = filePath; + const expectedBindingToken = agentContext.currentBindingToken; + if (agentContext.viewProcess) { + // The view owns the authoritative Markdown state while it exists. + // Any failure here (timeout, error) leaves us with no trustworthy + // snapshot of what the browser is showing; silently falling back + // to the on-disk file would let us read stale content while the + // browser is showing something newer. Propagate the error and + // let the caller decide (typically: fail the action). + const response = await getDocumentContentFromView( + agentContext.viewProcess, + { + expectedBindingToken, + expectedRoot: root, + expectedRelativePath: relativeName, + }, + ); + if (response.identityMismatch) { + throw new Error( + `View is bound to a different document (expected token ${expectedBindingToken ?? ""}, view reports ${response.bindingToken ?? ""})`, + ); + } + if (typeof response.bindingToken === "string") { + agentContext.currentBindingToken = response.bindingToken; + } + const revision = + typeof response.revision === "string" + ? response.revision + : computeContentRevision(response.content); + debug( + `Got content from view process: ${response.content.length} chars (revision ${revision})`, + ); + return { + content: response.content, + bindingToken: agentContext.currentBindingToken, + revision, + }; + } + // Headless (no view) path. Read directly from the on-disk workspace + // document. The path was already re-validated against the authorized + // root above. + const content = fs.readFileSync(filePath, "utf-8"); + const revision = computeContentRevision(content); + debug( + `Read document from filesystem: ${content.length} chars (revision ${revision})`, + ); + return { + content, + bindingToken: agentContext.currentBindingToken, + revision, + }; } -async function handleMarkdownAction( - action: MarkdownAction, +// Recover the bound file/root from the view when the agent has none. The +// view responds with paths it already validated against its trusted root, +// but recovery still verifies that the reported root was authorized by +// the host (via a prior ActionContext.workingDirectory on create/open, or +// by the currently-supplied ActionContext) before adopting it. Anything +// else fails closed - the agent never adopts arbitrary child-reported +// authorization, and no session/conversation storage is consulted. +async function adoptBoundPathFromView( + agentContext: MarkdownActionContext, actionContext: ActionContext, -) { - let result: ActionResult | undefined = undefined; +): Promise { + const viewProcess = agentContext.viewProcess; + if (!viewProcess) { + return; + } + let response: ViewDocumentContentResponse; + try { + response = await getDocumentContentFromView(viewProcess); + } catch (error) { + debug( + `[AGENT] Recovery from view failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return; + } + const target = evaluateBoundPathAdoption( + { + boundFilePath: response.boundFilePath, + boundRoot: response.boundRoot, + boundRelativePath: response.boundRelativePath, + }, + actionContext.workingDirectory, + { + resolveRealDirectory, + resolveExistingFileWithinRoot, + isAuthorizedRoot: isAuthorizedWorkspaceRoot, + authorizeRoot: authorizeWorkspaceRoot, + }, + ); + if (target === undefined) { + debug( + `[AGENT] Rejecting recovery: view-reported binding is not authorized in this process`, + ); + return; + } + agentContext.currentWorkspaceRoot = target.canonicalRoot; + agentContext.currentFileName = target.relativePath; + agentContext.currentFilePath = target.resolvedAbsolute; + if (typeof response.bindingToken === "string") { + agentContext.currentBindingToken = response.bindingToken; + } + debug( + `[AGENT] Adopted view-reported bound path: ${target.resolvedAbsolute} (root ${target.canonicalRoot}, relative ${target.relativePath})`, + ); +} - // Accumulates the LLM token usage consumed while handling this action so - // it can be reported back to the dispatcher as "Action Tokens". The agent - // accumulates into this via the model's completion callback. - const tokenUsage = { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }; - const createAgent = async () => { - const agent = await createMarkdownAgent("GPT_4_O"); - agent.tokenUsage = tokenUsage; - return agent; - }; +/** + * Apply LLM operations and persist the result to the absolute on-disk + * document path. Callers must only invoke this when there is no view + * process. Otherwise the view process owns Yjs state and its autosave path + * is authoritative. Throws when no currentFilePath is known, because + * silently dropping the update would leave the user's edit in nowhere. + */ +async function persistOperationsToFile( + actionContext: ActionContext, + currentContent: string, + operations: DocumentOperation[], +): Promise { + const agentContext = actionContext.sessionContext.agentContext; + const { root, relativeName } = getCurrentWorkspaceDocument(agentContext); + const filePath = resolveWritableFileWithinRoot(root, relativeName); + if (filePath === undefined) { + throw new Error( + "The current markdown document is no longer writable within the authorized workspace", + ); + } + const updatedContent = applyDocumentOperations(currentContent, operations); + fs.writeFileSync(filePath, updatedContent, "utf-8"); + agentContext.currentFilePath = filePath; +} - const storage = actionContext.sessionContext.sessionStorage; - if (!storage) { - throw new Error("Markdown actions require session storage"); +function getCurrentWorkspaceDocument(agentContext: MarkdownActionContext): { + root: string; + relativeName: string; +} { + const root = agentContext.currentWorkspaceRoot; + const relativeName = agentContext.currentFileName; + if (!root || !relativeName || !agentContext.currentFilePath) { + throw new Error( + "No markdown document is open. Use createDocument or openDocument first.", + ); + } + if (!isCanonicalDirectory(root)) { + throw new Error( + "The authorized markdown workspace root is no longer accessible", + ); } + return { root, relativeName }; +} - switch (action.actionName) { - case "openDocument": - case "createDocument": { - if (!action.parameters.name) { - result = createActionResult( - "Document could not be created: no name was provided", - ); - } else { - result = createActionResult("Opening document ..."); +/** + * Build the loopback URL clients can click to open the document in the view + * service. `relativeName` is the full normalized POSIX-style relative path + * (including `.md`); the trailing `.md` is dropped and each remaining path + * segment is percent-encoded so nested layouts like `docs/team/roadmap` + * round-trip through the URL and back into the view's document router. + * Returns `undefined` when the view process is not running or no valid + * port has been negotiated yet, so callers can decide whether to include + * a link at all rather than emitting a broken one. + */ +function buildDocumentLoopbackUrl( + agentContext: MarkdownActionContext, + relativeName: string, +): string | undefined { + if (!agentContext.viewProcess) { + return undefined; + } + const port = agentContext.localHostPort; + if (!Number.isInteger(port) || port <= 0) { + return undefined; + } + const withoutExt = relativeName.toLowerCase().endsWith(".md") + ? relativeName.slice(0, -".md".length) + : relativeName; + const encoded = withoutExt + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `http://127.0.0.1:${port}/document/${encoded}`; +} - let newFileName = action.parameters.name.trim(); - if (!newFileName.endsWith(".md")) { - newFileName += ".md"; - } +async function handleCreateOrOpenDocument( + action: MarkdownAction, + actionContext: ActionContext, +): Promise { + const parameters = action.parameters as { + name?: string; + content?: string; + }; + const rawName = parameters.name; + if (!rawName) { + return createActionResult( + "Document could not be created: no name was provided", + ); + } - actionContext.sessionContext.agentContext.currentFileName = - newFileName; - - const documentExisted = await storage.exists(newFileName); - const initialContent = - action.actionName === "createDocument" - ? (action.parameters.content ?? "") - : ""; - if (!documentExisted) { - await storage.write(newFileName, initialContent); - } else if (initialContent) { - const existingContent = - (await storage.read(newFileName, "utf8")) ?? ""; - if (existingContent) { - throw new Error( - `Document ${newFileName} already contains content`, - ); - } - await storage.write(newFileName, initialContent); - } + const relativeCandidate = normalizeRelativeDocumentPath(rawName); + if (relativeCandidate === undefined) { + throw new Error( + `Document name is not a safe relative path: ${JSON.stringify(rawName)}`, + ); + } + const relativeName = relativeCandidate.toLowerCase().endsWith(".md") + ? relativeCandidate + : `${relativeCandidate}.md`; + + const agentContext = actionContext.sessionContext.agentContext; + const workingDirectory = actionContext.workingDirectory; + if (workingDirectory === undefined) { + throw new Error( + "Markdown document actions require a host-authorized working directory", + ); + } + const canonicalRoot = resolveRealDirectory(workingDirectory); + if (canonicalRoot === undefined) { + throw new Error( + `Configured workingDirectory is not a real directory: ${workingDirectory}`, + ); + } + const absoluteFilePath = resolveWritableFileWithinRoot( + canonicalRoot, + relativeName, + { createSubdirs: true }, + ); + if (absoluteFilePath === undefined) { + throw new Error( + `Document name escapes workingDirectory: ${JSON.stringify(rawName)}`, + ); + } - const fullPath = await getFullMarkdownFilePath( - newFileName, - storage, - ); - if (actionContext.sessionContext.agentContext.viewProcess) { - if (!fullPath) { - throw new Error( - `Unable to resolve the path for ${newFileName}`, - ); - } + const initialContent = + action.actionName === "createDocument" + ? (parameters.content ?? "") + : ""; - actionContext.sessionContext.agentContext.viewProcess.send({ - type: "setFile", - filePath: path.basename(fullPath), - folderPath: path.dirname(fullPath), - }); - } - const actionLabel = documentExisted ? "opened" : "created"; - result = createActionResult( - `Document ${actionLabel} at ${fullPath ?? newFileName}`, - ); - result.resultEntity = { - name: newFileName, - type: ["file", "markdown"], - }; - result.activityContext = { - activityName: "editingMarkdown", - description: "Editing a Markdown document", - state: { - fileName: newFileName, - }, - openLocalView: true, - }; - } - break; + const documentExisted = fs.existsSync(absoluteFilePath); + if (!documentExisted) { + fs.writeFileSync(absoluteFilePath, initialContent, { + encoding: "utf-8", + flag: "wx", + }); + } else if (initialContent) { + const existingContent = fs.readFileSync(absoluteFilePath, "utf-8"); + if (existingContent) { + throw new Error( + `Document ${relativeName} already contains content`, + ); } - case "updateDocument": { - const agent = await createAgent(); - debug("Starting updateDocument action in agent process"); - result = createActionResult("Updating document ..."); - - const filePath = `${actionContext.sessionContext.agentContext.currentFileName}`; - - let markdownContent = ""; - - if (actionContext.sessionContext.agentContext.viewProcess) { - try { - markdownContent = await getDocumentContentFromView( - actionContext.sessionContext.agentContext.viewProcess, - ); - debug( - `Got content from view process: ${markdownContent?.length || 0} chars`, - ); - debug( - `Content preview: ${markdownContent?.substring(0, 200)}...`, - ); - } catch (error) { - console.warn( - "Failed to get content from view, using empty content fallback:", - error, - ); - // Use empty content as fallback to allow agent to continue processing - markdownContent = ""; - debug("Using empty content fallback"); - } - } else { - // Fallback if no view process - if (await storage.exists(filePath)) { - markdownContent = - (await storage.read(filePath, "utf8")) || ""; - debug( - "No view process, read content from storage:", - markdownContent?.length, - "chars", - ); - } - } + fs.writeFileSync(absoluteFilePath, initialContent, "utf-8"); + } - // Handle synchronous requests through the agent - const originalRequest = - "originalRequest" in action.parameters - ? action.parameters.originalRequest - : ""; - - const cursorPosition = - "cursorPosition" in action.parameters - ? action.parameters.cursorPosition - : undefined; - - const context = - "context" in action.parameters && action.parameters.context - ? (() => { - try { - return JSON.parse(action.parameters.context); - } catch (error) { - debug( - `[AGENT] Failed to parse context JSON: ${error}, using undefined`, - ); - return undefined; - } - })() - : undefined; + agentContext.currentFileName = relativeName; + agentContext.currentFilePath = absoluteFilePath; + agentContext.currentWorkspaceRoot = canonicalRoot; - debug( - `[AGENT] About to call LLM service with request: "${originalRequest}"`, - ); - debug( - `[AGENT] Document content length: ${markdownContent?.length || 0} chars`, - ); + // Record the canonical workspace root as host-authorized so recovery + // via adoptBoundPathFromView can later adopt the same-root binding + // without persisting arbitrary child-reported values. + authorizeWorkspaceRoot(canonicalRoot); - const response = await agent.updateDocument( - markdownContent, - originalRequest, - cursorPosition, - context, - ); + if (agentContext.viewProcess) { + agentContext.viewProcess.send({ + type: "setFile", + workspaceRoot: canonicalRoot, + relativePath: relativeName, + }); + } - debug(`[AGENT] LLM service returned, success: ${response.success}`); + const actionLabel = documentExisted ? "opened" : "created"; + const loopbackUrl = buildDocumentLoopbackUrl(agentContext, relativeName); - if (response.success) { - const updateResult = response.data; - debug( - `[AGENT] LLM processing successful, operations count: ${updateResult.operations?.length || 0}`, - ); + const displayLines: string[] = []; + displayLines.push(`Document ${actionLabel}: ${relativeName}`); + if (loopbackUrl) { + displayLines.push("", `[Open document](${loopbackUrl})`); + } + displayLines.push(""); + displayLines.push(`Path: \`${absoluteFilePath}\``); - // Apply operations to the document - if ( - updateResult.operations && - updateResult.operations.length > 0 - ) { - // Send operations to view process for application - if (actionContext.sessionContext.agentContext.viewProcess) { - debug( - "Agent sending operations to view process for Yjs application", - ); + const result = createActionResultFromMarkdownDisplay( + displayLines.join("\n"), + `Document ${actionLabel} at ${absoluteFilePath}`, + ); + result.resultEntity = { + name: relativeName, + type: ["file", "markdown"], + }; + result.activityContext = { + activityName: "editingMarkdown", + description: "Editing a Markdown document", + state: { + fileName: relativeName, + }, + openLocalView: true, + }; + return result; +} - const success = await sendOperationsToView( - actionContext.sessionContext.agentContext - .viewProcess, - updateResult.operations, - ); +type DocumentUpdateAction = Extract< + MarkdownAction, + { actionName: "updateDocument" | "streamingUpdateDocument" } +>; - if (!success) { - throw new Error( - "Failed to apply operations in view process", - ); - } +function parseEditorContext(serializedContext: string | undefined): unknown { + if (!serializedContext) { + return undefined; + } + try { + return JSON.parse(serializedContext); + } catch (error) { + debug( + `[AGENT] Failed to parse context JSON: ${ + error instanceof Error ? error.message : String(error) + }, using undefined`, + ); + return undefined; + } +} - debug( - "Operations applied successfully via view process", - ); - } else { - const updatedContent = applyDocumentOperations( - markdownContent, - updateResult.operations, - ); - await storage.write(filePath, updatedContent); - debug("Applied operations directly to session storage"); - } - } else { - debug("[AGENT] No operations returned from LLM"); - } +async function updateCurrentDocument( + action: DocumentUpdateAction, + actionContext: ActionContext, + agent: Awaited>, +): Promise { + const { + content: markdownContent, + bindingToken, + revision, + } = await readCurrentDocumentContent(actionContext); + const response = await agent.updateDocument( + markdownContent, + action.parameters.originalRequest, + action.parameters.cursorPosition, + parseEditorContext(action.parameters.context), + ); - if (updateResult.operationSummary) { - result = createActionResult(updateResult.operationSummary); - } else { - result = createActionResult("Updated document"); - } + if (!response.success) { + const errorMessage = + (response as { message?: string }).message ?? + "Unknown error occurred"; + console.error("Translation failed:", errorMessage); + return createActionResult(`Failed to update document: ${errorMessage}`); + } - debug(`[AGENT] updateDocument case completed successfully`); - } else { - const errorMessage = - (response as any).message || "Unknown error occurred"; - console.error("Translation failed:", errorMessage); - result = createActionResult( - "Failed to update document: " + errorMessage, - ); - } - break; - } - case "streamingUpdateDocument": { - const agent = await createAgent(); - // Handle streaming AI commands - now unified with regular updateDocument flow - debug( - "Starting streamingUpdateDocument action - using standard translator flow", + const updateResult = response.data; + if (updateResult.operations?.length) { + const viewProcess = + actionContext.sessionContext.agentContext.viewProcess; + if (viewProcess) { + // When a live view is authoritative, applyLLMOperations is the + // single source of truth: the service reads the current + // Markdown, revalidates the revision, applies the operations + // over raw Markdown, persists the bound file, updates its Yjs + // mirror, and broadcasts a post-commit snapshot. We never + // silently fall back to a headless filesystem write while the + // view is connected because that path bypasses that pipeline. + const agentContext = actionContext.sessionContext.agentContext; + const applied = await sendOperationsToView( + viewProcess, + updateResult.operations, + { + expectedBindingToken: bindingToken, + expectedRoot: agentContext.currentWorkspaceRoot, + expectedRelativePath: agentContext.currentFileName, + expectedRevision: revision, + }, ); - result = createActionResult("Updating document ..."); - - const filePath = `${actionContext.sessionContext.agentContext.currentFileName}`; - - let markdownContent = ""; - - if (actionContext.sessionContext.agentContext.viewProcess) { - try { - markdownContent = await getDocumentContentFromView( - actionContext.sessionContext.agentContext.viewProcess, + if (!applied.success) { + if (applied.identityMismatch) { + throw new Error( + "Document identity changed while applying operations; refusing to write to the wrong file", ); - debug( - `Got content from view process: ${markdownContent?.length || 0} chars`, - ); - debug( - `Content preview: ${markdownContent?.substring(0, 200)}...`, - ); - } catch (error) { - console.warn( - "Failed to get content from view, using empty content fallback:", - error, - ); - // Use empty content as fallback to allow agent to continue processing - markdownContent = ""; - debug("Using empty content fallback"); } - } else { - // Fallback if no view process - if (await storage.exists(filePath)) { - markdownContent = - (await storage.read(filePath, "utf8")) || ""; - debug( - "No view process, read content from storage:", - markdownContent?.length, - "chars", + if (applied.revisionMismatch) { + throw new Error( + "Document changed between read and apply; refusing to overwrite (revision mismatch)", ); } + throw new Error( + applied.error ?? + "Failed to apply operations in view process", + ); } - - // Handle streaming requests through the standard agent (same as updateDocument) - const response = await agent.updateDocument( + debug("Operations applied successfully via view process"); + } else { + await persistOperationsToFile( + actionContext, markdownContent, - action.parameters.originalRequest, + updateResult.operations, ); + debug("Applied operations directly to filesystem document"); + } + } else { + debug("[AGENT] No operations returned from LLM"); + } - if (response.success) { - const updateResult = response.data; - - // Apply operations to the document - if ( - updateResult.operations && - updateResult.operations.length > 0 - ) { - // Send operations to view process for application - if (actionContext.sessionContext.agentContext.viewProcess) { - debug( - "Agent sending operations to view process for Yjs application", - ); - - const success = await sendOperationsToView( - actionContext.sessionContext.agentContext - .viewProcess, - updateResult.operations, - ); + return createActionResult( + updateResult.operationSummary ?? "Updated document", + ); +} - if (!success) { - throw new Error( - "Failed to apply operations in view process", - ); - } +async function handleMarkdownAction( + action: MarkdownAction, + actionContext: ActionContext, +) { + let result: ActionResult | undefined = undefined; - debug( - "Operations applied successfully via view process", - ); - } else { - const updatedContent = applyDocumentOperations( - markdownContent, - updateResult.operations, - ); - await storage.write(filePath, updatedContent); - debug( - "Applied streaming operations directly to session storage", - ); - } - } + // Accumulates the LLM token usage consumed while handling this action so + // it can be reported back to the dispatcher as "Action Tokens". The agent + // accumulates into this via the model's completion callback. + const tokenUsage = { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }; + const createAgent = async () => { + const agent = await createMarkdownAgent("GPT_4_O"); + agent.tokenUsage = tokenUsage; + return agent; + }; - if (updateResult.operationSummary) { - result = createActionResult(updateResult.operationSummary); - } else { - result = createActionResult("Updated document"); - } - } else { - const errorMessage = - (response as any).message || "Unknown error occurred"; - console.error("Translation failed:", errorMessage); - result = createActionResult( - "Failed to update document: " + errorMessage, - ); - } + switch (action.actionName) { + case "openDocument": + case "createDocument": { + result = await handleCreateOrOpenDocument(action, actionContext); + break; + } + case "updateDocument": + case "streamingUpdateDocument": { + const agent = await createAgent(); + result = await updateCurrentDocument(action, actionContext, agent); break; } } @@ -898,104 +1186,200 @@ async function handleMarkdownAction( /** * Send operations to view process for application (Flow 1 implementation) */ +let applyRequestCounter = 0; + +type ApplyResult = { + success: boolean; + identityMismatch: boolean; + revisionMismatch: boolean; + error?: string; +}; + +type ApplyExpectations = { + expectedBindingToken?: string | undefined; + expectedRoot?: string | undefined; + expectedRelativePath?: string | undefined; + expectedRevision?: string | undefined; +}; + async function sendOperationsToView( viewProcess: ChildProcess | undefined, operations: DocumentOperation[], -): Promise { + expectations: ApplyExpectations, +): Promise { + const { + expectedBindingToken, + expectedRoot, + expectedRelativePath, + expectedRevision, + } = expectations; if (!viewProcess) { - return false; + return { + success: false, + identityMismatch: false, + revisionMismatch: false, + }; } + const requestId = `apply_${++applyRequestCounter}`; return new Promise((resolve) => { const timeout = setTimeout(() => { console.error("[AGENT] View process operation timeout"); - resolve(false); + viewProcess.off("message", responseHandler); + resolve({ + success: false, + identityMismatch: false, + revisionMismatch: false, + }); }, 5000); - // Listen for response + // Only accept the response tagged with our requestId. This keeps + // out-of-order or concurrent operationsApplied messages from + // resolving the wrong promise. const responseHandler = (message: any) => { - if (message.type === "operationsApplied") { - clearTimeout(timeout); - viewProcess.off("message", responseHandler); - - if (message.success) { - resolve(true); - } else { - console.error( - "[AGENT] View failed to apply operations:", - message.error, - ); - resolve(false); - } + if ( + message.type !== "operationsApplied" || + message.requestId !== requestId + ) { + return; + } + clearTimeout(timeout); + viewProcess.off("message", responseHandler); + + if (message.success) { + resolve({ + success: true, + identityMismatch: false, + revisionMismatch: false, + }); + return; } + if (!message.identityMismatch && !message.revisionMismatch) { + console.error( + "[AGENT] View failed to apply operations:", + message.error, + ); + } + resolve({ + success: false, + identityMismatch: message.identityMismatch === true, + revisionMismatch: message.revisionMismatch === true, + error: message.error, + }); }; viewProcess.on("message", responseHandler); - // Send operations viewProcess.send({ type: "applyLLMOperations", - operations: operations, + requestId, + operations, timestamp: Date.now(), + expectedBindingToken, + expectedRoot, + expectedRelativePath, + expectedRevision, }); - debug(`[AGENT] Sent ${operations.length} operations to view process`); + debug( + `[AGENT] Sent ${operations.length} operations to view process (requestId ${requestId}, bindingToken ${expectedBindingToken ?? "-"})`, + ); }); } /** * Get document content from view process (Flow 1 implementation) */ +type ViewDocumentContentResponse = { + content: string; + boundDocumentId?: string; + boundFilePath?: string | null; + boundRoot?: string | null; + boundRelativePath?: string | null; + bindingToken?: string | null; + revision?: string | null; + identityMismatch: boolean; + error?: string; +}; + +let getContentRequestCounter = 0; + +type ReadExpectations = { + expectedBindingToken?: string | undefined; + expectedRoot?: string | undefined; + expectedRelativePath?: string | undefined; +}; + async function getDocumentContentFromView( viewProcess: ChildProcess, -): Promise { + expectations: ReadExpectations = {}, +): Promise { + const { expectedBindingToken, expectedRoot, expectedRelativePath } = + expectations; + const requestId = `get_${++getContentRequestCounter}`; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - debug( - "[AGENT] Content request timeout, trying fallback to empty content", - ); - - // Use empty content as fallback when view process fails - // This allows the agent to continue processing even if content retrieval fails - console.warn( - "[AGENT] View process content request timed out, using empty content fallback", - ); - resolve(""); + viewProcess.off("message", responseHandler); + reject(new Error("View process content request timed out")); }, 15000); // 15 second timeout + // Only accept the documentContent tagged with our requestId. This + // is what lets multiple in-flight reads (or concurrent apply + // acknowledgements) coexist without cross-talk. const responseHandler = (message: any) => { - if (message.type === "documentContent") { - clearTimeout(timeout); - viewProcess.off("message", responseHandler); - - // Log the source of the content for debugging - const source = message.source || "unknown"; - debug( - `[AGENT] Received document content from ${source}: ${message.content?.length || 0} chars`, - ); + if ( + message.type !== "documentContent" || + message.requestId !== requestId + ) { + return; + } + clearTimeout(timeout); + viewProcess.off("message", responseHandler); - if (message.error) { - debug( - `[AGENT] Content retrieval had error: ${message.error}`, - ); - // Still resolve with content even if there was an error - } + const source = message.source || "unknown"; + debug( + `[AGENT] Received document content (requestId ${requestId}) from ${source}: ${message.content?.length || 0} chars`, + ); - resolve(message.content || ""); + if (message.error) { + debug(`[AGENT] Content retrieval had error: ${message.error}`); } + + resolve({ + content: message.content || "", + boundDocumentId: message.boundDocumentId, + boundFilePath: message.boundFilePath ?? null, + boundRoot: message.boundRoot ?? null, + boundRelativePath: message.boundRelativePath ?? null, + bindingToken: message.bindingToken ?? null, + revision: message.revision ?? null, + identityMismatch: message.identityMismatch === true, + error: message.error, + }); }; viewProcess.on("message", responseHandler); - debug("[AGENT] Sending getDocumentContent request to view process"); - viewProcess.send({ type: "getDocumentContent" }); + debug( + `[AGENT] Sending getDocumentContent request to view process (requestId ${requestId}, bindingToken ${expectedBindingToken ?? "-"})`, + ); + viewProcess.send({ + type: "getDocumentContent", + requestId, + expectedBindingToken, + expectedRoot, + expectedRelativePath, + }); }); } // NOTE: Function commented out per Flow 1 consolidation // Collaboration server now managed by view process +// Fork the view service. The service starts in memory-only mode (no file +// bound); the agent later reroots it via setFile once the user runs +// create/openDocument. Passing no TYPEAGENT_MARKDOWN_ROOT lets the service +// use its own default until the trusted parent IPC picks a real workspace. async function createViewServiceHost( - filePath: string, port: number, ): Promise<{ process: ChildProcess; port: number } | undefined> { let timeoutHandle: NodeJS.Timeout; @@ -1009,7 +1393,7 @@ async function createViewServiceHost( const viewServicePromise = new Promise< { process: ChildProcess; port: number } | undefined - >((resolve, reject) => { + >((resolve) => { try { const expressService = fileURLToPath( new URL( @@ -1018,20 +1402,12 @@ async function createViewServiceHost( ), ); - const folderPath = path.dirname(filePath!); - const childProcess = fork(expressService, [port.toString()], { env: { ...process.env, - TYPEAGENT_MARKDOWN_ROOT: folderPath, }, }); - childProcess.send({ - type: "setFile", - filePath: path.basename(filePath), - }); - childProcess.on("message", function (message: any) { if (message?.type === "Success") { resolve({ process: childProcess, port: message.port }); diff --git a/ts/packages/agents/markdown/src/agent/pathPolicy.ts b/ts/packages/agents/markdown/src/agent/pathPolicy.ts new file mode 100644 index 0000000000..470a620d4d --- /dev/null +++ b/ts/packages/agents/markdown/src/agent/pathPolicy.ts @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Path safety helpers for the markdown agent and view service. Both callers +// need the same canonical + symlink-aware rules, so they live under +// `src/agent/` and are imported directly from the view route via the +// composite project reference. Keep this module free of runtime state so it +// can be reused from either process. + +import fs from "node:fs"; +import path from "node:path"; + +interface RootPaths { + resolvedRoot: string; + canonicalRoot: string; +} + +function isFileNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "ENOENT" + ); +} + +function resolveRootPaths(root: string): RootPaths { + const resolvedRoot = path.resolve(root); + return { + resolvedRoot, + canonicalRoot: fs.realpathSync(resolvedRoot), + }; +} + +function resolveCandidateWithinRoot( + root: RootPaths, + requestedPath: string, +): string | undefined { + const candidate = path.resolve(root.resolvedRoot, requestedPath); + if (isPathWithinRoot(root.resolvedRoot, candidate)) { + return path.resolve( + root.canonicalRoot, + path.relative(root.resolvedRoot, candidate), + ); + } + return isPathWithinRoot(root.canonicalRoot, candidate) + ? candidate + : undefined; +} + +function resolveExistingFile( + root: RootPaths, + candidate: string, +): string | undefined { + let canonicalCandidate: string; + try { + canonicalCandidate = fs.realpathSync(candidate); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + return isPathWithinRoot(root.canonicalRoot, canonicalCandidate) && + fs.statSync(canonicalCandidate).isFile() + ? canonicalCandidate + : undefined; +} + +export function isPathWithinRoot(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +export function resolvePathWithinRoot( + root: string, + requestedPath: string, +): string | undefined { + return resolveCandidateWithinRoot(resolveRootPaths(root), requestedPath); +} + +export function resolveExistingFileWithinRoot( + root: string, + requestedPath: string, +): string | undefined { + const rootPaths = resolveRootPaths(root); + const candidate = resolveCandidateWithinRoot(rootPaths, requestedPath); + if (candidate === undefined) { + return undefined; + } + return resolveExistingFile(rootPaths, candidate); +} + +// Ensure every directory segment between the canonical root and the requested +// relative directory exists and stays inside the canonical root. Missing +// segments are created one at a time; each existing segment is re-checked via +// realpath so a symlink that resolves outside the root aborts the walk. Returns +// the canonical absolute path of the deepest directory when the walk succeeds, +// or undefined when any segment escapes the root (or the path is otherwise +// invalid). +export function ensureDirectoryWithinRoot( + root: string, + relativeDir: string, +): string | undefined { + const rootPaths = resolveRootPaths(root); + const candidate = resolveCandidateWithinRoot(rootPaths, relativeDir); + if (candidate === undefined) { + return undefined; + } + const relative = path.relative(rootPaths.canonicalRoot, candidate); + if (relative === "") { + return rootPaths.canonicalRoot; + } + const segments = relative.split(path.sep).filter((s) => s.length > 0); + let currentReal = rootPaths.canonicalRoot; + for (const segment of segments) { + if (segment === "." || segment === "..") { + return undefined; + } + const next = path.join(currentReal, segment); + let stats: fs.Stats | undefined; + try { + stats = fs.lstatSync(next); + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } + if (stats === undefined) { + fs.mkdirSync(next); + } else if (stats.isSymbolicLink()) { + // Never follow a symlink when descending into the workspace tree. + return undefined; + } else if (!stats.isDirectory()) { + return undefined; + } + const canonicalNext = fs.realpathSync(next); + if (!isPathWithinRoot(rootPaths.canonicalRoot, canonicalNext)) { + return undefined; + } + currentReal = canonicalNext; + } + return currentReal; +} + +export function resolveWritableFileWithinRoot( + root: string, + requestedPath: string, + options?: { createSubdirs?: boolean }, +): string | undefined { + const rootPaths = resolveRootPaths(root); + const candidate = resolveCandidateWithinRoot(rootPaths, requestedPath); + if (candidate === undefined) { + return undefined; + } + + let canonicalParent: string; + if (options?.createSubdirs) { + const parentRel = path.relative( + rootPaths.canonicalRoot, + path.dirname(candidate), + ); + const parentReal = ensureDirectoryWithinRoot( + rootPaths.canonicalRoot, + parentRel === "" ? "." : parentRel, + ); + if (parentReal === undefined) { + return undefined; + } + canonicalParent = parentReal; + } else { + try { + canonicalParent = fs.realpathSync(path.dirname(candidate)); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + if (!isPathWithinRoot(rootPaths.canonicalRoot, canonicalParent)) { + return undefined; + } + } + + const writablePath = path.join(canonicalParent, path.basename(candidate)); + try { + fs.lstatSync(writablePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return writablePath; + } + throw error; + } + return resolveExistingFile(rootPaths, writablePath); +} + +// Resolve an absolute directory path supplied through the trusted parent IPC +// channel. Returns the canonical (realpath) of the directory when it exists +// and refers to a real directory (not a file, not a broken symlink). Callers +// still validate at their own trust boundary; this helper enforces "must be +// absolute, must be a directory". +export function resolveRealDirectory(absolutePath: string): string | undefined { + if (typeof absolutePath !== "string" || absolutePath.length === 0) { + return undefined; + } + if (!path.isAbsolute(absolutePath)) { + return undefined; + } + let canonical: string; + try { + canonical = fs.realpathSync(absolutePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + let stats: fs.Stats; + try { + stats = fs.statSync(canonical); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + return stats.isDirectory() ? canonical : undefined; +} + +export function isCanonicalDirectory(absolutePath: string): boolean { + const canonical = resolveRealDirectory(absolutePath); + return ( + canonical !== undefined && + path.relative(path.resolve(absolutePath), canonical) === "" + ); +} + +// Validate a document name supplied by an action parameter or the trusted +// parent IPC channel. Rejects absolute paths, traversal segments, empty +// strings, and non-string inputs. Returns the normalized POSIX-style relative +// path on success, or undefined when the input is invalid. Callers then feed +// this string into resolveWritableFileWithinRoot so the on-disk resolution +// still enforces symlink safety. +export function normalizeRelativeDocumentPath( + name: unknown, +): string | undefined { + if (typeof name !== "string") { + return undefined; + } + const trimmed = name.trim(); + if (trimmed.length === 0) { + return undefined; + } + if (path.isAbsolute(trimmed)) { + return undefined; + } + // Reject Windows drive-qualified segments like "C:foo" that + // path.isAbsolute misses on POSIX but which point to a drive on Windows. + if (/^[a-zA-Z]:/.test(trimmed)) { + return undefined; + } + const normalized = trimmed.replace(/\\/g, "/"); + const segments = normalized.split("/"); + for (const segment of segments) { + if (segment === "" || segment === "." || segment === "..") { + return undefined; + } + } + return segments.join("/"); +} diff --git a/ts/packages/agents/markdown/src/view/route/collaborationManager.ts b/ts/packages/agents/markdown/src/view/route/collaborationManager.ts index e5f7f66f8f..280a42e07f 100644 --- a/ts/packages/agents/markdown/src/view/route/collaborationManager.ts +++ b/ts/packages/agents/markdown/src/view/route/collaborationManager.ts @@ -3,14 +3,20 @@ import * as Y from "yjs"; import registerDebug from "debug"; -import { applyDocumentOperations } from "../../agent/documentOperations.js"; -import type { DocumentOperation } from "../../agent/markdownOperationSchema.js"; const debug = registerDebug("typeagent:markdown:collaboration"); /** - * Server-side collaboration manager for handling Yjs synchronization - * This works alongside the y-websocket-server for custom TypeAgent features + * Server-side collaboration manager for Yjs document lifecycle. + * + * NOTE: We intentionally do NOT expose an `applyOperations` method + * here. The production write path in `service.ts` reads live content + * from the connected browser (which is the actual source of truth for + * unsaved edits), runs `applyDocumentOperations` against that string, + * and then mirrors the result into the appropriate authoritative Yjs + * room. Running a separate `applyOperations` off the Yjs mirror would + * be a second, divergent write path with a different input surface, + * so it stays deleted rather than reintroduced as parallel logic. */ export class CollaborationManager { private documents: Map = new Map(); @@ -46,6 +52,19 @@ export class CollaborationManager { `Using existing Y.js document: ${documentId} ${filePath ? `(${filePath})` : "(memory-only)"}`, ); } + + /** + * Forget everything we know about a document. Callers use this + * from the service level after a binding rotation, when no + * WebSocket clients are attached to the old room, so the old + * Yjs mirror does not linger in memory forever. + */ + forgetDocument(documentId: string): void { + this.documents.delete(documentId); + this.documentPaths.delete(documentId); + debug(`Forgot document: ${documentId}`); + } + getStats(): any { return { documents: this.documents.size, @@ -54,32 +73,6 @@ export class CollaborationManager { }; } - applyOperations( - documentId: string, - operations: DocumentOperation[], - ): string { - const ydoc = this.documents.get(documentId); - if (!ydoc) { - throw new Error(`No document found for ID: ${documentId}`); - } - - const ytext = ydoc.getText("content"); - const updatedContent = applyDocumentOperations( - ytext.toString(), - operations, - ); - - ydoc.transact(() => { - ytext.delete(0, ytext.length); - ytext.insert(0, updatedContent); - }); - - debug( - `Applied ${operations.length} operations to document ${documentId}`, - ); - return updatedContent; - } - /** * Get document content as string (SINGLE SOURCE OF TRUTH) */ diff --git a/ts/packages/agents/markdown/src/view/route/pathPolicy.ts b/ts/packages/agents/markdown/src/view/route/pathPolicy.ts deleted file mode 100644 index dcb0aa7f08..0000000000 --- a/ts/packages/agents/markdown/src/view/route/pathPolicy.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import fs from "node:fs"; -import path from "node:path"; - -interface RootPaths { - resolvedRoot: string; - canonicalRoot: string; -} - -function isFileNotFoundError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - error.code === "ENOENT" - ); -} - -function resolveRootPaths(root: string): RootPaths { - const resolvedRoot = path.resolve(root); - return { - resolvedRoot, - canonicalRoot: fs.realpathSync(resolvedRoot), - }; -} - -function resolveCandidateWithinRoot( - root: RootPaths, - requestedPath: string, -): string | undefined { - const candidate = path.resolve(root.resolvedRoot, requestedPath); - if (isPathWithinRoot(root.resolvedRoot, candidate)) { - return path.resolve( - root.canonicalRoot, - path.relative(root.resolvedRoot, candidate), - ); - } - return isPathWithinRoot(root.canonicalRoot, candidate) - ? candidate - : undefined; -} - -function resolveExistingFile( - root: RootPaths, - candidate: string, -): string | undefined { - let canonicalCandidate: string; - try { - canonicalCandidate = fs.realpathSync(candidate); - } catch (error) { - if (isFileNotFoundError(error)) { - return undefined; - } - throw error; - } - - return isPathWithinRoot(root.canonicalRoot, canonicalCandidate) && - fs.statSync(canonicalCandidate).isFile() - ? canonicalCandidate - : undefined; -} - -export function isPathWithinRoot(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return ( - relative !== ".." && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative) - ); -} - -export function resolvePathWithinRoot( - root: string, - requestedPath: string, -): string | undefined { - return resolveCandidateWithinRoot(resolveRootPaths(root), requestedPath); -} - -export function resolveExistingFileWithinRoot( - root: string, - requestedPath: string, -): string | undefined { - const rootPaths = resolveRootPaths(root); - const candidate = resolveCandidateWithinRoot(rootPaths, requestedPath); - if (candidate === undefined) { - return undefined; - } - return resolveExistingFile(rootPaths, candidate); -} - -export function resolveWritableFileWithinRoot( - root: string, - requestedPath: string, -): string | undefined { - const rootPaths = resolveRootPaths(root); - const candidate = resolveCandidateWithinRoot(rootPaths, requestedPath); - if (candidate === undefined) { - return undefined; - } - - const canonicalParent = fs.realpathSync(path.dirname(candidate)); - if (!isPathWithinRoot(rootPaths.canonicalRoot, canonicalParent)) { - return undefined; - } - - const writablePath = path.join(canonicalParent, path.basename(candidate)); - try { - fs.lstatSync(writablePath); - } catch (error) { - if (isFileNotFoundError(error)) { - return writablePath; - } - throw error; - } - return resolveExistingFile(rootPaths, writablePath); -} diff --git a/ts/packages/agents/markdown/src/view/route/service.ts b/ts/packages/agents/markdown/src/view/route/service.ts index b8b2c2d3ee..3da9489f0c 100644 --- a/ts/packages/agents/markdown/src/view/route/service.ts +++ b/ts/packages/agents/markdown/src/view/route/service.ts @@ -18,14 +18,34 @@ import * as encoding from "lib0/encoding"; import * as decoding from "lib0/decoding"; import registerDebug from "debug"; import sanitizeFilename from "sanitize-filename"; +import { randomUUID } from "node:crypto"; import { isAllowedViewOrigin } from "./originAllowlist.js"; import { + isCanonicalDirectory, + normalizeRelativeDocumentPath, resolveExistingFileWithinRoot, - resolvePathWithinRoot, + resolveRealDirectory, resolveWritableFileWithinRoot, -} from "./pathPolicy.js"; +} from "../../agent/pathPolicy.js"; +import { computeContentRevision } from "../../agent/contentRevision.js"; +import { applyDocumentOperations } from "../../agent/documentOperations.js"; +import type { DocumentOperation } from "../../agent/markdownOperationSchema.js"; const debug = registerDebug("typeagent:markdown:service"); +// Sentinel error thrown when /api/markdown-response echoes a bindingToken +// that does not match the token pinned to the pending request. Callers +// MUST rethrow this (not fall back to Yjs / on-disk file), because a +// mirror-based fallback paired with a rejected browser echo would let +// stale-mirror content resolve a request that the browser explicitly +// answered under a different binding. Ordinary unavailability (no +// clients connected, timeout, transport error) still falls back through +// the normal path. +class ClientBindingMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = "ClientBindingMismatchError"; + } +} const app: Express = express(); const LOOPBACK_HOST = "127.0.0.1"; @@ -67,62 +87,82 @@ app.get("/", (req: Request, res: Response) => { res.sendFile(path.join(staticPath, "index.html")); }); -// Document-specific route -app.get("/document/:documentName", (req: Request, res: Response) => { +// Document-specific route. Uses a wildcard so nested user-relative paths +// like /document/docs/team/roadmap round-trip; the browser's SPA reads +// the path itself and does not depend on Express extracting a name. +app.get(/^\/document\/.+/, (req: Request, res: Response) => { res.sendFile(path.join(staticPath, "index.html")); }); -// API endpoint to get current document name from URL +// API endpoint to get current document name from URL. Reports the same +// relative path (POSIX form) the browser needs to route into nested +// directories; the absolute path is intentionally omitted from the +// public surface - the browser must never trust or emit it, and +// pathPolicy re-validates the relative path server-side on every write. +// +// The bindingToken is intentionally NOT returned here. The browser's +// authoritative source for the current bindingToken is the SSE +// `bindingBootstrap` / `documentChanged` events (and the response to +// `/api/switch-document`); returning it from a broadly-cacheable GET +// invites callers to treat it as an authorization credential, which it +// is not. It is an anti-confusion identity marker whose lifecycle is +// bound to setFile/switch-document rotation events. app.get("/api/current-document", (req: Request, res: Response) => { res.json({ currentDocument: filePath ? path.basename(filePath, ".md") : null, - fullPath: filePath || null, + relativePath: boundRelativePath, + boundRelativePath, }); }); -// API endpoint to switch to a specific document +// API endpoint to switch to a specific document. Accepts a normalized +// safe relative path under the currently-authorized root. Nested paths +// (`docs/team/roadmap`) are preserved through the switch. Input is +// re-validated against pathPolicy so an attacker-controlled tab cannot +// smuggle traversal. Absolute browser paths are rejected. The response +// carries the freshly-rotated bindingToken, the new documentId (Yjs +// room), and the full relativePath so the browser can adopt them +// atomically before switching editor rooms or issuing an autosave. app.post( "/api/switch-document", express.json(), (req: Request, res: Response) => { try { - const { documentName } = req.body; - - if (!documentName || !/^[a-zA-Z0-9_\- ]+$/.test(documentName)) { + // Accept `documentPath` (preferred) or `documentName` (back-compat). + // Both go through normalizeRelativeDocumentPath so a nested + // safe relative path is honored while absolute paths, traversal, + // and Windows-drive segments are rejected. + const rawPath = + typeof req.body?.documentPath === "string" + ? req.body.documentPath + : typeof req.body?.documentName === "string" + ? req.body.documentName + : undefined; + if (typeof rawPath !== "string" || rawPath.length === 0) { res.status(400).json({ - error: "Invalid document name. Only alphanumeric characters and underscores are allowed.", + error: "documentPath (or documentName) is required", }); return; } - debug("Switch document called with parameter ", documentName); - - // Construct file path - const sanitizedDocumentName = sanitizeFilename(documentName); - - if (!sanitizedDocumentName) { - res.status(400).json({ error: "Invalid document name" }); - return; - } - - // Construct and normalize file path - const documentPath = resolvePathWithinRoot( - ROOT_DIR, - `${sanitizedDocumentName}.md`, - ); - - debug("Sanitized document path ", documentPath); - // Verify that the file path is within the safe root directory - if (documentPath === undefined) { - res.status(403).json({ - error: "Access to the specified path is forbidden.", + const normalized = normalizeRelativeDocumentPath(rawPath); + if (normalized === undefined) { + res.status(400).json({ + error: "Invalid document path", }); return; } + const relativeWithExt = normalized.toLowerCase().endsWith(".md") + ? normalized + : `${normalized}.md`; + debug("Switch document called with parameter ", relativeWithExt); + + const root = getValidatedCurrentRoot(); let safeDocumentPath = resolveWritableFileWithinRoot( - ROOT_DIR, - documentPath, + root, + relativeWithExt, + { createSubdirs: true }, ); if (safeDocumentPath === undefined) { res.status(403).json({ @@ -131,41 +171,100 @@ app.post( return; } + // Derive a display name for new-file seeding from the last + // path segment (without .md). We never trust the raw input as + // a filename target - only for the visible heading. + const lastSegment = relativeWithExt + .slice(0, -".md".length) + .split("/") + .pop() as string; + const displayName = sanitizeFilename(lastSegment) || lastSegment; + if (!fs.existsSync(safeDocumentPath)) { - // Create new document if it doesn't exist fs.writeFileSync( safeDocumentPath, - `# ${documentName}\n\nThis is a new document.\n`, + `# ${displayName}\n\nThis is a new document.\n`, { flag: "wx" }, ); safeDocumentPath = fs.realpathSync(safeDocumentPath); } + const oldFilePath = filePath; + // Capture the previous documentId BEFORE we rotate, so + // that we can evict its Yjs mirror / awareness once we + // know no clients still need it. This must happen with + // the old bindingToken still in place - after rotation, + // getCurrentDocumentId() will return the new token. + const previousDocumentId = getCurrentDocumentId(); filePath = safeDocumentPath; - - // Initialize collaboration for new document - const documentId = sanitizedDocumentName; + boundRelativePath = relativeWithExt; + bindingToken = randomUUID(); + notifyBindingToParent(); + + // Room ID for the Yjs mirror / autosave / snapshot broadcasts + // is scoped to this binding (opaque token), so `a/note.md` + // and `b/note.md` cannot share a room via matching + // basenames. The user-visible name stays as the basename. + const documentId = getCurrentDocumentId(); + // Drop the previous room's state if nothing is holding on + // to it. The check inside evictRoomIfIdle keeps connected + // clients from having their Y.Doc yanked mid-session. + if (previousDocumentId !== documentId) { + evictRoomIfIdle(previousDocumentId); + } + const documentName = path.basename(relativeWithExt, ".md"); collaborationManager.initializeDocument( - sanitizedDocumentName, + documentId, safeDocumentPath, ); - // Load content into collaboration manager const content = fs.readFileSync(safeDocumentPath, "utf-8"); debug("Raw content: ", content); - // collaborationManager.setDocumentContent(documentId, content); const ydoc = getAuthoritativeDocument(documentId); const ytext = ydoc.getText("content"); - - // Update document content ytext.delete(0, ytext.length); ytext.insert(0, content); + const revision = computeContentRevision(content); + + // Broadcast documentChanged so other browsers connected to + // this view update their editor room and adopt the new + // binding token. Skip the broadcast when the underlying file + // did not change (a re-select of the same path). + if (oldFilePath !== filePath) { + const activeToken = bindingToken; + const activeRelative = boundRelativePath; + clients.forEach((client) => { + try { + client.write( + `data: ${JSON.stringify({ + type: "documentChanged", + newDocumentId: documentId, + newDocumentName: documentName, + bindingToken: activeToken, + boundRelativePath: activeRelative, + revision, + timestamp: Date.now(), + })}\n\n`, + ); + } catch (sseError) { + console.error( + "[SSE] Failed to send documentChanged:", + sseError, + ); + } + }); + } res.json({ success: true, - documentName: documentName, - content: content, + documentName, + documentId, + relativePath: relativeWithExt, + boundRelativePath: relativeWithExt, + bindingToken, + content, + revision, documentPath: safeDocumentPath, }); } catch (error) { @@ -177,13 +276,23 @@ app.post( }, ); -// API endpoint to handle markdown response from clients +// API endpoint to handle markdown response from clients. Each pending +// request records the binding token that was live when the requestMarkdown +// SSE was sent; the browser echoes its `currentBindingToken` in the +// response. When the two disagree (browser rebound mid-flight, or a +// stale/attacker-supplied token) the pending promise is rejected so the +// caller reads/applies against consistent identity rather than pairing new +// content with the old binding. app.post( "/api/markdown-response", express.json(), (req: Request, res: Response) => { try { const { requestId, markdown, positionInfo, error } = req.body; + const responseToken = + typeof req.body?.bindingToken === "string" + ? req.body.bindingToken + : null; if (!requestId) { res.status(400).json({ error: "Request ID is required" }); @@ -202,6 +311,18 @@ app.post( if (error) { pendingRequest.reject(new Error(error)); + } else if ( + pendingRequest.expectedBindingToken !== null && + responseToken !== pendingRequest.expectedBindingToken + ) { + debug( + `[MARKDOWN-RESPONSE] Rejecting ${requestId}: bindingToken mismatch (expected ${pendingRequest.expectedBindingToken}, got ${responseToken ?? ""})`, + ); + pendingRequest.reject( + new ClientBindingMismatchError( + `Client markdown response bindingToken mismatch: expected ${pendingRequest.expectedBindingToken}, got ${responseToken ?? ""}`, + ), + ); } else { pendingRequest.resolve({ markdown: markdown || "", @@ -231,7 +352,19 @@ app.post( ); let clients: any[] = []; -let filePath: string | null; +let filePath: string | null = null; +// The currently-bound relative path under `currentRoot`, normalized to +// POSIX separators. Kept alongside `filePath` so the trusted parent IPC +// can rebind by full user-relative path and recovery on the agent side +// can reproduce the exact original binding rather than reconstructing it +// from `basename(filePath)` (which loses nested directories). +let boundRelativePath: string | null = null; +// Opaque token rotated on every trusted rebinding (setFile from parent IPC +// or /api/switch-document from the browser). The agent tags every read +// and apply IPC with the token it observed, so a switch (even to the same +// basename or same relative path) forces a fresh binding roundtrip and +// prevents stale requests from clobbering the new file. +let bindingToken: string | null = null; let collaborationManager: CollaborationManager; // UI Command routing state @@ -240,10 +373,317 @@ const pendingCommands = new Map(); // Markdown request state let markdownRequestCounter = 0; -const pendingMarkdownRequests = new Map(); +type PendingMarkdownRequest = { + resolve: (value: { + markdown: string; + positionInfo: { + position: number; + selection?: { from: number; to: number }; + }; + }) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + // The binding token live when the SSE was sent; the browser must echo + // it in /api/markdown-response or the pending request is rejected. + // `null` means "no active binding at request time"; the browser will + // send `null` too because its currentBindingToken is unset. + expectedBindingToken: string | null; +}; +const pendingMarkdownRequests = new Map(); const userHomeDir = os.homedir(); -const ROOT_DIR = +const INITIAL_ROOT_DIR = process.env.TYPEAGENT_MARKDOWN_ROOT || path.join(userHomeDir, "Documents"); +// The active document root. Mutated only through the trusted parent IPC +// `setFile` message (see the process message handler below). HTTP routes read +// this variable but never write it, so a compromised browser page cannot +// pivot the server onto another directory. +let currentRoot: string = + resolveRealDirectory(INITIAL_ROOT_DIR) ?? INITIAL_ROOT_DIR; + +function getValidatedCurrentRoot(): string { + if (!isCanonicalDirectory(currentRoot)) { + throw new Error("The document root is no longer accessible"); + } + return currentRoot; +} + +// Emit a bindingUpdated IPC message to the parent agent so it can attach the +// rotated token to subsequent read/apply requests. Silently no-ops when +// the process is not IPC-connected (unit-test / standalone runs). +function notifyBindingToParent(): void { + process.send?.({ + type: "bindingUpdated", + bindingToken, + boundFilePath: filePath, + boundRoot: filePath ? currentRoot : null, + boundRelativePath, + }); +} + +// Validate an inbound IPC message that may carry any of the identity +// expectations (`expectedBindingToken`, `expectedRoot`, +// `expectedRelativePath`). Returns undefined when every expectation +// present in the message matches the current binding; returns a human +// -readable rejection reason otherwise. Callers may also pass a +// pre-captured `snapshot` to check against (used to re-validate a +// snapshot after an in-flight async read that could have raced with a +// rebinding). Unlike an identity match on a basename (which happily +// accepts two files that share `notes` in a nested tree), the token +// is opaque and rotates on every trusted rebinding, so a stale value +// forces the agent through a fresh getDocumentContent before it can +// apply. +type BindingSnapshot = { + bindingToken: string | null; + currentRoot: string; + filePath: string | null; + boundRelativePath: string | null; +}; + +function captureBindingSnapshot(): BindingSnapshot { + return { + bindingToken, + currentRoot, + filePath, + boundRelativePath, + }; +} + +function bindingsDiffer(a: BindingSnapshot, b: BindingSnapshot): boolean { + return ( + a.bindingToken !== b.bindingToken || + a.currentRoot !== b.currentRoot || + a.filePath !== b.filePath || + a.boundRelativePath !== b.boundRelativePath + ); +} + +type BoundWriteValidation = + | { + ok: true; + snapshot: BindingSnapshot; + targetFilePath: string; + targetDocumentId: string; + roomMismatch: boolean; + } + | { + ok: false; + status: number; + error: string; + revision?: string; + content?: string; + }; + +// Shared trust check used by every browser-initiated full-document +// write (POST /document and POST /autosave). It: +// 1. Snapshots the module-level binding at entry so a concurrent +// setFile / /api/switch-document during the handler cannot swap +// the target file underneath us. +// 2. Requires the request to carry a bindingToken that matches the +// snapshot. This is an anti-confusion identity check, not +// authorization: a stale browser tab that never processed the +// latest bindingBootstrap MUST NOT be allowed to silently +// overwrite the new binding with content it authored under the +// old one. +// 3. Re-validates the root is still a canonical directory and the +// relative path is still resolvable inside it, so a swapped +// symlink or a moved workspace root cannot widen the write. +// 4. Chooses the Yjs room by the bound identity, ignoring any +// documentId the browser sent - the browser value is only +// inspected for a diagnostic roomMismatch flag. +// Callers still have to re-check `bindingsDiffer` right before the +// actual write in case any awaited work slipped in between. +function validateBoundWriteRequest(body: { + bindingToken?: unknown; + documentId?: unknown; + expectedRevision?: unknown; +}): BoundWriteValidation { + const requestBindingToken = + typeof body?.bindingToken === "string" ? body.bindingToken : null; + + const snapshot = captureBindingSnapshot(); + + if (!snapshot.filePath) { + return { + ok: false, + status: 409, + error: "No file is bound. This endpoint requires a parent-established file binding.", + }; + } + + if ( + snapshot.bindingToken === null || + requestBindingToken === null || + requestBindingToken !== snapshot.bindingToken + ) { + return { + ok: false, + status: 409, + error: "bindingToken is missing or stale. Reload to adopt the current binding.", + }; + } + + if (!isCanonicalDirectory(snapshot.currentRoot)) { + return { + ok: false, + status: 403, + error: "The document root is no longer accessible", + }; + } + const targetFilePath = resolveWritableFileWithinRoot( + snapshot.currentRoot, + snapshot.filePath, + ); + if (targetFilePath === undefined) { + return { ok: false, status: 403, error: "Invalid file path" }; + } + + const targetDocumentId = getCurrentDocumentId(snapshot); + const currentContent = fs.existsSync(targetFilePath) + ? fs.readFileSync(targetFilePath, "utf-8") + : ""; + const currentRevision = computeContentRevision(currentContent); + const expectedRevision = + typeof body.expectedRevision === "string" + ? body.expectedRevision + : undefined; + if ( + expectedRevision === undefined || + expectedRevision !== currentRevision + ) { + return { + ok: false, + status: 409, + error: + expectedRevision === undefined + ? "expectedRevision is required for a bound document write." + : "Document content changed since it was loaded.", + revision: currentRevision, + content: currentContent, + }; + } + + const rawDocumentId = + typeof body.documentId === "string" ? body.documentId : undefined; + const requestedDocumentId = rawDocumentId + ? sanitizeFilename(rawDocumentId) + : undefined; + const roomMismatch = + requestedDocumentId !== undefined && + requestedDocumentId !== targetDocumentId; + + return { + ok: true, + snapshot, + targetFilePath, + targetDocumentId, + roomMismatch, + }; +} + +// Room ID for the currently-bound document. The Yjs WebSocket room / Yjs +// mirror / autosave target / documentSnapshot broadcasts are keyed by +// this ID. We use the opaque bindingToken (rotated on every trusted +// rebinding) whenever a file is bound, so `a/note.md` and `b/note.md` +// - which share basename `note` - are never coalesced into the same +// Yjs room and cannot cross-write into each other via a browser that +// sent a stale, browser-selected documentId. Memory-only mode has no +// binding and shares the literal "default" room, which is intentional: +// there is no file target, so there is no cross-file risk. +function getCurrentDocumentId( + snapshot: BindingSnapshot = captureBindingSnapshot(), +): string { + if (snapshot.filePath && snapshot.bindingToken) { + return snapshot.bindingToken; + } + return "default"; +} + +function checkExpectedIdentity( + message: { + expectedBindingToken?: unknown; + expectedRoot?: unknown; + expectedRelativePath?: unknown; + }, + against: BindingSnapshot = captureBindingSnapshot(), +): string | undefined { + if (typeof message.expectedBindingToken === "string") { + if (message.expectedBindingToken !== against.bindingToken) { + return `Binding token mismatch: expected ${message.expectedBindingToken}, current ${against.bindingToken ?? ""}`; + } + } + if (typeof message.expectedRoot === "string") { + if (message.expectedRoot !== against.currentRoot) { + return `Binding root mismatch: expected ${message.expectedRoot}, current ${against.currentRoot}`; + } + } + if (typeof message.expectedRelativePath === "string") { + if (message.expectedRelativePath !== against.boundRelativePath) { + return `Binding relative path mismatch: expected ${message.expectedRelativePath}, current ${against.boundRelativePath ?? ""}`; + } + } + return undefined; +} + +// Read the current authoritative Markdown for `documentId`. Prefer the +// connected browser (which owns the live editor state and may have edits +// the Yjs mirror has not received yet); fall back to the Yjs mirror and +// then to the raw file, in that order. Both paths return raw Markdown - +// the server never applies operations against ProseMirror offsets. +// +// `snapshot` pins the read to the binding captured at request entry. The +// browser request carries the snapshotted bindingToken and boundRelative +// Path in the SSE payload and echoes the token back in +// /api/markdown-response; a mismatch surfaces as a rejected client +// request, and the file fallback below uses the snapshotted root/file +// so a concurrent rebinding cannot widen the read. +async function readCurrentMarkdownServerSide( + documentId: string, + snapshot: BindingSnapshot = captureBindingSnapshot(), +): Promise { + if (clients.length > 0) { + try { + const response = await requestMarkdownFromClient(0, snapshot); + return response.markdown; + } catch (error) { + // A binding-token mismatch means the browser explicitly + // answered for a different identity than the one the read + // was pinned to. Rethrow so the caller reports + // identityMismatch instead of silently reading the Yjs + // mirror (which would return content for the current + // binding, then be paired with the caller's stale expected + // identity - the exact confusion we are guarding). + if (error instanceof ClientBindingMismatchError) { + throw error; + } + debug( + `[VIEW] Falling back to Yjs mirror after client-serializer failure: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + const ydoc = getAuthoritativeDocument(documentId); + const ytext = ydoc.getText("content"); + const yjsContent = ytext.toString(); + if (yjsContent.length > 0 || !snapshot.filePath) { + return yjsContent; + } + // Last-resort file read for a bound document whose Yjs mirror is + // still empty (e.g. first read after setFile discovered a missing + // file). Path validity is re-checked against the SNAPSHOTTED root + // so a concurrent rebinding cannot widen what we read here. + if (!isCanonicalDirectory(snapshot.currentRoot)) { + return ""; + } + const readableFilePath = resolveExistingFileWithinRoot( + snapshot.currentRoot, + snapshot.filePath, + ); + if (readableFilePath === undefined) { + return ""; + } + return fs.readFileSync(readableFilePath, "utf-8"); +} // Streaming state for LLM responses const activeStreamingSessions = new Map< @@ -366,9 +806,19 @@ async function sendUICommandToAgentWithStreaming( } /** - * Request markdown content from connected client with retry logic + * Request markdown content from connected client with retry logic. The + * snapshotted binding is threaded into the SSE payload as + * `expectedBindingToken` (plus `expectedRelativePath` for logs/debug on + * the browser side) and stashed on the pending entry so + * /api/markdown-response can reject a response that echoes back a + * different token. Retries reuse the SAME snapshot: retrying with the + * current live binding would defeat the identity check that the caller + * relied on. */ -async function requestMarkdownFromClient(retryCount: number = 0): Promise<{ +async function requestMarkdownFromClient( + retryCount: number = 0, + snapshot: BindingSnapshot = captureBindingSnapshot(), +): Promise<{ markdown: string; positionInfo: { position: number; @@ -376,6 +826,7 @@ async function requestMarkdownFromClient(retryCount: number = 0): Promise<{ }; }> { const maxRetries = 3; // Increased from 2 to 3 + const expectedBindingToken = snapshot.bindingToken; return new Promise((resolve, reject) => { const requestId = `markdown_req_${++markdownRequestCounter}`; @@ -389,7 +840,7 @@ async function requestMarkdownFromClient(retryCount: number = 0): Promise<{ // Retry after a longer delay for better reliability setTimeout( () => { - requestMarkdownFromClient(retryCount + 1) + requestMarkdownFromClient(retryCount + 1, snapshot) .then(resolve) .catch(reject); }, @@ -400,8 +851,16 @@ async function requestMarkdownFromClient(retryCount: number = 0): Promise<{ } }, 8000); // 8 second timeout (increased from 5s) - // Store resolver for this request - pendingMarkdownRequests.set(requestId, { resolve, reject, timeout }); + // Store resolver for this request. Include the expected binding + // token so /api/markdown-response can reject responses whose echo + // does not match, which would indicate the browser rebound + // between the SSE and the response. + pendingMarkdownRequests.set(requestId, { + resolve, + reject, + timeout, + expectedBindingToken, + }); // Send request to clients via SSE debug( @@ -422,6 +881,8 @@ async function requestMarkdownFromClient(retryCount: number = 0): Promise<{ `data: ${JSON.stringify({ type: "requestMarkdown", requestId: requestId, + expectedBindingToken, + expectedRelativePath: snapshot.boundRelativePath, timestamp: Date.now(), })}\n\n`, ); @@ -518,6 +979,7 @@ app.get("/document", (req: Request, res: Response) => { const ydoc = getAuthoritativeDocument(documentId); const ytext = ydoc.getText("content"); const content = ytext.toString(); + res.setHeader("X-Content-Revision", computeContentRevision(content)); debug( `Retrieved content from authoritative Y.js doc: ${documentId}, ${content.length} chars`, @@ -532,11 +994,20 @@ app.get("/document", (req: Request, res: Response) => { filePath, ); - // File mode: get content from authoritative document (which should be synced with file) - const documentId = path.basename(filePath, ".md"); + // File mode: get content from the authoritative Y.js doc + // scoped to the current binding (opaque token). Uses the same + // room key that setFile / autosave / applyLLMOperations use. + const documentId = getCurrentDocumentId(); const ydoc = getAuthoritativeDocument(documentId); const ytext = ydoc.getText("content"); const content = ytext.toString(); + const persistedContent = fs.existsSync(filePath) + ? fs.readFileSync(filePath, "utf-8") + : ""; + res.setHeader( + "X-Content-Revision", + computeContentRevision(persistedContent), + ); debug( `Retrieved content from authoritative Y.js doc: ${documentId}, ${content.length} chars`, @@ -551,63 +1022,94 @@ app.get("/document", (req: Request, res: Response) => { } }); -// Save document from markdown text +// Save document from markdown text. Memory-only mode (no file bound) +// still just writes to the shared "default" Yjs room. File mode goes +// through validateBoundWriteRequest so it applies the same +// bindingToken / snapshot / re-resolve trust checks the /autosave path +// does; a browser that missed a rebinding cannot silently overwrite a +// new binding with content authored against the old one. app.post("/document", express.json(), (req: Request, res: Response) => { - const markdownContent = req.body.content || ""; + const markdownContent = + typeof req.body?.content === "string" ? req.body.content : ""; if (!filePath) { - // Memory-only mode: save to authoritative Y.js document - const documentId = "default"; // Use consistent document ID - + // Memory-only mode: save to authoritative Y.js document. + const documentId = "default"; const ydoc = getAuthoritativeDocument(documentId); const ytext = ydoc.getText("content"); - // Replace entire content atomically ytext.delete(0, ytext.length); ytext.insert(0, markdownContent); debug( - `Saved content to authoritative Y.js doc: ${markdownContent.length} chars`, + `Saved content to authoritative Y.js doc (memory-only): ${markdownContent.length} chars`, ); res.json({ success: true, message: "Content saved to memory (no file mode)", }); - return; } try { - const writableFilePath = resolveWritableFileWithinRoot( - ROOT_DIR, - filePath, - ); - if (writableFilePath === undefined) { - res.status(403).json({ error: "Access to the file is forbidden" }); + const validation = validateBoundWriteRequest(req.body ?? {}); + if (!validation.ok) { + debug(`POST /document rejected: ${validation.error}`); + res.status(validation.status).json({ + error: validation.error, + revision: validation.revision, + content: validation.content, + }); return; } + const { snapshot, targetFilePath, targetDocumentId, roomMismatch } = + validation; + if (roomMismatch) { + debug( + `POST /document documentId mismatch (browser=${ + typeof req.body?.documentId === "string" + ? req.body.documentId + : "" + }, bound=${targetDocumentId}); persisting to bound file`, + ); + } - // File mode: save to both authoritative document and file - const documentId = path.basename(writableFilePath, ".md"); - const ydoc = getAuthoritativeDocument(documentId); - const ytext = ydoc.getText("content"); + // Guard against a same-tick rebinding between validation and + // write. This is cheap (no awaits precede it) and fails closed. + if (bindingsDiffer(captureBindingSnapshot(), snapshot)) { + debug( + "POST /document rejected: binding rotated between validation and write", + ); + res.status(409).json({ + error: "Binding rotated during request", + }); + return; + } - // Update authoritative document first + const ydoc = getAuthoritativeDocument(targetDocumentId); + const ytext = ydoc.getText("content"); ytext.delete(0, ytext.length); ytext.insert(0, markdownContent); - // Then save to file - fs.writeFileSync(writableFilePath, markdownContent, "utf-8"); - filePath = writableFilePath; + fs.writeFileSync(targetFilePath, markdownContent, "utf-8"); + filePath = targetFilePath; + const revision = computeContentRevision(markdownContent); debug( - `Saved content to both Y.js doc and file: ${writableFilePath}, ${markdownContent.length} chars`, + `Saved content to both Y.js doc and file: ${targetFilePath}, ${markdownContent.length} chars`, ); - res.json({ success: true }); + res.json({ + success: true, + filePath: targetFilePath, + documentId: targetDocumentId, + roomMismatch, + revision, + }); } catch (error) { + console.error("[DOCUMENT] Save failed:", error); res.status(500).json({ error: "Failed to save document", - details: error, + details: error instanceof Error ? error.message : error, }); } }); @@ -670,81 +1172,58 @@ app.post("/api/ai-awareness", express.json(), (req: Request, res: Response) => { } }); -// Add auto-save endpoint +// Add auto-save endpoint. Like POST /document, it requires the browser +// to identify the binding and revision it edited: the request must carry a +// `bindingToken` that matches the module-level token. Missing or stale +// tokens (browser did not process bindingBootstrap, or another party +// rotated the binding) are rejected without touching disk. The +// browser-supplied `documentId` still may only select the Yjs room - +// never the file target, which is always the SNAPSHOTTED filePath and +// currentRoot captured at request entry. app.post("/autosave", express.json(), (req: Request, res: Response) => { try { - const { content, filePath: requestFilePath, documentId } = req.body; - - if (!content && content !== "") { + const content = + typeof req.body?.content === "string" ? req.body.content : null; + if (content === null) { res.status(400).json({ error: "Content is required" }); return; } - debug( - `Auto-save request received for document: ${documentId}, path: ${requestFilePath}, content: ${content.length} chars`, - ); - - // Use the provided file path or fall back to current filePath - let sanitizedFilePath = sanitizeFilename(documentId || filePath); - if (!sanitizedFilePath.endsWith(".md")) { - sanitizedFilePath += ".md"; - } - - const resolvedFilePath = resolveWritableFileWithinRoot( - ROOT_DIR, - sanitizedFilePath, - ); - if (resolvedFilePath === undefined) { - res.status(403).json({ error: "Invalid file path" }); + const validation = validateBoundWriteRequest(req.body ?? {}); + if (!validation.ok) { + debug(`Auto-save rejected: ${validation.error}`); + res.status(validation.status).json({ + error: validation.error, + revision: validation.revision, + content: validation.content, + }); return; } - const targetFilePath = resolvedFilePath; - const targetDocumentId = - documentId || - (sanitizedFilePath - ? path.basename(sanitizedFilePath, ".md") - : "default"); - - if (!targetFilePath) { - // Memory-only mode: save to authoritative Y.js document + const { snapshot, targetFilePath, targetDocumentId, roomMismatch } = + validation; + if (roomMismatch) { debug( - `Memory-only mode auto-save to Y.js document: ${targetDocumentId}`, + `Auto-save documentId mismatch (browser=${ + typeof req.body?.documentId === "string" + ? req.body.documentId + : "" + }, bound=${targetDocumentId}); persisting to bound file`, ); + } + debug( + `Auto-save request received for bound document: ${targetDocumentId}, path: ${targetFilePath}, content: ${content.length} chars`, + ); - const ydoc = getAuthoritativeDocument(targetDocumentId); - const ytext = ydoc.getText("content"); - - // Replace entire content atomically - ytext.delete(0, ytext.length); - ytext.insert(0, content); - + // Guard once more against a rebinding that raced our snapshot + // capture. Between validateBoundWriteRequest and the write below + // we did no `await`, but the token could still have rotated on + // a same-tick IPC. This is cheap and fails closed. + if (bindingsDiffer(captureBindingSnapshot(), snapshot)) { debug( - `Auto-save completed to Y.js document: ${targetDocumentId}, ${content.length} chars`, + "Auto-save rejected: binding rotated between validation and write", ); - - // Notify clients via SSE - clients.forEach((client) => { - try { - client.write( - `data: ${JSON.stringify({ - type: "autoSave", - documentId: targetDocumentId, - contentLength: content.length, - timestamp: Date.now(), - })}\n\n`, - ); - } catch (error) { - console.error( - "[SSE] Failed to send auto-save event to client:", - error, - ); - } - }); - - res.json({ - success: true, - message: "Auto-saved to memory", - documentId: targetDocumentId, + res.status(409).json({ + error: "Autosave binding rotated during request", }); return; } @@ -759,6 +1238,7 @@ app.post("/autosave", express.json(), (req: Request, res: Response) => { // Then save to file fs.writeFileSync(targetFilePath, content, "utf-8"); + const revision = computeContentRevision(content); debug( `Auto-save completed to both Y.js document and file: ${targetFilePath}, ${content.length} chars`, @@ -772,6 +1252,8 @@ app.post("/autosave", express.json(), (req: Request, res: Response) => { type: "autoSave", filePath: targetFilePath, documentId: targetDocumentId, + bindingToken: snapshot.bindingToken, + revision, contentLength: content.length, timestamp: Date.now(), })}\n\n`, @@ -789,6 +1271,8 @@ app.post("/autosave", express.json(), (req: Request, res: Response) => { message: "Auto-saved successfully", filePath: targetFilePath, documentId: targetDocumentId, + roomMismatch, + revision, }); } catch (error) { console.error("[AUTO-SAVE] Auto-save failed:", error); @@ -824,66 +1308,32 @@ app.post("/autosave", express.json(), (req: Request, res: Response) => { // Add collaboration info endpoint app.get("/collaboration/info", (req: Request, res: Response) => { const stats = collaborationManager.getStats(); - const currentDocument = filePath - ? path.basename(filePath, ".md") + + // The browser MUST use `documentId` (the opaque bindingToken when + // bound, `"default"` otherwise) as the Yjs room key. Two files that + // happen to share a basename (e.g. `a/note.md` and `b/note.md`) get + // distinct documentIds and therefore distinct rooms; deriving the + // room from the basename on the browser side would cross-collab + // them. `currentDocument` is retained only as a human-readable + // display name for logs and page titles. + const snapshot = captureBindingSnapshot(); + const documentId = getCurrentDocumentId(snapshot); + const currentDocument = snapshot.filePath + ? path.basename(snapshot.filePath, ".md") : "default"; debug( - `[COLLAB-INFO] Returning collaboration info - currentDocument: "${currentDocument}", filePath: ${filePath}`, + `[COLLAB-INFO] Returning collaboration info - documentId: "${documentId}", currentDocument: "${currentDocument}", filePath: ${snapshot.filePath}`, ); res.json({ ...stats, websocketServerUrl: `ws://${LOOPBACK_HOST}:${port}`, - currentDocument: currentDocument, + documentId, + currentDocument, }); }); -// Add file operations endpoints -app.post("/file/load", express.json(), (req: Request, res: Response) => { - try { - const { filePath: newFilePath } = req.body; - - if (typeof newFilePath !== "string" || !newFilePath) { - res.status(400).json({ error: "File path is required" }); - return; - } - - const resolvedPath = resolveExistingFileWithinRoot( - ROOT_DIR, - newFilePath, - ); - if (resolvedPath === undefined) { - res.status(403).json({ - error: "Access to the file is forbidden or file not found", - }); - return; - } - - // Set new file path - filePath = resolvedPath; - - // Initialize collaboration for new document - const documentId = path.basename(resolvedPath, ".md"); - collaborationManager.initializeDocument(documentId, resolvedPath); - - // Load content into collaboration manager - const content = fs.readFileSync(resolvedPath, "utf-8"); - collaborationManager.setDocumentContent(documentId, content); - - res.json({ - success: true, - fileName: path.basename(newFilePath), - content: content, - }); - } catch (error) { - res.status(500).json({ - error: "Failed to load file", - details: error, - }); - } -}); - app.get("/file/info", (req: Request, res: Response) => { if (!filePath) { res.status(404).json({ error: "No file loaded" }); @@ -891,10 +1341,18 @@ app.get("/file/info", (req: Request, res: Response) => { } try { - const stats = fs.statSync(filePath); + const readableFilePath = resolveExistingFileWithinRoot( + getValidatedCurrentRoot(), + filePath, + ); + if (readableFilePath === undefined) { + res.status(403).json({ error: "Access to the file is forbidden" }); + return; + } + const stats = fs.statSync(readableFilePath); res.json({ - fileName: path.basename(filePath), - fullPath: filePath, + fileName: path.basename(readableFilePath), + fullPath: readableFilePath, size: stats.size, modified: stats.mtime, }); @@ -1486,9 +1944,88 @@ app.get("/events", (req: Request, res: Response) => { res.flushHeaders(); clients.push(res); + // Assign primary/secondary role from SSE ordering. The first + // connected client is the primary autosave writer; subsequent + // browsers do not autosave. When the primary disconnects (close + // handler below) we promote the next client and send it a + // `primaryElected` SSE so it flips its autosave flag on. This + // replaces the earlier scheme where the role was implicit in + // now-removed `llmOperations` events, so a browser could sit + // forever as a non-writer. + const clientRole = clients[0] === res ? "primary" : "secondary"; + + // Bootstrap the newly-connected browser with the currently-active + // binding. Without this a browser that connected AFTER the last + // setFile / /api/switch-document (i.e. it missed the documentChanged + // SSE) would have no token to compare a documentSnapshot against. + // The bootstrap from the trusted same-origin service is authoritative + // on every connection, so the browser adopts it atomically rather + // than ignoring a differing token from a stale in-memory value. + try { + // Room ID uses the current binding token (opaque, unique per + // binding), so a browser connecting after setFile joins the + // same room the Yjs mirror is keyed under. The user-facing + // name stays as the basename for URL / title purposes. + const currentDocumentId = filePath ? getCurrentDocumentId() : null; + const currentDocumentName = filePath + ? path.basename(filePath, ".md") + : null; + const revision = filePath + ? computeContentRevision( + fs.existsSync(filePath) + ? fs.readFileSync(filePath, "utf-8") + : "", + ) + : null; + res.write( + `data: ${JSON.stringify({ + type: "bindingBootstrap", + bindingToken, + documentId: currentDocumentId, + documentName: currentDocumentName, + boundRelativePath, + revision, + clientRole, + timestamp: Date.now(), + })}\n\n`, + ); + } catch (bootstrapError) { + console.error("[SSE] Failed to send bindingBootstrap:", bootstrapError); + } req.on("close", () => { + const wasPrimary = clients[0] === res; clients = clients.filter((client) => client !== res); + if (wasPrimary && clients.length > 0) { + // Promote the next-connected browser so autosave keeps + // working when the previous primary tab closes. Include the + // persisted revision so a secondary that did not perform the + // previous save can use the current optimistic-concurrency base. + const promoted = clients[0]; + try { + const snapshot = captureBindingSnapshot(); + const persistedContent = snapshot.filePath + ? fs.existsSync(snapshot.filePath) + ? fs.readFileSync(snapshot.filePath, "utf-8") + : "" + : getAuthoritativeDocument("default") + .getText("content") + .toString(); + promoted.write( + `data: ${JSON.stringify({ + type: "primaryElected", + bindingToken: snapshot.bindingToken, + revision: computeContentRevision(persistedContent), + timestamp: Date.now(), + })}\n\n`, + ); + } catch (promoteError) { + console.error( + "[SSE] Failed to send primaryElected:", + promoteError, + ); + } + } }); }); @@ -1501,22 +2038,73 @@ process.on("message", async (message: any) => { ); if (message.type == "setFile") { - if (message.filePath) { - // Resolve and validate the file path + // Only trusted parent IPC can reroot the service. HTTP routes can + // select files within currentRoot but cannot change that root. + // The message shape is `{ workspaceRoot, relativePath }`: the agent + // passes the canonical workspace root it authorized (from the host + // ActionContext.workingDirectory) and the full normalized user + // -relative path. Preserving the nested relative path (rather than + // reducing to basename+dirname) keeps subdirectory layouts intact + // through recovery. + const nextRoot = + typeof message.workspaceRoot === "string" && message.workspaceRoot + ? resolveRealDirectory(message.workspaceRoot) + : currentRoot; + if (nextRoot === undefined) { + debug( + `Ignoring setFile: workspaceRoot ${message.workspaceRoot} is not a real directory`, + ); + return; + } + const rawRelative = + typeof message.relativePath === "string" + ? message.relativePath + : ""; + if (rawRelative) { + const relative = normalizeRelativeDocumentPath(rawRelative); + if (relative === undefined) { + debug( + `Ignoring setFile: relativePath ${rawRelative} is not a safe relative path`, + ); + return; + } const resolvedFilePath = resolveWritableFileWithinRoot( - ROOT_DIR, - path.basename(message.filePath), + nextRoot, + relative, ); if (resolvedFilePath === undefined) { - debug("Invalid file path provided in message"); + debug( + `Ignoring setFile: relativePath ${rawRelative} escapes workspaceRoot`, + ); return; } + if (currentRoot !== nextRoot) { + currentRoot = nextRoot; + debug(`Document root switched to ${currentRoot}`); + } + const oldFilePath = filePath; + // Capture previous documentId before rotation so we can + // evict its Yjs mirror when nothing is holding it. + const previousDocumentId = getCurrentDocumentId(); filePath = resolvedFilePath; - - // Initialize collaboration for this document using authoritative document - const documentId = path.basename(message.filePath, ".md"); + boundRelativePath = relative; + // Rotate the binding token on every accepted rebinding, including + // rebinding to the same basename or same relative path. Callers + // that observed the previous token are then forced through a + // fresh read before they can apply. + bindingToken = randomUUID(); + notifyBindingToParent(); + + // Room ID is scoped to this binding (opaque token) - see + // getCurrentDocumentId(). Display name stays as the file + // basename for the URL / title. + const documentId = getCurrentDocumentId(); + if (previousDocumentId !== documentId) { + evictRoomIfIdle(previousDocumentId); + } + const documentName = path.basename(relative, ".md"); // Get or create the authoritative Y.js document const ydoc = getAuthoritativeDocument(documentId); @@ -1531,37 +2119,52 @@ process.on("message", async (message: any) => { ytext.insert(0, content); // Insert file content debug( - `File loaded into authoritative document: ${documentId}, ${content.length} chars from ${message.filePath}`, + `File loaded into authoritative document: ${documentId}, ${content.length} chars from ${relative}`, ); } else { debug( `File doesn't exist, authoritative document ${documentId} remains empty`, ); } + const revision = computeContentRevision( + ydoc.getText("content").toString(), + ); // Notify frontend clients if the document has changed if (oldFilePath !== filePath) { - // Send SSE notification to all clients to switch rooms + const activeToken = bindingToken; + const activeRelative = boundRelativePath; clients.forEach((client) => { client.write( `data: ${JSON.stringify({ type: "documentChanged", newDocumentId: documentId, - newDocumentName: path.basename( - message.filePath, - ".md", - ), + newDocumentName: documentName, + bindingToken: activeToken, + boundRelativePath: activeRelative, + revision, timestamp: Date.now(), })}\n\n`, ); }); } } else { + currentRoot = nextRoot; + // Capture the previous documentId before dropping to + // memory-only mode, so we can evict the room state that + // no longer has a bound file. + const previousDocumentId = getCurrentDocumentId(); // No file mode - initialize with default content using authoritative document filePath = null; + boundRelativePath = null; + bindingToken = null; + notifyBindingToParent(); debug("Running in memory-only mode (no file)"); const documentId = "default"; + if (previousDocumentId !== documentId) { + evictRoomIfIdle(previousDocumentId); + } // Get or create authoritative Y.js document for memory-only mode const ydoc = getAuthoritativeDocument(documentId); @@ -1620,102 +2223,216 @@ Start typing to see the editor in action! ); } } - } else if (message.type == "applyOperations") { - // Send operations to frontend - debug( - "View received IPC operations from agent:", - message.operations?.length, - ); - clients.forEach((client) => { - client.write( - `data: ${JSON.stringify({ - type: "operations", - operations: message.operations, - })}\n\n`, - ); - }); } else if (message.type === "applyLLMOperations") { + const requestId = + typeof message.requestId === "string" ? message.requestId : ""; + // Snapshot the binding at request start. `readCurrentMarkdown + // ServerSide` awaits, and a setFile / /api/switch-document + // during that await would otherwise let us persist operations + // against a file the agent never authorized. After the await + // we re-check the snapshot and reject if the binding rotated. + // The snapshot is hoisted out of the try so the outer catch + // (which reports back to the agent) can still surface the + // binding identity we were operating under. + const snapshot = captureBindingSnapshot(); + const snapshotDocumentId = getCurrentDocumentId(snapshot); try { if (!Array.isArray(message.operations)) { throw new Error("Document operations must be an array"); } - if (clients.length > 0) { - const operationsEvent = { - type: "llmOperations", - operations: message.operations, - timestamp: message.timestamp || Date.now(), - source: "agent", - clientRole: "primary", - }; - clients[0].write( - `data: ${JSON.stringify(operationsEvent)}\n\n`, - ); - - const notificationEvent = { - type: "operationsBeingApplied", - timestamp: Date.now(), - operationCount: message.operations.length, - source: "agent", - }; - clients.slice(1).forEach((client) => { - client.write( - `data: ${JSON.stringify(notificationEvent)}\n\n`, - ); + const bindingCheck = checkExpectedIdentity(message, snapshot); + if (bindingCheck !== undefined) { + process.send?.({ + type: "operationsApplied", + requestId, + success: false, + identityMismatch: true, + error: bindingCheck, + bindingToken: snapshot.bindingToken, + documentId: snapshotDocumentId, + method: "binding-check", }); + return; + } + // Server-authoritative apply. Regardless of whether a browser is + // connected, the view resolves the operations against raw + // Markdown and persists the result. When the browser is present + // we pull its current serialized Markdown (via the existing + // requestMarkdown SSE), verify it matches the base revision the + // agent read, then apply and push a post-commit snapshot back + // so the editor adopts the new text. Browser presence never + // changes the persistence semantics. + const operations = message.operations as DocumentOperation[]; + const currentMarkdown = await readCurrentMarkdownServerSide( + snapshotDocumentId, + snapshot, + ); + + // Re-check the snapshot after the potentially-awaiting read. + // A concurrent setFile/switch-document during the await would + // have rotated bindingToken/filePath; persisting against the + // new binding with content read for the old one is exactly + // the race we are guarding. + if (bindingsDiffer(captureBindingSnapshot(), snapshot)) { + debug( + `[VIEW] Rejecting applyLLMOperations: binding rotated during in-flight read (requestId ${requestId})`, + ); process.send?.({ type: "operationsApplied", - success: true, - operationCount: message.operations.length, - method: "sse-forwarded", - clientsNotified: clients.length, + requestId, + success: false, + identityMismatch: true, + error: "Binding rotated during in-flight read", + bindingToken, + documentId: snapshotDocumentId, + method: "binding-recheck", }); return; } - const documentId = filePath - ? path.basename(filePath, ".md") - : "default"; - getAuthoritativeDocument(documentId); + const expectedRevision = + typeof message.expectedRevision === "string" + ? message.expectedRevision + : undefined; + const baseRevision = computeContentRevision(currentMarkdown); + if ( + expectedRevision !== undefined && + expectedRevision !== baseRevision + ) { + debug( + `[VIEW] Rejecting applyLLMOperations: revision mismatch (expected ${expectedRevision}, current ${baseRevision})`, + ); + process.send?.({ + type: "operationsApplied", + requestId, + success: false, + revisionMismatch: true, + error: "Document content changed since the agent read it", + bindingToken: snapshot.bindingToken, + revision: baseRevision, + documentId: snapshotDocumentId, + method: "revision-check", + }); + return; + } + // Resolve the write target against the snapshotted filePath + + // currentRoot. Using globals here would race a concurrent + // rebinding; the snapshot recheck above only guarantees state + // was consistent at entry and after the await, so we still + // pin the write to the snapshot. let writableFilePath: string | undefined; - if (filePath) { + if (snapshot.filePath) { + if (!isCanonicalDirectory(snapshot.currentRoot)) { + throw new Error( + "The document root is no longer accessible", + ); + } writableFilePath = resolveWritableFileWithinRoot( - ROOT_DIR, - filePath, + snapshot.currentRoot, + snapshot.filePath, ); if (writableFilePath === undefined) { throw new Error("Access to the file is forbidden"); } } - const content = collaborationManager.applyOperations( - documentId, - message.operations, + const updatedContent = applyDocumentOperations( + currentMarkdown, + operations, ); + + // Update the authoritative Yjs mirror so any concurrent + // WebSocket peer receives the raw-Markdown update. + const ydoc = getAuthoritativeDocument(snapshotDocumentId); + const ytext = ydoc.getText("content"); + ydoc.transact(() => { + ytext.delete(0, ytext.length); + ytext.insert(0, updatedContent); + }); + if (writableFilePath) { - fs.writeFileSync(writableFilePath, content, "utf-8"); + fs.writeFileSync(writableFilePath, updatedContent, "utf-8"); filePath = writableFilePath; } + const revision = computeContentRevision(updatedContent); + + // Post-commit snapshot: tell browsers to reload from the raw + // Markdown the server just persisted. The snapshot carries the + // active binding token so a browser that has since rebound + // discards it instead of clobbering its editor. We use the + // snapshotted token here because `bindingToken` above was + // proven equal to the snapshot at this point. + if (snapshot.bindingToken) { + const publishedSnapshot = { + type: "documentSnapshot", + bindingToken: snapshot.bindingToken, + markdown: updatedContent, + revision, + timestamp: Date.now(), + }; + clients.forEach((client) => { + try { + client.write( + `data: ${JSON.stringify(publishedSnapshot)}\n\n`, + ); + } catch (sseError) { + console.error( + "[SSE] Failed to send documentSnapshot:", + sseError, + ); + } + }); + } + debug( - `[VIEW] Applied ${message.operations.length} operations to ${documentId}`, + `[VIEW] Applied ${operations.length} operations to ${snapshotDocumentId} (revision ${revision})`, ); process.send?.({ type: "operationsApplied", + requestId, success: true, - operationCount: message.operations.length, + operationCount: operations.length, method: "server-applied", clientsNotified: clients.length, + bindingToken: snapshot.bindingToken, + revision, + documentId: snapshotDocumentId, }); } catch (error) { + if (error instanceof ClientBindingMismatchError) { + // Same fail-closed rule as getDocumentContent: the + // server-authoritative read that fed this apply came + // from a browser that answered under a mismatched + // binding. Report identityMismatch so the agent forces + // a fresh read under the current binding, rather than + // treating the failure as a generic apply error. + debug( + `[VIEW] Rejecting applyLLMOperations: browser echoed mismatched bindingToken (requestId ${requestId})`, + ); + process.send?.({ + type: "operationsApplied", + requestId, + success: false, + identityMismatch: true, + error: error.message, + bindingToken: snapshot.bindingToken, + documentId: snapshotDocumentId, + method: "client-binding-mismatch", + }); + return; + } console.error("[VIEW] Failed to apply operations:", error); process.send?.({ type: "operationsApplied", + requestId, success: false, error: error instanceof Error ? error.message : "Unknown error", + bindingToken, method: "server-applied", }); } @@ -1726,17 +2443,45 @@ Start typing to see the editor in action! // Handle content requests from agent - try client markdown first, fallback to Y.js // Process this asynchronously to avoid blocking other messages (async () => { + const requestId = + typeof message.requestId === "string" ? message.requestId : ""; + + // Snapshot binding state at request entry. `requestMarkdown + // FromClient` awaits a network round-trip to the browser, + // and a setFile / /api/switch-document during that await + // could otherwise let us return content paired with a + // newly-rotated binding token. We re-check after the await + // and fail closed as identityMismatch when the snapshot no + // longer holds. + const snapshot = captureBindingSnapshot(); + const snapshotDocumentId = getCurrentDocumentId(snapshot); + const snapshotBoundFilePath = snapshot.filePath ?? null; + const snapshotBoundRoot = snapshotBoundFilePath + ? snapshot.currentRoot + : null; + const snapshotBoundRelativePath = snapshot.boundRelativePath; + + const bindingCheck = checkExpectedIdentity(message, snapshot); + if (bindingCheck !== undefined) { + process.send?.({ + type: "documentContent", + requestId, + content: "", + source: "error", + identityMismatch: true, + error: bindingCheck, + bindingToken: snapshot.bindingToken, + boundDocumentId: snapshotDocumentId, + boundFilePath: snapshotBoundFilePath, + boundRoot: snapshotBoundRoot, + boundRelativePath: snapshotBoundRelativePath, + revision: null, + timestamp: Date.now(), + }); + return; + } try { - let documentId = ""; - - if (!filePath) { - // Use default document ID for memory-only mode - documentId = "default"; - } else { - documentId = path.basename(filePath, ".md"); - } - - debug("Using documentID " + documentId); + debug("Using documentID " + snapshotDocumentId); let content = ""; let source = "unknown"; @@ -1748,7 +2493,7 @@ Start typing to see the editor in action! `[VIEW] Attempting to get markdown from connected client...`, ); const markdownResponse = - await requestMarkdownFromClient(); + await requestMarkdownFromClient(0, snapshot); content = markdownResponse.markdown; source = "client-serializer"; debug( @@ -1758,6 +2503,33 @@ Start typing to see the editor in action! throw new Error("No clients connected"); } } catch (clientError) { + // Browser explicitly answered under a mismatched + // binding token. Do NOT fall back to the Yjs mirror + // / file: those would return content for the + // current binding while the agent has pinned an + // expected identity, and the mismatch would be + // silently laundered as apparently-fresh content. + if (clientError instanceof ClientBindingMismatchError) { + debug( + `[VIEW] Rejecting getDocumentContent: browser echoed mismatched bindingToken (requestId ${requestId})`, + ); + process.send?.({ + type: "documentContent", + requestId, + content: "", + source: "error", + identityMismatch: true, + error: clientError.message, + bindingToken: snapshot.bindingToken, + boundDocumentId: snapshotDocumentId, + boundFilePath: snapshotBoundFilePath, + boundRoot: snapshotBoundRoot, + boundRelativePath: snapshotBoundRelativePath, + revision: null, + timestamp: Date.now(), + }); + return; + } const errorMessage = clientError instanceof Error ? clientError.message @@ -1767,7 +2539,7 @@ Start typing to see the editor in action! ); // FALLBACK: Get content from authoritative Y.js document - const ydoc = getAuthoritativeDocument(documentId); + const ydoc = getAuthoritativeDocument(snapshotDocumentId); const yText = ydoc.getText("content"); content = yText.toString(); source = "yjs-fallback"; @@ -1775,10 +2547,31 @@ Start typing to see the editor in action! `[VIEW] Retrieved content from Y.js fallback: ${content.length} chars`, ); - // If Y.js is also empty, try reading from file as last resort - if (!content && filePath && fs.existsSync(filePath)) { + // If Y.js is also empty, try reading from file as last + // resort. Validate the snapshotted path against the + // snapshotted root so a concurrent rebinding cannot + // widen what we read here. + if (!content && snapshot.filePath) { try { - content = fs.readFileSync(filePath, "utf-8"); + if (!isCanonicalDirectory(snapshot.currentRoot)) { + throw new Error( + "The document root is no longer accessible", + ); + } + const readableFilePath = + resolveExistingFileWithinRoot( + snapshot.currentRoot, + snapshot.filePath, + ); + if (readableFilePath === undefined) { + throw new Error( + "Access to the file is forbidden", + ); + } + content = fs.readFileSync( + readableFilePath, + "utf-8", + ); source = "file-fallback"; debug( `[VIEW] Retrieved content from file fallback: ${content.length} chars`, @@ -1791,15 +2584,48 @@ Start typing to see the editor in action! } } + // Re-check the snapshot after the possibly-awaiting read. + // If binding rotated during the round-trip, fail closed + // with identityMismatch so the agent does not pair the + // browser-selected content with its old identity. + if (bindingsDiffer(captureBindingSnapshot(), snapshot)) { + debug( + `[VIEW] Rejecting getDocumentContent: binding rotated during in-flight read (requestId ${requestId})`, + ); + process.send?.({ + type: "documentContent", + requestId, + content: "", + source: "error", + identityMismatch: true, + error: "Binding rotated during in-flight read", + bindingToken, + boundDocumentId: snapshotDocumentId, + boundFilePath: snapshotBoundFilePath, + boundRoot: snapshotBoundRoot, + boundRelativePath: snapshotBoundRelativePath, + revision: null, + timestamp: Date.now(), + }); + return; + } + debug( `[VIEW] Sending document content to agent (source: ${source}, ${content.length} chars)`, ); process.send?.({ type: "documentContent", + requestId, content: content, source: source, timestamp: Date.now(), + bindingToken: snapshot.bindingToken, + boundDocumentId: snapshotDocumentId, + boundFilePath: snapshotBoundFilePath, + boundRoot: snapshotBoundRoot, + boundRelativePath: snapshotBoundRelativePath, + revision: computeContentRevision(content), }); debug("[SENT] [VIEW] Sent document content to agent process"); @@ -1807,6 +2633,7 @@ Start typing to see the editor in action! console.error("[VIEW] Failed to get document content:", error); process.send?.({ type: "documentContent", + requestId, content: "", source: "error", error: @@ -1814,6 +2641,12 @@ Start typing to see the editor in action! ? error.message : "Unknown error", timestamp: Date.now(), + bindingToken: snapshot.bindingToken, + boundDocumentId: snapshotDocumentId, + boundFilePath: snapshotBoundFilePath, + boundRoot: snapshotBoundRoot, + boundRelativePath: snapshotBoundRelativePath, + revision: null, }); } })(); @@ -1930,6 +2763,50 @@ function getAuthoritativeDocument(documentId: string): Y.Doc { return ydoc; } +/** + * Free the Y.Doc / Awareness / connection-tracking state for a room + * whose binding just rotated away. Callers pass the OLD documentId + * captured before the rotation. We refuse to evict when any WebSocket + * client is still attached to the old room; a connected client is + * likely still syncing or authoring against that mirror and pulling it + * out from under them would corrupt their editor. When the room is + * idle (no attached sockets) we destroy the Y.Doc, drop the Awareness + * instance, and let CollaborationManager forget it too. + */ +function evictRoomIfIdle(oldDocumentId: string | null): void { + if (oldDocumentId === null || oldDocumentId === "default") { + // "default" is a shared memory-only fallback; keep it around. + return; + } + if (!docs.has(oldDocumentId)) { + return; + } + const attached = roomConnections.get(oldDocumentId); + if (attached && attached.size > 0) { + debug( + `Skipping eviction of ${oldDocumentId}: ${attached.size} client(s) still attached`, + ); + return; + } + try { + const ydoc = docs.get(oldDocumentId); + if (ydoc) { + ydoc.destroy(); + } + } catch (error) { + console.error( + `[EVICT] Failed to destroy Y.Doc for ${oldDocumentId}:`, + error, + ); + } + docs.delete(oldDocumentId); + awarenessStates.delete(oldDocumentId); + roomConnections.delete(oldDocumentId); + roomAwarenessConnections.delete(oldDocumentId); + collaborationManager.forgetDocument(oldDocumentId); + debug(`Evicted idle Y.Doc / awareness room: ${oldDocumentId}`); +} + // Helper function to setup a Yjs connection (compatible with y-websocket) function setupWSConnection(conn: any, req: any, roomName: string): void { debug(`Setting up WebSocket connection for room: ${roomName}`); @@ -1997,6 +2874,9 @@ function setupWSConnection(conn: any, req: any, roomName: string): void { debug( `Client disconnected from room: ${roomName}, ${connections.size} clients remaining`, ); + if (connections.size === 0 && roomName !== getCurrentDocumentId()) { + evictRoomIfIdle(roomName); + } } }; diff --git a/ts/packages/agents/markdown/src/view/route/urlPath.ts b/ts/packages/agents/markdown/src/view/route/urlPath.ts new file mode 100644 index 0000000000..c092e7eca1 --- /dev/null +++ b/ts/packages/agents/markdown/src/view/route/urlPath.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Parse the nested user-relative document path from a browser URL like +// `/document/team/2025/plan` or `/document/my%20notes`. Every segment is +// URL-decoded independently so slashes and spaces round-trip cleanly. +// Returns null when the URL does not target the /document/... route or +// when any segment fails to decode. +export function parseDocumentPathFromUrl(pathname: string): string | null { + if (typeof pathname !== "string") { + return null; + } + const match = pathname.match(/^\/document\/(.+)$/); + if (match === null) { + return null; + } + const encoded = match[1].replace(/\/+$/, ""); + if (encoded.length === 0) { + return null; + } + const segments: string[] = []; + for (const segment of encoded.split("/")) { + if (segment.length === 0) { + // A leading, trailing, or double slash points at nothing. + return null; + } + try { + const decoded = decodeURIComponent(segment); + if ( + decoded.length === 0 || + decoded.includes("/") || + decoded.includes("\\") + ) { + return null; + } + segments.push(decoded); + } catch { + return null; + } + } + return segments.join("/"); +} + +// Normalize a raw user-relative path so it can be compared to a bound +// relative path reported by the service. The service always includes +// the `.md` extension in `boundRelativePath`; the browser may or may +// not have appended it depending on the caller. Callers on the browser +// side pass the display form (without `.md`) or the fully-qualified +// form, so we accept either and always return the `.md` form. +export function ensureMarkdownExtension(relativePath: string): string { + return relativePath.toLowerCase().endsWith(".md") + ? relativePath + : `${relativePath}.md`; +} + +export function encodeDocumentPathForUrl(relativePath: string): string { + const withoutExtension = relativePath.replace(/\.md$/i, ""); + return withoutExtension + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} diff --git a/ts/packages/agents/markdown/src/view/site/core/collaboration-manager.ts b/ts/packages/agents/markdown/src/view/site/core/collaboration-manager.ts index e3c7cb0d53..e87d11c32f 100644 --- a/ts/packages/agents/markdown/src/view/site/core/collaboration-manager.ts +++ b/ts/packages/agents/markdown/src/view/site/core/collaboration-manager.ts @@ -180,13 +180,18 @@ export class CollaborationManager { `[COLLAB] Retrieved collaboration info for document: ${collabInfo.currentDocument}`, ); + // Never derive a room key from the display basename: + // same-basename files in different folders would collide. + const authoritativeDocumentId = + typeof collabInfo.documentId === "string" && + collabInfo.documentId.length > 0 + ? collabInfo.documentId + : COLLABORATION_CONFIG.DEFAULT_DOCUMENT_ID; const config = { websocketServerUrl: collabInfo.websocketServerUrl || COLLABORATION_CONFIG.DEFAULT_WEBSOCKET_URL, - documentId: collabInfo.currentDocument - ? collabInfo.currentDocument.replace(".md", "") - : COLLABORATION_CONFIG.DEFAULT_DOCUMENT_ID, + documentId: authoritativeDocumentId, fallbackToLocal: true, }; diff --git a/ts/packages/agents/markdown/src/view/site/core/document-manager.ts b/ts/packages/agents/markdown/src/view/site/core/document-manager.ts index 67e46b1bc9..9fcc361967 100644 --- a/ts/packages/agents/markdown/src/view/site/core/document-manager.ts +++ b/ts/packages/agents/markdown/src/view/site/core/document-manager.ts @@ -1,10 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { Editor } from "@milkdown/core"; -import { editorViewCtx, parserCtx } from "@milkdown/core"; +import { editorViewCtx, type Editor } from "@milkdown/core"; import { AI_CONFIG, DEFAULT_MARKDOWN_CONTENT, EDITOR_CONFIG } from "../config"; import { getMarkdownFromEditor, getEditorPositionInfo } from "../utils"; +import { + encodeDocumentPathForUrl, + ensureMarkdownExtension, +} from "../../route/urlPath.js"; + +class DocumentWriteConflictError extends Error { + public constructor(message: string) { + super(message); + this.name = "DocumentWriteConflictError"; + } +} + +interface DocumentWriteResponse { + content?: unknown; + error?: unknown; + revision?: unknown; +} export class DocumentManager { private notificationManager: any = null; @@ -13,7 +29,33 @@ export class DocumentManager { private autoSaveTimer: NodeJS.Timeout | null = null; private isPrimaryClient = false; private lastAutoSaveContent = ""; + // A 409 with different on-disk content blocks repeated autosaves of + // exactly the same editor state. A subsequent edit may try again, but + // the conflicted payload is never retried indefinitely. + private lastConflictedAutoSaveContent: string | null = null; private currentDocumentId = "default"; + private currentRevision: string | null = null; + // Token rotated by the view service on every trusted rebinding + // (setFile from the agent or /api/switch-document from another + // browser). Snapshots we accept must carry this exact value or + // we discard them - a stale snapshot for a previous binding must + // never overwrite the current editor content. + private currentBindingToken: string | null = null; + // Full user-relative path (POSIX form) of the currently-bound + // document. Nested paths (docs/team/roadmap.md) are preserved end + // to end; the browser never derives this from an absolute path + // because the service does not expose absolute paths to callers. + private currentBoundRelativePath: string | null = null; + + /** + * Expose the current bound relative path (POSIX form, includes .md) + * for callers that need to render or route with it. Read-only from + * outside; the value is only mutated from bindingBootstrap / + * documentChanged / switchToDocument. + */ + public getCurrentBoundRelativePath(): string | null { + return this.currentBoundRelativePath; + } public setNotificationManager(notificationManager: any): void { this.notificationManager = notificationManager; @@ -34,6 +76,7 @@ export class DocumentManager { public async initialize(): Promise { // Set up SSE connection for document change notifications this.setupSSEConnection(); + await this.loadCurrentBindingPath(); // Initialize auto-save if enabled if (EDITOR_CONFIG.FEATURES.AUTO_SAVE) { @@ -41,6 +84,26 @@ export class DocumentManager { } } + private async loadCurrentBindingPath(): Promise { + try { + const response = await fetch("/api/current-document"); + if (!response.ok) { + return; + } + const current = (await response.json()) as { + boundRelativePath?: unknown; + }; + if (typeof current.boundRelativePath === "string") { + this.currentBoundRelativePath = current.boundRelativePath; + } + } catch (error) { + console.warn( + "[DOCUMENT] Failed to load current binding path:", + error, + ); + } + } + /** * Start auto-save timer for primary client */ @@ -59,7 +122,11 @@ export class DocumentManager { } /** - * Perform auto-save if content has changed + * Perform auto-save if content has changed. Autosave requires a + * live binding token learned from bindingBootstrap / + * documentChanged; without one the server would (and does) reject + * the request, so we skip locally rather than firing a doomed + * write. */ private async performAutoSave(): Promise { try { @@ -74,66 +141,68 @@ export class DocumentManager { return; } - // Get current content using editor API - const currentContent = await this.getMarkdownContent(editor); + if (this.currentBindingToken === null) { + console.log( + "[AUTO-SAVE] Skipping - no bindingToken yet (unbootstrapped)", + ); + return; + } - // Only save if content has changed + const currentContent = await this.getMarkdownContent(editor); if (currentContent === this.lastAutoSaveContent) { console.log("[AUTO-SAVE] Skipping - content unchanged"); return; } + if (currentContent === this.lastConflictedAutoSaveContent) { + console.warn( + "[AUTO-SAVE] Skipping unchanged content after a write conflict", + ); + return; + } console.log(`[AUTO-SAVE] Content changed, auto-saving...`); - // Get current document path from server - const docInfo = await this.getCurrentDocumentInfo(); - - // Send auto-save request + // Send the binding identity and base revision with the save. + // They prevent a stale tab from confusing one binding or + // revision for another; they are not authorization credentials. + // We do NOT + // send any absolute path - the server writes only to its + // snapshotted trusted file/root. const response = await fetch(AI_CONFIG.ENDPOINTS.AUTOSAVE, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: currentContent, - filePath: docInfo.fullPath, documentId: this.currentDocumentId, + bindingToken: this.currentBindingToken, + expectedRevision: this.currentRevision, }), }); + await this.reconcileDocumentWriteResponse( + response, + currentContent, + "Auto-save", + ); if (response.ok) { - this.lastAutoSaveContent = currentContent; console.log("[AUTO-SAVE] Successfully saved document"); } else { - console.error( - "[AUTO-SAVE] Failed to save:", - response.statusText, + console.log( + "[AUTO-SAVE] Reconciled with content already persisted by another client", ); } } catch (error) { console.error("[AUTO-SAVE] Error during auto-save:", error); - } - } - - /** - * Get current document info from server - */ - private async getCurrentDocumentInfo(): Promise<{ - currentDocument: string; - fullPath: string | null; - }> { - try { - const response = await fetch("/api/current-document"); - if (response.ok) { - return await response.json(); + if ( + error instanceof DocumentWriteConflictError && + this.notificationManager + ) { + this.notificationManager.showNotification( + error.message, + "error", + ); } - } catch (error) { - console.warn("Failed to get current document info:", error); } - - // Fallback - return { - currentDocument: this.currentDocumentId, - fullPath: null, - }; } private setupSSEConnection(): void { @@ -180,7 +249,20 @@ export class DocumentManager { switch (data.type) { case "documentChanged": console.log(`[SSE] Document changed to: ${data.newDocumentId}`); - this.currentDocumentId = data.newDocumentId; + // Adopt the new identity atomically before we touch the + // editor or fire autosave: pairing the new token with the + // new documentId prevents a same-tick autosave from + // carrying a stale token that would then be rejected. + if (typeof data.newDocumentId === "string") { + this.currentDocumentId = data.newDocumentId; + } + if (typeof data.bindingToken === "string") { + this.currentBindingToken = data.bindingToken; + } + if (typeof data.boundRelativePath === "string") { + this.currentBoundRelativePath = data.boundRelativePath; + } + this.adoptRevision(data.revision); // Reset sync notification state for new document if (this.notificationManager) { @@ -202,6 +284,16 @@ export class DocumentManager { case "autoSave": console.log(`[SSE] Auto-save completed for: ${data.filePath}`); + if ( + typeof data.bindingToken === "string" && + data.bindingToken === this.currentBindingToken + ) { + this.adoptRevision(data.revision); + } else { + console.warn( + "[SSE] Ignoring autoSave revision for a stale binding", + ); + } // Auto-save notification removed per user request break; @@ -210,60 +302,118 @@ export class DocumentManager { // Auto-save error notification removed per user request break; - case "llmOperations": - // PRODUCTION: Handle LLM operations sent to PRIMARY client only via SSE - // Apply operations through editor API for proper markdown parsing + case "bindingBootstrap": { + // Trusted bootstrap from the same-origin service. Its + // view of the current binding is authoritative on every + // reconnect: a stale in-memory token here would just + // paper over a real rebinding (e.g. another tab or the + // agent switched files while this tab was offline). + // Adopt the identity atomically so a subsequent + // autosave and any pending snapshots compare against + // a consistent token/documentId pair. + if (typeof data.bindingToken === "string") { + this.currentBindingToken = data.bindingToken; + if (typeof data.documentId === "string") { + this.currentDocumentId = data.documentId; + } + if (typeof data.boundRelativePath === "string") { + this.currentBoundRelativePath = data.boundRelativePath; + } + this.adoptRevision(data.revision); + console.log( + `[SSE] Adopted bindingBootstrap token for ${data.documentId ?? ""}`, + ); + } else if (data.bindingToken === null) { + // View is in memory-only mode. Clear our token so a + // stale value cannot survive across an unbind / + // rebind cycle and get re-associated with a + // different file. + this.currentBindingToken = null; + this.currentBoundRelativePath = null; + this.currentRevision = null; + } + // clientRole is assigned by SSE connection ordering (see + // service /events). The first-connected browser is the + // primary autosave writer; secondary tabs skip autosave. + if (data.clientRole === "primary") { + this.isPrimaryClient = true; + console.log("[SSE] bindingBootstrap assigned PRIMARY role"); + } else if (data.clientRole === "secondary") { + this.isPrimaryClient = false; + console.log( + "[SSE] bindingBootstrap assigned SECONDARY role", + ); + } + break; + } + + case "primaryElected": { + // Sent to the next-connected browser when the previous + // primary tab closed. Flip the autosave flag on so this + // tab starts writing on the next timer tick. Promotion must + // not seat a new binding token by itself: without the matching + // documentChanged/bootstrap content that could pair stale + // editor content with a new file identity. + if (data.bindingToken !== this.currentBindingToken) { + console.warn( + "[SSE] Ignoring primaryElected for a stale binding", + ); + break; + } + this.isPrimaryClient = true; + this.adoptRevision(data.revision); + console.log("[SSE] Promoted to PRIMARY for autosave"); + break; + } + + case "documentSnapshot": { + // Post-commit snapshot from the server after it applied + // LLM operations to raw Markdown. Only adopt it when the + // binding token matches the one we last recorded from + // documentChanged / bindingBootstrap. Fail closed when + // we have no token yet: an untrusted snapshot on an + // unbootstrapped browser must never seat a token from + // arbitrary content. + const snapshotToken = data.bindingToken; + if (typeof snapshotToken !== "string") { + console.warn( + "[SSE] Ignoring documentSnapshot with no bindingToken", + ); + break; + } + if (this.currentBindingToken === null) { + console.warn( + `[SSE] Ignoring documentSnapshot: no established binding token to compare against (snapshot ${snapshotToken})`, + ); + break; + } + if (snapshotToken !== this.currentBindingToken) { + console.warn( + `[SSE] Ignoring documentSnapshot for stale binding (snapshot ${snapshotToken}, current ${this.currentBindingToken})`, + ); + break; + } if ( - data.clientRole === "primary" && - data.operations && - Array.isArray(data.operations) && - this.editorManager + this.editorManager && + typeof data.markdown === "string" && + typeof this.editorManager.setContent === "function" ) { try { - // Mark this client as primary for auto-save - this.isPrimaryClient = true; + await this.editorManager.setContent(data.markdown); + this.lastAutoSaveContent = data.markdown; + this.adoptRevision(data.revision); console.log( - "[SSE] Marked as PRIMARY CLIENT for auto-save", + `[SSE] Adopted documentSnapshot (${data.markdown.length} chars, revision ${data.revision ?? "-"})`, ); - - // Apply operations through editor API for proper markdown parsing - const editor = this.editorManager.getEditor(); - if (editor) { - await this.applyOperationsThroughEditor( - editor, - data.operations, - ); - console.log( - ` [SSE] Applied ${data.operations.length} operations via editor API`, - ); - } else { - console.warn( - ` [SSE] No editor available to apply operations`, - ); - } } catch (error) { console.error( - `[ERROR] [SSE] Failed to apply LLM operations:`, + "[ERROR] [SSE] Failed to apply documentSnapshot:", error, ); - if (this.notificationManager) { - this.notificationManager.showNotification( - `❌ Failed to apply AI changes`, - "error", - ); - } } - } else if (data.clientRole !== "primary") { - // Mark as secondary client - this.isPrimaryClient = false; - console.log(`[SSE] Marked as SECONDARY CLIENT`); - } else { - console.warn( - `[SSE] Invalid LLM operations received:`, - data, - ); } break; + } case "operationsBeingApplied": // Handle notification that operations are being applied by primary client @@ -307,6 +457,7 @@ export class DocumentManager { const response = await fetch(documentUrl); const content = response.ok ? await response.text() : ""; + this.adoptRevisionFromResponse(response); console.log( ` [DOCUMENT] Frontend switched to document: "${documentId}"`, ); @@ -317,9 +468,16 @@ export class DocumentManager { } // Update page title and URL - document.title = `${documentName} - AI-Enhanced Markdown Editor`; - const newUrl = `/document/${encodeURIComponent(documentName)}`; - window.history.pushState({ documentName }, document.title, newUrl); + const relativePath = + this.currentBoundRelativePath ?? `${documentName}.md`; + const displayPath = relativePath.replace(/\.md$/i, ""); + document.title = `${displayPath} - AI-Enhanced Markdown Editor`; + const newUrl = `/document/${encodeDocumentPathForUrl(relativePath)}`; + window.history.pushState( + { documentPath: displayPath }, + document.title, + newUrl, + ); } catch (error) { console.error( "[DOCUMENT] Failed to handle backend document change:", @@ -381,7 +539,13 @@ export class DocumentManager { } console.log(`[CLIENT] Sending markdown response to server...`); - // Send markdown content back to view process + // Send markdown content back to view process. Echo our + // currentBindingToken so the service can reject the + // response when we rebound mid-flight (the pending server + // request is pinned to the token that was live when the + // SSE was sent). A null echo is fine and expected during + // the pre-bootstrap window; the service simply skips the + // check when both sides carry null. const response = await fetch("/api/markdown-response", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -389,6 +553,7 @@ export class DocumentManager { requestId: requestId, markdown: markdown, positionInfo: positionInfo, + bindingToken: this.currentBindingToken, timestamp: Date.now(), }), }); @@ -417,6 +582,7 @@ export class DocumentManager { error instanceof Error ? error.message : "Unknown error", + bindingToken: this.currentBindingToken, timestamp: Date.now(), }), }); @@ -429,9 +595,20 @@ export class DocumentManager { } } + /** + * Persist the current editor state to the bound file. Payload is + * the serialized Markdown from getMarkdownContent - never plain + * text - so headings/bold/code/links survive a manual save. When + * an editor is provided, we carry the current bindingToken as an + * identity proof: the service applies the same trust check the + * autosave endpoint does and rejects a missing/stale token + * without touching disk. + */ public async saveDocument(editor?: Editor): Promise { try { - // Get markdown content from editor or server + // Get markdown content from editor (via serializer) or, + // when there is no editor to serialize from, from the + // service's current view of the bound file. const content = editor ? await this.getMarkdownContent(editor) : await this.loadContentFromServer(); @@ -441,12 +618,19 @@ export class DocumentManager { const response = await fetch(saveUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), + body: JSON.stringify({ + content, + documentId: this.currentDocumentId, + bindingToken: this.currentBindingToken, + expectedRevision: this.currentRevision, + }), }); - if (!response.ok) { - throw new Error(`Save failed: ${response.status}`); - } + await this.reconcileDocumentWriteResponse( + response, + content, + "Save", + ); console.log(` [DOCUMENT] Document saved successfully`); } catch (error) { @@ -458,37 +642,17 @@ export class DocumentManager { } } + /** + * Get the full serialized Markdown for the editor. This is the + * ONLY content that autosave / saveDocument may persist: it runs + * the ProseMirror doc through Milkdown's serializerCtx so + * headings, bold, code fences, links, etc. round-trip. The + * ProseMirror `textContent` shortcut is intentionally not used + * here - it strips formatting and would silently overwrite the + * bound file with plain text. + */ public async getMarkdownContent(editor: Editor): Promise { - if (!editor) return ""; - - try { - // Get content directly from editor first (most current state) - const editorContent = await new Promise((resolve) => { - editor.action((ctx) => { - const view = ctx.get(editorViewCtx); - resolve(view.state.doc.textContent || ""); - }); - }); - - if (editorContent) { - return editorContent; - } - } catch (error) { - console.warn("Failed to get content from editor:", error); - } - - try { - // Fallback to server content if editor content is empty - const response = await fetch(AI_CONFIG.ENDPOINTS.DOCUMENT); - if (response.ok) { - const serverContent = await response.text(); - return serverContent; - } - } catch (error) { - console.warn("Failed to fetch document from server:", error); - } - - return ""; + return getMarkdownFromEditor(editor); } public async loadInitialContent(): Promise { @@ -499,6 +663,8 @@ export class DocumentManager { if (response.ok) { const content = await response.text(); + this.adoptRevisionFromResponse(response); + this.lastAutoSaveContent = content; return content; } else { return this.getDefaultContent(); @@ -516,6 +682,7 @@ export class DocumentManager { if (response.ok) { const content = await response.text(); + this.adoptRevisionFromResponse(response); return content; } throw new Error( @@ -536,6 +703,7 @@ export class DocumentManager { if (response.ok) { const content = await response.text(); + this.adoptRevisionFromResponse(response); return content; } throw new Error( @@ -554,14 +722,19 @@ export class DocumentManager { const response = await fetch(saveUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), + body: JSON.stringify({ + content, + documentId: this.currentDocumentId, + bindingToken: this.currentBindingToken, + expectedRevision: this.currentRevision, + }), }); - if (!response.ok) { - throw new Error( - `Failed to set document content: ${response.status} ${response.statusText}`, - ); - } + await this.reconcileDocumentWriteResponse( + response, + content, + "Set document content", + ); console.log(` [DOCUMENT] Document content updated successfully`); // Don't reload the whole page, just notify the editor will update via collaboration @@ -618,15 +791,43 @@ export class DocumentManager { } } - public async switchToDocument(documentName: string): Promise { + public async switchToDocument(documentPath: string): Promise { try { + // Short-circuit when the SSE bootstrap already bound this + // browser to the requested document. Without this guard + // the initial `/document/team/2025/plan.md` load would + // trigger a redundant /api/switch-document call, which + // rotates the binding token, might race the + // bootstrap adoption path, and - if the raw path is not + // normalized identically - could persuade the service to + // create a new empty file. + if (this.currentBoundRelativePath !== null) { + const targetRelative = ensureMarkdownExtension(documentPath); + if (this.currentBoundRelativePath === targetRelative) { + console.log( + `[DOCUMENT] Already bound to ${this.currentBoundRelativePath}; skipping /api/switch-document`, + ); + return; + } + } + const switchUrl = "/api/switch-document"; - // Call server to switch document + // Send the raw user-relative path (possibly nested, e.g. + // "docs/team/roadmap"). The service re-validates via + // pathPolicy, appends .md if needed, and returns the full + // normalized relative path plus the freshly-rotated + // bindingToken and documentId (Yjs room) it assigned. const response = await fetch(switchUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ documentName }), + body: JSON.stringify({ + documentPath, + // Back-compat for older callers/tests that read + // this on the server side; the server prefers + // documentPath. + documentName: documentPath, + }), }); if (!response.ok) { @@ -636,11 +837,33 @@ export class DocumentManager { } const result = await response.json(); - console.log(`[DOCUMENT] Server switched to: ${documentName}`); + console.log(`[DOCUMENT] Server switched to: ${documentPath}`); + + // Adopt the new identity atomically BEFORE the editor + // room switch and any autosave. Otherwise the next + // autosave tick would carry the previous token/documentId + // and be rejected by the service - and worse, if the + // service had already rotated to a different binding + // (concurrent switch), we would pair new content with + // the old identity. + const documentId = + typeof result.documentId === "string" + ? result.documentId + : documentPath; + const relative = + typeof result.boundRelativePath === "string" + ? result.boundRelativePath + : typeof result.relativePath === "string" + ? result.relativePath + : null; + this.currentDocumentId = documentId; + this.currentBoundRelativePath = relative; + if (typeof result.bindingToken === "string") { + this.currentBindingToken = result.bindingToken; + } + this.adoptRevision(result.revision); - // Switch editor collaboration to new document room if (this.editorManager) { - const documentId = documentName; // Document ID is same as document name (without .md) await this.editorManager.switchToDocument( documentId, result.content, @@ -650,16 +873,105 @@ export class DocumentManager { ); } - // Update page title and URL - document.title = `${documentName} - AI-Enhanced Markdown Editor`; - const newUrl = `/document/${encodeURIComponent(documentName)}`; - window.history.pushState({ documentName }, document.title, newUrl); + // Update page title and URL. For nested paths, encode each + // segment so slashes are preserved. + const displayPath = relative + ? relative.replace(/\.md$/i, "") + : documentPath; + document.title = `${displayPath} - AI-Enhanced Markdown Editor`; + const encodedPath = encodeDocumentPathForUrl( + relative ?? ensureMarkdownExtension(documentPath), + ); + const newUrl = `/document/${encodedPath}`; + window.history.pushState( + { documentPath: displayPath }, + document.title, + newUrl, + ); } catch (error) { console.error("[DOCUMENT] Failed to switch document:", error); throw error; } } + private adoptRevision(revision: unknown): void { + if (typeof revision === "string" && revision.length > 0) { + this.currentRevision = revision; + } + } + + private adoptRevisionFromResponse(response: Response): void { + this.adoptRevision(response.headers.get("X-Content-Revision")); + } + + private async parseDocumentWriteResponse( + response: Response, + ): Promise { + try { + const result: unknown = await response.json(); + if ( + typeof result === "object" && + result !== null && + !Array.isArray(result) + ) { + return result as DocumentWriteResponse; + } + } catch (error) { + console.warn( + `[DOCUMENT] Could not parse ${response.status} response body:`, + error, + ); + } + return undefined; + } + + /** + * Reconcile an optimistic document write. A 409 is considered resolved + * only when the server proves that the exact attempted content is already + * on disk. Otherwise we retain the old base revision and surface a + * conflict, preventing a follow-up save from overwriting newer disk data. + */ + private async reconcileDocumentWriteResponse( + response: Response, + attemptedContent: string, + operation: string, + ): Promise { + const result = await this.parseDocumentWriteResponse(response); + + if (response.ok) { + this.adoptRevision(result?.revision); + this.lastAutoSaveContent = attemptedContent; + this.lastConflictedAutoSaveContent = null; + return; + } + + if ( + response.status === 409 && + typeof result?.revision === "string" && + result.content === attemptedContent + ) { + this.adoptRevision(result.revision); + this.lastAutoSaveContent = attemptedContent; + this.lastConflictedAutoSaveContent = null; + return; + } + + if (response.status === 409) { + this.lastConflictedAutoSaveContent = attemptedContent; + const detail = + typeof result?.error === "string" ? ` ${result.error}` : ""; + throw new DocumentWriteConflictError( + `${operation} conflict: the document changed on disk and was not overwritten.${detail}`, + ); + } + + const detail = + typeof result?.error === "string" ? ` ${result.error}` : ""; + throw new Error( + `${operation} failed: ${response.status} ${response.statusText}.${detail}`, + ); + } + private async hasUnsavedChanges(): Promise { try { if (!this.editorManager) return false; @@ -686,205 +998,6 @@ export class DocumentManager { } } - /** - * Apply operations through the editor API for proper markdown parsing and DOM updates - */ - private async applyOperationsThroughEditor( - editor: any, - operations: any[], - ): Promise { - console.log( - `[WRITE] [EDITOR-API] Applying ${operations.length} operations through editor`, - ); - - await editor.action((ctx: any) => { - const view = ctx.get(editorViewCtx); - const parser = ctx.get(parserCtx); - let tr = view.state.tr; - - for (const operation of operations) { - console.log( - `[EDITOR-API] Applying operation: ${operation.type} at position ${operation.position || 0}`, - ); - - try { - switch (operation.type) { - case "insert": { - // Convert operation content to markdown text - const markdownText = - this.operationContentToMarkdown( - operation.content, - ); - - const position = Math.min( - operation.position || 0, - view.state.doc.content.size, - ); - - // Parse markdown to ProseMirror nodes - const doc = parser(markdownText); - if (doc && doc.content) { - tr = tr.insert(position, doc.content); - console.log( - ` [EDITOR-API] Inserted "${markdownText}" at position ${position}`, - ); - } else { - console.warn( - ` [EDITOR-API] Failed to parse markdown: "${markdownText}"`, - ); - } - break; - } - case "replace": { - const markdownText = - this.operationContentToMarkdown( - operation.content, - ); - - const fromPos = Math.min( - operation.from || 0, - view.state.doc.content.size, - ); - const toPos = Math.min( - operation.to || fromPos + 1, - view.state.doc.content.size, - ); - - // Parse markdown to ProseMirror nodes - const doc = parser(markdownText); - if (doc && doc.content) { - tr = tr.replaceWith( - fromPos, - toPos, - doc.content, - ); - console.log( - ` [EDITOR-API] Replaced content from ${fromPos} to ${toPos} with "${markdownText}"`, - ); - } - break; - } - case "delete": { - const fromPos = Math.min( - operation.from || 0, - view.state.doc.content.size, - ); - const toPos = Math.min( - operation.to || fromPos + 1, - view.state.doc.content.size, - ); - - tr = tr.delete(fromPos, toPos); - console.log( - ` [EDITOR-API] Deleted content from ${fromPos} to ${toPos}`, - ); - break; - } - default: - console.warn( - `[ERROR] [EDITOR-API] Unknown operation type: ${operation.type}`, - ); - break; - } - } catch (operationError) { - console.error( - `[ERROR] [EDITOR-API] Failed to apply operation ${operation.type}:`, - operationError, - ); - } - } - - // Dispatch all changes in a single transaction - if (tr.docChanged) { - view.dispatch(tr); - console.log( - ` [EDITOR-API] Applied ${operations.length} operations successfully`, - ); - } else { - console.log(` [EDITOR-API] No document changes to apply`); - } - }); - } - - /** - * Convert operation content array to markdown text - */ - private operationContentToMarkdown(content: any[]): string { - if (!Array.isArray(content)) { - const result = String(content || ""); - return result; - } - - const result = content - .map((item: any) => { - if (typeof item === "string") { - return item; - } - - if (item && typeof item === "object") { - // Handle different content types - switch (item.type) { - case "heading": - const headingText = this.extractTextFromContent( - item.content || item.text, - ); - return headingText; - - case "paragraph": - const paragraphText = this.extractTextFromContent( - item.content || item.text, - ); - return paragraphText; - - case "text": - const textResult = item.text || ""; - return textResult; - - default: - // Fallback: extract any text content - const fallbackResult = - this.extractTextFromContent(item.content) || - item.text || - ""; - return fallbackResult; - } - } - - const stringResult = String(item || ""); - return stringResult; - }) - .join("\n"); - - return result; - } - - /** - * Extract plain text from nested content structures - */ - private extractTextFromContent(content: any): string { - if (!content) return ""; - - if (typeof content === "string") { - return content; - } - - if (Array.isArray(content)) { - return content - .map((item) => this.extractTextFromContent(item)) - .join(""); - } - - if (content.text) { - return content.text; - } - - if (content.content) { - return this.extractTextFromContent(content.content); - } - - return ""; - } - /** * Handle AI awareness events from SSE */ diff --git a/ts/packages/agents/markdown/src/view/site/index.ts b/ts/packages/agents/markdown/src/view/site/index.ts index 7c7af70e3d..3202474116 100644 --- a/ts/packages/agents/markdown/src/view/site/index.ts +++ b/ts/packages/agents/markdown/src/view/site/index.ts @@ -18,6 +18,7 @@ import { UIManager } from "./ui/ui-manager"; // Import utilities import { getRequiredElement, eventHandlers } from "./utils"; +import { parseDocumentPathFromUrl } from "../route/urlPath.js"; // Global state for the application let editorManager: EditorManager | null = null; @@ -35,10 +36,12 @@ document.addEventListener("DOMContentLoaded", async () => { }); async function initializeApplication(): Promise { - // Check if we have a document name in the URL - const urlPath = window.location.pathname; - const documentNameMatch = urlPath.match(/\/document\/([^\/]+)/); - const documentName = documentNameMatch ? documentNameMatch[1] : null; + // Parse the target document path from the URL. Nested paths + // (`/document/team/2025/plan`) and per-segment percent-encoded + // spaces (`/document/my%20notes`) round-trip end-to-end; the + // service also matches on the full path so no dirname/basename + // reduction happens on either side. + const documentPath = parseDocumentPathFromUrl(window.location.pathname); // Initialize managers editorManager = new EditorManager(); @@ -54,9 +57,13 @@ async function initializeApplication(): Promise { // Connect DocumentManager to UI components uiManager.setDocumentManager(documentManager); - // If we have a document name in URL, switch to that document - if (documentName) { - await switchToDocument(documentName); + // If the URL asked for a specific document, ask the DocumentManager + // to align to it. The DocumentManager compares against the binding + // the service just bootstrapped over SSE, so a redundant switch + // (URL already matches the bound file) becomes a no-op instead of + // creating a stray file or rotating the trusted binding token. + if (documentPath) { + await switchToDocument(documentPath); } // Get required DOM elements @@ -81,31 +88,30 @@ async function initializeApplication(): Promise { console.log("[APP] Application initialized successfully"); } -async function switchToDocument(documentName: string): Promise { +async function switchToDocument(documentPath: string): Promise { try { if (documentManager) { - await documentManager.switchToDocument(documentName); + await documentManager.switchToDocument(documentPath); console.log( - `[APP] Successfully switched to document: ${documentName}`, + `[APP] Successfully switched to document: ${documentPath}`, ); } else { throw new Error("DocumentManager not initialized"); } } catch (error) { console.error("[APP] Failed to switch document:", error); - showError(`Failed to load document: ${documentName}`); + showError(`Failed to load document: ${documentPath}`); } } function setupBrowserHistoryHandling(): void { - // Handle browser back/forward navigation + // Handle browser back/forward navigation. Reuse the same nested + // URL parser as initial load so `/document/team/2025/plan` and + // percent-encoded segments navigate correctly. window.addEventListener("popstate", async (event) => { - const urlPath = window.location.pathname; - const documentNameMatch = urlPath.match(/\/document\/([^\/]+)/); - const documentName = documentNameMatch ? documentNameMatch[1] : null; - - if (documentName && event.state?.documentName !== documentName) { - await switchToDocument(documentName); + const target = parseDocumentPathFromUrl(window.location.pathname); + if (target && event.state?.documentPath !== target) { + await switchToDocument(target); } }); } diff --git a/ts/packages/agents/markdown/src/view/site/tsconfig.json b/ts/packages/agents/markdown/src/view/site/tsconfig.json index b9d1c6fce3..90ca7915ce 100644 --- a/ts/packages/agents/markdown/src/view/site/tsconfig.json +++ b/ts/packages/agents/markdown/src/view/site/tsconfig.json @@ -14,6 +14,7 @@ "noUnusedParameters": true }, "include": ["./**/*"], + "references": [{ "path": "../route" }], "ts-node": { "esm": true } diff --git a/ts/packages/agents/markdown/src/view/site/types.ts b/ts/packages/agents/markdown/src/view/site/types.ts index 168359c331..5cc8891a62 100644 --- a/ts/packages/agents/markdown/src/view/site/types.ts +++ b/ts/packages/agents/markdown/src/view/site/types.ts @@ -81,6 +81,13 @@ export type NotificationType = "success" | "error" | "info"; export interface CollaborationInfo { websocketServerUrl: string; + // Opaque server-authoritative identifier for the Yjs collaboration + // room. Always prefer this over any file-name derived key, because + // two files with the same basename in different folders map to + // different rooms. + documentId: string; + // Human-readable display name (typically the file basename without + // the .md extension). Not safe to use as a room key. currentDocument: string; documents: number; totalClients: number; diff --git a/ts/packages/agents/markdown/src/view/site/ui/toolbar-manager.ts b/ts/packages/agents/markdown/src/view/site/ui/toolbar-manager.ts index 5430d94d36..05b55aa57e 100644 --- a/ts/packages/agents/markdown/src/view/site/ui/toolbar-manager.ts +++ b/ts/packages/agents/markdown/src/view/site/ui/toolbar-manager.ts @@ -3,6 +3,7 @@ import { DocumentManager } from "../core/document-manager"; import { getElementById } from "../utils"; +import { encodeDocumentPathForUrl } from "../../route/urlPath.js"; //import { editorViewCtx } from "@milkdown/core"; export class ToolbarManager { @@ -169,17 +170,19 @@ export class ToolbarManager { } const docInfo = await response.json(); - const documentName = docInfo.currentDocument || "live"; + const documentPath = + docInfo.boundRelativePath || docInfo.currentDocument || "live"; // Create shareable URL const baseUrl = window.location.origin; - const shareUrl = `${baseUrl}/document/${documentName}`; + const encodedPath = encodeDocumentPathForUrl(documentPath); + const shareUrl = `${baseUrl}/document/${encodedPath}`; // Copy to clipboard await navigator.clipboard.writeText(shareUrl); this.showNotification( - `🔗 Link copied: /document/${documentName}`, + `🔗 Link copied: /document/${encodedPath}`, "success", ); } catch (error) { @@ -189,8 +192,11 @@ export class ToolbarManager { try { const response = await fetch("/api/current-document"); const docInfo = await response.json(); - const documentName = docInfo.currentDocument || "live"; - const shareUrl = `${window.location.origin}/document/${documentName}`; + const documentPath = + docInfo.boundRelativePath || + docInfo.currentDocument || + "live"; + const shareUrl = `${window.location.origin}/document/${encodeDocumentPathForUrl(documentPath)}`; // Show URL in prompt as fallback prompt("Copy this shareable URL:", shareUrl); diff --git a/ts/packages/agents/markdown/src/view/site/utils.ts b/ts/packages/agents/markdown/src/view/site/utils.ts index 1998b738b5..53215f5939 100644 --- a/ts/packages/agents/markdown/src/view/site/utils.ts +++ b/ts/packages/agents/markdown/src/view/site/utils.ts @@ -93,9 +93,11 @@ export function hasClass(element: HTMLElement, className: string): boolean { * This ensures we get accurate markdown formatting and position information */ export async function getMarkdownFromEditor(editor: Editor): Promise { - if (!editor) return ""; + if (!editor) { + throw new Error("Cannot serialize Markdown without an editor"); + } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { try { editor.action((ctx) => { const view = ctx.get(editorViewCtx); @@ -104,12 +106,11 @@ export async function getMarkdownFromEditor(editor: Editor): Promise { resolve(markdown); }); } catch (error) { - console.warn("Failed to serialize markdown from editor:", error); - // Fallback to text content if serializer fails - editor.action((ctx) => { - const view = ctx.get(editorViewCtx); - resolve(view.state.doc.textContent || ""); - }); + reject( + error instanceof Error + ? error + : new Error("Failed to serialize Markdown"), + ); } }); } diff --git a/ts/packages/agents/markdown/test/bindingUpdatedFilter.spec.ts b/ts/packages/agents/markdown/test/bindingUpdatedFilter.spec.ts new file mode 100644 index 0000000000..db73e15f33 --- /dev/null +++ b/ts/packages/agents/markdown/test/bindingUpdatedFilter.spec.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + applyBindingUpdateFromView, + shouldAdoptBindingUpdate, +} from "../src/agent/markdownActionHandler.js"; + +type Ctx = { + currentFileName?: string | undefined; + currentFilePath?: string | undefined; + currentWorkspaceRoot?: string | undefined; + currentBindingToken?: string | undefined; + localHostPort: number; +}; + +function makeCtx(overrides: Partial = {}): Ctx { + return { + localHostPort: 0, + currentFileName: "notes/plan.md", + currentFilePath: "/root/notes/plan.md", + currentWorkspaceRoot: "/root", + currentBindingToken: "T-original", + ...overrides, + }; +} + +describe("shouldAdoptBindingUpdate / applyBindingUpdateFromView", () => { + test("adopts when boundRoot + boundRelativePath + boundFilePath match agent context", () => { + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/root/notes/plan.md", + }); + expect(decision).toEqual({ kind: "adopt", bindingToken: "T-new" }); + + applyBindingUpdateFromView(ctx as any, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/root/notes/plan.md", + }); + expect(ctx.currentBindingToken).toBe("T-new"); + }); + + test("rejects (leaves token untouched) when boundRelativePath differs", () => { + // Simulates the browser switching via /api/switch-document to a + // different file. The agent still has plan.md active but the view + // rebound to something else - adopting would let a subsequent + // apply carry the fresh token and land on the wrong file. + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "other.md", + boundFilePath: "/root/other.md", + }); + expect(decision).toEqual({ kind: "reject-path-mismatch" }); + + applyBindingUpdateFromView(ctx as any, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "other.md", + boundFilePath: "/root/other.md", + }); + expect(ctx.currentBindingToken).toBe("T-original"); + }); + + test("rejects when boundRoot differs from agent's current workspace root", () => { + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/other-root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/other-root/notes/plan.md", + }); + expect(decision).toEqual({ kind: "reject-path-mismatch" }); + + applyBindingUpdateFromView(ctx as any, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/other-root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/other-root/notes/plan.md", + }); + expect(ctx.currentBindingToken).toBe("T-original"); + }); + + test("rejects when boundFilePath differs even if relative path matches", () => { + // Edge case: view reports the same relative path but an absolute + // path that doesn't match the agent's currentFilePath. Fail + // closed to catch a canonicalization mismatch. + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/root/via-symlink/notes/plan.md", + }); + expect(decision).toEqual({ kind: "reject-file-mismatch" }); + + applyBindingUpdateFromView(ctx as any, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/root/via-symlink/notes/plan.md", + }); + expect(ctx.currentBindingToken).toBe("T-original"); + }); + + test("rejects when agent has no active document yet", () => { + // A bindingUpdated arriving before create/openDocument has set + // the agent's currentFileName must NOT be adopted - the agent has + // nothing to pair the token with, and adopting would let a + // browser-selected binding silently become the agent's active + // document. + const ctx = makeCtx({ + currentFileName: undefined, + currentFilePath: undefined, + currentWorkspaceRoot: undefined, + currentBindingToken: undefined, + }); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "any.md", + boundFilePath: "/root/any.md", + }); + expect(decision).toEqual({ kind: "reject-path-mismatch" }); + + applyBindingUpdateFromView(ctx as any, { + type: "bindingUpdated", + bindingToken: "T-new", + boundRoot: "/root", + boundRelativePath: "any.md", + boundFilePath: "/root/any.md", + }); + expect(ctx.currentBindingToken).toBeUndefined(); + }); + + test("adopts a rebinding to the same relative path (e.g. our own setFile ack)", () => { + // Rebinding to the same file rotates the token; the agent must + // adopt the new token so its subsequent apply carries the freshest + // value. + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: "T-rotated", + boundRoot: "/root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/root/notes/plan.md", + }); + expect(decision).toEqual({ + kind: "adopt", + bindingToken: "T-rotated", + }); + + applyBindingUpdateFromView(ctx as any, { + type: "bindingUpdated", + bindingToken: "T-rotated", + boundRoot: "/root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/root/notes/plan.md", + }); + expect(ctx.currentBindingToken).toBe("T-rotated"); + }); + + test("clears the token when the view reports memory-only mode", () => { + // The view emits bindingUpdated with all-null fields when a setFile + // switches it into memory-only mode. The agent must clear its + // cached token so a later same-token rebinding cannot silently + // reattach. + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: null, + boundRoot: null, + boundRelativePath: null, + boundFilePath: null, + }); + expect(decision).toEqual({ kind: "clear" }); + + applyBindingUpdateFromView(ctx as any, { + type: "bindingUpdated", + bindingToken: null, + boundRoot: null, + boundRelativePath: null, + boundFilePath: null, + }); + expect(ctx.currentBindingToken).toBeUndefined(); + }); + + test("ignores non-binding messages", () => { + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "somethingElse", + bindingToken: "T-new", + }); + expect(decision).toEqual({ kind: "ignore-non-binding" }); + expect(ctx.currentBindingToken).toBe("T-original"); + }); + + test("ignores a bindingUpdated with a non-string token", () => { + const ctx = makeCtx(); + const decision = shouldAdoptBindingUpdate(ctx, { + type: "bindingUpdated", + bindingToken: 42, + boundRoot: "/root", + boundRelativePath: "notes/plan.md", + boundFilePath: "/root/notes/plan.md", + }); + expect(decision).toEqual({ kind: "ignore-missing-fields" }); + expect(ctx.currentBindingToken).toBe("T-original"); + }); +}); diff --git a/ts/packages/agents/markdown/test/boundPathAdoption.spec.ts b/ts/packages/agents/markdown/test/boundPathAdoption.spec.ts new file mode 100644 index 0000000000..bdd4209531 --- /dev/null +++ b/ts/packages/agents/markdown/test/boundPathAdoption.spec.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + evaluateBoundPathAdoption, + type BoundPathAdoptionDeps, +} from "../src/agent/boundPathAdoption.js"; +import { + resolveExistingFileWithinRoot, + resolveRealDirectory, +} from "../src/agent/pathPolicy.js"; + +// Direct coverage for adoptBoundPathFromView's gating logic. The pure +// helper decides whether the view-reported binding may be adopted; the +// agent-side caller only assigns to agentContext when the helper returns +// a target. Testing the helper directly therefore proves both branches +// of "rejected recovery returns no binding" (undefined return) and +// "accepted recovery preserves full nested relative path" (relativePath +// keeps its nested segments), without spinning up a view process or +// leaking process-global state between tests. + +describe("evaluateBoundPathAdoption (authorized recovery gating)", () => { + let temporaryDirectory: string; + let workspaceRoot: string; + let authorizedRoots: Set; + let authorizeCalls: string[]; + let deps: BoundPathAdoptionDeps; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-markdown-adopt-"), + ); + workspaceRoot = path.join(temporaryDirectory, "workspace"); + fs.mkdirSync(workspaceRoot); + authorizedRoots = new Set(); + authorizeCalls = []; + deps = { + resolveRealDirectory, + resolveExistingFileWithinRoot, + isAuthorizedRoot: (canonicalRoot: string) => + authorizedRoots.has(canonicalRoot), + authorizeRoot: (canonicalRoot: string) => { + authorizeCalls.push(canonicalRoot); + authorizedRoots.add(canonicalRoot); + }, + }; + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + test("adopts an in-process authorized root and preserves the full nested relative path", () => { + const canonicalRoot = fs.realpathSync(workspaceRoot); + // Pre-authorize the canonical root as if a prior create/open under + // a trusted ActionContext.workingDirectory had accepted it. + authorizedRoots.add(canonicalRoot); + + const nestedDir = path.join(canonicalRoot, "notes", "2025"); + fs.mkdirSync(nestedDir, { recursive: true }); + const nestedFile = path.join(nestedDir, "plan.md"); + fs.writeFileSync(nestedFile, "body"); + + // Simulate a recovery call that arrived without a workingDirectory + // (the UI-synthesized ActionContext case). Authorization must + // come from the pre-populated authorized set alone. + const target = evaluateBoundPathAdoption( + { + boundFilePath: nestedFile, + boundRoot: canonicalRoot, + boundRelativePath: "notes/2025/plan.md", + }, + undefined, + deps, + ); + + expect(target).toEqual({ + canonicalRoot, + relativePath: "notes/2025/plan.md", + resolvedAbsolute: fs.realpathSync(nestedFile), + }); + // The helper must not silently authorize on the recovery path + // when no workingDirectory was supplied. Authorization can only + // come from the pre-populated set here. + expect(authorizeCalls).toEqual([]); + }); + + test("rejects an unapproved reported root and yields no binding when ActionContext has no workingDirectory", () => { + const canonicalRoot = fs.realpathSync(workspaceRoot); + const targetFile = path.join(canonicalRoot, "orphan.md"); + fs.writeFileSync(targetFile, "body"); + + // authorizedRoots is empty and no workingDirectory is supplied. + // A UI-synthesized ActionContext must never widen the trust + // boundary on its own, so recovery must fail closed. + const target = evaluateBoundPathAdoption( + { + boundFilePath: targetFile, + boundRoot: canonicalRoot, + boundRelativePath: "orphan.md", + }, + undefined, + deps, + ); + + expect(target).toBeUndefined(); + // Authorization must not be granted as a side effect of a + // rejected recovery: the set stays empty and no authorizeRoot + // call was issued. + expect(authorizeCalls).toEqual([]); + expect(authorizedRoots.size).toBe(0); + }); +}); diff --git a/ts/packages/agents/markdown/test/browserPersistence.spec.ts b/ts/packages/agents/markdown/test/browserPersistence.spec.ts new file mode 100644 index 0000000000..3e3a0fa54c --- /dev/null +++ b/ts/packages/agents/markdown/test/browserPersistence.spec.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fileURLToPath } from "node:url"; +import { jest } from "@jest/globals"; +import { createServer, type ViteDevServer } from "vite"; + +const packageRoot = fileURLToPath(new URL("../../", import.meta.url)); + +describe("browser document persistence", () => { + let vite: ViteDevServer; + let DocumentManager: new () => any; + let CollaborationManager: new () => any; + const originalFetch = globalThis.fetch; + + beforeAll(async () => { + vite = await createServer({ + root: packageRoot, + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ DocumentManager } = await vite.ssrLoadModule( + "/src/view/site/core/document-manager.ts", + )); + ({ CollaborationManager } = await vite.ssrLoadModule( + "/src/view/site/core/collaboration-manager.ts", + )); + }); + + beforeEach(() => { + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + afterAll(async () => { + await vite.close(); + }); + + test("autosave and saveDocument persist serializer Markdown, including formatting-only edits", async () => { + let markdown = + "# Heading\n\nParagraph with **bold** text.\n\n```ts\nconst x = 1;\n```\n"; + const editor = createEditor( + () => markdown, + "HeadingParagraph with bold text.const x = 1;", + ); + const requests: Array> = []; + globalThis.fetch = (async (_input, init) => { + requests.push(JSON.parse(init?.body as string)); + return Response.json({ revision: `revision-${requests.length}` }); + }) as typeof fetch; + + const manager = new DocumentManager(); + manager.editorManager = { getEditor: () => editor }; + manager.isPrimaryClient = true; + manager.currentBindingToken = "binding-1"; + manager.currentDocumentId = "binding-1"; + manager.currentRevision = "revision-0"; + + await manager.performAutoSave(); + markdown = + "# Heading\n\nParagraph with *bold* text.\n\n```ts\nconst x = 1;\n```\n"; + await manager.performAutoSave(); + await manager.saveDocument(editor); + + expect(requests).toHaveLength(3); + expect(requests[0]).toMatchObject({ + content: + "# Heading\n\nParagraph with **bold** text.\n\n```ts\nconst x = 1;\n```\n", + bindingToken: "binding-1", + expectedRevision: "revision-0", + }); + expect(requests[1]).toMatchObject({ + content: + "# Heading\n\nParagraph with *bold* text.\n\n```ts\nconst x = 1;\n```\n", + expectedRevision: "revision-1", + }); + expect(requests[2]).toMatchObject({ + content: + "# Heading\n\nParagraph with *bold* text.\n\n```ts\nconst x = 1;\n```\n", + expectedRevision: "revision-2", + }); + }); + + test("serializer failure aborts persistence instead of falling back to textContent", async () => { + const editor = { + action(callback: (ctx: { get: () => unknown }) => void): void { + let getCount = 0; + callback({ + get: () => { + if (getCount++ === 0) { + return { + state: { + doc: { textContent: "formatting was lost" }, + }, + }; + } + throw new Error("serializer unavailable"); + }, + }); + }, + }; + const fetchMock = jest.fn(); + globalThis.fetch = fetchMock as typeof fetch; + + const manager = new DocumentManager(); + manager.currentBindingToken = "binding-1"; + manager.currentRevision = "revision-0"; + + await expect(manager.saveDocument(editor)).rejects.toThrow( + "serializer unavailable", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("adopts current revisions from autosave and primary promotion events", async () => { + const manager = new DocumentManager(); + manager.currentBindingToken = "binding-1"; + manager.currentRevision = "revision-0"; + + await manager.handleSSEEvent({ + type: "autoSave", + bindingToken: "binding-1", + revision: "revision-1", + }); + expect(manager.currentRevision).toBe("revision-1"); + + await manager.handleSSEEvent({ + type: "autoSave", + bindingToken: "stale-binding", + revision: "wrong-revision", + }); + expect(manager.currentRevision).toBe("revision-1"); + + await manager.handleSSEEvent({ + type: "primaryElected", + bindingToken: "stale-binding", + revision: "wrong-revision", + }); + expect(manager.isPrimaryClient).toBe(false); + expect(manager.currentRevision).toBe("revision-1"); + + await manager.handleSSEEvent({ + type: "primaryElected", + bindingToken: "binding-1", + revision: "revision-2", + }); + expect(manager.isPrimaryClient).toBe(true); + expect(manager.currentRevision).toBe("revision-2"); + }); + + test("reconciles a 409 only when the same content is already on disk", async () => { + const markdown = "# Shared edit\n"; + const editor = createEditor(() => markdown, "Shared edit"); + const fetchMock = jest.fn(async () => + Response.json( + { + error: "Document content changed since it was loaded.", + content: markdown, + revision: "revision-from-primary", + }, + { status: 409 }, + ), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const manager = new DocumentManager(); + manager.editorManager = { getEditor: () => editor }; + manager.currentBindingToken = "binding-1"; + manager.currentDocumentId = "binding-1"; + manager.currentRevision = "stale-revision"; + + await manager.performAutoSave(); + await manager.performAutoSave(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(manager.currentRevision).toBe("revision-from-primary"); + expect(manager.lastAutoSaveContent).toBe(markdown); + }); + + test("surfaces a divergent or malformed 409 without retrying or adopting its revision", async () => { + const markdown = "# Local edit\n"; + const editor = createEditor(() => markdown, "Local edit"); + const fetchMock = jest + .fn<() => Promise>() + .mockResolvedValueOnce( + Response.json( + { + error: "Document content changed since it was loaded.", + content: "# Newer disk edit\n", + revision: "newer-disk-revision", + }, + { status: 409 }, + ), + ) + .mockResolvedValue( + new Response("not-json", { + status: 409, + statusText: "Conflict", + }), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const manager = new DocumentManager(); + manager.editorManager = { getEditor: () => editor }; + manager.currentBindingToken = "binding-1"; + manager.currentDocumentId = "binding-1"; + manager.currentRevision = "local-base-revision"; + + await manager.performAutoSave(); + await manager.performAutoSave(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(manager.currentRevision).toBe("local-base-revision"); + expect(console.error).toHaveBeenCalledWith( + "[AUTO-SAVE] Error during auto-save:", + expect.objectContaining({ name: "DocumentWriteConflictError" }), + ); + + manager.lastConflictedAutoSaveContent = null; + await expect(manager.saveDocument(editor)).rejects.toThrow( + "changed on disk and was not overwritten", + ); + expect(manager.currentRevision).toBe("local-base-revision"); + }); + + test("matching bootstrap path skips a redundant switch request", async () => { + const fetchMock = jest.fn(async () => + Response.json({ + boundRelativePath: "team/2025/plan.md", + }), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const manager = new DocumentManager(); + try { + await manager.initialize(); + await manager.switchToDocument("team/2025/plan.md"); + } finally { + manager.destroy(); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith("/api/current-document"); + }); + + test("collaboration config uses the server room id, not the basename", async () => { + globalThis.fetch = (async () => + Response.json({ + websocketServerUrl: "ws://127.0.0.1:4321", + documentId: "opaque-binding-token", + currentDocument: "note", + documents: 1, + totalClients: 0, + })) as typeof fetch; + + const manager = new CollaborationManager(); + const config = await manager.getCollaborationConfig(); + + expect(config.documentId).toBe("opaque-binding-token"); + }); +}); + +function createEditor( + getMarkdown: () => string, + textContent: string, +): { + action(callback: (ctx: { get: () => unknown }) => void): void; +} { + return { + action(callback): void { + let getCount = 0; + callback({ + get: () => + getCount++ === 0 + ? { state: { doc: { textContent } } } + : () => getMarkdown(), + }); + }, + }; +} diff --git a/ts/packages/agents/markdown/test/collaborationManager.spec.ts b/ts/packages/agents/markdown/test/collaborationManager.spec.ts deleted file mode 100644 index 3888db6290..0000000000 --- a/ts/packages/agents/markdown/test/collaborationManager.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { CollaborationManager } from "../src/view/route/collaborationManager.js"; - -describe("markdown document operations", () => { - test("applies generated markdown without a connected view client", () => { - const manager = new CollaborationManager(); - manager.initializeDocument("cli-document", null); - - const content = manager.applyOperations("cli-document", [ - { - type: "insert", - position: 0, - content: [ - { - type: "heading", - attrs: { level: 1 }, - content: [ - { - type: "text", - text: "CLI validation", - }, - ], - }, - { - type: "bullet_list", - content: [ - { - type: "list_item", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Created headlessly.", - }, - ], - }, - ], - }, - ], - }, - ], - }, - ]); - - expect(content).toBe("# CLI validation\n\n- Created headlessly.\n\n"); - expect(manager.getDocumentContent("cli-document")).toBe(content); - }); - - test("applies a batch atomically when an operation is invalid", () => { - const manager = new CollaborationManager(); - manager.initializeDocument("atomic-document", null); - manager.setDocumentContent("atomic-document", "original"); - - expect(() => - manager.applyOperations("atomic-document", [ - { - type: "insert", - position: 8, - content: [{ type: "text", text: " updated" }], - }, - { - type: "delete", - from: 5, - to: 3, - }, - ]), - ).toThrow("Invalid document range"); - expect(manager.getDocumentContent("atomic-document")).toBe("original"); - }); -}); diff --git a/ts/packages/agents/markdown/test/documentOperations.spec.ts b/ts/packages/agents/markdown/test/documentOperations.spec.ts new file mode 100644 index 0000000000..2d646c6b95 --- /dev/null +++ b/ts/packages/agents/markdown/test/documentOperations.spec.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { applyDocumentOperations } from "../src/agent/documentOperations.js"; +import type { DocumentOperation } from "../src/agent/markdownOperationSchema.js"; + +describe("format DocumentOperation", () => { + test("adds strong marks around a character range", () => { + const before = "hello world"; + const op: DocumentOperation = { + type: "format", + from: 6, + to: 11, + add: true, + marks: [{ type: "strong" }], + }; + expect(applyDocumentOperations(before, [op])).toBe("hello **world**"); + }); + + test("adds nested em+strong innermost-first", () => { + const before = "abc"; + const op: DocumentOperation = { + type: "format", + from: 0, + to: 3, + add: true, + marks: [{ type: "strong" }, { type: "em" }], + }; + // strong applied first (innermost), then em wraps: *abc* + expect(applyDocumentOperations(before, [op])).toBe("***abc***"); + }); + + test("adds code marks", () => { + const before = "run cmd here"; + const op: DocumentOperation = { + type: "format", + from: 4, + to: 7, + add: true, + marks: [{ type: "code" }], + }; + expect(applyDocumentOperations(before, [op])).toBe("run `cmd` here"); + }); + + test("adds code marks around selection containing a backtick using a longer delimiter", () => { + // CommonMark code spans: choose a backtick run STRICTLY longer + // than any run in the content, and pad with a single space when + // the content begins or ends with a backtick, so removal can + // symmetrically peel the emitted form back to the original. + const before = "prefix `x suffix"; + const add: DocumentOperation = { + type: "format", + from: 7, + to: 9, + add: true, + marks: [{ type: "code" }], + }; + const wrapped = applyDocumentOperations(before, [add]); + // Delimiter must be at least length 2 (content has a run of 1). + expect(wrapped).toBe("prefix `` `x `` suffix"); + // Removal targets the CONTENT positions in the wrapped string + // (matching how the strong/em `from/to` semantics work): the + // content "`x" now sits at positions 10..12 in the wrapped + // string. The peel walks outward through the padding and the + // discovered backtick run so both delimiters and pads are + // stripped symmetrically. + const remove: DocumentOperation = { + type: "format", + from: 10, + to: 12, + add: false, + marks: [{ type: "code" }], + }; + expect(applyDocumentOperations(wrapped, [remove])).toBe(before); + }); + + test("adds a link when href is provided", () => { + const before = "click here"; + const op: DocumentOperation = { + type: "format", + from: 6, + to: 10, + add: true, + marks: [{ type: "link", attrs: { href: "https://example.com" } }], + }; + expect(applyDocumentOperations(before, [op])).toBe( + "click [here](https://example.com)", + ); + }); + + test("drops a link mark with no href instead of emitting empty target", () => { + const before = "click here"; + const op: DocumentOperation = { + type: "format", + from: 6, + to: 10, + add: true, + marks: [{ type: "link", attrs: {} }], + }; + expect(applyDocumentOperations(before, [op])).toBe("click here"); + }); + + test("removes strong marks around a character range", () => { + const before = "hello **world**"; + const op: DocumentOperation = { + type: "format", + from: 8, + to: 13, + add: false, + marks: [{ type: "strong" }], + }; + expect(applyDocumentOperations(before, [op])).toBe("hello world"); + }); + + test("removes nested marks innermost-first", () => { + const before = "***abc***"; + const op: DocumentOperation = { + type: "format", + from: 3, + to: 6, + add: false, + marks: [{ type: "strong" }, { type: "em" }], + }; + // Peel ** first (matches inner **), then * around it: "abc" + expect(applyDocumentOperations(before, [op])).toBe("abc"); + }); + + test("removes a link", () => { + const before = "click [here](https://example.com)"; + const op: DocumentOperation = { + type: "format", + from: 7, + to: 11, + add: false, + marks: [{ type: "link", attrs: { href: "https://example.com" } }], + }; + expect(applyDocumentOperations(before, [op])).toBe("click here"); + }); + + test("remove is idempotent when the delimiter is not present", () => { + const before = "hello world"; + const op: DocumentOperation = { + type: "format", + from: 6, + to: 11, + add: false, + marks: [{ type: "strong" }], + }; + expect(applyDocumentOperations(before, [op])).toBe("hello world"); + }); + + test("empty range is a no-op", () => { + const before = "hello"; + const op: DocumentOperation = { + type: "format", + from: 2, + to: 2, + add: true, + marks: [{ type: "strong" }], + }; + expect(applyDocumentOperations(before, [op])).toBe("hello"); + }); + + test("removes __strong__ alt-delimiter form", () => { + const before = "hello __world__"; + const op: DocumentOperation = { + type: "format", + from: 8, + to: 13, + add: false, + marks: [{ type: "strong" }], + }; + expect(applyDocumentOperations(before, [op])).toBe("hello world"); + }); + + test("removes _em_ alt-delimiter form", () => { + const before = "hello _world_"; + const op: DocumentOperation = { + type: "format", + from: 7, + to: 12, + add: false, + marks: [{ type: "em" }], + }; + expect(applyDocumentOperations(before, [op])).toBe("hello world"); + }); +}); diff --git a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts index f539fb4a51..daeaa5ee56 100644 --- a/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts +++ b/ts/packages/agents/markdown/test/markdownActionHandler.spec.ts @@ -1,20 +1,28 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { ActionContext, Storage } from "@typeagent/agent-sdk"; +import type { ActionContext } from "@typeagent/agent-sdk"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { configFromEnvRecord, getRuntimeConfig, setRuntimeConfig, } from "@typeagent/aiclient"; -import { instantiate } from "../src/agent/markdownActionHandler.js"; +import { + instantiate, + reconcileViewBinding, +} from "../src/agent/markdownActionHandler.js"; import { createMarkdownAgent } from "../src/agent/translator.js"; describe("markdown document actions", () => { test.each(["createDocument", "openDocument"] as const)( "%s works without model configuration", async (actionName) => { + const workspace = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-markdown-model-free-"), + ); const savedModelSettings = Object.entries(process.env).filter( ([key]) => key.startsWith("AZURE_OPENAI_") || @@ -26,23 +34,12 @@ describe("markdown document actions", () => { delete process.env[key]; } - const checkedPaths: string[] = []; - const writes: [string, string][] = []; - const fullPath = path.resolve("storage", "notes.md"); - const storage = { - exists: async (storagePath: string) => { - checkedPaths.push(storagePath); - return false; - }, - write: async (storagePath: string, data: string) => { - writes.push([storagePath, data]); - }, - list: async () => [fullPath], - } as unknown as Storage; + const fullPath = path.join(fs.realpathSync(workspace), "notes.md"); const context = { + workingDirectory: workspace, sessionContext: { agentContext: { localHostPort: 0 }, - sessionStorage: storage, + sessionStorage: undefined, }, } as unknown as ActionContext<{ currentFileName?: string; @@ -65,8 +62,7 @@ describe("markdown document actions", () => { if ("error" in result) { throw new Error(result.error); } - expect(checkedPaths).toEqual(["notes.md"]); - expect(writes).toEqual([["notes.md", ""]]); + expect(fs.readFileSync(fullPath, "utf-8")).toBe(""); expect(result.tokenUsage).toEqual({ prompt_tokens: 0, completion_tokens: 0, @@ -83,6 +79,7 @@ describe("markdown document actions", () => { for (const [key, value] of savedModelSettings) { process.env[key] = value; } + fs.rmSync(workspace, { recursive: true, force: true }); } }, ); @@ -112,38 +109,333 @@ describe("markdown document actions", () => { } }); - test("creates a document with initial markdown content", async () => { - const fullPath = path.resolve("storage", "filled.md"); - const writes: [string, string][] = []; - const storage = { - exists: async () => false, - write: async (storagePath: string, data: string) => { - writes.push([storagePath, data]); - }, - list: async () => [fullPath], - } as unknown as Storage; - const context = { - sessionContext: { - agentContext: { localHostPort: 0 }, - sessionStorage: storage, - }, - } as unknown as ActionContext<{ - currentFileName?: string; + describe("with a host-authorized workingDirectory", () => { + type WorkspaceAgentContext = { + currentFileName?: string | undefined; + currentFilePath?: string | undefined; + currentWorkspaceRoot?: string | undefined; + viewProcess?: unknown; localHostPort: number; - }>; - - await instantiate().executeAction!( - { - schemaName: "markdown", - actionName: "createDocument", - parameters: { - name: "filled.md", - content: "# Filled\n\nLorem ipsum.", + }; + + let workspace: string; + + beforeEach(() => { + workspace = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-markdown-agent-"), + ); + }); + + afterEach(() => { + fs.rmSync(workspace, { recursive: true, force: true }); + }); + + function buildContext(overrides?: { + viewProcess?: unknown; + localHostPort?: number; + }): { + context: ActionContext; + agentContext: WorkspaceAgentContext; + } { + const agentContext: WorkspaceAgentContext = { + localHostPort: overrides?.localHostPort ?? 0, + viewProcess: overrides?.viewProcess, + }; + const context = { + workingDirectory: workspace, + sessionContext: { + agentContext, + sessionStorage: undefined, }, - }, - context, - ); + } as unknown as ActionContext; + return { context, agentContext }; + } + + test("creates a document under the workspace root with initial content", async () => { + const { context, agentContext } = buildContext(); + const result = await instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { + name: "hello", + content: "# Hello", + }, + }, + context, + ); + if (result === undefined || "error" in result) { + throw new Error("Expected success result"); + } + const expectedPath = path.join( + fs.realpathSync(workspace), + "hello.md", + ); + expect(fs.readFileSync(expectedPath, "utf-8")).toBe("# Hello"); + expect(agentContext.currentFilePath).toBe(expectedPath); + expect(agentContext.currentFileName).toBe("hello.md"); + expect(agentContext.currentWorkspaceRoot).toBe( + fs.realpathSync(workspace), + ); + expect(result.historyText).toBe( + `Document created at ${expectedPath}`, + ); + }); + + test("creates the document under a relative subdirectory", async () => { + const sent: unknown[] = []; + const { context } = buildContext({ + viewProcess: { + send: (message: unknown) => sent.push(message), + }, + }); + await instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { + name: "notes/subdir/plan", + content: "body", + }, + }, + context, + ); + const target = path.join( + fs.realpathSync(workspace), + "notes", + "subdir", + "plan.md", + ); + expect(fs.readFileSync(target, "utf-8")).toBe("body"); + expect(sent).toEqual([ + { + type: "setFile", + workspaceRoot: fs.realpathSync(workspace), + relativePath: "notes/subdir/plan.md", + }, + ]); + }); + + test.each([ + ["traversal segment", "../escape"], + ["absolute path", "/tmp/escape"], + ["windows drive", "C:evil"], + ])("rejects %s", async (_label, badName) => { + const { context } = buildContext(); + await expect( + instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { name: badName }, + }, + context, + ), + ).rejects.toThrow(/safe relative path/); + }); + + test("refuses to overwrite an existing non-empty file", async () => { + const target = path.join(fs.realpathSync(workspace), "keep.md"); + fs.writeFileSync(target, "already here"); + const { context } = buildContext(); + await expect( + instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { + name: "keep", + content: "overwrite me", + }, + }, + context, + ), + ).rejects.toThrow(/already contains content/); + expect(fs.readFileSync(target, "utf-8")).toBe("already here"); + }); + + test("emits a loopback link and absolute path when a view port is registered", async () => { + const sent: unknown[] = []; + const viewProcess = { + send: (message: unknown) => sent.push(message), + }; + const { context } = buildContext({ + viewProcess, + localHostPort: 54321, + }); + const result = await instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { name: "loopy" }, + }, + context, + ); + if (result === undefined || "error" in result) { + throw new Error("Expected success result"); + } + const display = result.displayContent as + | { type: "markdown"; content: string } + | undefined; + expect(display?.type).toBe("markdown"); + expect(display?.content).toContain( + "http://127.0.0.1:54321/document/loopy", + ); + expect(display?.content).toContain( + path.join(fs.realpathSync(workspace), "loopy.md"), + ); + expect(sent).toEqual([ + { + type: "setFile", + workspaceRoot: fs.realpathSync(workspace), + relativePath: "loopy.md", + }, + ]); + }); + + test("emits a nested loopback link built from the full user-relative path", async () => { + // The loopback link must preserve nested directories from the + // user-relative path (per-segment encoded), not flatten to + // basename. This is what lets the SPA route directly to a + // nested document via /document/team/2025/plan. + const sent: unknown[] = []; + const viewProcess = { + send: (message: unknown) => sent.push(message), + }; + const { context } = buildContext({ + viewProcess, + localHostPort: 54321, + }); + const result = await instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { name: "team/2025/plan" }, + }, + context, + ); + if (result === undefined || "error" in result) { + throw new Error("Expected success result"); + } + const display = result.displayContent as + | { type: "markdown"; content: string } + | undefined; + expect(display?.type).toBe("markdown"); + expect(display?.content).toContain( + "http://127.0.0.1:54321/document/team/2025/plan", + ); + // The last setFile the handler emitted (there may be more + // than one during instantiate; only the create leg matters) + // must carry the full user-relative path. + const setFileMessages = sent.filter( + (message): message is { type: string; relativePath: string } => + typeof message === "object" && + message !== null && + (message as any).type === "setFile", + ); + expect(setFileMessages.length).toBeGreaterThan(0); + expect( + setFileMessages[setFileMessages.length - 1].relativePath, + ).toBe("team/2025/plan.md"); + }); + + test("omits the loopback link when no view port is registered", async () => { + const { context } = buildContext(); + const result = await instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { name: "portless" }, + }, + context, + ); + if (result === undefined || "error" in result) { + throw new Error("Expected success result"); + } + const display = result.displayContent as + | { type: "markdown"; content: string } + | undefined; + expect(display?.content).not.toContain("http://"); + }); + + test("reconcileViewBinding actually sends setFile with the full nested user-relative path when a view forks late", async () => { + // Delayed-startup case: create runs before the view is attached. + // A caller that later hands the agent a viewProcess (mirroring + // the post-fork reconcile path) must call reconcileViewBinding + // and observe a setFile IPC that carries workspaceRoot and the + // full nested relativePath - not a dirname/basename split, and + // not just a synthetic object built by the test. + const { context, agentContext } = buildContext(); + const result = await instantiate().executeAction!( + { + schemaName: "markdown", + actionName: "createDocument", + parameters: { + name: "team/2025/plan", + content: "body", + }, + }, + context, + ); + if (result === undefined || "error" in result) { + throw new Error("Expected success result"); + } + expect(agentContext.currentFileName).toBe("team/2025/plan.md"); + expect(agentContext.currentWorkspaceRoot).toBe( + fs.realpathSync(workspace), + ); + + // Mock the child_process handle. Only .send is exercised by + // reconcileViewBinding; everything else stays undefined so an + // accidental read of connected/pid would surface as a failure. + const sent: unknown[] = []; + const fakeViewProcess = { + send: (message: unknown) => { + sent.push(message); + return true; + }, + } as unknown as import("node:child_process").ChildProcess; + + reconcileViewBinding( + agentContext as unknown as Parameters< + typeof reconcileViewBinding + >[0], + fakeViewProcess, + ); + + expect(sent).toEqual([ + { + type: "setFile", + workspaceRoot: fs.realpathSync(workspace), + relativePath: "team/2025/plan.md", + }, + ]); + }); + + test("reconcileViewBinding is a no-op when no file is bound yet", () => { + // The reconcile path must silently skip when create/open has + // not yet populated the binding; otherwise a freshly-forked + // view would receive setFile with undefined fields and the + // service would reject the message. + const { agentContext } = buildContext(); + agentContext.currentFileName = undefined; + agentContext.currentWorkspaceRoot = undefined; + const sent: unknown[] = []; + const fakeViewProcess = { + send: (message: unknown) => { + sent.push(message); + return true; + }, + } as unknown as import("node:child_process").ChildProcess; + + reconcileViewBinding( + agentContext as unknown as Parameters< + typeof reconcileViewBinding + >[0], + fakeViewProcess, + ); - expect(writes).toEqual([["filled.md", "# Filled\n\nLorem ipsum."]]); + expect(sent).toEqual([]); + }); }); }); diff --git a/ts/packages/agents/markdown/test/markdownUpdatePersistence.spec.ts b/ts/packages/agents/markdown/test/markdownUpdatePersistence.spec.ts new file mode 100644 index 0000000000..492f0e7603 --- /dev/null +++ b/ts/packages/agents/markdown/test/markdownUpdatePersistence.spec.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionContext, AppAgent } from "@typeagent/agent-sdk"; +import { jest } from "@jest/globals"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const updateDocument = jest.fn(async (currentContent: string | undefined) => ({ + success: true as const, + data: { + operations: [ + { + type: "insert" as const, + position: currentContent?.length ?? 0, + content: [{ type: "text" as const, text: " updated" }], + }, + ], + operationSummary: "Updated document", + }, +})); + +jest.unstable_mockModule("../src/agent/translator.js", () => ({ + createMarkdownAgent: async () => ({ + updateDocument, + tokenUsage: undefined, + }), +})); + +const { instantiate } = await import("../src/agent/markdownActionHandler.js"); + +describe("markdown update persistence without a view process", () => { + let temporaryDirectory: string; + let workspace: string; + let filePath: string; + let agent: AppAgent; + + beforeEach(() => { + temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-markdown-update-"), + ); + workspace = path.join(temporaryDirectory, "workspace"); + const documentDirectory = path.join(workspace, "notes"); + fs.mkdirSync(documentDirectory, { recursive: true }); + filePath = path.join(documentDirectory, "plan.md"); + fs.writeFileSync(filePath, "original", "utf-8"); + agent = instantiate(); + updateDocument.mockClear(); + }); + + afterEach(() => { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + test.each(["updateDocument", "streamingUpdateDocument"] as const)( + "%s persists operations to the workspace file", + async (actionName) => { + await agent.executeAction?.( + { + schemaName: "markdown", + actionName, + parameters: { + originalRequest: "update it", + }, + }, + createActionContext(), + ); + + expect(updateDocument).toHaveBeenCalledWith( + "original", + "update it", + undefined, + undefined, + ); + expect(fs.readFileSync(filePath, "utf-8")).toBe("original updated"); + }, + ); + + test("rejects an update after the document directory escapes through a junction", async () => { + const originalDocumentDirectory = path.dirname(filePath); + const movedDocumentDirectory = path.join(workspace, "moved-notes"); + const outsideDirectory = path.join(temporaryDirectory, "outside"); + fs.mkdirSync(outsideDirectory); + const outsideFile = path.join(outsideDirectory, "plan.md"); + fs.writeFileSync(outsideFile, "outside", "utf-8"); + fs.renameSync(originalDocumentDirectory, movedDocumentDirectory); + fs.symlinkSync(outsideDirectory, originalDocumentDirectory, "junction"); + + try { + await expect( + agent.executeAction?.( + { + schemaName: "markdown", + actionName: "updateDocument", + parameters: { + originalRequest: "update it", + }, + }, + createActionContext(), + ), + ).rejects.toThrow(/no longer accessible/); + + expect(updateDocument).not.toHaveBeenCalled(); + expect(fs.readFileSync(outsideFile, "utf-8")).toBe("outside"); + } finally { + fs.unlinkSync(originalDocumentDirectory); + } + }); + + function createActionContext(): ActionContext<{ + currentFileName: string; + currentFilePath: string; + currentWorkspaceRoot: string; + localHostPort: number; + }> { + return { + workingDirectory: workspace, + sessionContext: { + agentContext: { + currentFileName: path.join("notes", "plan.md"), + currentFilePath: filePath, + currentWorkspaceRoot: fs.realpathSync(workspace), + localHostPort: 0, + }, + }, + } as ActionContext<{ + currentFileName: string; + currentFilePath: string; + currentWorkspaceRoot: string; + localHostPort: number; + }>; + } +}); diff --git a/ts/packages/agents/markdown/test/pathPolicy.spec.ts b/ts/packages/agents/markdown/test/pathPolicy.spec.ts index 0baf28b859..58abe3fea5 100644 --- a/ts/packages/agents/markdown/test/pathPolicy.spec.ts +++ b/ts/packages/agents/markdown/test/pathPolicy.spec.ts @@ -5,10 +5,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + ensureDirectoryWithinRoot, + isCanonicalDirectory, + normalizeRelativeDocumentPath, resolveExistingFileWithinRoot, resolvePathWithinRoot, + resolveRealDirectory, resolveWritableFileWithinRoot, -} from "../src/view/route/pathPolicy.js"; +} from "../src/agent/pathPolicy.js"; describe("markdown path policy", () => { let temporaryDirectory: string; @@ -128,4 +132,71 @@ describe("markdown path policy", () => { expect(resolveWritableFileWithinRoot(root, "dangling")).toBeUndefined(); }); + + test("creates nested subdirectories when createSubdirs is set", () => { + const target = resolveWritableFileWithinRoot( + root, + path.join("sub", "deeper", "note.md"), + { createSubdirs: true }, + ); + expect(target).toBe( + path.join(fs.realpathSync(root), "sub", "deeper", "note.md"), + ); + expect(fs.existsSync(path.join(root, "sub", "deeper"))).toBe(true); + }); + + test("ensureDirectoryWithinRoot refuses a symlink mid-walk", () => { + const linked = path.join(root, "linked"); + fs.symlinkSync(sibling, linked, "junction"); + expect( + ensureDirectoryWithinRoot(root, path.join("linked", "child")), + ).toBeUndefined(); + }); + + test("normalizeRelativeDocumentPath accepts a plain relative name", () => { + expect(normalizeRelativeDocumentPath("notes/first.md")).toBe( + "notes/first.md", + ); + expect(normalizeRelativeDocumentPath("notes\\second.md")).toBe( + "notes/second.md", + ); + }); + + test("normalizeRelativeDocumentPath rejects unsafe inputs", () => { + expect(normalizeRelativeDocumentPath("")).toBeUndefined(); + expect(normalizeRelativeDocumentPath(" ")).toBeUndefined(); + expect(normalizeRelativeDocumentPath(undefined)).toBeUndefined(); + expect(normalizeRelativeDocumentPath(123)).toBeUndefined(); + expect(normalizeRelativeDocumentPath("../escape.md")).toBeUndefined(); + expect( + normalizeRelativeDocumentPath("sub/../escape.md"), + ).toBeUndefined(); + expect(normalizeRelativeDocumentPath("./x.md")).toBeUndefined(); + expect(normalizeRelativeDocumentPath("C:foo.md")).toBeUndefined(); + // Absolute paths are rejected on POSIX and Windows alike. + expect(normalizeRelativeDocumentPath("/etc/passwd.md")).toBeUndefined(); + }); + + test("resolveRealDirectory accepts existing absolute directories", () => { + expect(resolveRealDirectory(root)).toBe(fs.realpathSync(root)); + expect(isCanonicalDirectory(root)).toBe(true); + expect(resolveRealDirectory("relative/path")).toBeUndefined(); + expect( + resolveRealDirectory(path.join(root, "missing")), + ).toBeUndefined(); + const filePath = path.join(root, "note.md"); + fs.writeFileSync(filePath, "hello"); + expect(resolveRealDirectory(filePath)).toBeUndefined(); + }); + + test("detects when a canonical root path is replaced by a junction", () => { + const originalRoot = path.join(temporaryDirectory, "Original"); + fs.renameSync(root, originalRoot); + fs.symlinkSync(sibling, root, "junction"); + try { + expect(isCanonicalDirectory(root)).toBe(false); + } finally { + fs.unlinkSync(root); + } + }); }); diff --git a/ts/packages/agents/markdown/test/urlPath.spec.ts b/ts/packages/agents/markdown/test/urlPath.spec.ts new file mode 100644 index 0000000000..12ff22f26e --- /dev/null +++ b/ts/packages/agents/markdown/test/urlPath.spec.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + parseDocumentPathFromUrl, + ensureMarkdownExtension, + encodeDocumentPathForUrl, +} from "../src/view/route/urlPath.js"; + +describe("parseDocumentPathFromUrl", () => { + // Regression for Fix #2: the browser previously used + // /\/document\/([^\/]+)/, which captured only the first segment of + // a nested path. `parseDocumentPathFromUrl` now preserves the full + // relative path and decodes each segment independently so slashes, + // dots, and spaces round-trip. + test("returns the full nested path", () => { + expect(parseDocumentPathFromUrl("/document/team/2025/plan.md")).toBe( + "team/2025/plan.md", + ); + }); + + test("decodes each segment independently", () => { + expect(parseDocumentPathFromUrl("/document/my%20notes.md")).toBe( + "my notes.md", + ); + expect( + parseDocumentPathFromUrl("/document/team%20a/2025/plan%20v2.md"), + ).toBe("team a/2025/plan v2.md"); + }); + + test("returns null when the URL is not a /document route", () => { + expect(parseDocumentPathFromUrl("/")).toBeNull(); + expect(parseDocumentPathFromUrl("/other/thing")).toBeNull(); + expect(parseDocumentPathFromUrl("/documents/foo")).toBeNull(); + }); + + test("returns null for empty or malformed document paths", () => { + expect(parseDocumentPathFromUrl("/document/")).toBeNull(); + expect(parseDocumentPathFromUrl("/document//foo")).toBeNull(); + expect(parseDocumentPathFromUrl("/document/foo//bar")).toBeNull(); + // Undecodable percent sequence. + expect(parseDocumentPathFromUrl("/document/broken%GZ")).toBeNull(); + expect(parseDocumentPathFromUrl("/document/team%2Fplan.md")).toBeNull(); + expect(parseDocumentPathFromUrl("/document/team%5Cplan.md")).toBeNull(); + }); + + test("strips a single trailing slash", () => { + expect(parseDocumentPathFromUrl("/document/team/plan.md/")).toBe( + "team/plan.md", + ); + }); + + test("guards against non-string input", () => { + expect( + parseDocumentPathFromUrl(undefined as unknown as string), + ).toBeNull(); + expect(parseDocumentPathFromUrl(null as unknown as string)).toBeNull(); + }); +}); + +describe("ensureMarkdownExtension", () => { + test("appends .md when missing", () => { + expect(ensureMarkdownExtension("team/plan")).toBe("team/plan.md"); + }); + + describe("encodeDocumentPathForUrl", () => { + test("preserves nested path separators and encodes spaces per segment", () => { + expect(encodeDocumentPathForUrl("team/2025/plan.md")).toBe( + "team/2025/plan", + ); + expect(encodeDocumentPathForUrl("my notes.md")).toBe("my%20notes"); + expect(encodeDocumentPathForUrl("team a/plan v2.md")).toBe( + "team%20a/plan%20v2", + ); + }); + }); + test("does not double-append", () => { + expect(ensureMarkdownExtension("team/plan.md")).toBe("team/plan.md"); + expect(ensureMarkdownExtension("team/plan.MD")).toBe("team/plan.MD"); + }); +}); diff --git a/ts/packages/agents/markdown/test/viewService.spec.ts b/ts/packages/agents/markdown/test/viewService.spec.ts index e14f32757a..9e24c72696 100644 --- a/ts/packages/agents/markdown/test/viewService.spec.ts +++ b/ts/packages/agents/markdown/test/viewService.spec.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import WebSocket from "ws"; const servicePath = fileURLToPath( new URL("../view/route/service.js", import.meta.url), @@ -43,10 +44,12 @@ describe("markdown view service", () => { ); viewProcess.send({ type: "setFile", - filePath: "headless.md", + workspaceRoot: root, + relativePath: "headless.md", }); viewProcess.send({ type: "applyLLMOperations", + requestId: "apply-headless-1", operations: [ { type: "insert", @@ -63,7 +66,9 @@ describe("markdown view service", () => { const response = await waitForMessage( viewProcess, - (message) => message.type === "operationsApplied", + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-headless-1", ); expect(response).toMatchObject({ success: true, @@ -76,42 +81,2247 @@ describe("markdown view service", () => { ); }); - test("persists browser autosave content", async () => { + test("reroots via setFile workspaceRoot and persists under the new root", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const workspace = fs.mkdtempSync( + path.join(os.tmpdir(), "markdown-view-workspace-"), + ); + try { + const initialFile = path.join(root, "seed.md"); + fs.writeFileSync(initialFile, "", "utf-8"); + const nestedDirectory = path.join(workspace, "nested"); + fs.mkdirSync(nestedDirectory); + const target = path.join(nestedDirectory, "note.md"); + fs.writeFileSync(target, "", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: nestedDirectory, + relativePath: "note.md", + }); + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-reroot-1", + operations: [ + { + type: "insert", + position: 0, + content: [ + { + type: "text", + text: "# Rerooted", + }, + ], + }, + ], + }); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-reroot-1", + ); + expect(response.success).toBe(true); + expect(fs.readFileSync(target, "utf-8")).toBe("# Rerooted"); + // The original root was left untouched. + expect(fs.readFileSync(initialFile, "utf-8")).toBe(""); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("ignores setFile with an invalid workspaceRoot", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "keep.md"); + fs.writeFileSync(filePath, "", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + // First, bind a file under the initial root. + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "keep.md", + }); + // A subsequent setFile whose workspaceRoot is not an absolute + // existing directory must be ignored end-to-end (root does not + // switch, and the previously bound file remains the target). + viewProcess.send({ + type: "setFile", + workspaceRoot: "not-absolute", + relativePath: "ignored.md", + }); + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-still-original", + operations: [ + { + type: "insert", + position: 0, + content: [ + { + type: "text", + text: "still original", + }, + ], + }, + ], + }); + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-still-original", + ); + expect(response.success).toBe(true); + // Root was NOT switched — the write landed on the file we set + // before the invalid setFile message. + expect(fs.readFileSync(filePath, "utf-8")).toBe("still original"); + expect(fs.existsSync(path.join(root, "ignored.md"))).toBe(false); + }); + + test("rejects a headless update after the document root is replaced by a junction", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const workspace = fs.mkdtempSync( + path.join(os.tmpdir(), "markdown-view-workspace-"), + ); + const documentRoot = path.join(workspace, "notes"); + const movedDocumentRoot = path.join(workspace, "moved-notes"); + const outsideRoot = path.join(workspace, "outside"); + fs.mkdirSync(documentRoot); + fs.mkdirSync(outsideRoot); + fs.writeFileSync(path.join(documentRoot, "plan.md"), "inside", "utf-8"); + const outsideFile = path.join(outsideRoot, "plan.md"); + fs.writeFileSync(outsideFile, "outside", "utf-8"); + + try { + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: documentRoot, + relativePath: "plan.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "set-file-complete", + }); + await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "set-file-complete", + ); + + fs.renameSync(documentRoot, movedDocumentRoot); + fs.symlinkSync(outsideRoot, documentRoot, "junction"); + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-junction", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "escaped" }], + }, + ], + }); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-junction", + ); + expect(response.success).toBe(false); + expect(response.error).toMatch(/root is no longer accessible/); + expect(fs.readFileSync(outsideFile, "utf-8")).toBe("outside"); + } finally { + if (fs.existsSync(documentRoot)) { + fs.unlinkSync(documentRoot); + } + fs.rmSync(workspace, { recursive: true, force: true }); + } + }); + + test("persists browser autosave content when bound via setFile", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "browser.md"); + fs.writeFileSync(filePath, "", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "browser.md", + }); + + // Capture the current bindingToken the way the browser would - + // via a getDocumentContent roundtrip on the IPC channel. The + // /autosave endpoint now requires the trusted token. + viewProcess.send({ + type: "getDocumentContent", + requestId: "capture-token-autosave-1", + }); + const bound = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "capture-token-autosave-1", + ); + const bindingToken = bound.bindingToken; + expect(typeof bindingToken).toBe("string"); + + const response = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId: "browser", + bindingToken, + expectedRevision: bound.revision, + content: "# Browser\n\nPersisted by autosave.", + }), + }, + ); + + expect(response.ok).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe( + "# Browser\n\nPersisted by autosave.", + ); + }); + + test("rejects browser autosave when bindingToken is missing", async () => { + // Autosave without a token is fail-closed: the browser must have + // adopted the bootstrap identity before it is allowed to write. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "browser.md"); + fs.writeFileSync(filePath, "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "browser.md", + }); + // Wait until bound so we know the reject reason is the missing + // token, not the missing binding. + viewProcess.send({ + type: "getDocumentContent", + requestId: "await-bind-missing-token", + }); + await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "await-bind-missing-token", + ); + + const response = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId: "browser", + content: "should not persist", + }), + }, + ); + + expect(response.status).toBe(409); + expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); + }); + + test("rejects browser autosave when bindingToken is stale", async () => { + // After a rebind, the token rotates. Autosave callers pinned to + // the pre-rebind token must be rejected so an editor + // mid-navigation cannot flush its previous content onto the + // newly-bound file. root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); const filePath = path.join(root, "browser.md"); + fs.writeFileSync(filePath, "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "browser.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "await-bind-stale-1", + }); + const first = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "await-bind-stale-1", + ); + const staleToken = first.bindingToken; + expect(typeof staleToken).toBe("string"); + + // Rebind to rotate the token. + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "browser.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "await-bind-stale-2", + }); + const second = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "await-bind-stale-2", + ); + expect(second.bindingToken).not.toBe(staleToken); + + const response = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId: "browser", + bindingToken: staleToken, + content: "stale content", + }), + }, + ); + + expect(response.status).toBe(409); + expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); + }); + + test("rejects browser autosave when no file is bound", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + + const response = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId: "default", + content: "should not be persisted", + }), + }, + ); + + expect(response.status).toBe(409); + expect(fs.readdirSync(root)).toEqual([]); + }); + + test("autosave binds trust boundary: default documentId still writes to bound file", async () => { + // Regression for Round 1 Blocker 1: browser must never be able to + // choose the target path. Even when the client sends a wrong or + // default documentId, autosave must land in the trusted bound file + // (notes.md) and never write to default.md. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const boundFile = path.join(root, "notes.md"); + fs.writeFileSync(boundFile, "", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "notes.md", + }); + // Capture the trusted bindingToken; only the token is trusted for + // path selection, the documentId is intentionally left as + // "default" so we prove it does NOT influence the target path. + viewProcess.send({ + type: "getDocumentContent", + requestId: "capture-token-trust", + }); + const boundContent = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "capture-token-trust", + ); + const bindingToken = boundContent.bindingToken; + expect(typeof bindingToken).toBe("string"); + + const response = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId: "default", + bindingToken, + expectedRevision: boundContent.revision, + content: "# Notes\n\ncontent", + }), + }, + ); + + expect(response.ok).toBe(true); + const body = await response.json(); + expect(body).toMatchObject({ roomMismatch: true }); + expect(fs.readFileSync(boundFile, "utf-8")).toBe("# Notes\n\ncontent"); + expect(fs.existsSync(path.join(root, "default.md"))).toBe(false); + }); + + test("applyLLMOperations rejects on expectedBindingToken mismatch", async () => { + // Regression for Round 1 Blocker 2 (rebased on token identity): + // agent's read/apply must not clobber a file the view has been + // rebound to since the read - and a stale token still fails even + // when the view is rebound to the same basename/relative path. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "bound.md"); + fs.writeFileSync(filePath, "existing", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "bound.md", + }); + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-stale", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "should-not-write" }], + }, + ], + expectedBindingToken: "stale-token", + }); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-stale", + ); + expect(response.success).toBe(false); + expect(response.identityMismatch).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe("existing"); + }); + + test("rebinding to the same relative path rotates the binding token", async () => { + // A rebound view must reject callers pinned to the pre-rebind + // token even when the new binding uses the same basename or the + // same relative path. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "same-basename.md"); + fs.writeFileSync(filePath, "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + + const bindingUpdates: string[] = []; + viewProcess.on("message", (message: any) => { + if ( + message?.type === "bindingUpdated" && + typeof message.bindingToken === "string" + ) { + bindingUpdates.push(message.bindingToken); + } + }); + + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "same-basename.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "capture-token-1", + }); + const first = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "capture-token-1", + ); + const firstToken = first.bindingToken; + expect(typeof firstToken).toBe("string"); + + // Rebind to the same relative path - the token must rotate. + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "same-basename.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "capture-token-2", + }); + const second = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "capture-token-2", + ); + expect(second.bindingToken).not.toBe(firstToken); + + // A caller still pinned to the stale token must be rejected. + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-stale-rebind", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "clobber" }], + }, + ], + expectedBindingToken: firstToken, + }); + const rejected = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-stale-rebind", + ); + expect(rejected.success).toBe(false); + expect(rejected.identityMismatch).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); + }); + + test("applyLLMOperations rejects on expectedRevision mismatch", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "revised.md"); + fs.writeFileSync(filePath, "original", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "revised.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-revision", + }); + const read = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "read-revision", + ); + + // Send an apply with a bogus expected revision - it must be rejected. + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-bad-rev", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "clobber" }], + }, + ], + expectedBindingToken: read.bindingToken, + expectedRevision: "not-a-real-revision", + }); + const rejected = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-bad-rev", + ); + expect(rejected.success).toBe(false); + expect(rejected.revisionMismatch).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe("original"); + }); + + test("concurrent getDocumentContent requests are correlated by requestId", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "concurrent.md"); + fs.writeFileSync(filePath, "hello", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "concurrent.md", + }); + + viewProcess.send({ type: "getDocumentContent", requestId: "req-a" }); + viewProcess.send({ type: "getDocumentContent", requestId: "req-b" }); + + const first = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "req-a", + ); + const second = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "req-b", + ); + expect(first.requestId).toBe("req-a"); + expect(second.requestId).toBe("req-b"); + expect(typeof first.revision).toBe("string"); + expect(first.revision).toBe(second.revision); + }); + + test("getDocumentContent reports bound file and root for recovery", async () => { + // Regression for Round 1 Blocker 3: after restart the agent may not + // know its bound path; the view must report boundFilePath/boundRoot + // so the agent can safely adopt them. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "restart.md"); + fs.writeFileSync(filePath, "hello", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "restart.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-restart", + }); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "read-restart", + ); + // boundDocumentId is now the binding-scoped opaque room ID + // (equal to the freshly-rotated bindingToken), so `a/note.md` + // and `b/note.md` cannot collide on shared basename `note`. + expect(typeof response.bindingToken).toBe("string"); + expect(response.boundDocumentId).toBe(response.bindingToken); + expect(response.boundFilePath).toBe(filePath); + expect(fs.realpathSync(response.boundRoot)).toBe(fs.realpathSync(root)); + expect(response.boundRelativePath).toBe("restart.md"); + expect(typeof response.revision).toBe("string"); + expect(response.identityMismatch).toBeFalsy(); + }); + + test("preserves nested user-relative paths through setFile and reports them for recovery", async () => { + // Regression for the "preserve nested user-relative paths" + // requirement: setFile must accept "sub/dir/file.md" and the + // recovery response must report the same normalized POSIX path + // rather than reducing it to a basename. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const nested = path.join(root, "docs", "team"); + fs.mkdirSync(nested, { recursive: true }); + const filePath = path.join(nested, "roadmap.md"); + fs.writeFileSync(filePath, "hi", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "docs/team/roadmap.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-nested", + }); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "read-nested", + ); + expect(response.boundRelativePath).toBe("docs/team/roadmap.md"); + expect(fs.realpathSync(response.boundFilePath)).toBe( + fs.realpathSync(filePath), + ); + }); + + test("applyLLMOperations rejects on expectedRelativePath mismatch even when token is absent", async () => { + // Startup window: the agent may issue an apply before it has + // observed a bindingToken (e.g. the setFile ack is still in + // flight). The expected root+relativePath fields must then be + // enough on their own to make the view reject an apply pinned + // to the wrong identity. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const boundFile = path.join(root, "correct.md"); + const otherFile = path.join(root, "other.md"); + fs.writeFileSync(boundFile, "existing", "utf-8"); + fs.writeFileSync(otherFile, "existing-other", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "correct.md", + }); + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-wrong-relative", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "should-not-write" }], + }, + ], + // Omit expectedBindingToken to simulate the pre-bindingUpdated + // startup window; only send the path expectation. + expectedRoot: root, + expectedRelativePath: "other.md", + }); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-wrong-relative", + ); + expect(response.success).toBe(false); + expect(response.identityMismatch).toBe(true); + expect(fs.readFileSync(boundFile, "utf-8")).toBe("existing"); + expect(fs.readFileSync(otherFile, "utf-8")).toBe("existing-other"); + }); + + test("getDocumentContent rejects on expectedRoot mismatch even when token is absent", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "bound.md"), "content", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "bound.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-wrong-root", + expectedRoot: path.join(os.tmpdir(), "some-other-root"), + expectedRelativePath: "bound.md", + }); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "read-wrong-root", + ); + expect(response.identityMismatch).toBe(true); + expect(response.content).toBe(""); + }); + + test("emits bindingBootstrap SSE event to newly-connected clients", async () => { + // Regression for the browser fail-closed rework: a browser that + // connects AFTER the agent's setFile must learn the current + // binding token from the initial SSE event, otherwise its + // documentSnapshot handler (which now fails closed on a null + // token) would ignore server-published snapshots and stop + // reflecting agent edits. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "bootstrap.md"), "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "bootstrap.md", + }); + // Wait for the setFile to be fully processed - use a getDocumentContent + // roundtrip so we know the view is bound before we connect the SSE. + viewProcess.send({ + type: "getDocumentContent", + requestId: "await-bind", + }); + const boundResponse = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "await-bind", + ); + const expectedToken = boundResponse.bindingToken; + expect(typeof expectedToken).toBe("string"); + + const controller = new AbortController(); + try { + const response = await fetch( + `http://127.0.0.1:${ready.port}/events`, + { signal: controller.signal }, + ); + expect(response.body).toBeTruthy(); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + let bootstrap: any = undefined; + const readDeadline = Date.now() + 5000; + while (bootstrap === undefined && Date.now() < readDeadline) { + const { value, done } = await reader.read(); + if (done) { + break; + } + buffered += decoder.decode(value, { stream: true }); + for (const chunk of buffered.split(/\n\n/)) { + if (!chunk.startsWith("data: ")) { + continue; + } + const payload = chunk.slice("data: ".length).trim(); + if (!payload) { + continue; + } + try { + const parsed = JSON.parse(payload); + if (parsed?.type === "bindingBootstrap") { + bootstrap = parsed; + break; + } + } catch { + // partial chunk; wait for more + } + } + } + expect(bootstrap).toBeDefined(); + expect(bootstrap.bindingToken).toBe(expectedToken); + // documentId is the binding-scoped opaque room ID (token), + // not the file basename; basename lives in documentName. + expect(bootstrap.documentId).toBe(expectedToken); + expect(bootstrap.documentName).toBe("bootstrap"); + expect(bootstrap.boundRelativePath).toBe("bootstrap.md"); + } finally { + controller.abort(); + } + }); + + test("in-flight getDocumentContent rejects when setFile rotates during the browser await", async () => { + // Race test: a browser is connected, so getDocumentContent must + // await `requestMarkdownFromClient`. During that await the + // agent (or another browser) issues setFile, rotating the + // binding. The response must land as identityMismatch, not as + // `content that matches the wrong identity`. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "first.md"), "first-content", "utf-8"); + fs.writeFileSync( + path.join(root, "second.md"), + "second-content", + "utf-8", + ); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "first.md", + }); + + // Connect a fake `browser` via SSE. The view will send + // `requestMarkdown` events to it; we intercept the requestId and + // hold off responding while we rotate the binding. + const controller = new AbortController(); + const decoder = new TextDecoder(); + let markdownRequestId: string | undefined; + try { + const eventsResponse = await fetch( + `http://127.0.0.1:${ready.port}/events`, + { signal: controller.signal }, + ); + const reader = eventsResponse.body!.getReader(); + + // Kick off a background reader so requestMarkdown / other + // SSE events are consumed and we notice the requestId. + const eventBuffer: string[] = []; + const readerLoop = (async () => { + let buffered = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) { + return; + } + buffered += decoder.decode(value, { stream: true }); + const parts = buffered.split(/\n\n/); + buffered = parts.pop() ?? ""; + for (const chunk of parts) { + if (chunk.startsWith("data: ")) { + eventBuffer.push(chunk.slice(6)); + } + } + } + })(); + readerLoop.catch(() => { + /* ignore reader abort */ + }); + + // Wait for the SSE connection to register on the view side by + // reading the bindingBootstrap it emits on connect. + const bootstrapDeadline = Date.now() + 3000; + while (Date.now() < bootstrapDeadline) { + const bootstrap = eventBuffer.find((event) => + event.includes(`"type":"bindingBootstrap"`), + ); + if (bootstrap) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + // Send the getDocumentContent request while the client is + // `connected`. The view will send a requestMarkdown SSE and + // wait for our /api/markdown-response. + viewProcess.send({ + type: "getDocumentContent", + requestId: "race-read", + }); + + const markdownRequestDeadline = Date.now() + 5000; + while ( + markdownRequestId === undefined && + Date.now() < markdownRequestDeadline + ) { + for (const raw of eventBuffer) { + const match = raw.match( + /"type":"requestMarkdown","requestId":"([^"]+)"/, + ); + if (match) { + markdownRequestId = match[1]; + break; + } + } + if (markdownRequestId === undefined) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + expect(typeof markdownRequestId).toBe("string"); + + // Rotate the binding while the view is still waiting for our + // /api/markdown-response. + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "second.md", + }); + + // Wait a tick so the setFile is definitely processed by the + // view before we let the markdown request complete. + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Now let the browser respond with content matching the OLD + // binding. The view's recheck must catch the rotation and + // return identityMismatch instead of pairing the response + // with the new binding token. + const postResponse = await fetch( + `http://127.0.0.1:${ready.port}/api/markdown-response`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId: markdownRequestId, + markdown: "first-content-from-browser", + positionInfo: { position: 0 }, + }), + }, + ); + expect(postResponse.ok).toBe(true); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "race-read", + ); + expect(response.identityMismatch).toBe(true); + expect(response.content).toBe(""); + } finally { + controller.abort(); + } + }); + + test("bindingBootstrap tags the first SSE client as primary and subsequent as secondary", async () => { + // Primary role must be established by SSE ordering in + // bindingBootstrap, not by a legacy llmOperations broadcast. + // The first client to connect is designated primary; a second + // client sees itself as secondary until the primary drops. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "roles.md"), "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "roles.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "roles-await-bind", + }); + await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "roles-await-bind", + ); + + const controllerA = new AbortController(); + const controllerB = new AbortController(); + try { + const bootstrapA = await readBindingBootstrap( + `http://127.0.0.1:${ready.port}/events`, + controllerA.signal, + ); + expect(bootstrapA.clientRole).toBe("primary"); + + const bootstrapB = await readBindingBootstrap( + `http://127.0.0.1:${ready.port}/events`, + controllerB.signal, + ); + expect(bootstrapB.clientRole).toBe("secondary"); + } finally { + controllerA.abort(); + controllerB.abort(); + } + }); + + test("promotes a secondary with the revision written by the former primary", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "handoff.md"); + fs.writeFileSync(filePath, "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "handoff.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "handoff-await-bind", + }); + const bound = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "handoff-await-bind", + ); + + const primaryController = new AbortController(); + const secondaryController = new AbortController(); + try { + const primaryEvents = await openSseEventStream( + `http://127.0.0.1:${ready.port}/events`, + primaryController.signal, + ); + const primaryBootstrap = await waitForSseEvent( + primaryEvents, + "bindingBootstrap", + ); + expect(primaryBootstrap.clientRole).toBe("primary"); + + const secondaryEvents = await openSseEventStream( + `http://127.0.0.1:${ready.port}/events`, + secondaryController.signal, + ); + const secondaryBootstrap = await waitForSseEvent( + secondaryEvents, + "bindingBootstrap", + ); + expect(secondaryBootstrap.clientRole).toBe("secondary"); + + const primaryContent = "# Written by the primary\n"; + const primarySave = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: primaryContent, + documentId: bound.bindingToken, + bindingToken: bound.bindingToken, + expectedRevision: bound.revision, + }), + }, + ); + expect(primarySave.ok).toBe(true); + const primaryResult = (await primarySave.json()) as { + revision: string; + }; + + const observedSave = await waitForSseEvent( + secondaryEvents, + "autoSave", + ); + expect(observedSave).toMatchObject({ + bindingToken: bound.bindingToken, + revision: primaryResult.revision, + }); + + primaryController.abort(); + const promotion = await waitForSseEvent( + secondaryEvents, + "primaryElected", + ); + expect(promotion).toMatchObject({ + bindingToken: bound.bindingToken, + revision: primaryResult.revision, + }); + + const promotedContent = `${primaryContent}\nContinued by secondary.\n`; + const promotedSave = await fetch( + `http://127.0.0.1:${ready.port}/autosave`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: promotedContent, + documentId: bound.bindingToken, + bindingToken: bound.bindingToken, + expectedRevision: promotion.revision, + }), + }, + ); + expect(promotedSave.ok).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe(promotedContent); + } finally { + primaryController.abort(); + secondaryController.abort(); + } + }); + + test("requestMarkdown SSE carries expectedBindingToken; mismatch rejects the pending read", async () => { + // The async markdown request/response protocol is identity-scoped: + // the SSE event to the browser must include the expected + // bindingToken, and the browser MUST echo its current token in + // the /api/markdown-response body. A mismatched echo (e.g. the + // browser rebound mid-flight) fails the pending read. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "token.md"), "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "token.md", + }); + + const controller = new AbortController(); + try { + // Connect an SSE client so getDocumentContent goes through + // requestMarkdownFromClient (which is where the token is + // threaded), not the file fallback. + const eventsResponse = await fetch( + `http://127.0.0.1:${ready.port}/events`, + { signal: controller.signal }, + ); + const reader = eventsResponse.body!.getReader(); + const decoder = new TextDecoder(); + const eventBuffer: string[] = []; + const readerLoop = (async () => { + let buffered = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) { + return; + } + buffered += decoder.decode(value, { stream: true }); + const parts = buffered.split(/\n\n/); + buffered = parts.pop() ?? ""; + for (const chunk of parts) { + if (chunk.startsWith("data: ")) { + eventBuffer.push(chunk.slice(6)); + } + } + } + })(); + readerLoop.catch(() => { + /* ignore reader abort */ + }); + + // Wait for bindingBootstrap so we know the SSE is registered. + const bootstrapDeadline = Date.now() + 3000; + while (Date.now() < bootstrapDeadline) { + const found = eventBuffer.find((event) => + event.includes(`"type":"bindingBootstrap"`), + ); + if (found) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + viewProcess.send({ + type: "getDocumentContent", + requestId: "token-race", + }); + + // Wait for the requestMarkdown SSE event and capture its + // expectedBindingToken plus requestId. + let markdownRequestId: string | undefined; + let expectedBindingToken: string | undefined; + const markdownRequestDeadline = Date.now() + 5000; + while ( + markdownRequestId === undefined && + Date.now() < markdownRequestDeadline + ) { + for (const raw of eventBuffer) { + if (!raw.includes(`"type":"requestMarkdown"`)) { + continue; + } + try { + const parsed = JSON.parse(raw); + if (parsed?.type === "requestMarkdown") { + markdownRequestId = parsed.requestId; + expectedBindingToken = parsed.expectedBindingToken; + break; + } + } catch { + // partial chunk; wait for more + } + } + if (markdownRequestId === undefined) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + expect(typeof markdownRequestId).toBe("string"); + expect(typeof expectedBindingToken).toBe("string"); + + // Reply with an obviously-wrong token; the pending read must + // fail with identityMismatch (surfaced via the file fallback + // being reached in this scenario, followed by the post-read + // snapshot recheck). The view's fallback here will use the + // pinned snapshot to read `seed` from token.md; the outer + // recheck still succeeds because binding did not rotate. So + // we assert that a mismatched token does NOT return the + // browser-provided content. + const postResponse = await fetch( + `http://127.0.0.1:${ready.port}/api/markdown-response`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId: markdownRequestId, + markdown: "wrong-token-content", + bindingToken: "not-the-real-token", + positionInfo: { position: 0 }, + }), + }, + ); + expect(postResponse.ok).toBe(true); + + const response = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "token-race", + ); + // Mismatched browser echo must surface as identityMismatch + // (not silently laundered through the Yjs / file fallback): + // the browser explicitly answered under a different + // binding, so the read fails closed and returns no content. + expect(response.identityMismatch).toBe(true); + expect(response.content).toBe(""); + expect(response.content).not.toBe("wrong-token-content"); + } finally { + controller.abort(); + } + }); + + test("/api/switch-document accepts a nested documentPath and preserves the full relative path", async () => { + // Nested paths (docs/team/plan) must round-trip through the API: + // the response echoes the full relative path (with .md) and the + // freshly-rotated bindingToken/documentId, and the file lands at + // the requested nested location under the trusted root. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + + const response = await fetch( + `http://127.0.0.1:${ready.port}/api/switch-document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ documentPath: "docs/team/plan" }), + }, + ); + expect(response.ok).toBe(true); + const body: any = await response.json(); + expect(body.relativePath).toBe("docs/team/plan.md"); + expect(body.boundRelativePath).toBe("docs/team/plan.md"); + expect(typeof body.bindingToken).toBe("string"); + // documentId (Yjs room) is scoped to the binding, not the file + // basename, so it equals the freshly-rotated bindingToken. + expect(body.documentId).toBe(body.bindingToken); + expect(body.documentName).toBe("plan"); + expect(fs.existsSync(path.join(root, "docs", "team", "plan.md"))).toBe( + true, + ); + }); + + test("same-basename nested paths get distinct room IDs and cannot cross-write", async () => { + // Rooms are scoped by opaque bindingToken, not by file basename. + // Two files that share basename `note` (in `a/` and `b/`) + // must therefore get distinct room IDs, and a stale + // expectedBindingToken pinned to the previous binding must be + // rejected as identityMismatch even though the basename matches. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.mkdirSync(path.join(root, "a")); + fs.mkdirSync(path.join(root, "b")); + const aFile = path.join(root, "a", "note.md"); + const bFile = path.join(root, "b", "note.md"); + fs.writeFileSync(aFile, "seed-a", "utf-8"); + fs.writeFileSync(bFile, "seed-b", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + + // Bind a/note.md and capture its room ID + token. + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "a/note.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-a", + }); + const readA = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "read-a", + ); + const tokenA = readA.bindingToken; + const idA = readA.boundDocumentId; + expect(typeof tokenA).toBe("string"); + expect(idA).toBe(tokenA); + expect(readA.boundRelativePath).toBe("a/note.md"); + expect(readA.content).toBe("seed-a"); + + // Rebind to b/note.md - basename identical, path differs. + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "b/note.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-b", + }); + const readB = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "read-b", + ); + const tokenB = readB.bindingToken; + const idB = readB.boundDocumentId; + expect(typeof tokenB).toBe("string"); + expect(idB).toBe(tokenB); + expect(readB.boundRelativePath).toBe("b/note.md"); + expect(readB.content).toBe("seed-b"); + + // Distinct room IDs / tokens - proving basename does not + // scope the Yjs room. + expect(tokenA).not.toBe(tokenB); + expect(idA).not.toBe(idB); + + // Apply under the new binding writes only to b/note.md. + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-b", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "B-write:" }], + }, + ], + expectedBindingToken: tokenB, + }); + const applyB = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-b", + ); + expect(applyB.success).toBe(true); + expect(fs.readFileSync(aFile, "utf-8")).toBe("seed-a"); + expect(fs.readFileSync(bFile, "utf-8")).toBe("B-write:seed-b"); + + // Apply pinned to the stale tokenA must be rejected as + // identityMismatch: the view no longer holds the a/ binding + // even though its basename matches the current one. + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-stale-a", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "should-not-write" }], + }, + ], + expectedBindingToken: tokenA, + }); + const rejected = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-stale-a", + ); + expect(rejected.success).toBe(false); + expect(rejected.identityMismatch).toBe(true); + // Neither file was touched by the stale-pinned request. + expect(fs.readFileSync(aFile, "utf-8")).toBe("seed-a"); + expect(fs.readFileSync(bFile, "utf-8")).toBe("B-write:seed-b"); + }); + + test("autosave persists Markdown syntax (headings, bold, code) verbatim", async () => { + // Regression: browser autosave used to serialize the editor with + // ProseMirror `.textContent` which drops all Markdown markers, + // so a document with `# H`, `**bold**`, and ```` ```code``` ```` + // was silently persisted as `H bold code`. The fix routes + // autosave through the same Milkdown serializer as everything + // else, and the endpoint just writes whatever the browser sent. + // We simulate the fixed browser payload directly and assert the + // on-disk file preserves every Markdown marker. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "styled.md"); fs.writeFileSync(filePath, "", "utf-8"); viewProcess = fork(servicePath, ["0"], { - env: { - ...process.env, - TYPEAGENT_MARKDOWN_ROOT: root, - }, + env: { ...process.env, TYPEAGENT_MARKDOWN_ROOT: root }, stdio: ["ignore", "ignore", "ignore", "ipc"], }); const ready = await waitForMessage( viewProcess, - (message) => message.type === "Success", + (m) => m.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "styled.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "capture-styled", + }); + const bound = await waitForMessage( + viewProcess, + (m) => + m.type === "documentContent" && + m.requestId === "capture-styled", ); + const bindingToken = bound.bindingToken; + + const markdown = [ + "# Heading", + "", + "Paragraph with **bold** and *italic* words.", + "", + "```ts", + "const x = 1;", + "```", + "", + ].join("\n"); + const response = await fetch( `http://127.0.0.1:${ready.port}/autosave`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - documentId: "browser", - content: "# Browser\n\nPersisted by autosave.", + documentId: "styled", + bindingToken, + expectedRevision: bound.revision, + content: markdown, }), }, ); - expect(response.ok).toBe(true); - expect(fs.readFileSync(filePath, "utf-8")).toBe( - "# Browser\n\nPersisted by autosave.", + + const persisted = fs.readFileSync(filePath, "utf-8"); + expect(persisted).toBe(markdown); + // Guard against a regression that would strip individual markers. + expect(persisted).toContain("# Heading"); + expect(persisted).toContain("**bold**"); + expect(persisted).toContain("```ts"); + }); + + test("POST /document requires bindingToken and preserves Markdown syntax", async () => { + // Regression for Fix #5: POST /document (used by the manual + // save path in the browser) had no trust checks - it accepted + // any content field and wrote to disk. A browser tab that + // missed the last rebind could silently overwrite a different + // file. The fixed handler shares the /autosave validator, so + // both fail-closed on a missing token and succeed when the + // caller carries the current one. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "manual.md"); + fs.writeFileSync(filePath, "seed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { ...process.env, TYPEAGENT_MARKDOWN_ROOT: root }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const ready = await waitForMessage( + viewProcess, + (m) => m.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "manual.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "capture-manual", + }); + const bound = await waitForMessage( + viewProcess, + (m) => + m.type === "documentContent" && + m.requestId === "capture-manual", + ); + const bindingToken = bound.bindingToken; + + // No token -> 409 and no write. + const rejected = await fetch( + `http://127.0.0.1:${ready.port}/document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "# should-not-write\n" }), + }, + ); + expect(rejected.status).toBe(409); + expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); + + // Stale token -> 409 and no write. + const staleRejected = await fetch( + `http://127.0.0.1:${ready.port}/document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: "# should-not-write\n", + bindingToken: "stale-token-xyz", + }), + }, + ); + expect(staleRejected.status).toBe(409); + expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); + + // A current token with a stale revision is also rejected. + const staleRevision = await fetch( + `http://127.0.0.1:${ready.port}/document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: "# should-not-write\n", + bindingToken, + expectedRevision: "stale-revision", + }), + }, + ); + expect(staleRevision.status).toBe(409); + await expect(staleRevision.json()).resolves.toMatchObject({ + content: "seed", + revision: bound.revision, + }); + expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); + + // Correct identity and revision -> 200 and Markdown persisted verbatim. + const markdown = + "# Manual save\n\nPersisted **via** POST /document.\n\n```md\ncode\n```\n"; + const accepted = await fetch( + `http://127.0.0.1:${ready.port}/document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: markdown, + bindingToken, + expectedRevision: bound.revision, + }), + }, + ); + expect(accepted.ok).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe(markdown); + }); + + test("POST /file/load is unavailable and cannot retarget the active binding", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "bound.md"), "bound-content", "utf-8"); + fs.writeFileSync(path.join(root, "other.md"), "other-content", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { ...process.env, TYPEAGENT_MARKDOWN_ROOT: root }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "bound.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "before-dead-load", + }); + const before = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "before-dead-load", + ); + + const response = await fetch( + `http://127.0.0.1:${ready.port}/file/load`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filePath: "other.md" }), + }, + ); + expect(response.status).toBe(404); + + viewProcess.send({ + type: "getDocumentContent", + requestId: "after-dead-load", + }); + const after = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "after-dead-load", + ); + expect(after).toMatchObject({ + bindingToken: before.bindingToken, + boundRelativePath: "bound.md", + content: "bound-content", + }); + }); + + test("/collaboration/info returns the opaque documentId, distinct for same-basename siblings", async () => { + // Regression for Fix #3: the endpoint used to return only the + // basename (`note`) as `currentDocument`, and the browser then + // used that as its Yjs room key. Two files that share a + // basename in different folders would coalesce onto the same + // room. The fixed endpoint returns a `documentId` equal to + // the opaque bindingToken (the actual room key the service + // uses everywhere else), so nested siblings are isolated. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.mkdirSync(path.join(root, "a")); + fs.mkdirSync(path.join(root, "b")); + fs.writeFileSync(path.join(root, "a", "note.md"), "A", "utf-8"); + fs.writeFileSync(path.join(root, "b", "note.md"), "B", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { ...process.env, TYPEAGENT_MARKDOWN_ROOT: root }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + const ready = await waitForMessage( + viewProcess, + (m) => m.type === "Success", + ); + + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "a/note.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-a-info", + }); + const readA = await waitForMessage( + viewProcess, + (m) => + m.type === "documentContent" && m.requestId === "read-a-info", + ); + const collabA = (await ( + await fetch(`http://127.0.0.1:${ready.port}/collaboration/info`) + ).json()) as { documentId: string; currentDocument: string }; + expect(collabA.documentId).toBe(readA.bindingToken); + expect(collabA.currentDocument).toBe("note"); + + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "b/note.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "read-b-info", + }); + const readB = await waitForMessage( + viewProcess, + (m) => + m.type === "documentContent" && m.requestId === "read-b-info", + ); + const collabB = (await ( + await fetch(`http://127.0.0.1:${ready.port}/collaboration/info`) + ).json()) as { documentId: string; currentDocument: string }; + expect(collabB.documentId).toBe(readB.bindingToken); + expect(collabB.currentDocument).toBe("note"); + + // Same basename, different bound files - documentIds MUST + // differ so the browser opens different Yjs rooms. + expect(collabA.documentId).not.toBe(collabB.documentId); + }); + + test("nested /api/switch-document opens the intended file with no junk siblings", async () => { + // Regression for Fix #2 (service side): the switch endpoint + // must honor the full nested relative path. Combined with the + // browser-side URL parser fix, this stops `/document/team/2025/plan` + // from being reduced to `team` on the browser and then landing + // on a stray `team.md` at the root. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.mkdirSync(path.join(root, "team", "2025"), { recursive: true }); + const targetPath = path.join(root, "team", "2025", "plan.md"); + fs.writeFileSync(targetPath, "planned", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { ...process.env, TYPEAGENT_MARKDOWN_ROOT: root }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + const ready = await waitForMessage( + viewProcess, + (m) => m.type === "Success", + ); + + const before = fs.readdirSync(root).sort(); + const switchResp = await fetch( + `http://127.0.0.1:${ready.port}/api/switch-document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ documentPath: "team/2025/plan.md" }), + }, + ); + expect(switchResp.ok).toBe(true); + const switchBody = (await switchResp.json()) as { + boundRelativePath: string; + content: string; + }; + expect(switchBody.boundRelativePath).toBe("team/2025/plan.md"); + expect(switchBody.content).toBe("planned"); + expect(fs.existsSync(targetPath)).toBe(true); + // No stray `team.md` at the root - would appear if the + // browser had reduced `/document/team/2025/plan` to `team` + // and the endpoint had treated `team` as a new document. + expect(fs.existsSync(path.join(root, "team.md"))).toBe(false); + expect(fs.readdirSync(root).sort()).toEqual(before); + + // And a URL-encoded space in a segment must also decode to a + // real relative filename, not the literal `my%20notes.md`. + fs.writeFileSync(path.join(root, "my notes.md"), "notes", "utf-8"); + const spacedResp = await fetch( + `http://127.0.0.1:${ready.port}/api/switch-document`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ documentPath: "my notes.md" }), + }, + ); + expect(spacedResp.ok).toBe(true); + const spacedBody = (await spacedResp.json()) as { + boundRelativePath: string; + content: string; + }; + expect(spacedBody.boundRelativePath).toBe("my notes.md"); + expect(spacedBody.content).toBe("notes"); + // No `my%20notes.md` literal created. + expect(fs.existsSync(path.join(root, "my%20notes.md"))).toBe(false); + }); + + test("evicts stale Y.Doc / awareness state after binding rotation with no attached clients", async () => { + // Regression for Fix #6: every rebinding creates a new opaque + // documentId, so the OLD room's Y.Doc used to linger in the + // in-memory `docs` map forever. The fixed service evicts the + // old room when no WebSocket client is attached. We probe the + // internal maps indirectly by observing the debug output + // through the /collaboration/info stats: `documents` reports + // the size of `docs` and must not grow unboundedly after + // repeated rebindings. + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "a.md"), "A", "utf-8"); + fs.writeFileSync(path.join(root, "b.md"), "B", "utf-8"); + fs.writeFileSync(path.join(root, "c.md"), "C", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { ...process.env, TYPEAGENT_MARKDOWN_ROOT: root }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + const ready = await waitForMessage( + viewProcess, + (m) => m.type === "Success", + ); + + async function bindAndGetStats(rel: string): Promise<{ + documents: number; + }> { + viewProcess!.send({ + type: "setFile", + workspaceRoot: root!, + relativePath: rel, + }); + viewProcess!.send({ + type: "getDocumentContent", + requestId: `bind-${rel}`, + }); + await waitForMessage( + viewProcess!, + (m) => + m.type === "documentContent" && + m.requestId === `bind-${rel}`, + ); + const info = (await ( + await fetch(`http://127.0.0.1:${ready.port}/collaboration/info`) + ).json()) as { documents: number }; + return { documents: info.documents }; + } + + const afterA = await bindAndGetStats("a.md"); + const afterB = await bindAndGetStats("b.md"); + const afterC = await bindAndGetStats("c.md"); + + // Without eviction, `documents` would grow monotonically with + // every rebinding (1 -> 2 -> 3). With eviction of idle rooms + // it must stay at 1 across all three rebinds because there is + // no WebSocket client attached to any old room. + expect(afterA.documents).toBe(1); + expect(afterB.documents).toBe(1); + expect(afterC.documents).toBe(1); + }); + + test("evicts a rotated room after its last WebSocket disconnects", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + fs.writeFileSync(path.join(root, "a.md"), "A", "utf-8"); + fs.writeFileSync(path.join(root, "b.md"), "B", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { ...process.env, TYPEAGENT_MARKDOWN_ROOT: root }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + const ready = await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "a.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "connected-room-a", + }); + const first = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "connected-room-a", ); + + const socket = new WebSocket( + `ws://127.0.0.1:${ready.port}/${first.bindingToken}`, + { origin: `http://127.0.0.1:${ready.port}` }, + ); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + + try { + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "b.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "connected-room-b", + }); + await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "connected-room-b", + ); + + const whileConnected = (await ( + await fetch(`http://127.0.0.1:${ready.port}/collaboration/info`) + ).json()) as { documents: number }; + expect(whileConnected.documents).toBe(2); + } finally { + await new Promise((resolve) => { + socket.once("close", resolve); + socket.close(); + }); + } + + await waitForDocumentCount(ready.port, 1); }); }); +async function readBindingBootstrap( + url: string, + signal: AbortSignal, +): Promise { + const response = await fetch(url, { signal }); + if (!response.body) { + throw new Error("SSE response body missing"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) { + throw new Error("SSE stream closed before bindingBootstrap"); + } + buffered += decoder.decode(value, { stream: true }); + const parts = buffered.split(/\n\n/); + buffered = parts.pop() ?? ""; + for (const chunk of parts) { + if (!chunk.startsWith("data: ")) { + continue; + } + const payload = chunk.slice("data: ".length).trim(); + if (!payload) { + continue; + } + try { + const parsed = JSON.parse(payload); + if (parsed?.type === "bindingBootstrap") { + return parsed; + } + } catch { + // partial chunk; keep reading + } + } + } + throw new Error("Timed out waiting for bindingBootstrap SSE event"); +} + +async function openSseEventStream( + url: string, + signal: AbortSignal, +): Promise { + const response = await fetch(url, { signal }); + if (!response.body) { + throw new Error("SSE response body missing"); + } + + const events: any[] = []; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + void (async () => { + let buffered = ""; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + return; + } + buffered += decoder.decode(value, { stream: true }); + const chunks = buffered.split(/\n\n/); + buffered = chunks.pop() ?? ""; + for (const chunk of chunks) { + if (!chunk.startsWith("data: ")) { + continue; + } + try { + events.push(JSON.parse(chunk.slice("data: ".length))); + } catch { + // Ignore malformed events; the next complete event can + // still be consumed from this long-lived stream. + } + } + } + } catch (error) { + if (!signal.aborted) { + throw error; + } + } + })(); + return events; +} + +async function waitForSseEvent( + events: any[], + type: string, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const event = events.find((candidate) => candidate?.type === type); + if (event !== undefined) { + return event; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`Timed out waiting for ${type} SSE event`); +} + function waitForMessage( child: ChildProcess, predicate: (message: any) => boolean, @@ -141,3 +2351,20 @@ function waitForMessage( child.on("exit", onExit); }); } + +async function waitForDocumentCount( + port: number, + expected: number, +): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const info = (await ( + await fetch(`http://127.0.0.1:${port}/collaboration/info`) + ).json()) as { documents: number }; + if (info.documents === expected) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for ${expected} collaboration document`); +} diff --git a/ts/packages/cli/src/commands/run/request.ts b/ts/packages/cli/src/commands/run/request.ts index 139e9eaf58..f45adefb99 100644 --- a/ts/packages/cli/src/commands/run/request.ts +++ b/ts/packages/cli/src/commands/run/request.ts @@ -95,6 +95,7 @@ export default class RequestCommand extends Command { conversation.dispatcher, `@dispatcher request ${args.request}`, this.loadAttachment(args.attachment), + { workingDirectory: process.cwd() }, ); }); } finally { diff --git a/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts index e368a4b028..1182fbabb3 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/actionContext.ts @@ -78,6 +78,7 @@ export function getActionContext( const actionContext: ActionContext = { streamingContext: undefined, isFromReasoningLoop: context.isInsideReasoningLoop, + workingDirectory: systemContext.currentOptions?.workingDirectory, activityContext: // Only make activityContext available if the action is from the same agent. context.activityContext?.appAgentName === appAgentName diff --git a/ts/packages/dispatcher/dispatcher/test/actionContext.spec.ts b/ts/packages/dispatcher/dispatcher/test/actionContext.spec.ts index c0c1aa73c4..6b5f977a92 100644 --- a/ts/packages/dispatcher/dispatcher/test/actionContext.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/actionContext.spec.ts @@ -5,12 +5,14 @@ import { getActionContext } from "../src/execute/actionContext.js"; // Builds the minimal CommandHandlerContext surface that getActionContext and // makeClientIOMessage touch for non-error display content. -function makeContext() { +function makeContext(workingDirectory?: string) { const calls: { type: string; mode?: string }[] = []; const context = { displayCount: 0, reasoningSourceIcon: undefined, collectCommandResult: false, + currentOptions: + workingDirectory === undefined ? undefined : { workingDirectory }, metricsManager: undefined, agents: { getSessionContext: () => ({}) as any, @@ -79,4 +81,17 @@ describe("getActionContext displayCount tracking", () => { actionContext.actionIO.appendDisplay(content, "temporary"); expect(context.displayCount).toBe(0); }); + + it("exposes the host-authorized working directory", () => { + const workingDirectory = "C:\\workspace"; + const { context } = makeContext(workingDirectory); + const { actionContext } = getActionContext( + "agent", + context, + requestId, + 0, + ); + + expect(actionContext.workingDirectory).toBe(workingDirectory); + }); }); diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 8fcc455e9e..bf9e9682c2 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -2905,6 +2905,9 @@ importers: specifier: ^13.6.8 version: 13.6.27 devDependencies: + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 '@milkdown/ctx': specifier: ^7.3.6 version: 7.13.1 From ac87fc936c2c2bd974de623711dba954465f58d8 Mon Sep 17 00:00:00 2001 From: George Ng Date: Wed, 2 Sep 2026 19:57:29 -0700 Subject: [PATCH 7/7] fix(markdown): harden persistent updates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../markdown/src/agent/documentOperations.ts | 95 +++++++++++-- .../agents/markdown/src/agent/ipcTypes.ts | 1 + .../src/agent/markdownActionHandler.ts | 132 ++++++++++-------- .../agents/markdown/src/view/route/service.ts | 43 +++++- .../markdown/test/documentOperations.spec.ts | 59 ++++++++ .../agents/markdown/test/viewService.spec.ts | 94 +++++++++---- 6 files changed, 320 insertions(+), 104 deletions(-) diff --git a/ts/packages/agents/markdown/src/agent/documentOperations.ts b/ts/packages/agents/markdown/src/agent/documentOperations.ts index ba6e519ca7..2c7ad216f1 100644 --- a/ts/packages/agents/markdown/src/agent/documentOperations.ts +++ b/ts/packages/agents/markdown/src/agent/documentOperations.ts @@ -19,20 +19,88 @@ export function applyDocumentOperations( content: string, operations: DocumentOperation[], ): string { - return operations.reduce( + const orderedOperations = orderBaseRelativeOperations( + operations, + content.length, + ); + return orderedOperations.reduce( (updatedContent, operation) => applyDocumentOperation(updatedContent, operation), content, ); } +type OperationSpan = { + operation: DocumentOperation; + index: number; + from: number; + to: number; +}; + +function orderBaseRelativeOperations( + operations: DocumentOperation[], + contentLength: number, +): DocumentOperation[] { + const spans = operations.map((operation, index) => { + const [from, to] = + operation.type === "insert" + ? [ + validatePosition(operation.position, contentLength), + operation.position, + ] + : validateRange(operation.from, operation.to, contentLength); + return { operation, index, from, to }; + }); + + for (let leftIndex = 0; leftIndex < spans.length; leftIndex += 1) { + for ( + let rightIndex = leftIndex + 1; + rightIndex < spans.length; + rightIndex += 1 + ) { + if (operationsOverlap(spans[leftIndex], spans[rightIndex])) { + throw new Error("Document operations must not overlap"); + } + } + } + + return spans + .sort((left, right) => { + const positionOrder = right.from - left.from; + if (positionOrder !== 0) { + return positionOrder; + } + if (left.from === left.to && right.from !== right.to) { + return 1; + } + if (right.from === right.to && left.from !== left.to) { + return -1; + } + return right.index - left.index; + }) + .map(({ operation }) => operation); +} + +function operationsOverlap(left: OperationSpan, right: OperationSpan): boolean { + if (left.from === left.to) { + return right.from < left.from && left.from < right.to; + } + if (right.from === right.to) { + return left.from < right.from && right.from < left.to; + } + return left.from < right.to && right.from < left.to; +} + function applyDocumentOperation( content: string, operation: DocumentOperation, ): string { switch (operation.type) { case "insert": { - const position = clampPosition(operation.position, content.length); + const position = validatePosition( + operation.position, + content.length, + ); return ( content.slice(0, position) + contentItemsToText(operation.content) + @@ -40,7 +108,7 @@ function applyDocumentOperation( ); } case "replace": { - const [from, to] = clampRange( + const [from, to] = validateRange( operation.from, operation.to, content.length, @@ -52,7 +120,7 @@ function applyDocumentOperation( ); } case "delete": { - const [from, to] = clampRange( + const [from, to] = validateRange( operation.from, operation.to, content.length, @@ -60,7 +128,7 @@ function applyDocumentOperation( return content.slice(0, from) + content.slice(to); } case "format": { - const [from, to] = clampRange( + const [from, to] = validateRange( operation.from, operation.to, content.length, @@ -451,14 +519,18 @@ function peelLink( return { leftPos: leftPos - 1, rightPos: closeParen + 1 }; } -function clampPosition(position: number, contentLength: number): number { - if (!Number.isInteger(position) || position < 0) { +function validatePosition(position: number, contentLength: number): number { + if ( + !Number.isInteger(position) || + position < 0 || + position > contentLength + ) { throw new Error(`Invalid document position: ${position}`); } - return Math.min(position, contentLength); + return position; } -function clampRange( +function validateRange( from: number, to: number, contentLength: number, @@ -467,9 +539,10 @@ function clampRange( !Number.isInteger(from) || !Number.isInteger(to) || from < 0 || - to < from + to < from || + to > contentLength ) { throw new Error(`Invalid document range: ${from}-${to}`); } - return [Math.min(from, contentLength), Math.min(to, contentLength)]; + return [from, to]; } diff --git a/ts/packages/agents/markdown/src/agent/ipcTypes.ts b/ts/packages/agents/markdown/src/agent/ipcTypes.ts index f5aabd7261..c94686aad9 100644 --- a/ts/packages/agents/markdown/src/agent/ipcTypes.ts +++ b/ts/packages/agents/markdown/src/agent/ipcTypes.ts @@ -114,6 +114,7 @@ export interface LLMOperationsMessage { expectedRoot?: string; expectedRelativePath?: string; expectedRevision?: string; + expectedUpdatedRevision?: string; } export interface OperationsAppliedMessage { diff --git a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts index dd2969689c..9448d92d04 100644 --- a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts +++ b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts @@ -565,8 +565,11 @@ async function handleStreamingMarkdownAction( // Read the current document. Prefer the view process (which has the // authoritative Yjs state) when it exists; otherwise pull directly from // the on-disk workspace document. Session storage is not a fallback. - const { content: markdownContent } = - await readCurrentDocumentContent(actionContext); + const { + content: markdownContent, + bindingToken, + revision, + } = await readCurrentDocumentContent(actionContext); try { // Call agent with streaming callback @@ -616,18 +619,20 @@ async function handleStreamingMarkdownAction( actionContext, ); - // Persist directly to the filesystem when the view process is - // not running. Otherwise the view process is responsible for - // applying and autosaving the operations. - if ( - !actionContext.sessionContext.agentContext.viewProcess && - updateResult.operations && - updateResult.operations.length > 0 - ) { - await persistOperationsToFile( + if (updateResult.operations?.length) { + const operations = + updateResult.operations as DocumentOperation[]; + const updatedContent = applyDocumentOperations( + markdownContent, + operations, + ); + await applyOperationsForCurrentDocument( actionContext, markdownContent, - updateResult.operations as DocumentOperation[], + operations, + bindingToken, + revision, + computeContentRevision(updatedContent), ); } @@ -1085,52 +1090,13 @@ async function updateCurrentDocument( const updateResult = response.data; if (updateResult.operations?.length) { - const viewProcess = - actionContext.sessionContext.agentContext.viewProcess; - if (viewProcess) { - // When a live view is authoritative, applyLLMOperations is the - // single source of truth: the service reads the current - // Markdown, revalidates the revision, applies the operations - // over raw Markdown, persists the bound file, updates its Yjs - // mirror, and broadcasts a post-commit snapshot. We never - // silently fall back to a headless filesystem write while the - // view is connected because that path bypasses that pipeline. - const agentContext = actionContext.sessionContext.agentContext; - const applied = await sendOperationsToView( - viewProcess, - updateResult.operations, - { - expectedBindingToken: bindingToken, - expectedRoot: agentContext.currentWorkspaceRoot, - expectedRelativePath: agentContext.currentFileName, - expectedRevision: revision, - }, - ); - if (!applied.success) { - if (applied.identityMismatch) { - throw new Error( - "Document identity changed while applying operations; refusing to write to the wrong file", - ); - } - if (applied.revisionMismatch) { - throw new Error( - "Document changed between read and apply; refusing to overwrite (revision mismatch)", - ); - } - throw new Error( - applied.error ?? - "Failed to apply operations in view process", - ); - } - debug("Operations applied successfully via view process"); - } else { - await persistOperationsToFile( - actionContext, - markdownContent, - updateResult.operations, - ); - debug("Applied operations directly to filesystem document"); - } + await applyOperationsForCurrentDocument( + actionContext, + markdownContent, + updateResult.operations, + bindingToken, + revision, + ); } else { debug("[AGENT] No operations returned from LLM"); } @@ -1140,6 +1106,51 @@ async function updateCurrentDocument( ); } +async function applyOperationsForCurrentDocument( + actionContext: ActionContext, + baseContent: string, + operations: DocumentOperation[], + bindingToken: string | undefined, + revision: string, + expectedUpdatedRevision?: string, +): Promise { + const agentContext = actionContext.sessionContext.agentContext; + if (!agentContext.viewProcess) { + await persistOperationsToFile(actionContext, baseContent, operations); + debug("Applied operations directly to filesystem document"); + return; + } + + const applied = await sendOperationsToView( + agentContext.viewProcess, + operations, + { + expectedBindingToken: bindingToken, + expectedRoot: agentContext.currentWorkspaceRoot, + expectedRelativePath: agentContext.currentFileName, + expectedRevision: revision, + expectedUpdatedRevision, + }, + ); + if (applied.success) { + debug("Operations applied successfully via view process"); + return; + } + if (applied.identityMismatch) { + throw new Error( + "Document identity changed while applying operations; refusing to write to the wrong file", + ); + } + if (applied.revisionMismatch) { + throw new Error( + "Document changed between read and apply; refusing to overwrite (revision mismatch)", + ); + } + throw new Error( + applied.error ?? "Failed to apply operations in view process", + ); +} + async function handleMarkdownAction( action: MarkdownAction, actionContext: ActionContext, @@ -1200,6 +1211,7 @@ type ApplyExpectations = { expectedRoot?: string | undefined; expectedRelativePath?: string | undefined; expectedRevision?: string | undefined; + expectedUpdatedRevision?: string | undefined; }; async function sendOperationsToView( @@ -1212,6 +1224,7 @@ async function sendOperationsToView( expectedRoot, expectedRelativePath, expectedRevision, + expectedUpdatedRevision, } = expectations; if (!viewProcess) { return { @@ -1231,7 +1244,7 @@ async function sendOperationsToView( identityMismatch: false, revisionMismatch: false, }); - }, 5000); + }, 15000); // Only accept the response tagged with our requestId. This keeps // out-of-order or concurrent operationsApplied messages from @@ -1279,6 +1292,7 @@ async function sendOperationsToView( expectedRoot, expectedRelativePath, expectedRevision, + expectedUpdatedRevision, }); debug( diff --git a/ts/packages/agents/markdown/src/view/route/service.ts b/ts/packages/agents/markdown/src/view/route/service.ts index 3da9489f0c..525c1a2c7c 100644 --- a/ts/packages/agents/markdown/src/view/route/service.ts +++ b/ts/packages/agents/markdown/src/view/route/service.ts @@ -639,10 +639,15 @@ function checkExpectedIdentity( async function readCurrentMarkdownServerSide( documentId: string, snapshot: BindingSnapshot = captureBindingSnapshot(), + clientReadRetries: number = 3, ): Promise { if (clients.length > 0) { try { - const response = await requestMarkdownFromClient(0, snapshot); + const response = await requestMarkdownFromClient( + 0, + snapshot, + clientReadRetries, + ); return response.markdown; } catch (error) { // A binding-token mismatch means the browser explicitly @@ -818,6 +823,7 @@ async function sendUICommandToAgentWithStreaming( async function requestMarkdownFromClient( retryCount: number = 0, snapshot: BindingSnapshot = captureBindingSnapshot(), + maxRetries: number = 3, ): Promise<{ markdown: string; positionInfo: { @@ -825,7 +831,6 @@ async function requestMarkdownFromClient( selection?: { from: number; to: number }; }; }> { - const maxRetries = 3; // Increased from 2 to 3 const expectedBindingToken = snapshot.bindingToken; return new Promise((resolve, reject) => { @@ -840,7 +845,11 @@ async function requestMarkdownFromClient( // Retry after a longer delay for better reliability setTimeout( () => { - requestMarkdownFromClient(retryCount + 1, snapshot) + requestMarkdownFromClient( + retryCount + 1, + snapshot, + maxRetries, + ) .then(resolve) .catch(reject); }, @@ -2079,6 +2088,16 @@ process.on("message", async (message: any) => { return; } + if ( + currentRoot === nextRoot && + filePath === resolvedFilePath && + boundRelativePath === relative && + bindingToken !== null + ) { + notifyBindingToParent(); + return; + } + if (currentRoot !== nextRoot) { currentRoot = nextRoot; debug(`Document root switched to ${currentRoot}`); @@ -2268,6 +2287,7 @@ Start typing to see the editor in action! const currentMarkdown = await readCurrentMarkdownServerSide( snapshotDocumentId, snapshot, + 0, ); // Re-check the snapshot after the potentially-awaiting read. @@ -2296,8 +2316,18 @@ Start typing to see the editor in action! typeof message.expectedRevision === "string" ? message.expectedRevision : undefined; + const expectedUpdatedRevision = + typeof message.expectedUpdatedRevision === "string" + ? message.expectedUpdatedRevision + : undefined; const baseRevision = computeContentRevision(currentMarkdown); + // Streaming clients may have already rendered the final operations. + // Persist that exact result instead of applying the offsets twice. + const operationsAlreadyApplied = + expectedUpdatedRevision !== undefined && + expectedUpdatedRevision === baseRevision; if ( + !operationsAlreadyApplied && expectedRevision !== undefined && expectedRevision !== baseRevision ) { @@ -2339,10 +2369,9 @@ Start typing to see the editor in action! } } - const updatedContent = applyDocumentOperations( - currentMarkdown, - operations, - ); + const updatedContent = operationsAlreadyApplied + ? currentMarkdown + : applyDocumentOperations(currentMarkdown, operations); // Update the authoritative Yjs mirror so any concurrent // WebSocket peer receives the raw-Markdown update. diff --git a/ts/packages/agents/markdown/test/documentOperations.spec.ts b/ts/packages/agents/markdown/test/documentOperations.spec.ts index 2d646c6b95..91bceb1e66 100644 --- a/ts/packages/agents/markdown/test/documentOperations.spec.ts +++ b/ts/packages/agents/markdown/test/documentOperations.spec.ts @@ -4,6 +4,65 @@ import { applyDocumentOperations } from "../src/agent/documentOperations.js"; import type { DocumentOperation } from "../src/agent/markdownOperationSchema.js"; +describe("base-relative DocumentOperation batches", () => { + test("applies length-changing operations without shifting later offsets", () => { + const before = + "# Title\n\nAlpha paragraph.\n\nBravo paragraph.\n\nCharlie paragraph.\n"; + const charlieStart = before.indexOf("Charlie"); + const operations: DocumentOperation[] = [ + { + type: "insert", + position: before.indexOf("Alpha"), + content: [{ type: "text", text: "NEW INTRO\n\n" }], + }, + { + type: "delete", + from: charlieStart, + to: before.length, + }, + ]; + + expect(applyDocumentOperations(before, operations)).toBe( + "# Title\n\nNEW INTRO\n\nAlpha paragraph.\n\nBravo paragraph.\n\n", + ); + }); + + test("preserves operation order for inserts at the same position", () => { + const operations: DocumentOperation[] = [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "first " }], + }, + { + type: "insert", + position: 0, + content: [{ type: "text", text: "second " }], + }, + ]; + + expect(applyDocumentOperations("body", operations)).toBe( + "first second body", + ); + }); + + test("rejects overlapping operations", () => { + const operations: DocumentOperation[] = [ + { type: "delete", from: 0, to: 4 }, + { + type: "replace", + from: 2, + to: 6, + content: [{ type: "text", text: "updated" }], + }, + ]; + + expect(() => applyDocumentOperations("content", operations)).toThrow( + "Document operations must not overlap", + ); + }); +}); + describe("format DocumentOperation", () => { test("adds strong marks around a character range", () => { const before = "hello world"; diff --git a/ts/packages/agents/markdown/test/viewService.spec.ts b/ts/packages/agents/markdown/test/viewService.spec.ts index 9e24c72696..b4d4b27442 100644 --- a/ts/packages/agents/markdown/test/viewService.spec.ts +++ b/ts/packages/agents/markdown/test/viewService.spec.ts @@ -81,6 +81,62 @@ describe("markdown view service", () => { ); }); + test("does not apply streaming operations twice", async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); + const filePath = path.join(root, "streamed.md"); + fs.writeFileSync(filePath, "already streamed", "utf-8"); + + viewProcess = fork(servicePath, ["0"], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: root, + }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + await waitForMessage( + viewProcess, + (message) => message.type === "Success", + ); + viewProcess.send({ + type: "setFile", + workspaceRoot: root, + relativePath: "streamed.md", + }); + viewProcess.send({ + type: "getDocumentContent", + requestId: "capture-streamed", + }); + const bound = await waitForMessage( + viewProcess, + (message) => + message.type === "documentContent" && + message.requestId === "capture-streamed", + ); + + viewProcess.send({ + type: "applyLLMOperations", + requestId: "apply-streamed", + operations: [ + { + type: "insert", + position: 0, + content: [{ type: "text", text: "duplicate " }], + }, + ], + expectedRevision: "base-before-streaming", + expectedUpdatedRevision: bound.revision, + }); + const applied = await waitForMessage( + viewProcess, + (message) => + message.type === "operationsApplied" && + message.requestId === "apply-streamed", + ); + + expect(applied.success).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe("already streamed"); + }); + test("reroots via setFile workspaceRoot and persists under the new root", async () => { root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); const workspace = fs.mkdtempSync( @@ -396,6 +452,8 @@ describe("markdown view service", () => { root = fs.mkdtempSync(path.join(os.tmpdir(), "markdown-view-test-")); const filePath = path.join(root, "browser.md"); fs.writeFileSync(filePath, "seed", "utf-8"); + const nextFilePath = path.join(root, "next.md"); + fs.writeFileSync(nextFilePath, "next", "utf-8"); viewProcess = fork(servicePath, ["0"], { env: { @@ -427,11 +485,11 @@ describe("markdown view service", () => { const staleToken = first.bindingToken; expect(typeof staleToken).toBe("string"); - // Rebind to rotate the token. + // Rebind to another file to rotate the token. viewProcess.send({ type: "setFile", workspaceRoot: root, - relativePath: "browser.md", + relativePath: "next.md", }); viewProcess.send({ type: "getDocumentContent", @@ -460,6 +518,7 @@ describe("markdown view service", () => { expect(response.status).toBe(409); expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); + expect(fs.readFileSync(nextFilePath, "utf-8")).toBe("next"); }); test("rejects browser autosave when no file is bound", async () => { @@ -607,7 +666,7 @@ describe("markdown view service", () => { expect(fs.readFileSync(filePath, "utf-8")).toBe("existing"); }); - test("rebinding to the same relative path rotates the binding token", async () => { + test("rebinding to the same relative path preserves the binding token", async () => { // A rebound view must reject callers pinned to the pre-rebind // token even when the new binding uses the same basename or the // same relative path. @@ -656,7 +715,7 @@ describe("markdown view service", () => { const firstToken = first.bindingToken; expect(typeof firstToken).toBe("string"); - // Rebind to the same relative path - the token must rotate. + // Rebinding the current file is an acknowledgement, not a new identity. viewProcess.send({ type: "setFile", workspaceRoot: root, @@ -672,29 +731,10 @@ describe("markdown view service", () => { message.type === "documentContent" && message.requestId === "capture-token-2", ); - expect(second.bindingToken).not.toBe(firstToken); - - // A caller still pinned to the stale token must be rejected. - viewProcess.send({ - type: "applyLLMOperations", - requestId: "apply-stale-rebind", - operations: [ - { - type: "insert", - position: 0, - content: [{ type: "text", text: "clobber" }], - }, - ], - expectedBindingToken: firstToken, - }); - const rejected = await waitForMessage( - viewProcess, - (message) => - message.type === "operationsApplied" && - message.requestId === "apply-stale-rebind", - ); - expect(rejected.success).toBe(false); - expect(rejected.identityMismatch).toBe(true); + expect(second.bindingToken).toBe(firstToken); + expect( + bindingUpdates.filter((token) => token === firstToken), + ).toHaveLength(2); expect(fs.readFileSync(filePath, "utf-8")).toBe("seed"); });