55b02a568d
- Enabled tag-based path recovery for artifacts within the session `local://` sandbox. - Restructured `hashline` path recovery logic to ensure preflight validation runs on resolved paths. - Prevented ACP editor buffer routing for session `local://` sandbox artifacts. - Added comprehensive tests covering sandbox target identification and recovery for bare file names.
156 lines
6.3 KiB
TypeScript
156 lines
6.3 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 {
|
|
type LocalProtocolOptions,
|
|
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 `local://` options the session uses, preferring its own
|
|
* {@link LocalProtocolOptions} (the mapping `read`/`write`/`eval` resolve
|
|
* through) over the bare `getArtifactsDir`/`getSessionId` pair. Subagents and
|
|
* multi-session hosts (cmux/ACP, embedded SDK) pin `local://` to a parent/foreign
|
|
* root via `localProtocolOptions`; the sandbox root the plan-mode guard derives
|
|
* must match where the artifact actually lives, or it rejects a legitimate plan
|
|
* edit (and tag-based path recovery onto the sandbox would miss it). */
|
|
function planLocalProtocolOptions(session: ToolSession): LocalProtocolOptions {
|
|
return (
|
|
session.localProtocolOptions ?? {
|
|
getArtifactsDir: () => session.getArtifactsDir?.() ?? null,
|
|
getSessionId: () => session.getSessionId?.() ?? null,
|
|
}
|
|
);
|
|
}
|
|
|
|
/** 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(planLocalProtocolOptions(session)));
|
|
} 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 — and tag-based path recovery may rebind onto them. */
|
|
export 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, planLocalProtocolOptions(session));
|
|
}
|
|
|
|
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.",
|
|
);
|
|
}
|