src / core / internalStorage.ts
src / core / internalStorage.ts
import { lstat, mkdir, readFile, readdir, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { Dirent } from "node:fs";
import { AgenticError } from "./errors";
import {
appendJsonLine,
atomicWriteJson,
atomicWriteText,
readJson,
} from "./json";
import type { WorkspaceBoundary } from "../workspace/boundary";
/**
* Resolves every plugin-owned path through WorkspaceBoundary before touching it.
* This rejects pre-existing symlink redirects in nested `.agentic` directories,
* not just a redirected `.agentic` root.
*/
export class InternalStorage {
public readonly rootRelative = ".agentic";
public constructor(public readonly boundary: WorkspaceBoundary) {}
public relative(...segments: string[]): string {
return join(this.rootRelative, ...segments).replaceAll("\\", "/");
}
public lexical(relativePath: string): string {
return this.boundary.resolveInternal(relativePath);
}
public async initialize(): Promise<string> {
return await this.ensureDirectory(this.rootRelative);
}
public async ensureDirectory(relativePath: string): Promise<string> {
const absolute = await this.boundary.resolveInternalWrite(relativePath);
await mkdir(absolute, { recursive: true });
// Re-resolve after creation so a replaced/redirected directory fails closed.
const verified = await this.boundary.resolveInternalWrite(relativePath);
const info = await lstat(verified);
if (!info.isDirectory()) {
throw new AgenticError(
"INTERNAL",
`Plugin storage path is not a directory: ${relativePath}`,
);
}
return verified;
}
public async resolveWrite(relativePath: string): Promise<string> {
return await this.boundary.resolveInternalWrite(relativePath);
}
public async resolveRead(relativePath: string): Promise<string> {
// Plugin-owned state never accepts symlinks, even when they resolve inside
// the workspace. This prevents aliasing or replacement of audit records.
await this.boundary.resolveInternalWrite(relativePath);
return await this.boundary.resolveInternalRead(relativePath);
}
public async exists(relativePath: string): Promise<boolean> {
const absolute = await this.resolveWrite(relativePath);
try {
await lstat(absolute);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
public async lstat(relativePath: string) {
return await lstat(await this.resolveRead(relativePath));
}
public async readText(relativePath: string): Promise<string> {
return await readFile(await this.resolveRead(relativePath), "utf8");
}
public async readJson<T>(relativePath: string): Promise<T> {
return await readJson<T>(await this.resolveRead(relativePath));
}
public async writeText(relativePath: string, content: string): Promise<void> {
await atomicWriteText(await this.prepareWrite(relativePath), content);
}
public async writeJson(relativePath: string, value: unknown): Promise<void> {
await atomicWriteJson(await this.prepareWrite(relativePath), value);
}
public async appendJsonLine(relativePath: string, value: unknown): Promise<void> {
await appendJsonLine(await this.prepareWrite(relativePath), value);
}
public async readDirectory(
relativePath: string,
options: { withFileTypes: true },
): Promise<Dirent[]> {
return await readdir(await this.resolveRead(relativePath), options);
}
/** Removes a plugin-owned file; a missing path is not an error. Symlinks are rejected by resolveWrite. */
public async remove(relativePath: string): Promise<void> {
await rm(await this.resolveWrite(relativePath), { force: true });
}
private async prepareWrite(relativePath: string): Promise<string> {
const parent = dirname(relativePath).replaceAll("\\", "/");
if (parent !== ".") await this.ensureDirectory(parent);
// Resolve again after creating the parent so pre-existing or substituted
// nested symlinks are rejected before the atomic writer creates a temp file.
return await this.resolveWrite(relativePath);
}
}
import { lstat, mkdir, readFile, readdir, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { Dirent } from "node:fs";
import { AgenticError } from "./errors";
import {
appendJsonLine,
atomicWriteJson,
atomicWriteText,
readJson,
} from "./json";
import type { WorkspaceBoundary } from "../workspace/boundary";
/**
* Resolves every plugin-owned path through WorkspaceBoundary before touching it.
* This rejects pre-existing symlink redirects in nested `.agentic` directories,
* not just a redirected `.agentic` root.
*/
export class InternalStorage {
public readonly rootRelative = ".agentic";
public constructor(public readonly boundary: WorkspaceBoundary) {}
public relative(...segments: string[]): string {
return join(this.rootRelative, ...segments).replaceAll("\\", "/");
}
public lexical(relativePath: string): string {
return this.boundary.resolveInternal(relativePath);
}
public async initialize(): Promise<string> {
return await this.ensureDirectory(this.rootRelative);
}
public async ensureDirectory(relativePath: string): Promise<string> {
const absolute = await this.boundary.resolveInternalWrite(relativePath);
await mkdir(absolute, { recursive: true });
// Re-resolve after creation so a replaced/redirected directory fails closed.
const verified = await this.boundary.resolveInternalWrite(relativePath);
const info = await lstat(verified);
if (!info.isDirectory()) {
throw new AgenticError(
"INTERNAL",
`Plugin storage path is not a directory: ${relativePath}`,
);
}
return verified;
}
public async resolveWrite(relativePath: string): Promise<string> {
return await this.boundary.resolveInternalWrite(relativePath);
}
public async resolveRead(relativePath: string): Promise<string> {
// Plugin-owned state never accepts symlinks, even when they resolve inside
// the workspace. This prevents aliasing or replacement of audit records.
await this.boundary.resolveInternalWrite(relativePath);
return await this.boundary.resolveInternalRead(relativePath);
}
public async exists(relativePath: string): Promise<boolean> {
const absolute = await this.resolveWrite(relativePath);
try {
await lstat(absolute);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
public async lstat(relativePath: string) {
return await lstat(await this.resolveRead(relativePath));
}
public async readText(relativePath: string): Promise<string> {
return await readFile(await this.resolveRead(relativePath), "utf8");
}
public async readJson<T>(relativePath: string): Promise<T> {
return await readJson<T>(await this.resolveRead(relativePath));
}
public async writeText(relativePath: string, content: string): Promise<void> {
await atomicWriteText(await this.prepareWrite(relativePath), content);
}
public async writeJson(relativePath: string, value: unknown): Promise<void> {
await atomicWriteJson(await this.prepareWrite(relativePath), value);
}
public async appendJsonLine(relativePath: string, value: unknown): Promise<void> {
await appendJsonLine(await this.prepareWrite(relativePath), value);
}
public async readDirectory(
relativePath: string,
options: { withFileTypes: true },
): Promise<Dirent[]> {
return await readdir(await this.resolveRead(relativePath), options);
}
/** Removes a plugin-owned file; a missing path is not an error. Symlinks are rejected by resolveWrite. */
public async remove(relativePath: string): Promise<void> {
await rm(await this.resolveWrite(relativePath), { force: true });
}
private async prepareWrite(relativePath: string): Promise<string> {
const parent = dirname(relativePath).replaceAll("\\", "/");
if (parent !== ".") await this.ensureDirectory(parent);
// Resolve again after creating the parent so pre-existing or substituted
// nested symlinks are rejected before the atomic writer creates a temp file.
return await this.resolveWrite(relativePath);
}
}