- Extended ResolveContext / WriteContext with localProtocolOptions so the internal-URL router can thread the calling session's local-root mapping through to handlers. - LocalProtocolHandler.resolveOptions now prefers context.localProtocolOptions before consulting the process-global override or the first main-kind session in AgentRegistry, fixing multi-session ACP hosts (cmux) where reads of local://PLAN.md were routing to a sibling session's artifacts dir even though plan-mode writes succeeded against the calling session. - read, find, ast_grep, ast_edit, and search now thread this.session.localProtocolOptions into the router so local://, memory://, agent://, and other handlers see the right caller. - Added regression tests covering the override-vs-context priority and the ENOENT-against-caller-root path. Fixes #1608
275 lines
8.6 KiB
TypeScript
275 lines
8.6 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
import { AgentRegistry } from "../registry/agent-registry";
|
|
import { parseInternalUrl } from "./parse";
|
|
import { validateRelativePath } from "./skill-protocol";
|
|
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
|
|
|
|
export interface LocalProtocolOptions {
|
|
getArtifactsDir?: () => string | null;
|
|
getSessionId?: () => string | null;
|
|
}
|
|
|
|
function parseLocalUrl(input: string): InternalUrl {
|
|
return parseInternalUrl(input);
|
|
}
|
|
|
|
function ensureWithinRoot(targetPath: string, rootPath: string): void {
|
|
if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {
|
|
throw new Error("local:// URL escapes local root");
|
|
}
|
|
}
|
|
|
|
function toLocalValidationError(error: unknown): Error {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Error(message.replace("skill://", "local://"));
|
|
}
|
|
|
|
function getContentType(filePath: string): InternalResource["contentType"] {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (ext === ".md") return "text/markdown";
|
|
if (ext === ".json") return "application/json";
|
|
return "text/plain";
|
|
}
|
|
|
|
async function listFilesRecursively(rootPath: string): Promise<string[]> {
|
|
const pending = [""];
|
|
const files: string[] = [];
|
|
|
|
while (pending.length > 0) {
|
|
const relativeDir = pending.pop();
|
|
if (relativeDir === undefined) continue;
|
|
const absoluteDir = path.join(rootPath, relativeDir);
|
|
const entries = await fs.readdir(absoluteDir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(relativeDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
pending.push(entryPath);
|
|
continue;
|
|
}
|
|
if (entry.isFile()) {
|
|
files.push(entryPath.replaceAll(path.sep, "/"));
|
|
}
|
|
}
|
|
}
|
|
|
|
return files.sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
async function buildListing(url: InternalUrl, localRoot: string): Promise<InternalResource> {
|
|
const files = await listFilesRecursively(localRoot);
|
|
const listing = files.length === 0 ? "(empty)" : files.map(file => `- [${file}](local://${file})`).join("\n");
|
|
const content =
|
|
`# Local\n\n` +
|
|
`Session-scoped scratch space for large intermediate data, subagent handoffs, and reusable planning artifacts.\n\n` +
|
|
`Root: ${localRoot}\n\n` +
|
|
`${files.length} file${files.length === 1 ? "" : "s"} available:\n\n` +
|
|
`${listing}\n`;
|
|
|
|
return {
|
|
url: url.href,
|
|
content,
|
|
contentType: "text/markdown",
|
|
size: Buffer.byteLength(content, "utf-8"),
|
|
sourcePath: localRoot,
|
|
};
|
|
}
|
|
|
|
function extractRelativePath(url: InternalUrl): string {
|
|
const host = url.rawHost || url.hostname;
|
|
const pathname = url.rawPathname ?? url.pathname;
|
|
|
|
const combined = host
|
|
? pathname && pathname !== "/"
|
|
? `${host}${pathname}`
|
|
: host
|
|
: pathname && pathname !== "/"
|
|
? pathname.slice(1)
|
|
: "";
|
|
|
|
if (!combined) {
|
|
return "";
|
|
}
|
|
|
|
let decoded: string;
|
|
try {
|
|
decoded = decodeURIComponent(combined.replaceAll("\\", "/"));
|
|
} catch {
|
|
throw new Error(`Invalid URL encoding in local:// path: ${url.href}`);
|
|
}
|
|
try {
|
|
validateRelativePath(decoded);
|
|
} catch (error) {
|
|
throw toLocalValidationError(error);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export function resolveLocalRoot(options: LocalProtocolOptions): string {
|
|
const artifactsDir = options.getArtifactsDir?.();
|
|
if (artifactsDir) {
|
|
return path.resolve(artifactsDir, "local");
|
|
}
|
|
|
|
const sessionId = options.getSessionId?.() ?? "session";
|
|
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
return path.join(os.tmpdir(), "omp-local", safeSessionId);
|
|
}
|
|
|
|
export function resolveLocalUrlToPath(input: string | InternalUrl, options: LocalProtocolOptions): string {
|
|
const url = typeof input === "string" ? parseLocalUrl(input) : input;
|
|
const localRoot = path.resolve(resolveLocalRoot(options));
|
|
const relativePath = extractRelativePath(url);
|
|
|
|
if (!relativePath) {
|
|
return localRoot;
|
|
}
|
|
|
|
const resolved = path.resolve(localRoot, relativePath);
|
|
ensureWithinRoot(resolved, localRoot);
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Protocol handler for local:// URLs.
|
|
*
|
|
* URL forms:
|
|
* - local:// - Lists files at the session local root
|
|
* - local://<path> - Reads a file under the session local root
|
|
*/
|
|
export class LocalProtocolHandler implements ProtocolHandler {
|
|
readonly scheme = "local";
|
|
readonly immutable = false;
|
|
|
|
static #override: LocalProtocolOptions | undefined;
|
|
|
|
/**
|
|
* Install a process-global override that wins over the AgentRegistry-based
|
|
* derivation. Used by SDK consumers that wire `localProtocolOptions` on
|
|
* `createAgentSession` and by subagents that share their parent's root.
|
|
*/
|
|
static setOverride(value: LocalProtocolOptions | undefined): void {
|
|
LocalProtocolHandler.#override = value;
|
|
}
|
|
|
|
/** Reset the process-global override. Test-only. */
|
|
static resetOverrideForTests(): void {
|
|
LocalProtocolHandler.#override = undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns the active local-protocol options.
|
|
*
|
|
* Resolution order:
|
|
* 1. **Caller-supplied** `context.localProtocolOptions` (the actual session
|
|
* that initiated the `read`/`find`/`search`/`router.resolve` call). This
|
|
* is what keeps `local://` reads pinned to the calling session in
|
|
* multi-session hosts (cmux/ACP, embedded SDK consumers) where every
|
|
* session registers as `kind: "main"` and "first one wins" would route
|
|
* to the wrong artifacts directory.
|
|
* 2. Explicit process-global override installed via {@link setOverride}
|
|
* (used by SDK consumers with a custom artifacts/session-id mapping and
|
|
* by code paths that do not have a calling session, e.g. TUI hyperlink
|
|
* resolution).
|
|
* 3. The first `main`-kind session in `AgentRegistry.global()`. Its
|
|
* `SessionManager` supplies both `getArtifactsDir` and `getSessionId`.
|
|
* Last-resort fallback — every caller that has a session reference
|
|
* SHOULD thread it through `context` so this branch is never taken in
|
|
* multi-session setups.
|
|
*/
|
|
static resolveOptions(context?: ResolveContext): LocalProtocolOptions | undefined {
|
|
const fromContext = context?.localProtocolOptions;
|
|
if (fromContext) return fromContext;
|
|
const override = LocalProtocolHandler.#override;
|
|
if (override) return override;
|
|
const main = AgentRegistry.global()
|
|
.list()
|
|
.find(ref => ref.kind === "main");
|
|
const sessionManager = main?.session?.sessionManager;
|
|
if (!sessionManager) return undefined;
|
|
return {
|
|
getArtifactsDir: () => sessionManager.getArtifactsDir(),
|
|
getSessionId: () => sessionManager.getSessionId(),
|
|
};
|
|
}
|
|
|
|
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
|
|
const opts = LocalProtocolHandler.resolveOptions(context);
|
|
if (!opts) {
|
|
throw new Error("No session - local:// unavailable");
|
|
}
|
|
|
|
const localRoot = path.resolve(resolveLocalRoot(opts));
|
|
await fs.mkdir(localRoot, { recursive: true });
|
|
|
|
let resolvedRoot: string;
|
|
try {
|
|
resolvedRoot = await fs.realpath(localRoot);
|
|
} catch (error) {
|
|
if (isEnoent(error)) {
|
|
throw new Error("Unable to initialize local:// root");
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const relativePath = extractRelativePath(url);
|
|
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
|
|
ensureWithinRoot(targetPath, resolvedRoot);
|
|
|
|
if (targetPath === resolvedRoot) {
|
|
return buildListing(url, resolvedRoot);
|
|
}
|
|
|
|
const parentDir = path.dirname(targetPath);
|
|
try {
|
|
const realParent = await fs.realpath(parentDir);
|
|
ensureWithinRoot(realParent, resolvedRoot);
|
|
} catch (error) {
|
|
if (!isEnoent(error)) throw error;
|
|
}
|
|
|
|
let realTargetPath: string;
|
|
try {
|
|
realTargetPath = await fs.realpath(targetPath);
|
|
} catch (error) {
|
|
if (isEnoent(error)) {
|
|
throw new Error(`Local file not found: ${url.href}`);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
|
|
const stat = await fs.stat(realTargetPath);
|
|
if (!stat.isFile()) {
|
|
throw new Error(`local:// URL must resolve to a file: ${url.href}`);
|
|
}
|
|
|
|
const content = await Bun.file(realTargetPath).text();
|
|
return {
|
|
url: url.href,
|
|
content,
|
|
contentType: getContentType(realTargetPath),
|
|
size: Buffer.byteLength(content, "utf-8"),
|
|
sourcePath: realTargetPath,
|
|
notes: ["Use write path local://<file> to persist large intermediate artifacts across turns."],
|
|
};
|
|
}
|
|
|
|
async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
|
|
const opts = LocalProtocolHandler.resolveOptions(context);
|
|
if (!opts) return [];
|
|
const localRoot = path.resolve(resolveLocalRoot(opts));
|
|
try {
|
|
const files = await listFilesRecursively(localRoot);
|
|
return files.map(value => ({ value }));
|
|
} catch (err) {
|
|
if (isEnoent(err)) return [];
|
|
throw err;
|
|
}
|
|
}
|
|
}
|