285ed3c47d
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
143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
|
|
import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
|
|
import type { ToolSession } from ".";
|
|
import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
|
|
import { ToolError } from "./tool-errors";
|
|
|
|
const VAULT_SCHEME_PREFIX = "vault:";
|
|
const LOCAL_SCHEME_PREFIX = "local:";
|
|
const HL_TRAILING_TAG_RE = new RegExp(`${HL_FILE_HASH_SEP}[0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}}$`);
|
|
|
|
/** Resolve the absolute path of the session's `local://` artifact sandbox.
|
|
* Returns `null` when the session has no artifact wiring (e.g. tests). */
|
|
function localSandboxRoot(session: ToolSession): string | null {
|
|
try {
|
|
return path.resolve(
|
|
resolveLocalRoot({
|
|
getArtifactsDir: session.getArtifactsDir,
|
|
getSessionId: session.getSessionId,
|
|
}),
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** True when `absolutePath` resolves inside `root` (== root or under it). */
|
|
function isWithinRoot(absolutePath: string, root: string): boolean {
|
|
if (absolutePath === root) return true;
|
|
const sep = `${root}${path.sep}`;
|
|
return absolutePath.startsWith(sep);
|
|
}
|
|
|
|
/** Strip the hashline `[path#TAG]` wrapper from a write/edit target so the inner
|
|
* 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. 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 ||
|
|
trimmed[0] !== HL_FILE_PREFIX ||
|
|
trimmed[trimmed.length - 1] !== HL_FILE_SUFFIX
|
|
) {
|
|
return targetPath;
|
|
}
|
|
const inner = trimmed.slice(HL_FILE_PREFIX.length, trimmed.length - HL_FILE_SUFFIX.length);
|
|
const tagMatch = HL_TRAILING_TAG_RE.exec(inner);
|
|
const pathPart = tagMatch ? inner.slice(0, tagMatch.index) : inner;
|
|
// A valid header is exactly `PATH` or `PATH#XXXX`; reject any other shape
|
|
// (selectors, non-hex tags, embedded `#`) so we never silently rewrite a
|
|
// path the model did not author.
|
|
if (pathPart.length === 0 || pathPart.includes(HL_FILE_HASH_SEP)) return targetPath;
|
|
return pathPart;
|
|
}
|
|
|
|
/** True when `targetPath` resolves into the session-local artifact sandbox.
|
|
* Routes through {@link resolvePlanPath} so the guard and the eventual write
|
|
* always agree on the absolute target (including bracketed hashline headers,
|
|
* `local://` URLs, and bare absolute paths). Files inside the sandbox are not
|
|
* part of the working tree, so plan mode treats them as freely writable
|
|
* scratch/plan space. */
|
|
function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
|
|
const root = localSandboxRoot(session);
|
|
if (!root) return false;
|
|
let resolved: string;
|
|
try {
|
|
resolved = resolvePlanPath(session, targetPath);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!path.isAbsolute(resolved)) return false;
|
|
const absolute = path.resolve(resolved);
|
|
if (isWithinRoot(absolute, root)) return true;
|
|
// Compare realpath-normalized forms so that `/tmp/…` vs `/private/tmp/…`
|
|
// (macOS) and other symlink-collapsed roots both resolve to the same
|
|
// sandbox identity.
|
|
try {
|
|
const realRoot = fs.realpathSync.native(root);
|
|
if (isWithinRoot(absolute, realRoot)) return true;
|
|
const realParent = fs.realpathSync.native(path.dirname(absolute));
|
|
return isWithinRoot(path.join(realParent, path.basename(absolute)), realRoot);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a write/edit target to its absolute filesystem path, honoring the
|
|
* `local://` and `vault://` schemes. Plain paths resolve against the session cwd.
|
|
* Bracketed hashline headers (`[path#TAG]`) are unwrapped first so the inner
|
|
* filesystem path drives resolution — keeping the plan-mode guard and the
|
|
* eventual write in lockstep.
|
|
*/
|
|
export function resolvePlanPath(session: ToolSession, targetPath: string): string {
|
|
const unwrapped = unwrapHashlineHeaderPath(targetPath);
|
|
const normalized = normalizeLocalScheme(unwrapped);
|
|
if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
|
|
return resolveLocalUrlToPath(normalized, {
|
|
getArtifactsDir: session.getArtifactsDir,
|
|
getSessionId: session.getSessionId,
|
|
});
|
|
}
|
|
|
|
if (normalized.startsWith(VAULT_SCHEME_PREFIX)) {
|
|
return resolveVaultUrlToPath(normalized);
|
|
}
|
|
|
|
return resolveToCwd(normalized, session.cwd);
|
|
}
|
|
|
|
/**
|
|
* Plan mode keeps the working tree read-only while letting the agent draft its
|
|
* plan. Writes and edits to the `local://` artifact sandbox are allowed (that is
|
|
* where the plan and any scratch notes live); anything that would touch the
|
|
* working tree — or rename/delete a file — is rejected.
|
|
*/
|
|
export function enforcePlanModeWrite(
|
|
session: ToolSession,
|
|
targetPath: string,
|
|
options?: { move?: string; op?: "create" | "update" | "delete" },
|
|
): void {
|
|
const state = session.getPlanModeState?.();
|
|
if (!state?.enabled) return;
|
|
|
|
if (options?.move) {
|
|
throw new ToolError("Plan mode: renaming files is not allowed.");
|
|
}
|
|
|
|
if (options?.op === "delete") {
|
|
throw new ToolError("Plan mode: deleting files is not allowed.");
|
|
}
|
|
|
|
if (targetsLocalSandbox(session, targetPath)) return;
|
|
|
|
throw new ToolError(
|
|
"Plan mode: the working tree is read-only. Write your plan to a local://<slug>-plan.md file instead.",
|
|
);
|
|
}
|