fix(plan-mode): normalize write target before bridge routing

Unwrap bracketed [path#TAG] headers at the top of WriteTool.execute() so internal-URL detection, plan-mode guard, plan path resolution, and ACP bridge routing all see the same filesystem target. Without this, ['/data/workspaces/can1357__oh-my-pi__2472/.omp-session/2026-06-13T20-19-47-341Z_019ec2a3-fc0d-7000-b1e6-25831d3c3ec5/local/scratch.md' slipped past isInternalUrlPath() and was bridged to the editor instead of staying on disk as a session-local artifact.\n\nFixes #2472
This commit is contained in:
roboomp
2026-06-14 07:36:24 +00:00
parent 720086e8d1
commit 285ed3c47d
3 changed files with 45 additions and 4 deletions
@@ -36,8 +36,9 @@ function isWithinRoot(absolutePath: string, root: string): boolean {
* filesystem path drives both authorization and resolution. Only unwraps inputs
* that match the strict hashline header shape (`[path]` or `[path#XXXX]` with a
* 4-hex tag); anything else returns the original string so the downstream
* resolver surfaces the real error. */
function unwrapHashlineHeaderPath(targetPath: string): string {
* resolver surfaces the real error. Exported for callers (e.g. `write`) that
* make scheme/bridge-routing decisions before {@link resolvePlanPath} runs. */
export function unwrapHashlineHeaderPath(targetPath: string): string {
const trimmed = targetPath.trimEnd();
if (
trimmed.length < HL_FILE_PREFIX.length + HL_FILE_SUFFIX.length ||
+11 -2
View File
@@ -35,7 +35,7 @@ import {
import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
import { type OutputMeta, outputMeta } from "./output-meta";
import { formatPathRelativeToCwd, isInternalUrlPath } from "./path-utils";
import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
import { enforcePlanModeWrite, resolvePlanPath, unwrapHashlineHeaderPath } from "./plan-mode-guard";
import {
cachedRenderedString,
createRenderedStringCache,
@@ -819,11 +819,20 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
}
async execute(
_toolCallId: string,
{ path, content }: WriteParams,
{ path: rawPath, content }: WriteParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<WriteToolDetails>,
context?: AgentToolContext,
): Promise<AgentToolResult<WriteToolDetails>> {
// Strip a hashline `[path#TAG]` wrapper up front so every downstream
// decision (scheme routing, internal-URL handler dispatch, plan-mode
// guard, plan path resolution, ACP bridge routing) sees the same
// filesystem target. Without this, a model that pastes a `read`
// header as the `path` arg would slip past `isInternalUrlPath`
// (which fails on a leading `[`) and the bridge router would send a
// `[local://scratch.md#ABCD]` write to the editor instead of the
// session-local sandbox.
const path = unwrapHashlineHeaderPath(rawPath);
return untilAborted(signal, async () => {
// Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
@@ -100,4 +100,35 @@ describe("write tool ACP fs routing", () => {
).text(),
).toBe(planContent);
});
it("treats bracketed `[local://...#TAG]` headers as local artifacts, not bridge writes", async () => {
const planPath = "local://PLAN.md";
const scratchPath = "local://scratch.md";
// Active plan file is unrelated to the scratch artifact we are writing.
const bracketedScratch = `[${scratchPath}#ABCD]`;
const scratchContent = "scratch notes\n";
const bridge: ClientBridge = {
capabilities: { writeTextFile: true },
writeTextFile: async () => undefined,
};
const bridgeSpy = spyOn(bridge, "writeTextFile");
const session = createSession(tmpDir, {
bridge,
planMode: { enabled: true, planFilePath: planPath, workflow: "parallel", reentry: false },
});
await new WriteTool(session).execute("call-bracketed", { path: bracketedScratch, content: scratchContent });
// Bracketed local headers must not slip past the bridge router — they are
// still session-local artifacts and stay on disk under the local sandbox.
expect(bridgeSpy).not.toHaveBeenCalled();
expect(
await Bun.file(
resolveLocalUrlToPath(scratchPath, {
getArtifactsDir: session.getArtifactsDir,
getSessionId: session.getSessionId,
}),
).text(),
).toBe(scratchContent);
});
});