feat: separate frontend and backend services

This commit is contained in:
luckyyzh
2026-08-10 09:45:22 +08:00
parent cb9ec7a869
commit 7a9859e60d
132 changed files with 8393 additions and 6388 deletions
-24
View File
@@ -1,24 +0,0 @@
// In-memory roots that should be browsable in addition to roots derived from
// persisted sessions. Stored on globalThis so Next.js hot-reload keeps them.
declare global {
var __piAllowedRootsCache: { roots: Set<string>; expiresAt: number } | undefined;
var __piAdditionalAllowedRoots: Set<string> | undefined;
}
export function normalizeSlashes(filePath: string): string {
return filePath.replace(/\\/g, "/");
}
export function getAdditionalAllowedRoots(): Set<string> {
if (!globalThis.__piAdditionalAllowedRoots) {
globalThis.__piAdditionalAllowedRoots = new Set();
}
return globalThis.__piAdditionalAllowedRoots;
}
export function allowFileRoot(root: string): void {
if (!root) return;
const normalizedRoot = normalizeSlashes(root);
getAdditionalAllowedRoots().add(normalizedRoot);
globalThis.__piAllowedRootsCache?.roots.add(normalizedRoot);
}
+17 -1
View File
@@ -1,4 +1,20 @@
import type { ResourceDiagnostic } from "@earendil-works/pi-coding-agent";
// Structural copy of pi-coding-agent's ResourceDiagnostic/ResourceCollision so
// the frontend no longer depends on the pi packages (the backend owns them).
export interface ResourceCollision {
resourceType: "extension" | "skill" | "prompt" | "theme";
name: string;
winnerPath: string;
loserPath: string;
winnerSource?: string;
loserSource?: string;
}
export interface ResourceDiagnostic {
type: "warning" | "error" | "collision";
message: string;
path?: string;
collision?: ResourceCollision;
}
export interface SkillSearchResult {
package: string;
-106
View File
@@ -1,106 +0,0 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
const {
createAppSettingsManager,
getAppResourceLoaderOptions,
getManagedRuntimePaths,
getSkillsCliEnvironment,
isManagedPath,
} = await jiti.import("./app-runtime.ts");
async function managedFixture(t) {
const root = await mkdtemp(join(tmpdir(), "pi-web-managed-runtime-"));
const appRoot = join(root, "app");
const dataDir = join(appRoot, "data");
const agentDir = join(dataDir, "agent");
const resourcesDir = join(appRoot, "resources");
const skillsHomeDir = join(dataDir, "skills-home");
const stateDir = join(dataDir, "state");
const cwd = join(root, "workspace");
await Promise.all([
mkdir(agentDir, { recursive: true }),
mkdir(resourcesDir, { recursive: true }),
mkdir(skillsHomeDir, { recursive: true }),
mkdir(stateDir, { recursive: true }),
mkdir(join(cwd, ".pi"), { recursive: true }),
]);
const previous = new Map();
const env = {
PI_AGENT_MANAGED_RUNTIME: "1",
PI_AGENT_APP_ROOT: appRoot,
PI_AGENT_DATA_DIR: dataDir,
PI_AGENT_RESOURCES_DIR: resourcesDir,
PI_AGENT_SKILLS_HOME: skillsHomeDir,
PI_CODING_AGENT_DIR: agentDir,
XDG_STATE_HOME: stateDir,
};
for (const [key, value] of Object.entries(env)) {
previous.set(key, process.env[key]);
process.env[key] = value;
}
t.after(async () => {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await rm(root, { recursive: true, force: true });
});
return { root, appRoot, dataDir, agentDir, resourcesDir, skillsHomeDir, stateDir, cwd };
}
test("managed runtime resolves every mutable path inside its application profile", async (t) => {
const fixture = await managedFixture(t);
const paths = getManagedRuntimePaths();
assert.equal(paths.appRoot, fixture.appRoot);
assert.equal(paths.agentDir, fixture.agentDir);
assert.ok(paths.managedSkillRoots.every((path) => isManagedPath(path)));
const cliEnv = getSkillsCliEnvironment();
assert.equal(cliEnv.HOME, fixture.skillsHomeDir);
assert.equal(cliEnv.USERPROFILE, fixture.skillsHomeDir);
assert.equal(cliEnv.XDG_STATE_HOME, fixture.stateDir);
assert.equal(cliEnv.npm_config_cache, join(fixture.dataDir, "cache", "npm"));
});
test("managed resource policy removes skills outside the application", async (t) => {
const fixture = await managedFixture(t);
const appSkill = join(fixture.resourcesDir, "skills", "inside", "SKILL.md");
const externalSkill = join(fixture.root, "outside", "SKILL.md");
await mkdir(join(appSkill, ".."), { recursive: true });
await mkdir(join(externalSkill, ".."), { recursive: true });
await writeFile(appSkill, "---\nname: inside\n---\n");
await writeFile(externalSkill, "---\nname: outside\n---\n");
const options = getAppResourceLoaderOptions();
assert.ok(options.skillsOverride);
const filtered = options.skillsOverride({
skills: [
{ filePath: appSkill },
{ filePath: externalSkill },
],
diagnostics: [
{ type: "warning", message: "inside", path: appSkill },
{ type: "warning", message: "outside", path: externalSkill },
],
});
assert.deepEqual(filtered.skills.map((skill) => skill.filePath), [appSkill]);
assert.deepEqual(filtered.diagnostics.map((diagnostic) => diagnostic.path), [appSkill]);
});
test("managed settings ignore the opened workspace's .pi settings", async (t) => {
const fixture = await managedFixture(t);
await writeFile(join(fixture.agentDir, "settings.json"), JSON.stringify({ defaultModel: "app-model" }));
await writeFile(join(fixture.cwd, ".pi", "settings.json"), JSON.stringify({ defaultModel: "workspace-model" }));
const settings = createAppSettingsManager(fixture.cwd, fixture.agentDir);
assert.equal(settings.isProjectTrusted(), false);
assert.deepEqual(settings.getProjectSettings(), {});
assert.equal(settings.getDefaultModel(), "app-model");
});
-127
View File
@@ -1,127 +0,0 @@
import { existsSync, realpathSync } from "fs";
import { basename, dirname, isAbsolute, relative, resolve, sep } from "path";
import {
SettingsManager,
type ResourceDiagnostic,
type Skill,
} from "@earendil-works/pi-coding-agent";
export const MANAGED_RUNTIME_ENV = "PI_AGENT_MANAGED_RUNTIME";
export interface ManagedRuntimePaths {
appRoot: string;
dataDir: string;
agentDir: string;
resourcesDir: string;
skillsHomeDir: string;
stateDir: string;
managedSkillRoots: string[];
}
export interface AppResourceLoaderOptions {
additionalExtensionPaths?: string[];
additionalSkillPaths?: string[];
additionalPromptTemplatePaths?: string[];
additionalThemePaths?: string[];
skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
};
}
export function isManagedRuntime(): boolean {
return process.env[MANAGED_RUNTIME_ENV] === "1";
}
function requiredPath(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required in managed runtime mode`);
return resolve(value);
}
export function getManagedRuntimePaths(): ManagedRuntimePaths {
const appRoot = requiredPath("PI_AGENT_APP_ROOT");
const dataDir = requiredPath("PI_AGENT_DATA_DIR");
const agentDir = requiredPath("PI_CODING_AGENT_DIR");
const resourcesDir = requiredPath("PI_AGENT_RESOURCES_DIR");
const skillsHomeDir = requiredPath("PI_AGENT_SKILLS_HOME");
const stateDir = resolve(process.env.XDG_STATE_HOME ?? `${dataDir}/state`);
return {
appRoot,
dataDir,
agentDir,
resourcesDir,
skillsHomeDir,
stateDir,
managedSkillRoots: [
resolve(resourcesDir, "skills"),
resolve(agentDir, "skills"),
resolve(skillsHomeDir, ".agents", "skills"),
resolve(skillsHomeDir, ".pi", "agent", "skills"),
],
};
}
function canonicalPath(path: string): string {
const resolved = resolve(path);
if (!existsSync(resolved)) {
const parent = dirname(resolved);
if (parent === resolved) return resolved;
return resolve(canonicalPath(parent), basename(resolved));
}
try {
return realpathSync.native(resolved);
} catch {
return resolved;
}
}
export function isManagedPath(path: string): boolean {
if (!isManagedRuntime()) return true;
const candidate = canonicalPath(path);
const { appRoot, dataDir, resourcesDir } = getManagedRuntimePaths();
return [appRoot, dataDir, resourcesDir].some((root) => {
const rel = relative(canonicalPath(root), candidate);
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
});
}
export function createAppSettingsManager(
cwd: string,
agentDir: string,
projectTrusted = true,
): SettingsManager {
return SettingsManager.create(cwd, agentDir, {
projectTrusted: isManagedRuntime() ? false : projectTrusted,
});
}
export function getAppResourceLoaderOptions(): AppResourceLoaderOptions {
if (!isManagedRuntime()) return {};
const { resourcesDir, managedSkillRoots } = getManagedRuntimePaths();
return {
additionalExtensionPaths: [resolve(resourcesDir, "extensions")],
additionalSkillPaths: managedSkillRoots,
additionalPromptTemplatePaths: [resolve(resourcesDir, "prompts")],
additionalThemePaths: [resolve(resourcesDir, "themes")],
skillsOverride: ({ skills, diagnostics }) => ({
skills: skills.filter((skill) => isManagedPath(skill.filePath)),
diagnostics: diagnostics.filter((diagnostic) => !diagnostic.path || isManagedPath(diagnostic.path)),
}),
};
}
export function getSkillsCliEnvironment(): NodeJS.ProcessEnv {
if (!isManagedRuntime()) return { ...process.env, FORCE_COLOR: "0" };
const { dataDir, skillsHomeDir, stateDir } = getManagedRuntimePaths();
return {
...process.env,
HOME: skillsHomeDir,
USERPROFILE: skillsHomeDir,
XDG_STATE_HOME: stateDir,
npm_config_cache: resolve(dataDir, "cache", "npm"),
FORCE_COLOR: "0",
};
}
-63
View File
@@ -1,63 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
async function loadSubject() {
return import("./bash-output.ts");
}
test("accepts only pi bash logs directly inside the configured temp directory", async () => {
const { resolveBashOutputPath } = await loadSubject();
const tempRoot = join(tmpdir(), "pi-web-output-tests");
const expected = resolve(tempRoot, "pi-bash-ab12.log");
assert.equal(resolveBashOutputPath(join(tempRoot, "pi-bash-ab12.log"), tempRoot), expected);
assert.equal(resolveBashOutputPath(join(tempRoot, "..", "pi-bash-ab12.log"), tempRoot), null);
assert.equal(resolveBashOutputPath(join(tempRoot, "pi-bash-ab12.log.bak"), tempRoot), null);
assert.equal(resolveBashOutputPath(join(`${tempRoot}-other`, "pi-bash-ab12.log"), tempRoot), null);
});
test("reads small output and rejects oversized inline output before buffering it", async () => {
const { readUtf8FileWithinLimit } = await loadSubject();
const dir = await mkdtemp(join(tmpdir(), "pi-web-bash-output-"));
const filePath = join(dir, "pi-bash-ab12.log");
try {
await writeFile(filePath, "shell output", "utf8");
assert.deepEqual(await readUtf8FileWithinLimit(filePath, 32), {
tooLarge: false,
content: "shell output",
size: 12,
});
assert.deepEqual(await readUtf8FileWithinLimit(filePath, 4), {
tooLarge: true,
size: 12,
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("rejects symbolic links when opening bash output", async (t) => {
const { readUtf8FileWithinLimit } = await loadSubject();
const dir = await mkdtemp(join(tmpdir(), "pi-web-bash-output-link-"));
const targetPath = join(dir, "target.log");
const linkPath = join(dir, "pi-bash-link.log");
try {
await writeFile(targetPath, "not authorized through a link", "utf8");
try {
await symlink(targetPath, linkPath);
} catch (error) {
if (error?.code === "EPERM") {
t.skip("Creating symbolic links requires additional privileges on this platform");
return;
}
throw error;
}
await assert.rejects(() => readUtf8FileWithinLimit(linkPath));
} finally {
await rm(dir, { recursive: true, force: true });
}
});
-53
View File
@@ -1,53 +0,0 @@
import { constants } from "node:fs";
import { lstat, open } from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
export const MAX_INLINE_BASH_OUTPUT_BYTES = 5 * 1024 * 1024;
export function resolveBashOutputPath(filePath: string, tempRoot: string): string | null {
const resolvedPath = resolve(filePath);
if (dirname(resolvedPath) !== resolve(tempRoot)) return null;
if (!/^pi-bash-[A-Za-z0-9_-]+\.log$/.test(basename(resolvedPath))) return null;
return resolvedPath;
}
export async function openRegularFileNoFollow(filePath: string) {
const pathInfo = await lstat(filePath);
if (!pathInfo.isFile()) throw new Error("Bash output path is not a regular file");
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
const handle = await open(filePath, constants.O_RDONLY | noFollow);
try {
const fileInfo = await handle.stat();
if (!fileInfo.isFile()) throw new Error("Bash output path is not a regular file");
return { handle, fileInfo };
} catch (error) {
await handle.close();
throw error;
}
}
export async function readUtf8FileWithinLimit(
filePath: string,
maxBytes = MAX_INLINE_BASH_OUTPUT_BYTES,
): Promise<{ tooLarge: true; size: number } | { tooLarge: false; content: string; size: number }> {
const { handle, fileInfo } = await openRegularFileNoFollow(filePath);
try {
if (fileInfo.size > maxBytes) return { tooLarge: true, size: fileInfo.size };
const buffer = Buffer.alloc(fileInfo.size);
let bytesRead = 0;
while (bytesRead < buffer.length) {
const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
if (result.bytesRead === 0) break;
bytesRead += result.bytesRead;
}
return {
tooLarge: false,
content: buffer.subarray(0, bytesRead).toString("utf8"),
size: bytesRead,
};
} finally {
await handle.close();
}
}
-54
View File
@@ -1,54 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function loadSubject() {
return import("./bounded-form-data.ts");
}
function multipartBody(boundary, value) {
return `--${boundary}\r\nContent-Disposition: form-data; name="value"\r\n\r\n${value}\r\n--${boundary}--\r\n`;
}
test("rejects a declared oversized request before reading its body", async () => {
const { parseFormDataWithinLimit, RequestBodyTooLargeError } = await loadSubject();
const request = new Request("http://localhost/upload", {
method: "POST",
headers: { "content-length": "99", "content-type": "multipart/form-data; boundary=test" },
body: multipartBody("test", "small"),
});
await assert.rejects(() => parseFormDataWithinLimit(request, 10), RequestBodyTooLargeError);
});
test("stops a chunked request once its body exceeds the limit", async () => {
const { parseFormDataWithinLimit, RequestBodyTooLargeError } = await loadSubject();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("1234"));
controller.enqueue(new TextEncoder().encode("5678"));
controller.close();
},
});
const request = new Request("http://localhost/upload", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=test" },
body: stream,
duplex: "half",
});
await assert.rejects(() => parseFormDataWithinLimit(request, 6), RequestBodyTooLargeError);
});
test("parses multipart data inside the request limit", async () => {
const { parseFormDataWithinLimit } = await loadSubject();
const boundary = "test";
const body = multipartBody(boundary, "small");
const request = new Request("http://localhost/upload", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body,
});
const formData = await parseFormDataWithinLimit(request, body.length + 1);
assert.equal(formData.get("value"), "small");
});
-49
View File
@@ -1,49 +0,0 @@
export class RequestBodyTooLargeError extends Error {
constructor() {
super("Request body exceeds the allowed size");
}
}
function declaredContentLength(request: Request): number | null {
const value = request.headers.get("content-length");
if (!value || !/^\d+$/.test(value)) return null;
const length = Number(value);
return Number.isSafeInteger(length) ? length : null;
}
/**
* Parse multipart data only after constraining the complete wire body. This
* bounds chunked requests too, where Content-Length is unavailable or false.
*/
export async function parseFormDataWithinLimit(request: Request, maxBytes: number): Promise<FormData> {
const declared = declaredContentLength(request);
if (declared !== null && declared > maxBytes) {
throw new RequestBodyTooLargeError();
}
const reader = request.body?.getReader();
if (!reader) return request.formData();
const chunks: BlobPart[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (size + value.byteLength > maxBytes) {
await reader.cancel().catch(() => {});
throw new RequestBodyTooLargeError();
}
size += value.byteLength;
const chunk = new Uint8Array(value.byteLength);
chunk.set(value);
chunks.push(chunk);
}
} finally {
reader.releaseLock();
}
const contentType = request.headers.get("content-type");
const headers = contentType ? { "content-type": contentType } : undefined;
return new Response(new Blob(chunks), { headers }).formData();
}
-32
View File
@@ -1,32 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function loadSubject() {
return import("./custom-ui-terminal.ts");
}
test("headless custom UI exposes stable terminal dimensions", async () => {
const { createHeadlessCustomUiTui, DEFAULT_CUSTOM_UI_COLUMNS, DEFAULT_CUSTOM_UI_ROWS } = await loadSubject();
const tui = createHeadlessCustomUiTui(() => {});
assert.deepEqual(tui.terminal, {
columns: DEFAULT_CUSTOM_UI_COLUMNS,
rows: DEFAULT_CUSTOM_UI_ROWS,
kittyProtocolActive: false,
});
assert.equal(Object.isFrozen(tui), true);
assert.equal(Object.isFrozen(tui.terminal), true);
});
test("headless custom UI supports plugin rendering and render requests", async () => {
const { createHeadlessCustomUiTui } = await loadSubject();
let renders = 0;
const tui = createHeadlessCustomUiTui(() => { renders += 1; }, 80, 24);
const pluginComponent = {
render: (width) => [`${width}:${tui.terminal.columns}x${tui.terminal.rows}`],
};
assert.deepEqual(pluginComponent.render(80), ["80:80x24"]);
tui.requestRender();
assert.equal(renders, 1);
});
-27
View File
@@ -1,27 +0,0 @@
export const DEFAULT_CUSTOM_UI_COLUMNS = 92;
export const DEFAULT_CUSTOM_UI_ROWS = 40;
export interface HeadlessCustomUiTerminal {
readonly columns: number;
readonly rows: number;
readonly kittyProtocolActive: false;
}
export interface HeadlessCustomUiTui {
readonly terminal: HeadlessCustomUiTerminal;
requestRender(force?: boolean): void;
}
export function createHeadlessCustomUiTui(
requestRender: (force?: boolean) => void,
columns = DEFAULT_CUSTOM_UI_COLUMNS,
rows = DEFAULT_CUSTOM_UI_ROWS,
): HeadlessCustomUiTui {
const terminal = Object.freeze({
columns,
rows,
kittyProtocolActive: false as const,
});
return Object.freeze({ terminal, requestRender });
}
-42
View File
@@ -1,42 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
async function loadSubject() {
return import("./directory-browser.ts");
}
test("lists directories and directory symlinks without returning files", async () => {
const root = await mkdtemp(path.join(tmpdir(), "pi-web-browse-"));
try {
await mkdir(path.join(root, "project"));
await writeFile(path.join(root, "notes.txt"), "test", "utf8");
await symlink(path.join(root, "project"), path.join(root, "linked-project"));
const { listDirectories } = await loadSubject();
const directories = await listDirectories(root);
assert.deepEqual(directories.map((entry) => entry.name), ["linked-project", "project"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("expands home-relative paths and rejects missing directories", async () => {
const { getBrowseStartDirectory, normalizeDirectory, resolveDirectory } = await loadSubject();
assert.equal(getBrowseStartDirectory(), homedir());
assert.equal(getBrowseStartDirectory("/project"), "/project");
assert.equal(normalizeDirectory("~/project"), path.join(homedir(), "project"));
await assert.rejects(resolveDirectory(path.join(tmpdir(), `pi-web-missing-${Date.now()}`)));
});
test("finds parent directories across POSIX and Windows paths", async () => {
const { getParentDirectory } = await loadSubject();
assert.equal(getParentDirectory("/Users/alex/project"), "/Users/alex");
assert.equal(getParentDirectory("/"), null);
assert.equal(getParentDirectory("C:\\Users\\Alex\\project"), "C:\\Users\\Alex");
assert.equal(getParentDirectory("C:\\"), null);
});
-56
View File
@@ -1,56 +0,0 @@
import { readdir, realpath, stat } from "fs/promises";
import { homedir } from "os";
import path from "path";
export interface BrowsableDirectory {
name: string;
path: string;
}
export function getBrowseStartDirectory(directory?: string): string {
return directory || homedir();
}
export function normalizeDirectory(directory: string): string {
if (directory === "~") return homedir();
if (directory.startsWith("~/")) return path.resolve(homedir(), directory.slice(2));
return path.resolve(directory);
}
export function getParentDirectory(directory: string): string | null {
const pathApi = /^[a-zA-Z]:[\\/]/.test(directory) || directory.startsWith("\\\\")
? path.win32
: path;
const normalized = pathApi.normalize(directory);
const parent = pathApi.dirname(normalized);
return parent === normalized ? null : parent;
}
export async function resolveDirectory(directory: string): Promise<string> {
return realpath(normalizeDirectory(directory));
}
export async function listDirectories(directory: string): Promise<BrowsableDirectory[]> {
const entries = await readdir(directory, { withFileTypes: true });
// 忽略损坏、不可访问或不指向目录的符号链接。
const candidates = await Promise.all(entries.map(async (entry) => {
if (entry.isDirectory()) {
return { name: entry.name, path: path.join(directory, entry.name) };
}
if (!entry.isSymbolicLink()) return null;
try {
const entryPath = path.join(directory, entry.name);
const realEntryPath = await realpath(entryPath);
const entryStat = await stat(realEntryPath);
if (!entryStat.isDirectory()) return null;
return { name: entry.name, path: entryPath };
} catch {
return null;
}
}));
return candidates
.filter((entry): entry is BrowsableDirectory => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
}
-27
View File
@@ -1,27 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
async function loadSubject() {
return import("./path-security.ts");
}
test("rejects an existing path that escapes an allowed root through a symlink", async (t) => {
const { isExistingPathWithinRoots, isPathWithinRoots } = await loadSubject();
const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-file-access-"));
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
const allowed = path.join(base, "allowed");
const outside = path.join(base, "outside");
fs.mkdirSync(allowed);
fs.mkdirSync(outside);
fs.writeFileSync(path.join(outside, "secret.txt"), "secret");
const link = path.join(allowed, "link");
fs.symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir");
const target = path.join(link, "secret.txt");
const roots = new Set([allowed]);
assert.equal(isPathWithinRoots(target, roots), true);
assert.equal(isExistingPathWithinRoots(target, roots), false);
});
-75
View File
@@ -1,75 +0,0 @@
import { readdirSync } from "fs";
import { homedir } from "os";
import path from "path";
import { getAdditionalAllowedRoots, normalizeSlashes } from "./allowed-roots";
import { isExistingPathWithinRoots } from "./path-security";
import { listAllSessions } from "./session-reader";
export { allowFileRoot, normalizeSlashes } from "./allowed-roots";
// Short-TTL cache for the allowed-roots set. Without this, every file list/read
// request re-scans every pi session on disk just to check access. 5s is short
// enough that newly-created cwds appear promptly; stored on globalThis so it
// survives Next.js hot-reload.
declare global {
var __piAllowedRootsCache: { roots: Set<string>; expiresAt: number } | undefined;
}
const ALLOWED_ROOTS_TTL_MS = 5_000;
const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;
export function isWindowsAbsolutePath(filePath: string): boolean {
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
}
export async function getAllowedFileRoots(): Promise<Set<string>> {
const now = Date.now();
const cached = globalThis.__piAllowedRootsCache;
if (cached && cached.expiresAt > now) return cached.roots;
const sessions = await listAllSessions();
const roots = new Set<string>();
for (const s of sessions) {
if (s.cwd) roots.add(normalizeSlashes(s.cwd));
// The project root (main repo shared by all worktrees) is browsable too —
// the project dropdown lists it even when only worktrees have sessions.
if (s.projectRoot) roots.add(normalizeSlashes(s.projectRoot));
}
// Also allow ~/pi-cwd-* directories created by the default-cwd endpoint.
try {
for (const name of readdirSync(homedir())) {
if (/^pi-cwd-\d{8}$/.test(name)) {
roots.add(normalizeSlashes(path.join(homedir(), name)));
}
}
} catch {
// ignore if home is unreadable
}
for (const root of getAdditionalAllowedRoots()) roots.add(root);
globalThis.__piAllowedRootsCache = { roots, expiresAt: now + ALLOWED_ROOTS_TTL_MS };
return roots;
}
export function isFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
for (const root of allowedRoots) {
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
const resolver = useWindowsRules ? path.win32 : path;
const sep = useWindowsRules ? "\\" : path.sep;
const normalized = resolver.resolve(target);
const normalizedRoot = resolver.resolve(root);
const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) {
return true;
}
}
return false;
}
/** Authorize an existing path after resolving symbolic links. */
export function isExistingFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
return isExistingPathWithinRoots(target, allowedRoots);
}
-56
View File
@@ -1,56 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
async function loadSubject() {
return import("./file-dirent.ts");
}
test("uses Dirent types for regular files and directories", async () => {
const { resolveDirentIsDirectory } = await loadSubject();
const file = { isDirectory: () => false, isFile: () => true };
const directory = { isDirectory: () => true, isFile: () => false };
assert.equal(resolveDirentIsDirectory(file, "/unused/file"), false);
assert.equal(resolveDirentIsDirectory(directory, "/unused/directory"), true);
});
test("falls back to stat when the Dirent type is unknown", async (t) => {
const { resolveDirentIsDirectory } = await loadSubject();
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-dirent-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const directoryPath = path.join(root, "directory");
fs.mkdirSync(directoryPath);
const unknown = { isDirectory: () => false, isFile: () => false };
assert.equal(resolveDirentIsDirectory(unknown, directoryPath), true);
});
test("follows directory symlinks and skips dangling symlinks", async (t) => {
const { resolveDirentIsDirectory } = await loadSubject();
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-dirent-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.mkdirSync(path.join(root, "target"));
try {
fs.symlinkSync("target", path.join(root, "directory-link"), "dir");
fs.symlinkSync("missing", path.join(root, "dangling-link"), "file");
} catch (error) {
if (error?.code === "EPERM") {
t.skip("Creating symbolic links requires additional privileges on this platform");
return;
}
throw error;
}
const symlink = { isDirectory: () => false, isFile: () => false };
assert.equal(
resolveDirentIsDirectory(symlink, path.join(root, "directory-link")),
true,
);
assert.equal(
resolveDirentIsDirectory(symlink, path.join(root, "dangling-link")),
null,
);
});
-17
View File
@@ -1,17 +0,0 @@
import fs from "fs";
type DirentType = Pick<fs.Dirent, "isDirectory" | "isFile">;
export function resolveDirentIsDirectory(
dirent: DirentType,
fullPath: string,
): boolean | null {
if (dirent.isDirectory()) return true;
if (dirent.isFile()) return false;
try {
return fs.statSync(fullPath).isDirectory();
} catch {
return null;
}
}
-70
View File
@@ -1,70 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
async function loadSubject() {
return import("./file-upload.ts");
}
test("validates upload names without accepting paths or duplicates", async () => {
const { validateUploadFileNames } = await loadSubject();
assert.equal(validateUploadFileNames(["one.txt", "two file.md"]), null);
assert.match(validateUploadFileNames(["../secret.txt"]), /must not contain a path/);
assert.match(validateUploadFileNames(["folder\\secret.txt"]), /must not contain a path/);
assert.match(validateUploadFileNames(["same.txt", "same.txt"]), /Duplicate/);
assert.match(validateUploadFileNames([]), /No files/);
});
test("finds conflicts and prevents replacing directories", async (t) => {
const { inspectUploadTargets } = await loadSubject();
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-upload-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.writeFileSync(path.join(root, "file.txt"), "old");
fs.mkdirSync(path.join(root, "directory"));
assert.deepEqual(
inspectUploadTargets(root, ["new.txt", "file.txt", "directory"]),
{
conflicts: ["file.txt", "directory"],
nonReplaceable: ["directory"],
},
);
});
test("prevents replacing symbolic links", async (t) => {
const { inspectUploadTargets } = await loadSubject();
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-upload-link-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.writeFileSync(path.join(root, "file.txt"), "old");
try {
fs.symlinkSync("file.txt", path.join(root, "link.txt"));
} catch (error) {
if (error?.code === "EPERM") {
t.skip("Creating symbolic links requires additional privileges on this platform");
return;
}
throw error;
}
assert.deepEqual(
inspectUploadTargets(root, ["link.txt"]),
{
conflicts: ["link.txt"],
nonReplaceable: ["link.txt"],
},
);
});
test("parses only supported conflict strategies", async () => {
const { parseUploadConflictStrategy } = await loadSubject();
assert.equal(parseUploadConflictStrategy(null), "error");
assert.equal(parseUploadConflictStrategy("overwrite"), "overwrite");
assert.equal(parseUploadConflictStrategy("skip"), "skip");
assert.equal(parseUploadConflictStrategy("rename"), null);
});
-59
View File
@@ -1,59 +0,0 @@
import fs from "fs";
import path from "path";
export const UPLOAD_CONFLICT_STRATEGIES = ["error", "overwrite", "skip"] as const;
export type UploadConflictStrategy = typeof UPLOAD_CONFLICT_STRATEGIES[number];
const UPLOAD_CONFLICT_STRATEGY_SET = new Set<string>(UPLOAD_CONFLICT_STRATEGIES);
export interface UploadTargetInspection {
conflicts: string[];
nonReplaceable: string[];
}
export function parseUploadConflictStrategy(value: string | null): UploadConflictStrategy | null {
const candidate = value ?? "error";
return UPLOAD_CONFLICT_STRATEGY_SET.has(candidate)
? candidate as UploadConflictStrategy
: null;
}
export function validateUploadFileNames(fileNames: string[]): string | null {
if (fileNames.length === 0) return "No files selected";
const seen = new Set<string>();
for (const fileName of fileNames) {
if (!fileName || fileName === "." || fileName === ".." || fileName.includes("\0")) {
return `Invalid file name: ${fileName || "(empty)"}`;
}
if (fileName.includes("/") || fileName.includes("\\") || path.basename(fileName) !== fileName) {
return `File names must not contain a path: ${fileName}`;
}
if (seen.has(fileName)) return `Duplicate file name in upload: ${fileName}`;
seen.add(fileName);
}
return null;
}
export function inspectUploadTargets(directory: string, fileNames: string[]): UploadTargetInspection {
const conflicts: string[] = [];
const nonReplaceable: string[] = [];
for (const fileName of fileNames) {
const destination = path.join(directory, fileName);
let stat: fs.Stats;
try {
stat = fs.lstatSync(destination);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") continue;
throw error;
}
conflicts.push(fileName);
if (!stat.isFile() || stat.isSymbolicLink()) nonReplaceable.push(fileName);
}
return { conflicts, nonReplaceable };
}
-52
View File
@@ -1,52 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function loadSubject() {
return import("./git-status.ts");
}
test("parses null-delimited Git status entries including renames", async () => {
const { parseGitPorcelainV1 } = await loadSubject();
const entries = parseGitPorcelainV1([
" M components/App.tsx",
"?? notes.txt",
"R src/new-name.ts",
"src/old-name.ts",
"",
].join("\0"));
assert.deepEqual(entries, [
{
path: "components/App.tsx",
indexStatus: " ",
worktreeStatus: "M",
},
{
path: "notes.txt",
indexStatus: "?",
worktreeStatus: "?",
},
{
path: "src/new-name.ts",
originalPath: "src/old-name.ts",
indexStatus: "R",
worktreeStatus: " ",
},
]);
});
test("classifies Git status for explorer badges", async () => {
const { classifyGitStatus } = await loadSubject();
const classify = (pair) => classifyGitStatus({
path: "file.ts",
indexStatus: pair[0],
worktreeStatus: pair[1],
});
assert.deepEqual(classify(" M"), { status: "modified", code: "M" });
assert.deepEqual(classify("??"), { status: "untracked", code: "U" });
assert.deepEqual(classify("A "), { status: "added", code: "A" });
assert.deepEqual(classify("R "), { status: "renamed", code: "R" });
assert.deepEqual(classify("UU"), { status: "conflict", code: "C" });
assert.deepEqual(classify(" D"), { status: "deleted", code: "D" });
});
-232
View File
@@ -1,232 +0,0 @@
import { execFile } from "child_process";
import fs from "fs";
import path from "path";
import { promisify } from "util";
import { TEXT_PREVIEW_MAX_BYTES } from "./file-types";
import type {
GitFileDiffResponse,
GitFileStatus,
GitStatusResponse,
} from "./git-types";
import {
classifyGitStatus,
parseGitPorcelainV1,
type GitPorcelainEntry,
} from "./git-status";
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 10_000;
const GIT_STATUS_MAX_BUFFER = 8 * 1024 * 1024;
async function git(cwd: string, args: string[], maxBuffer = GIT_STATUS_MAX_BUFFER): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
timeout: GIT_TIMEOUT_MS,
maxBuffer,
env: { ...process.env, LC_ALL: "C" },
});
return stdout;
}
async function findRepositoryRoot(cwd: string): Promise<string | null> {
try {
return (await git(cwd, ["rev-parse", "--show-toplevel"])).trim() || null;
} catch {
return null;
}
}
function isWithinPath(parent: string, target: string): boolean {
const relative = path.relative(path.resolve(parent), path.resolve(target));
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
function toGitPath(filePath: string): string {
return filePath.split(path.sep).join("/");
}
async function readStatusEntries(repositoryRoot: string): Promise<GitPorcelainEntry[]> {
const output = await git(repositoryRoot, [
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
]);
return parseGitPorcelainV1(output);
}
async function readTrackedLineStats(
repositoryRoot: string,
cwd: string,
): Promise<{ additions: number; deletions: number }> {
const relativeCwd = toGitPath(path.relative(repositoryRoot, cwd));
const pathspec = relativeCwd || ".";
try {
const output = await git(repositoryRoot, [
"diff",
"--no-color",
"--no-ext-diff",
"--numstat",
"HEAD",
"--",
pathspec,
]);
let additions = 0;
let deletions = 0;
for (const line of output.split(/\r?\n/)) {
if (!line) continue;
const [added, deleted] = line.split("\t", 2);
const addedCount = Number(added);
const deletedCount = Number(deleted);
if (Number.isInteger(addedCount)) additions += addedCount;
if (Number.isInteger(deletedCount)) deletions += deletedCount;
}
return { additions, deletions };
} catch {
return { additions: 0, deletions: 0 };
}
}
function countUntrackedTextLines(filePath: string): number {
try {
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.size > TEXT_PREVIEW_MAX_BYTES) return 0;
const content = fs.readFileSync(filePath);
if (hasNullByte(content) || content.length === 0) return 0;
const text = content.toString("utf8");
return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
} catch {
return 0;
}
}
export async function getGitStatus(cwd: string): Promise<GitStatusResponse> {
const repositoryRoot = await findRepositoryRoot(cwd);
if (!repositoryRoot) {
return {
isGitRepository: false,
repositoryRoot: null,
files: [],
additions: 0,
deletions: 0,
};
}
const [entries, trackedLineStats] = await Promise.all([
readStatusEntries(repositoryRoot),
readTrackedLineStats(repositoryRoot, cwd),
]);
const files = entries.flatMap((entry): GitFileStatus[] => {
const filePath = path.resolve(repositoryRoot, entry.path);
if (!isWithinPath(cwd, filePath)) return [];
const classified = classifyGitStatus(entry);
return [{
filePath,
...classified,
indexStatus: entry.indexStatus,
worktreeStatus: entry.worktreeStatus,
}];
});
const untrackedAdditions = files.reduce(
(total, file) => total + (file.status === "untracked" ? countUntrackedTextLines(file.filePath) : 0),
0,
);
return {
isGitRepository: true,
repositoryRoot,
files,
additions: trackedLineStats.additions + untrackedAdditions,
deletions: trackedLineStats.deletions,
};
}
function hasNullByte(content: Buffer): boolean {
return content.includes(0);
}
function createAddedFilePatch(gitPath: string, content: string): string {
const hasTrailingNewline = content.endsWith("\n");
const lines = content.split("\n");
if (hasTrailingNewline) lines.pop();
const body = lines.map((line) => `+${line}`).join("\n");
const noNewlineMarker = !hasTrailingNewline && lines.length > 0
? "\n\\ No newline at end of file"
: "";
return [
`diff --git a/${gitPath} b/${gitPath}`,
"new file mode 100644",
"--- /dev/null",
`+++ b/${gitPath}`,
`@@ -0,0 +1,${lines.length} @@`,
`${body}${noNewlineMarker}`,
].join("\n");
}
async function createTrackedFilePatch(
repositoryRoot: string,
relativePath: string,
originalPath?: string,
): Promise<string | null> {
const paths = originalPath && originalPath !== relativePath
? [originalPath, relativePath]
: [relativePath];
try {
return await git(repositoryRoot, [
"diff",
"--no-color",
"--no-ext-diff",
"--unified=3",
"HEAD",
"--",
...paths,
], TEXT_PREVIEW_MAX_BYTES * 4);
} catch {
return null;
}
}
export async function getGitFileDiff(cwd: string, filePath: string): Promise<GitFileDiffResponse> {
const repositoryRoot = await findRepositoryRoot(cwd);
if (!repositoryRoot || !isWithinPath(repositoryRoot, filePath)) return { supported: false };
const resolvedFilePath = path.resolve(filePath);
const relativePath = toGitPath(path.relative(repositoryRoot, resolvedFilePath));
const entries = await readStatusEntries(repositoryRoot);
const entry = entries.find((candidate) => candidate.path === relativePath);
if (!entry) return { supported: false };
const { status } = classifyGitStatus(entry);
if (status === "deleted") {
const patch = await createTrackedFilePatch(repositoryRoot, relativePath, entry.originalPath);
if (!patch?.includes("\n@@ ")) return { supported: false };
return { supported: true, status, patch };
}
let stat: fs.Stats;
try {
stat = fs.lstatSync(resolvedFilePath);
} catch {
return { supported: false };
}
if (!stat.isFile() || stat.size > TEXT_PREVIEW_MAX_BYTES) return { supported: false };
const currentBuffer = fs.readFileSync(resolvedFilePath);
if (hasNullByte(currentBuffer)) return { supported: false };
const newContent = currentBuffer.toString("utf8");
let patch: string;
if (status === "untracked") {
patch = createAddedFilePatch(relativePath, newContent);
} else {
const trackedPatch = await createTrackedFilePatch(repositoryRoot, relativePath, entry.originalPath);
if (trackedPatch === null) {
if (status !== "added") return { supported: false };
patch = createAddedFilePatch(relativePath, newContent);
} else {
patch = trackedPatch;
}
}
if (!patch.includes("\n@@ ")) return { supported: false };
return { supported: true, status, patch };
}
-47
View File
@@ -1,47 +0,0 @@
import type { GitFileStatus } from "./git-types";
export interface GitPorcelainEntry {
path: string;
originalPath?: string;
indexStatus: string;
worktreeStatus: string;
}
function usesRenamePath(indexStatus: string, worktreeStatus: string): boolean {
return indexStatus === "R" || indexStatus === "C" || worktreeStatus === "R" || worktreeStatus === "C";
}
export function parseGitPorcelainV1(output: string): GitPorcelainEntry[] {
const records = output.split("\0");
const entries: GitPorcelainEntry[] = [];
for (let i = 0; i < records.length; i++) {
const record = records[i];
if (!record || record.length < 4 || record[2] !== " ") continue;
const indexStatus = record[0];
const worktreeStatus = record[1];
const entry: GitPorcelainEntry = {
path: record.slice(3),
indexStatus,
worktreeStatus,
};
if (usesRenamePath(indexStatus, worktreeStatus)) {
entry.originalPath = records[++i] || undefined;
}
entries.push(entry);
}
return entries;
}
const CONFLICT_STATUSES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
export function classifyGitStatus(entry: GitPorcelainEntry): Pick<GitFileStatus, "status" | "code"> {
const pair = `${entry.indexStatus}${entry.worktreeStatus}`;
if (pair === "??") return { status: "untracked", code: "U" };
if (CONFLICT_STATUSES.has(pair) || pair.includes("U")) return { status: "conflict", code: "C" };
if (pair.includes("D")) return { status: "deleted", code: "D" };
if (pair.includes("R") || pair.includes("C")) return { status: "renamed", code: "R" };
if (pair.includes("A")) return { status: "added", code: "A" };
return { status: "modified", code: "M" };
}
-88
View File
@@ -1,88 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { once } from "node:events";
import test from "node:test";
import { createJiti } from "jiti";
const PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"ALL_PROXY",
"all_proxy",
];
test("configures HTTP_PROXY, HTTPS_PROXY, and NO_PROXY for global fetch", async (t) => {
const originalEnv = new Map(PROXY_ENV_KEYS.map((key) => [key, process.env[key]]));
for (const key of PROXY_ENV_KEYS) delete process.env[key];
const connectTargets = [];
const tunneledRequests = [];
const proxy = createServer((req, res) => {
res.writeHead(204, { Connection: "close" });
res.end();
});
proxy.on("connect", (req, socket) => {
connectTargets.push(req.url);
if (req.url?.endsWith(":80")) {
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
socket.once("data", (chunk) => {
tunneledRequests.push(chunk.toString("utf8").split("\r\n", 1)[0]);
socket.end("HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
});
return;
}
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
});
proxy.listen(0, "127.0.0.1");
await once(proxy, "listening");
t.after(async () => {
for (const [key, value] of originalEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await new Promise((resolve, reject) => {
proxy.close((error) => error ? reject(error) : resolve());
});
});
const address = proxy.address();
assert.ok(address && typeof address === "object");
const proxyUrl = `http://127.0.0.1:${address.port}`;
process.env.HTTP_PROXY = proxyUrl;
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = "bypass.invalid";
const jiti = createJiti(import.meta.url);
const { configureHttpDispatcher } = await jiti.import("./http-dispatcher.ts");
const { getGlobalDispatcher } = await import("undici");
assert.throws(() => configureHttpDispatcher(-1), /Invalid HTTP idle timeout/);
configureHttpDispatcher(2_000);
const dispatcher = getGlobalDispatcher();
configureHttpDispatcher(5_000);
assert.equal(getGlobalDispatcher(), dispatcher, "configuration should be idempotent");
const httpResponse = await fetch("http://target.invalid/through-http-proxy", {
signal: AbortSignal.timeout(2_000),
});
assert.equal(httpResponse.status, 204);
assert.deepEqual(connectTargets, ["target.invalid:80"]);
assert.deepEqual(tunneledRequests, ["GET /through-http-proxy HTTP/1.1"]);
await assert.rejects(fetch("https://target.invalid/through-https-proxy", {
signal: AbortSignal.timeout(2_000),
}));
assert.deepEqual(connectTargets, ["target.invalid:80", "target.invalid:443"]);
const proxiedRequestCount = connectTargets.length;
await assert.rejects(fetch("http://bypass.invalid:9/no-proxy", {
signal: AbortSignal.timeout(2_000),
}));
assert.equal(connectTargets.length, proxiedRequestCount);
});
-86
View File
@@ -1,86 +0,0 @@
import { EventEmitter } from "node:events";
import * as undici from "undici";
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
type DispatcherGlobal = typeof globalThis & {
__piWebHttpDispatcherConfigured?: boolean;
};
const dispatcherGlobal = globalThis as DispatcherGlobal;
const originalGlobalFetch = globalThis.fetch;
const ignoreUndiciDispatcherError = (): void => {};
function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.toLowerCase() === "disabled") return 0;
if (trimmed.length === 0) return undefined;
return parseHttpIdleTimeoutMs(Number(trimmed));
}
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.floor(value);
}
// Undici can emit an internal Client error while terminating a response body.
// The body stream still rejects; this prevents the EventEmitter error from
// terminating the Next.js process first.
function withUndiciErrorListener<T extends undici.Dispatcher>(dispatcher: T): T {
if (dispatcher instanceof EventEmitter) {
EventEmitter.prototype.on.call(dispatcher, "error", ignoreUndiciDispatcherError);
}
return dispatcher;
}
function createUndiciClient(origin: string | URL, options: object): undici.Dispatcher {
return withUndiciErrorListener(
new undici.Client(origin, options as undici.Client.Options),
);
}
function createUndiciOriginDispatcher(origin: string | URL, options: object): undici.Dispatcher {
const dispatcherOptions = options as undici.Pool.Options;
if (dispatcherOptions.connections === 1) {
return createUndiciClient(origin, dispatcherOptions);
}
return withUndiciErrorListener(
new undici.Pool(origin, {
...dispatcherOptions,
factory: createUndiciClient,
}),
);
}
export function configureHttpDispatcher(
timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS,
): void {
if (dispatcherGlobal.__piWebHttpDispatcherConfigured) return;
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
if (normalizedTimeoutMs === undefined) {
throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);
}
const dispatcher = withUndiciErrorListener(
new undici.EnvHttpProxyAgent({
allowH2: false,
bodyTimeout: normalizedTimeoutMs,
headersTimeout: normalizedTimeoutMs,
clientFactory: createUndiciClient,
factory: createUndiciOriginDispatcher,
}),
);
undici.setGlobalDispatcher(dispatcher);
// Keep fetch and the dispatcher on the same undici implementation. Preserve
// an intentional fetch override installed after this module was loaded.
if (globalThis.fetch === originalGlobalFetch) {
undici.install?.();
}
dispatcherGlobal.__piWebHttpDispatcherConfigured = true;
}
-58
View File
@@ -1,58 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
export interface ModelDiscoveryAuth {
apiKey?: string;
headers: Record<string, string>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringRecord(value: unknown): Record<string, string> {
if (!isRecord(value)) return {};
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
}
export async function resolveModelDiscoveryAuth(
providerName: string,
provider: Record<string, unknown>,
): Promise<ModelDiscoveryAuth> {
let tempDir: string | undefined;
try {
tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-discovery-"));
const modelsPath = join(tempDir, "models.json");
const discoveryModelId = "__pi_web_model_discovery__";
writeFileSync(modelsPath, JSON.stringify({
providers: {
[providerName]: {
...provider,
models: [{ id: discoveryModelId }],
},
},
}, null, 2), "utf8");
const modelRuntime = await ModelRuntime.create({ modelsPath });
const loadError = modelRuntime.getError();
if (loadError) throw new Error(loadError);
const model = modelRuntime.getModel(providerName, discoveryModelId);
if (!model) throw new Error(`Unable to load provider "${providerName}"`);
const resolved = await modelRuntime.getAuth(model);
if (resolved) {
return {
apiKey: resolved.auth.apiKey,
headers: stringRecord(resolved.auth.headers),
};
}
return {
headers: stringRecord(modelRuntime.getCompatibilityRequestConfig(model).headers),
};
} finally {
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
}
}
-104
View File
@@ -1,104 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { invalidateModelsCache, loadModelsWithCache, withModelRuntimeError } from "./models-cache.ts";
function modelsData(id) {
return {
models: { [`provider:${id}`]: id },
modelList: [{ id, name: id, provider: "provider" }],
defaultModel: null,
thinkingLevels: {},
thinkingLevelMaps: {},
};
}
test("caches model data independently for each cwd", async () => {
invalidateModelsCache();
let firstLoads = 0;
let secondLoads = 0;
const first = await loadModelsWithCache("/first", async () => {
firstLoads += 1;
return modelsData("first");
});
await loadModelsWithCache("/second", async () => {
secondLoads += 1;
return modelsData("second");
});
const firstAgain = await loadModelsWithCache("/first", async () => {
firstLoads += 1;
return modelsData("replacement");
});
assert.deepEqual(firstAgain, first);
assert.equal(firstLoads, 1);
assert.equal(secondLoads, 1);
});
test("shares one loader between concurrent requests for the same cwd", async () => {
invalidateModelsCache();
let loads = 0;
let finishLoad;
const loader = () => {
loads += 1;
return new Promise((resolve) => { finishLoad = resolve; });
};
const first = loadModelsWithCache("/shared", loader);
const second = loadModelsWithCache("/shared", loader);
await Promise.resolve();
assert.equal(loads, 1);
finishLoad(modelsData("shared"));
assert.deepEqual(await second, await first);
});
test("does not cache a stale load that finishes after invalidation", async () => {
invalidateModelsCache();
let finishOldLoad;
const oldLoad = loadModelsWithCache("/stale", () => new Promise((resolve) => { finishOldLoad = resolve; }));
await Promise.resolve();
invalidateModelsCache();
let freshLoads = 0;
const fresh = await loadModelsWithCache("/stale", async () => {
freshLoads += 1;
return modelsData("fresh");
});
finishOldLoad(modelsData("stale"));
await oldLoad;
const cached = await loadModelsWithCache("/stale", async () => {
freshLoads += 1;
return modelsData("unexpected");
});
assert.deepEqual(cached, fresh);
assert.equal(freshLoads, 1);
});
test("retries after a model load fails", async () => {
invalidateModelsCache();
await assert.rejects(
loadModelsWithCache("/failed", async () => { throw new Error("load failed"); }),
/load failed/,
);
let retries = 0;
const fresh = await loadModelsWithCache("/failed", async () => {
retries += 1;
return modelsData("fresh");
});
assert.deepEqual(fresh, modelsData("fresh"));
assert.equal(retries, 1);
});
test("adds runtime errors without discarding available models", () => {
const data = modelsData("builtin");
const result = withModelRuntimeError(data, "Invalid models.json schema");
assert.deepEqual(result, {
...data,
modelError: "Invalid models.json schema",
});
});
-94
View File
@@ -1,94 +0,0 @@
export interface ModelsData {
models: Record<string, string>;
modelList: {
id: string;
name: string;
provider: string;
serviceTier?: string;
}[];
defaultModel: { provider: string; modelId: string } | null;
thinkingLevels: Record<string, string[]>;
thinkingLevelMaps: Record<string, Record<string, string | null>>;
modelError?: string;
}
interface ModelsCacheState {
entries: Map<string, { data: ModelsData; expiresAt: number }>;
inFlight: Map<string, Promise<ModelsData>>;
generation: number;
}
declare global {
var __piModelsCacheState: ModelsCacheState | undefined;
}
const MODELS_CACHE_TTL_MS = 60_000;
const MAX_MODELS_CACHE_ENTRIES = 32;
function getModelsCacheState(): ModelsCacheState {
if (!globalThis.__piModelsCacheState) {
globalThis.__piModelsCacheState = {
entries: new Map(),
inFlight: new Map(),
generation: 0,
};
}
return globalThis.__piModelsCacheState;
}
export function invalidateModelsCache(): void {
const state = getModelsCacheState();
state.generation += 1;
state.entries.clear();
state.inFlight.clear();
}
export function withModelRuntimeError(
data: ModelsData,
modelError: string | undefined,
): ModelsData {
return modelError ? { ...data, modelError } : data;
}
export function loadModelsWithCache(
cwd: string,
loader: () => Promise<ModelsData>,
): Promise<ModelsData> {
const state = getModelsCacheState();
const cached = state.entries.get(cwd);
if (cached) {
if (cached.expiresAt > Date.now()) return Promise.resolve(cached.data);
state.entries.delete(cwd);
}
const existingLoad = state.inFlight.get(cwd);
if (existingLoad) return existingLoad;
const generation = state.generation;
const loadPromise: Promise<ModelsData> = Promise.resolve()
.then(loader)
.then((data) => {
if (
state.generation === generation &&
state.inFlight.get(cwd) === loadPromise
) {
const now = Date.now();
for (const [key, entry] of state.entries) {
if (entry.expiresAt <= now) state.entries.delete(key);
}
while (state.entries.size >= MAX_MODELS_CACHE_ENTRIES) {
const oldestKey = state.entries.keys().next().value;
if (oldestKey === undefined) break;
state.entries.delete(oldestKey);
}
state.entries.set(cwd, { data, expiresAt: now + MODELS_CACHE_TTL_MS });
}
return data;
})
.finally(() => {
if (state.inFlight.get(cwd) === loadPromise) state.inFlight.delete(cwd);
});
state.inFlight.set(cwd, loadPromise);
return loadPromise;
}
-62
View File
@@ -1,62 +0,0 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { existsSync } from "fs";
import { dirname, join } from "path";
import { execPath } from "process";
const execFileAsync = promisify(execFile);
/**
* Locate `npx-cli.js` shipped with the running Node.js installation.
*
* On Windows the `npx` on PATH is actually `npx.cmd`, which Node.js (since
* 20.12 due to CVE-2024-27980) refuses to spawn from `execFile`/`spawn`
* without `shell: true`. Going through a shell reintroduces quoting bugs for
* user-supplied args. Instead we find the real `npx-cli.js` and invoke it
* directly via the current `node` binary, which works identically on every
* platform and needs no shell.
*/
function findNpxCli(): string | null {
const nodeDir = dirname(execPath);
const candidates = [
// Windows MSI installer layout: node.exe and node_modules share a dir
join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js"),
// Unix layout: .../bin/node + .../lib/node_modules/npm/bin/npx-cli.js
join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js"),
];
for (const p of candidates) {
try {
if (existsSync(p)) return p;
} catch {
// ignore
}
}
return null;
}
export interface RunNpxOptions {
timeout?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
}
export interface RunNpxResult {
stdout: string;
stderr: string;
}
/**
* Cross-platform wrapper for invoking `npx <args>` without ever using a
* shell, so user-controlled arguments are never interpreted as shell syntax.
*/
export async function runNpx(args: string[], opts: RunNpxOptions = {}): Promise<RunNpxResult> {
const npxCli = findNpxCli();
const { command, commandArgs } = npxCli
? { command: execPath, commandArgs: [npxCli, ...args] }
: { command: "npx", commandArgs: args };
return execFileAsync(command, commandArgs, {
timeout: opts.timeout,
cwd: opts.cwd,
env: opts.env,
});
}
-42
View File
@@ -1,42 +0,0 @@
import { realpathSync } from "fs";
import path from "path";
const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;
function isWindowsAbsolutePath(filePath: string): boolean {
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
}
export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
for (const root of roots) {
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
const resolver = useWindowsRules ? path.win32 : path;
const sep = useWindowsRules ? "\\" : path.sep;
const normalized = resolver.resolve(target);
const normalizedRoot = resolver.resolve(root);
const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) return true;
}
return false;
}
export function isExistingPathWithinRoots(target: string, roots: Set<string>): boolean {
let realTarget: string;
try {
realTarget = realpathSync(target);
} catch {
return false;
}
const realRoots = new Set<string>();
for (const root of roots) {
try {
realRoots.add(realpathSync(root));
} catch {
// Ignore stale roots derived from removed sessions or worktrees.
}
}
return isPathWithinRoots(realTarget, realRoots);
}
+4 -141
View File
@@ -1,10 +1,7 @@
import type {
AgentSessionEvent,
SessionManager,
SettingsManager,
SlashCommandInfo,
Theme,
} from "@earendil-works/pi-coding-agent";
// Frontend-only subset of the pi type surface. The full AgentSessionLike
// contract (which references pi-coding-agent classes) lives in the backend
// (server/src/lib/pi-types.ts); the UI only renders session stats reported
// over HTTP, so this file must not import the pi packages.
export interface ContextUsage {
percent: number | null;
@@ -12,22 +9,6 @@ export interface ContextUsage {
tokens: number | null;
}
export interface ModelLike {
id: string;
provider: string;
}
export interface ToolInfo {
name: string;
description: string;
}
export interface NavigateTreeResult {
editorText?: string;
cancelled: boolean;
aborted?: boolean;
}
export interface SessionStatsInfo {
sessionFile?: string;
sessionId: string;
@@ -47,121 +28,3 @@ export interface SessionStatsInfo {
cost: number;
contextUsage?: ContextUsage;
}
interface PromptTemplateLike {
name: string;
description?: string;
sourceInfo: SlashCommandInfo["sourceInfo"];
}
interface SkillLike {
name: string;
description?: string;
sourceInfo: SlashCommandInfo["sourceInfo"];
}
interface ResourceLoaderLike {
getSkills(): { skills: SkillLike[] };
}
interface ExtensionRunnerLike {
getRegisteredCommands(): Array<{
invocationName: string;
description?: string;
sourceInfo: SlashCommandInfo["sourceInfo"];
}>;
setUIContext?(uiContext?: unknown, mode?: "tui" | "rpc" | "json" | "print"): void;
}
type DialogOptionsLike = {
signal?: AbortSignal;
timeout?: number;
};
type WidgetOptionsLike = {
placement?: "aboveEditor" | "belowEditor";
};
export interface ExtensionUiContextLike {
select(title: string, options: string[], opts?: DialogOptionsLike): Promise<string | undefined>;
confirm(title: string, message: string, opts?: DialogOptionsLike): Promise<boolean>;
input(title: string, placeholder?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
editor(title: string, prefill?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
notify(message: string, type?: "info" | "warning" | "error"): void;
onTerminalInput(): () => void;
setStatus(key: string, text: string | undefined): void;
setWorkingMessage(message?: string): void;
setWorkingVisible(visible: boolean): void;
setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
setHiddenThinkingLabel(label?: string): void;
setWidget(key: string, content: string[] | ((...args: never[]) => unknown) | undefined, options?: WidgetOptionsLike): void;
setFooter(factory: unknown): void;
setHeader(factory: unknown): void;
setTitle(title: string): void;
custom<T = unknown>(...args: unknown[]): Promise<T>;
pasteToEditor(text: string): void;
setEditorText(text: string): void;
getEditorText(): string;
addAutocompleteProvider(): void;
setEditorComponent(): void;
getEditorComponent(): undefined;
readonly theme: Theme;
getAllThemes(): unknown[];
getTheme(name: string): undefined;
setTheme(theme: unknown): { success: boolean; error?: string };
getToolsExpanded(): boolean;
setToolsExpanded(expanded: boolean): void;
}
export interface AgentSessionLike {
readonly sessionId: string;
readonly sessionFile: string | undefined;
readonly isStreaming: boolean;
readonly isCompacting: boolean;
readonly autoCompactionEnabled: boolean;
readonly autoRetryEnabled: boolean;
readonly model: ModelLike | undefined;
readonly modelRuntime: {
getModel: (provider: string, modelId: string) => ModelLike | undefined;
refresh: (options?: { allowNetwork?: boolean }) => Promise<unknown>;
};
readonly sessionManager: SessionManager;
readonly settingsManager: SettingsManager;
readonly agent: { state?: { systemPrompt?: string; thinkingLevel?: string } };
readonly extensionRunner: ExtensionRunnerLike;
readonly promptTemplates: readonly PromptTemplateLike[];
readonly resourceLoader: ResourceLoaderLike;
readonly bindExtensions?: unknown;
reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void>;
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
prompt(text: string, options?: {
images?: Array<{ type: "image"; data: string; mimeType: string }>;
streamingBehavior?: "steer" | "followUp";
source?: "interactive" | "rpc";
}): Promise<void>;
abort(): Promise<void>;
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode?: number; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string }>;
abortBash(): void;
readonly isBashRunning: boolean;
setModel(model: ModelLike): Promise<void>;
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<NavigateTreeResult>;
setThinkingLevel(level: string): void;
compact(customInstructions?: string): Promise<unknown>;
setSessionName(name: string): void;
getSessionStats(): Omit<SessionStatsInfo, "sessionName">;
getLastAssistantText(): string | undefined;
setAutoCompactionEnabled(enabled: boolean): void;
setAutoRetryEnabled(enabled: boolean): void;
steer(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
followUp(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
readonly pendingMessageCount: number;
getSteeringMessages(): readonly string[];
getFollowUpMessages(): readonly string[];
clearQueue(): { steering: string[]; followUp: string[] };
getAllTools(): ToolInfo[];
getActiveToolNames(): string[];
setActiveToolsByName(names: string[]): void;
abortCompaction(): void;
getContextUsage(): ContextUsage | undefined;
}
-143
View File
@@ -1,143 +0,0 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
const {
getProjectTrustStatus,
projectTrustReloadOptions,
trustProject,
} = await jiti.import("./project-trust.ts");
async function createProjectFixture(t) {
const root = await mkdtemp(join(tmpdir(), "pi-web-project-trust-"));
const cwd = join(root, "project");
const agentDir = join(root, "agent");
await mkdir(cwd, { recursive: true });
await mkdir(agentDir, { recursive: true });
t.after(() => rm(root, { recursive: true, force: true }));
return { root, cwd, agentDir };
}
test("clean projects stay on the normal trusted load path", async (t) => {
const { cwd, agentDir } = await createProjectFixture(t);
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
requiresTrust: false,
trusted: true,
});
assert.equal(projectTrustReloadOptions(cwd, agentDir), undefined);
});
test("managed runtime never enables project-local resources", async (t) => {
const { cwd, agentDir } = await createProjectFixture(t);
await mkdir(join(cwd, ".pi", "extensions"), { recursive: true });
const previous = process.env.PI_AGENT_MANAGED_RUNTIME;
process.env.PI_AGENT_MANAGED_RUNTIME = "1";
t.after(() => {
if (previous === undefined) delete process.env.PI_AGENT_MANAGED_RUNTIME;
else process.env.PI_AGENT_MANAGED_RUNTIME = previous;
});
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
requiresTrust: false,
trusted: false,
});
assert.deepEqual(trustProject(cwd, agentDir), {
requiresTrust: false,
trusted: false,
});
const reloadOptions = projectTrustReloadOptions(cwd, agentDir);
assert.ok(reloadOptions);
assert.equal(await reloadOptions.resolveProjectTrust(), false);
});
test("project extensions execute only after the project is trusted", async (t) => {
const { root, cwd, agentDir } = await createProjectFixture(t);
const extensionDir = join(cwd, ".pi", "extensions");
const marker = join(root, "extension-executed");
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "probe.js"),
`import { writeFileSync } from "node:fs";\nexport default () => { writeFileSync(${JSON.stringify(marker)}, "executed"); };\n`,
);
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
requiresTrust: true,
trusted: false,
});
const restrictedLoader = new DefaultResourceLoader({ cwd, agentDir });
await restrictedLoader.reload(projectTrustReloadOptions(cwd, agentDir));
assert.equal(existsSync(marker), false);
assert.equal(restrictedLoader.getExtensions().extensions.length, 0);
assert.deepEqual(trustProject(cwd, agentDir), {
requiresTrust: true,
trusted: true,
});
const trustedLoader = new DefaultResourceLoader({ cwd, agentDir });
await trustedLoader.reload(projectTrustReloadOptions(cwd, agentDir));
assert.equal(existsSync(marker), true);
assert.equal(trustedLoader.getExtensions().extensions.length, 1);
});
test("the reload resolver reads the latest persisted trust decision", async (t) => {
const { cwd, agentDir } = await createProjectFixture(t);
await mkdir(join(cwd, ".pi", "extensions"), { recursive: true });
const reloadOptions = projectTrustReloadOptions(cwd, agentDir);
assert.ok(reloadOptions);
assert.equal(await reloadOptions.resolveProjectTrust(), false);
trustProject(cwd, agentDir);
assert.equal(await reloadOptions.resolveProjectTrust(), true);
});
test("all project resource loaders and reloads enforce project trust", async () => {
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const modelsSource = await readFile(new URL("../app/api/models/route.ts", import.meta.url), "utf8");
const skillsSource = await readFile(new URL("./skills-service.ts", import.meta.url), "utf8");
const skillsInstallSource = await readFile(new URL("../app/api/skills/install/route.ts", import.meta.url), "utf8");
const pluginsSource = await readFile(new URL("../app/api/plugins/route.ts", import.meta.url), "utf8");
assert.match(rpcSource, /projectTrustReloadOptions\(cwd, agentDir\)/);
assert.match(rpcSource, /resourceLoaderReloadOptions: trustReloadOptions/);
assert.equal(
Array.from(rpcSource.matchAll(/this\.syncProjectTrust\(\);\s*await this\.inner\.reload/g)).length,
2,
);
assert.match(modelsSource, /projectTrustReloadOptions\(cwd, agentDir\)/);
assert.match(modelsSource, /resourceLoaderReloadOptions: trustReloadOptions/);
assert.match(skillsSource, /loader\.reload\(projectTrustReloadOptions\(cwd, agentDir\)\)/);
assert.match(pluginsSource, /createAppSettingsManager\(cwd, agentDir, projectTrust\.trusted\)/);
assert.match(
skillsInstallSource,
/getProjectTrustStatus\(cwd, getAgentDir\(\)\)\.trusted/,
);
assert.equal(
Array.from(pluginsSource.matchAll(/createAppSettingsManager\([^\n]+projectTrust\.trusted\)/g)).length,
2,
);
assert.match(pluginsSource, /scope === "project" && !projectTrust\.trusted/);
});
test("the trust API invalidates cached models and restricted runtimes", async () => {
const source = await readFile(new URL("../app/api/project-trust/route.ts", import.meta.url), "utf8");
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
assert.match(source, /trustProject\(result\.cwd, agentDir\)/);
assert.match(source, /invalidateModelsCache\(\)/);
assert.match(source, /destroyRpcSessionsForCwd\(result\.cwd\)/);
assert.match(source, /hasBusyRpcSessionForCwd\(result\.cwd\)/);
assert.match(rpcSource, /trackStartingSession\(cwd\)/);
assert.match(rpcSource, /realpathSync\(resolvedCwd\)/);
});
-52
View File
@@ -1,52 +0,0 @@
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
import type { ProjectTrustStatus } from "./api-types";
import { isManagedRuntime } from "./app-runtime";
export function getProjectTrustStatus(cwd: string, agentDir: string): ProjectTrustStatus {
if (isManagedRuntime()) return { requiresTrust: false, trusted: false };
const requiresTrust = Boolean(cwd) && hasTrustRequiringProjectResources(cwd);
if (!requiresTrust) return { requiresTrust: false, trusted: true };
const trustStore = new ProjectTrustStore(agentDir);
return {
requiresTrust: true,
trusted: trustStore.get(cwd) === true,
};
}
export function trustProject(cwd: string, agentDir: string): ProjectTrustStatus {
if (isManagedRuntime()) return { requiresTrust: false, trusted: false };
const status = getProjectTrustStatus(cwd, agentDir);
if (!status.requiresTrust) return status;
new ProjectTrustStore(agentDir).set(cwd, true);
return { requiresTrust: true, trusted: true };
}
/**
* Reload options that gate project-local, trust-requiring resources — a
* repository's `.pi/extensions`, project `.pi/settings.json` extension
* entries, and `.agents/skills` — behind the SDK's project-trust store.
*
* Pi Web *executes* project extensions when it builds session services: their
* factory runs on import and their `session_start` handlers run on startup.
* Without a trust gate, merely opening an untrusted repository in Pi Web runs
* repository-controlled code locally (issue #236). The SDK's resource loader
* only imports project extensions once `resolveProjectTrust` resolves true, so
* denying trust keeps them dormant.
*
* Pi Web and the `pi` CLI share the same trust store. Projects with gated
* resources default to untrusted until either client records a trust decision.
* Returns `undefined` when the project has no trust-requiring resources,
* leaving ordinary projects on their existing load path.
*/
export function projectTrustReloadOptions(
cwd: string,
agentDir: string,
): { resolveProjectTrust: () => Promise<boolean> } | undefined {
if (isManagedRuntime()) return { resolveProjectTrust: async () => false };
const status = getProjectTrustStatus(cwd, agentDir);
if (!status.requiresTrust) return undefined;
const trustStore = new ProjectTrustStore(agentDir);
return { resolveProjectTrust: async () => trustStore.get(cwd) === true };
}
-168
View File
@@ -1,168 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function loadSubject() {
return import("./request-security.ts");
}
test("allows same-origin and non-browser API requests", async () => {
const { isApiRequestAllowed } = await loadSubject();
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
method: "POST",
headers: {
host: "localhost:30141",
origin: "http://localhost:30141",
"sec-fetch-site": "same-origin",
},
})), true);
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
method: "POST",
headers: { host: "localhost:30141" },
})), true);
});
test("allows LAN same-origin requests when Next.js uses an internal localhost URL", async () => {
const { isApiRequestAllowed } = await loadSubject();
const request = new Request("http://localhost:30141/api/test", {
method: "POST",
headers: {
host: "192.168.32.7:30141",
origin: "http://192.168.32.7:30141",
"sec-fetch-site": "same-origin",
},
});
assert.equal(isApiRequestAllowed(request), true);
});
test("allows IPv6 and an explicitly configured hostname", async () => {
const { isApiRequestAllowed } = await loadSubject();
const ipv6 = new Request("http://localhost:30141/api/test", {
method: "POST",
headers: {
host: "[::1]:30141",
origin: "http://[::1]:30141",
"sec-fetch-site": "same-origin",
},
});
const configured = new Request("http://localhost:30141/api/test", {
method: "POST",
headers: {
host: "pi-web.internal:30141",
origin: "http://pi-web.internal:30141",
"sec-fetch-site": "same-origin",
},
});
assert.equal(isApiRequestAllowed(ipv6), true);
assert.equal(isApiRequestAllowed(configured, ["pi-web.internal"]), true);
});
test("rejects cross-origin browser API requests", async () => {
const { isApiRequestAllowed, shouldCheckApiRequestOrigin } = await loadSubject();
const post = new Request("http://localhost:30141/api/test", {
method: "POST",
headers: {
host: "localhost:30141",
origin: "https://attacker.example",
"sec-fetch-site": "cross-site",
},
});
const crossSiteGet = new Request("http://localhost:30141/api/sessions", {
headers: { host: "localhost:30141", "sec-fetch-site": "cross-site" },
});
assert.equal(shouldCheckApiRequestOrigin(post), true);
assert.equal(isApiRequestAllowed(post), false);
assert.equal(shouldCheckApiRequestOrigin(crossSiteGet), true);
assert.equal(isApiRequestAllowed(crossSiteGet), false);
});
test("allows only user-initiated session export document navigations from a PWA", async () => {
const { isApiRequestAllowed } = await loadSubject();
const navigationHeaders = {
host: "127.0.0.1:30141",
"sec-fetch-site": "cross-site",
"sec-fetch-mode": "navigate",
"sec-fetch-dest": "document",
"sec-fetch-user": "?1",
};
assert.equal(isApiRequestAllowed(new Request(
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
{ headers: navigationHeaders },
)), true);
assert.equal(isApiRequestAllowed(new Request(
"http://127.0.0.1:30141/api/sessions",
{ headers: navigationHeaders },
)), false);
assert.equal(isApiRequestAllowed(new Request(
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
{ headers: { ...navigationHeaders, "sec-fetch-dest": "empty" } },
)), false);
assert.equal(isApiRequestAllowed(new Request(
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
{
headers: {
...navigationHeaders,
"sec-fetch-user": "",
},
},
)), false);
assert.equal(isApiRequestAllowed(new Request(
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
{ method: "POST", headers: navigationHeaders },
)), false);
assert.equal(isApiRequestAllowed(new Request(
"http://127.0.0.1:30141/api/sessions/session-id/export?inline=1",
{ headers: { ...navigationHeaders, host: "attacker.example:30141" } },
)), false);
});
test("rejects an origin that does not match the external request host", async () => {
const { isApiRequestAllowed } = await loadSubject();
const request = new Request("http://localhost:30141/api/test", {
method: "POST",
headers: {
host: "192.168.32.7:30141",
origin: "http://attacker.example",
"sec-fetch-site": "same-site",
},
});
assert.equal(isApiRequestAllowed(request), false);
});
test("rejects DNS rebinding even when browser headers say same-origin", async () => {
const { isApiRequestAllowed } = await loadSubject();
const request = new Request("http://localhost:30141/api/skills/install", {
method: "POST",
headers: {
host: "attacker.example:30141",
origin: "http://attacker.example:30141",
"sec-fetch-site": "same-origin",
"content-type": "application/json",
},
});
assert.equal(isApiRequestAllowed(request), false);
});
test("rejects missing, malformed, and unconfigured Host headers", async () => {
const { isApiRequestAllowed } = await loadSubject();
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test")), false);
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
headers: { host: "localhost@attacker.example:30141" },
})), false);
assert.equal(isApiRequestAllowed(new Request("http://localhost:30141/api/test", {
headers: { host: "pi-web.internal:30141" },
})), false);
});
test("recognizes JSON request content types", async () => {
const { hasJsonContentType } = await loadSubject();
assert.equal(hasJsonContentType(new Request("http://localhost", {
headers: { "content-type": "application/json; charset=utf-8" },
})), true);
assert.equal(hasJsonContentType(new Request("http://localhost", {
headers: { "content-type": "application/problem+json" },
})), true);
assert.equal(hasJsonContentType(new Request("http://localhost", {
headers: { "content-type": "text/plain" },
})), false);
});
-118
View File
@@ -1,118 +0,0 @@
import { isIP } from "node:net";
function normalizeHostname(value: string): string {
const unbracketed = value.startsWith("[") && value.endsWith("]")
? value.slice(1, -1)
: value;
return unbracketed.toLowerCase().replace(/\.$/, "");
}
function hostnameFromAuthority(value: string): string | null {
if (!value || /[\s/@\\]/.test(value)) return null;
try {
const parsed = new URL(`http://${value}`);
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
return null;
}
return normalizeHostname(parsed.hostname);
} catch {
return null;
}
}
function normalizeConfiguredHostname(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
return isIP(trimmed) ? normalizeHostname(trimmed) : hostnameFromAuthority(trimmed);
}
function isLoopbackHostname(hostname: string): boolean {
return hostname === "localhost" || hostname.endsWith(".localhost");
}
function configuredHostnamesFromEnvironment(): string[] {
return [
process.env.PI_WEB_HOSTNAME,
...(process.env.PI_WEB_ALLOWED_HOSTS?.split(",") ?? []),
].filter((value): value is string => Boolean(value?.trim()));
}
function canonicalOrigin(value: string): string | null {
try {
return new URL(value).origin;
} catch {
return null;
}
}
function getRequestOrigin(request: Request): string | null {
const requestUrl = new URL(request.url);
const host = request.headers.get("host");
return host ? canonicalOrigin(`${requestUrl.protocol}//${host}`) : null;
}
function isUserInitiatedSessionExportNavigation(request: Request): boolean {
if (
request.method !== "GET"
|| request.headers.get("sec-fetch-mode") !== "navigate"
|| request.headers.get("sec-fetch-dest") !== "document"
|| request.headers.get("sec-fetch-user") !== "?1"
) {
return false;
}
try {
return /^\/api\/sessions\/[^/]+\/export$/.test(new URL(request.url).pathname);
} catch {
return false;
}
}
/**
* Only trust local names, IP literals, or the hostname explicitly selected by
* the operator. IP literals preserve LAN access but cannot be DNS-rebound
* because the browser keeps the literal address in the Host header.
*/
export function isApiRequestHostAllowed(
request: Request,
configuredHostnames = configuredHostnamesFromEnvironment(),
): boolean {
const host = request.headers.get("host");
const hostname = host ? hostnameFromAuthority(host) : null;
if (!hostname) return false;
if (isLoopbackHostname(hostname) || isIP(hostname)) return true;
return configuredHostnames.some(
(configured) => normalizeConfiguredHostname(configured) === hostname,
);
}
/** Reject browser cross-site API requests while preserving non-browser clients. */
export function isApiRequestOriginAllowed(request: Request): boolean {
const origin = request.headers.get("origin");
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite === "cross-site") return false;
if (!origin) return true;
const requestOrigin = getRequestOrigin(request);
return requestOrigin !== null && canonicalOrigin(origin) === requestOrigin;
}
export function shouldCheckApiRequestOrigin(request: Request): boolean {
return request.headers.has("origin") || request.headers.has("sec-fetch-site");
}
export function isApiRequestAllowed(
request: Request,
configuredHostnames = configuredHostnamesFromEnvironment(),
): boolean {
if (!isApiRequestHostAllowed(request, configuredHostnames)) return false;
if (isUserInitiatedSessionExportNavigation(request)) return true;
return !shouldCheckApiRequestOrigin(request) || isApiRequestOriginAllowed(request);
}
export function hasJsonContentType(request: Request): boolean {
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
return mediaType === "application/json"
|| Boolean(mediaType?.startsWith("application/") && mediaType.endsWith("+json"));
}
-34
View File
@@ -1,34 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("RPC session startup preloads extension-registered providers before restoring models", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const startupSource = source.slice(source.indexOf("export async function startRpcSession"));
assert.match(startupSource, /createAgentSessionServices\(/);
assert.match(startupSource, /createAgentSessionFromServices\(/);
assert.doesNotMatch(startupSource, /await createAgentSession\(/);
});
test("custom extension UI receives the fixed headless terminal facade", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const customUiSource = source.slice(
source.indexOf("private requestExtensionCustomUi"),
source.indexOf("private requestExtensionUi"),
);
assert.match(customUiSource, /createHeadlessCustomUiTui\(/);
assert.match(customUiSource, /width,/);
});
test("reloading a session invalidates the models cache", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const reloadSource = source.slice(
source.indexOf('case "reload"'),
source.indexOf('case "abort_compaction"'),
);
assert.match(reloadSource, /await this\.inner\.reload\(\)/);
assert.match(reloadSource, /this\.applyForcedEmptySystemPrompt\(\);\s*invalidateModelsCache\(\)/);
});
File diff suppressed because it is too large Load Diff
-100
View File
@@ -1,100 +0,0 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
const extensionPath = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../resources/extensions/searxng-search.ts",
);
const extensionDir = dirname(extensionPath);
test("managed SearXNG extension loads and maps the complete search contract", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-searxng-extension-"));
const agentDir = resolve(fixtureRoot, "agent");
mkdirSync(agentDir, { recursive: true });
const originalFetch = globalThis.fetch;
const originalToken = process.env.SEARXNG_TOKEN;
const originalUrl = process.env.SEARXNG_URL;
let capturedRequest;
process.env.SEARXNG_TOKEN = "test-token";
process.env.SEARXNG_URL = "https://search.example.test/search";
globalThis.fetch = async (input, init) => {
capturedRequest = { input: String(input), init };
return new Response(
JSON.stringify({
answers: ["A current answer"],
results: [
{
title: "Example result",
url: "https://example.test/result",
content: "Example snippet",
engines: ["google", "github"],
publishedDate: "2026-07-30",
},
],
suggestions: ["refined query"],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
try {
const loader = new DefaultResourceLoader({
cwd: fixtureRoot,
agentDir,
additionalExtensionPaths: [extensionDir],
});
await loader.reload();
const loaded = loader.getExtensions();
assert.deepEqual(loaded.errors, []);
const extension = loaded.extensions.find((candidate) => resolve(candidate.path) === extensionPath);
assert.ok(extension, "SearXNG extension should load through the managed extension directory manifest");
const registeredTool = extension.tools.get("web_search");
assert.ok(registeredTool, "web_search should be registered");
const tool = registeredTool.definition;
const result = await tool.execute(
"test-call",
{
query: "pi coding agent",
num_results: 5,
engines: ["google", "github"],
language: "en",
page: 2,
time_range: "week",
},
undefined,
undefined,
undefined,
);
assert.ok(capturedRequest, "web_search should make an HTTP request");
const requestUrl = new URL(capturedRequest.input);
assert.equal(requestUrl.searchParams.get("q"), "pi coding agent");
assert.equal(requestUrl.searchParams.get("format"), "json");
assert.equal(requestUrl.searchParams.get("engines"), "google,github");
assert.equal(requestUrl.searchParams.get("language"), "en");
assert.equal(requestUrl.searchParams.get("pageno"), "2");
assert.equal(requestUrl.searchParams.get("time_range"), "week");
assert.equal(new Headers(capturedRequest.init.headers).get("X-Search-Token"), "test-token");
assert.match(result.content[0].text, /Example result/);
assert.match(result.content[0].text, /https:\/\/example\.test\/result/);
assert.equal(result.details.resultCount, 1);
assert.equal(JSON.stringify(result).includes("test-token"), false);
} finally {
globalThis.fetch = originalFetch;
if (originalToken === undefined) delete process.env.SEARXNG_TOKEN;
else process.env.SEARXNG_TOKEN = originalToken;
if (originalUrl === undefined) delete process.env.SEARXNG_URL;
else process.env.SEARXNG_URL = originalUrl;
rmSync(fixtureRoot, { recursive: true, force: true });
}
});
@@ -1,82 +0,0 @@
import type { SessionEntry } from "./types";
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isValidSessionId(sessionId: string | null): sessionId is string {
return !!sessionId && SESSION_ID_RE.test(sessionId);
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function normalizeSlashes(value: string): string {
return value.replace(/\\/g, "/");
}
function isPathChar(ch: string): boolean {
return /[A-Za-z0-9._~+%@/\\:-]/.test(ch);
}
function hasReferenceBoundaryAfter(text: string, index: number): boolean {
if (index >= text.length) return true;
const ch = text[index];
if (ch === ":") return /\d/.test(text[index + 1] ?? "");
return !isPathChar(ch);
}
function containsExactPathReference(text: string, filePath: string): boolean {
const target = normalizeSlashes(filePath);
const targets = target.startsWith("/") ? [target, `file://${target}`] : [target];
const haystacks = new Set([normalizeSlashes(text), normalizeSlashes(safeDecode(text))]);
for (const haystack of haystacks) {
for (const t of targets) {
let index = haystack.indexOf(t);
while (index !== -1) {
const before = index === 0 ? "" : haystack[index - 1];
const afterIndex = index + t.length;
if ((index === 0 || !isPathChar(before)) && hasReferenceBoundaryAfter(haystack, afterIndex)) {
return true;
}
index = haystack.indexOf(t, index + 1);
}
}
}
return false;
}
function collectStrings(value: unknown, out: string[]): void {
if (typeof value === "string") {
out.push(value);
return;
}
if (!value || typeof value !== "object") return;
if (Array.isArray(value)) {
for (const item of value) collectStrings(item, out);
return;
}
for (const item of Object.values(value)) collectStrings(item, out);
}
export function isFilePathReferencedByEntries(filePath: string, entries: SessionEntry[]): boolean {
for (const entry of entries) {
const strings: string[] = [];
collectStrings(entry, strings);
if (strings.some((text) => containsExactPathReference(text, filePath))) return true;
}
return false;
}
export function isBashOutputPathReferencedByEntries(filePath: string, entries: SessionEntry[]): boolean {
return entries.some((entry) => (
entry.type === "message"
&& entry.message.role === "bashExecution"
&& entry.message.fullOutputPath === filePath
));
}
@@ -1,91 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function loadSubject() {
return import("./session-file-references-core.ts");
}
test("detects exact external file paths referenced in session entries", async () => {
const { isFilePathReferencedByEntries } = await loadSubject();
const entries = [
{
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [
{
type: "text",
text: "See [/home/me/.codex/config.toml:12](/home/me/.codex/config.toml:12)",
},
],
},
},
];
assert.equal(isFilePathReferencedByEntries("/home/me/.codex/config.toml", entries), true);
});
test("does not authorize sibling files by prefix match", async () => {
const { isFilePathReferencedByEntries } = await loadSubject();
const entries = [
{
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [
{
type: "text",
text: "See /home/me/.codex/config.toml.bak",
},
],
},
},
];
assert.equal(isFilePathReferencedByEntries("/home/me/.codex/config.toml", entries), false);
});
test("authorizes full output only from a bash execution message", async () => {
const { isBashOutputPathReferencedByEntries } = await loadSubject();
const outputPath = "/tmp/pi-bash-ab12.log";
const bashEntry = {
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "bashExecution",
command: "printf test",
output: "test",
fullOutputPath: outputPath,
},
};
const assistantEntry = {
type: "message",
id: "entry-2",
parentId: "entry-1",
timestamp: "2026-01-01T00:00:01.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: `mentioned ${outputPath}` }],
},
};
assert.equal(isBashOutputPathReferencedByEntries(outputPath, [bashEntry]), true);
assert.equal(isBashOutputPathReferencedByEntries(outputPath, [assistantEntry]), false);
assert.equal(isBashOutputPathReferencedByEntries("/tmp/pi-bash-other.log", [bashEntry]), false);
});
test("validates session ids before resolving session paths", async () => {
const { isValidSessionId } = await loadSubject();
assert.equal(isValidSessionId("not-a-session-id"), false);
assert.equal(isValidSessionId("../../sessions/foo"), false);
assert.equal(isValidSessionId("550e8400-e29b-41d4-a716-446655440000"), true);
});
-29
View File
@@ -1,29 +0,0 @@
import { getSessionEntries, resolveSessionPath } from "./session-reader";
export { isFilePathReferencedByEntries } from "./session-file-references-core";
import {
isBashOutputPathReferencedByEntries,
isFilePathReferencedByEntries,
isValidSessionId,
} from "./session-file-references-core";
export async function isFilePathReferencedBySession(filePath: string, sessionId: string | null): Promise<boolean> {
if (!isValidSessionId(sessionId)) return false;
try {
const sessionPath = await resolveSessionPath(sessionId);
if (!sessionPath) return false;
return isFilePathReferencedByEntries(filePath, getSessionEntries(sessionPath));
} catch {
return false;
}
}
export async function isBashOutputPathReferencedBySession(filePath: string, sessionId: string | null): Promise<boolean> {
if (!isValidSessionId(sessionId)) return false;
try {
const sessionPath = await resolveSessionPath(sessionId);
if (!sessionPath) return false;
return isBashOutputPathReferencedByEntries(filePath, getSessionEntries(sessionPath));
} catch {
return false;
}
}
-24
View File
@@ -1,24 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function loadSubject() {
return import("./session-path.ts");
}
test("normalizes Windows separators and casing for session identity", async () => {
const { sessionPathKey } = await loadSubject();
assert.equal(
sessionPathKey("C:\\Users\\Alex\\.pi\\sessions\\Parent.jsonl", "win32"),
sessionPathKey("c:/Users/Alex/.pi/sessions/parent.jsonl", "win32"),
);
});
test("preserves case when session paths are case-sensitive", async () => {
const { sessionPathKey } = await loadSubject();
assert.notEqual(
sessionPathKey("/var/lib/pi/Parent.jsonl", "linux"),
sessionPathKey("/var/lib/pi/parent.jsonl", "linux"),
);
});
-11
View File
@@ -1,11 +0,0 @@
import path from "node:path";
export function sessionPathKey(
filePath: string,
platform: NodeJS.Platform = process.platform,
): string {
const normalized = platform === "win32"
? path.win32.normalize(filePath)
: path.posix.normalize(filePath);
return platform === "win32" ? normalized.toLowerCase() : normalized;
}
-341
View File
@@ -1,341 +0,0 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
const { sessionPathKey } = await jiti.import("./session-path.ts");
const {
buildSessionContext,
cacheSessionPath,
invalidateSessionPathCache,
readSessionHeader,
resolveSessionIdByPath,
} = await jiti.import("./session-reader.ts");
function userEntry(id, parentId, content, timestamp = "2026-01-01T00:00:00.000Z") {
return {
type: "message",
id,
parentId,
timestamp,
message: {
role: "user",
content,
},
};
}
function assistantEntry(id, parentId, text, timestamp = "2026-01-01T00:00:00.000Z") {
return {
type: "message",
id,
parentId,
timestamp,
message: {
role: "assistant",
provider: "test",
model: "test-model",
content: [{ type: "text", text }],
},
};
}
test("renders the SDK compaction-aware context with aligned entry IDs", () => {
const entries = [
userEntry("u1", null, "old user request"),
assistantEntry("a1", "u1", "old assistant answer"),
userEntry("u2", "a1", "kept user request"),
{
type: "compaction",
id: "cmp",
parentId: "u2",
timestamp: "2026-01-01T00:00:03.000Z",
summary: "old exchange summary",
firstKeptEntryId: "u2",
tokensBefore: 123,
},
userEntry("u3", "cmp", "after compaction"),
];
const context = buildSessionContext(entries);
assert.deepEqual(context.entryIds, ["cmp", "u2", "u3"]);
assert.deepEqual(
context.messages.map((message) => [message.role, message.customType, message.content]),
[
["custom", "compaction", "old exchange summary"],
["user", undefined, "kept user request"],
["user", undefined, "after compaction"],
],
);
});
test("uses only the latest compaction on the active path", () => {
const entries = [
userEntry("u1", null, "old request"),
assistantEntry("a1", "u1", "old answer"),
userEntry("u2", "a1", "first kept request"),
{
type: "compaction",
id: "cmp1",
parentId: "u2",
timestamp: "2026-01-01T00:00:03.000Z",
summary: "first summary",
firstKeptEntryId: "u2",
tokensBefore: 100,
},
assistantEntry("a2", "cmp1", "second kept answer"),
userEntry("u3", "a2", "second kept request"),
{
type: "compaction",
id: "cmp2",
parentId: "u3",
timestamp: "2026-01-01T00:00:06.000Z",
summary: "latest summary",
firstKeptEntryId: "a2",
tokensBefore: 200,
},
assistantEntry("a3", "cmp2", "latest answer"),
];
const context = buildSessionContext(entries);
assert.deepEqual(context.entryIds, ["cmp2", "a2", "u3", "a3"]);
assert.equal(context.messages[0].role, "custom");
assert.equal(context.messages[0].content, "latest summary");
assert.equal(context.messages.length, context.entryIds.length);
});
test("uses the selected leaf's path before a later compaction", () => {
const entries = [
userEntry("u1", null, "root request"),
assistantEntry("a1", "u1", "root answer"),
userEntry("u2", "a1", "main branch"),
{
type: "compaction",
id: "cmp",
parentId: "u2",
timestamp: "2026-01-01T00:00:03.000Z",
summary: "main branch summary",
firstKeptEntryId: "u2",
tokensBefore: 100,
},
userEntry("alt", "a1", "alternate branch"),
];
const context = buildSessionContext(entries, "alt");
assert.deepEqual(context.entryIds, ["u1", "a1", "alt"]);
assert.equal(context.messages.some((message) => message.role === "custom"), false);
});
test("returns an empty context for a null leaf", () => {
const context = buildSessionContext([
userEntry("u1", null, "not active"),
], null);
assert.deepEqual(context.messages, []);
assert.deepEqual(context.entryIds, []);
});
test("defers historical thinking without changing live-session content", () => {
const entries = [
userEntry("u1", null, "start"),
{
...assistantEntry("a1", "u1", "answer"),
message: {
role: "assistant",
provider: "test",
model: "test-model",
content: [
{ type: "thinking", thinking: "large reasoning" },
{ type: "text", text: "answer" },
],
},
},
];
const deferred = buildSessionContext(entries, undefined, { deferThinking: true });
assert.deepEqual(deferred.messages[1].content[0], {
type: "thinking",
thinking: "",
deferred: true,
});
const full = buildSessionContext(entries);
assert.equal(full.messages[1].content[0].thinking, "large reasoning");
});
test("does not defer empty historical thinking blocks", () => {
const entries = [
userEntry("u1", null, "start"),
{
...assistantEntry("a1", "u1", "answer"),
message: {
role: "assistant",
provider: "test",
model: "test-model",
content: [
{ type: "thinking", thinking: "" },
{ type: "text", text: "answer" },
],
},
},
];
const context = buildSessionContext(entries, undefined, { deferThinking: true });
assert.deepEqual(context.messages[1].content[0], { type: "thinking", thinking: "" });
});
test("defers only base64 images from historical tool results", () => {
const userImage = {
type: "image",
source: { type: "base64", media_type: "image/png", data: "QUJDRA==" },
};
const toolImage = {
type: "image",
source: { type: "base64", media_type: "image/jpeg", data: "QUJDRA==" },
};
const toolUrlImage = {
type: "image",
source: { type: "url", url: "https://example.com/result.png" },
};
const flatToolImage = {
type: "image",
data: "QUJDRA==",
mimeType: "image/png",
};
const entries = [
userEntry("u1", null, [{ type: "text", text: "inspect this" }, userImage]),
assistantEntry("a1", "u1", "reading"),
{
type: "message",
id: "tr1",
parentId: "a1",
timestamp: "2026-01-01T00:00:01.000Z",
message: {
role: "toolResult",
toolCallId: "call1",
content: [
{ type: "text", text: "Read image file" },
toolImage,
flatToolImage,
toolUrlImage,
],
},
},
];
const deferred = buildSessionContext(entries, undefined, { deferToolResultImages: true });
assert.deepEqual(deferred.messages[0].content[1], userImage);
assert.deepEqual(deferred.messages[2].content[0], { type: "text", text: "Read image file" });
assert.deepEqual(deferred.messages[2].content[1], toolUrlImage);
assert.match(deferred.messages[2].content[2].text, /2 tool result images omitted.*image\/jpeg, image\/png.*~8 bytes/);
const full = buildSessionContext(entries);
assert.deepEqual(full.messages[2].content[1], toolImage);
assert.deepEqual(full.messages[2].content[2], flatToolImage);
assert.deepEqual(full.messages[2].content[3], toolUrlImage);
});
test("preserves hidden custom messages so the UI can render them collapsed", () => {
const entries = [
userEntry("u1", null, "start"),
{
type: "custom_message",
id: "c1",
parentId: "u1",
timestamp: "2026-01-01T00:00:01.000Z",
customType: "extension_debug",
content: "hidden extension payload",
display: false,
details: { source: "test" },
},
assistantEntry("a1", "c1", "done"),
];
const context = buildSessionContext(entries);
assert.deepEqual(context.entryIds, ["u1", "c1", "a1"]);
assert.equal(context.messages[1].role, "custom");
assert.equal(context.messages[1].customType, "extension_debug");
assert.equal(context.messages[1].display, false);
assert.equal(context.messages[1].content, "hidden extension payload");
});
test("preserves valid epoch timestamps on synthetic UI messages", () => {
const entries = [
userEntry("u1", null, "start"),
{
type: "compaction",
id: "cmp",
parentId: "u1",
timestamp: "1970-01-01T00:00:00.000Z",
summary: "epoch summary",
firstKeptEntryId: "u1",
tokensBefore: 10,
},
];
const context = buildSessionContext(entries);
assert.equal(context.messages[0].role, "custom");
assert.equal(context.messages[0].customType, "compaction");
assert.equal(context.messages[0].timestamp, 0);
});
test("reads only a bounded session header, including headers larger than 4 KiB", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-header-"));
const filePath = join(dir, "session.jsonl");
const parentSession = `/tmp/${"p".repeat(5_000)}.jsonl`;
writeFileSync(filePath, `${JSON.stringify({
type: "session",
version: 3,
id: "session",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: dir,
parentSession,
})}\n${JSON.stringify(userEntry("u1", null, "message"))}\n`);
try {
assert.equal(readSessionHeader(filePath)?.parentSession, parentSession);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("returns null for malformed or unbounded session headers", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-header-invalid-"));
const malformedPath = join(dir, "malformed.jsonl");
const oversizedPath = join(dir, "oversized.jsonl");
writeFileSync(malformedPath, "{not-json}\n");
writeFileSync(oversizedPath, "x".repeat(64 * 1024));
try {
assert.equal(readSessionHeader(malformedPath), null);
assert.equal(readSessionHeader(oversizedPath), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("keeps forward and reverse session path caches in sync", async () => {
const sessionId = "cache-test-session";
const filePath = join(tmpdir(), "pi-web-cache-test", "..", "cache-test", "session.jsonl");
cacheSessionPath(sessionId, filePath);
try {
assert.equal(
await resolveSessionIdByPath(filePath),
sessionId,
);
} finally {
invalidateSessionPathCache(sessionId);
}
assert.equal(globalThis.__piSessionPathCache?.has(sessionId), false);
assert.equal(globalThis.__piPathToSessionIdCache?.has(sessionPathKey(filePath)), false);
});
-350
View File
@@ -1,350 +0,0 @@
import {
SessionManager,
buildContextEntries as piBuildContextEntries,
buildSessionContext as piBuildSessionContext,
getAgentDir,
} from "@earendil-works/pi-coding-agent";
import { closeSync, openSync, readSync } from "fs";
import { normalize as normalizePath } from "path";
import type { AgentMessage, SessionEntry, SessionHeader, SessionInfo, SessionContext } from "./types";
import type { SessionEntry as PiSessionEntry, SessionInfo as PiSessionInfo } from "@earendil-works/pi-coding-agent";
import { normalizeToolCalls } from "./normalize";
import { sessionPathKey } from "./session-path";
import { resolveProject, type ProjectInfo } from "./worktree";
export { getAgentDir };
async function loadAllSessions(): Promise<SessionInfo[]> {
const piSessions: PiSessionInfo[] = await SessionManager.listAll();
const pathToId = new Map<string, string>();
for (const s of piSessions) pathToId.set(sessionPathKey(s.path), s.id);
// Resolve each unique cwd to its project root (main repo shared by all
// worktrees). resolveProject caches per-cwd, so this is cheap after warmup.
const uniqueCwds = [...new Set(piSessions.map((s) => s.cwd).filter(Boolean))];
const projectByCwd = new Map<string, ProjectInfo>();
await Promise.all(uniqueCwds.map(async (cwd) => {
projectByCwd.set(cwd, await resolveProject(cwd));
}));
return piSessions.map((s) => {
cacheSessionPath(s.id, s.path);
const project = s.cwd ? projectByCwd.get(s.cwd) : undefined;
return {
path: s.path,
id: s.id,
cwd: s.cwd,
name: s.name,
created: s.created instanceof Date ? s.created.toISOString() : String(s.created),
modified: s.modified instanceof Date ? s.modified.toISOString() : String(s.modified),
messageCount: s.messageCount,
firstMessage: s.firstMessage || "(no messages)",
parentSessionId: s.parentSessionPath ? pathToId.get(sessionPathKey(s.parentSessionPath)) : undefined,
projectRoot: project?.projectRoot ?? s.cwd,
...(project?.isWorktree && project.branch ? { worktreeBranch: project.branch } : {}),
};
});
}
export async function listAllSessions(): Promise<SessionInfo[]> {
const generation = globalThis.__piSessionListGeneration ?? 0;
// Return cached result if still fresh (avoids re-scanning session files
// and re-spawning git processes on every page load).
if (globalThis.__piSessionListCache && Date.now() - globalThis.__piSessionListCache.ts < SESSION_LIST_CACHE_TTL_MS) {
return globalThis.__piSessionListCache.data;
}
// Coalescing dedup: concurrent callers share the same in-flight promise
// only while it belongs to the current cache generation.
if (globalThis.__piSessionListPromise && globalThis.__piSessionListPromiseGeneration === generation) {
return globalThis.__piSessionListPromise;
}
const loadPromise = loadAllSessions().then((data) => {
// An invalidation may happen while the scan is in flight. Do not let that
// older result repopulate the cache after a session mutation.
if ((globalThis.__piSessionListGeneration ?? 0) === generation) {
globalThis.__piSessionListCache = { data, ts: Date.now() };
}
return data;
});
const trackedPromise = loadPromise.finally(() => {
if (globalThis.__piSessionListPromise === trackedPromise) {
globalThis.__piSessionListPromise = undefined;
globalThis.__piSessionListPromiseGeneration = undefined;
}
});
globalThis.__piSessionListPromise = trackedPromise;
globalThis.__piSessionListPromiseGeneration = generation;
return trackedPromise;
}
// ============================================================================
// Session path caches, stored in globalThis for hot-reload safety.
// ============================================================================
declare global {
var __piSessionPathCache: Map<string, string> | undefined;
var __piPathToSessionIdCache: Map<string, string> | undefined;
var __piSessionListPromise: Promise<SessionInfo[]> | undefined;
var __piSessionListPromiseGeneration: number | undefined;
var __piSessionListGeneration: number | undefined;
var __piSessionListCache: { data: SessionInfo[]; ts: number } | undefined;
}
const SESSION_LIST_CACHE_TTL_MS = 30_000;
export function invalidateSessionListCache(): void {
globalThis.__piSessionListGeneration = (globalThis.__piSessionListGeneration ?? 0) + 1;
globalThis.__piSessionListCache = undefined;
}
function getPathCache(): Map<string, string> {
if (!globalThis.__piSessionPathCache) globalThis.__piSessionPathCache = new Map();
return globalThis.__piSessionPathCache;
}
function getPathToIdCache(): Map<string, string> {
if (!globalThis.__piPathToSessionIdCache) globalThis.__piPathToSessionIdCache = new Map();
return globalThis.__piPathToSessionIdCache;
}
export async function resolveSessionPath(sessionId: string): Promise<string | null> {
const cached = getPathCache().get(sessionId);
if (cached) return cached;
// Cache miss: scan all sessions to populate cache, then retry
await listAllSessions();
return getPathCache().get(sessionId) ?? null;
}
export async function resolveSessionIdByPath(filePath: string): Promise<string | undefined> {
const pathKey = sessionPathKey(filePath);
const cached = getPathToIdCache().get(pathKey);
if (cached) return cached;
await listAllSessions();
return getPathToIdCache().get(pathKey);
}
export function cacheSessionPath(sessionId: string, filePath: string): void {
const normalizedPath = normalizePath(filePath);
const pathKey = sessionPathKey(normalizedPath);
const pathCache = getPathCache();
const reverseCache = getPathToIdCache();
const previousPath = pathCache.get(sessionId);
const previousPathKey = previousPath ? sessionPathKey(previousPath) : undefined;
const previousSessionId = reverseCache.get(pathKey);
const previousOwnerPath = previousSessionId ? pathCache.get(previousSessionId) : undefined;
if (previousPathKey && previousPathKey !== pathKey && reverseCache.get(previousPathKey) === sessionId) {
reverseCache.delete(previousPathKey);
}
if (
previousSessionId &&
previousSessionId !== sessionId &&
previousOwnerPath &&
sessionPathKey(previousOwnerPath) === pathKey
) {
pathCache.delete(previousSessionId);
}
pathCache.set(sessionId, normalizedPath);
reverseCache.set(pathKey, sessionId);
}
export function invalidateSessionPathCache(sessionId: string): void {
const pathCache = getPathCache();
const reverseCache = getPathToIdCache();
const filePath = pathCache.get(sessionId);
pathCache.delete(sessionId);
const pathKey = filePath ? sessionPathKey(filePath) : undefined;
if (pathKey && reverseCache.get(pathKey) === sessionId) {
reverseCache.delete(pathKey);
}
}
export function readSessionHeader(filePath: string): SessionHeader | null {
const fd = openSync(filePath, "r");
try {
const chunks: Buffer[] = [];
const maxHeaderBytes = 64 * 1024;
let position = 0;
let foundNewline = false;
while (position < maxHeaderBytes && !foundNewline) {
const buffer = Buffer.allocUnsafe(Math.min(4096, maxHeaderBytes - position));
const bytesRead = readSync(fd, buffer, 0, buffer.length, position);
if (bytesRead === 0) break;
const data = buffer.subarray(0, bytesRead);
const newlineIndex = data.indexOf(0x0a);
chunks.push(newlineIndex === -1 ? data : data.subarray(0, newlineIndex));
position += bytesRead;
foundNewline = newlineIndex !== -1;
}
if (!foundNewline && position >= maxHeaderBytes) return null;
const firstLine = Buffer.concat(chunks).toString("utf8").trimEnd();
if (!firstLine) return null;
try {
const header = JSON.parse(firstLine) as SessionHeader;
return header.type === "session" ? header : null;
} catch {
return null;
}
} finally {
closeSync(fd);
}
}
export function getSessionEntries(filePath: string): SessionEntry[] {
const entries = SessionManager.open(filePath).getEntries();
return entries as unknown as SessionEntry[];
}
export function buildSessionContext(
entries: SessionEntry[],
leafId?: string | null,
options: { deferThinking?: boolean; deferToolResultImages?: boolean } = {},
): SessionContext {
const byId = new Map<string, SessionEntry>();
for (const e of entries) byId.set(e.id, e);
const piEntries = entries as unknown as PiSessionEntry[];
const piCtx = piBuildSessionContext(piEntries, leafId, byId as unknown as Map<string, PiSessionEntry>);
const contextEntries = piBuildContextEntries(
piEntries,
leafId,
byId as unknown as Map<string, PiSessionEntry>,
);
// Convert the SDK-selected context entries and their IDs together. This keeps
// fork/navigation targets aligned while preserving pi's compaction ordering.
const messages: AgentMessage[] = [];
const entryIds: string[] = [];
for (const entry of contextEntries) {
const localEntry = entry as unknown as SessionEntry;
const m = entryToUiMessage(localEntry, options);
if (m) {
messages.push(m);
entryIds.push(localEntry.id);
}
}
return {
messages,
entryIds,
thinkingLevel: piCtx.thinkingLevel,
model: piCtx.model,
};
}
function parseEntryTimestamp(timestamp: string): number | undefined {
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function base64ImageInfo(block: unknown): { bytes: number; mime?: string } | null {
if (!isRecord(block) || block.type !== "image") return null;
let data: string | undefined;
let mime: string | undefined;
if (typeof block.data === "string") {
data = block.data;
mime = typeof block.mimeType === "string" ? block.mimeType : undefined;
} else if (isRecord(block.source) && block.source.type === "base64" && typeof block.source.data === "string") {
data = block.source.data;
mime = typeof block.source.media_type === "string" ? block.source.media_type : undefined;
}
if (!data) return null;
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
return { bytes: Math.max(0, Math.floor(data.length * 3 / 4) - padding), mime };
}
function omitToolResultBase64Images(message: AgentMessage): AgentMessage {
if (message.role !== "toolResult") return message;
let omitted = 0;
let bytes = 0;
const mimes = new Set<string>();
const content = message.content.filter((block) => {
const image = base64ImageInfo(block);
if (!image) return true;
omitted += 1;
bytes += image.bytes;
if (image.mime) mimes.add(image.mime);
return false;
});
if (omitted === 0) return message;
const mimeText = mimes.size > 0 ? `: ${[...mimes].join(", ")}` : "";
content.push({
type: "text",
text: `[${omitted} tool result image${omitted === 1 ? "" : "s"} omitted from initial history payload${mimeText}, ~${bytes} bytes]`,
});
return { ...message, content };
}
// Convert a session entry on the active branch into a UI message.
// Returns null for entries that do not map to chat history (metadata, non-message types).
function entryToUiMessage(
entry: SessionEntry,
options: { deferThinking?: boolean; deferToolResultImages?: boolean },
): AgentMessage | null {
// Supported message roles: user, assistant, toolResult, bashExecution.
// bashExecution messages enter the case "message" branch (entry.type === "message").
// The early return at line below ("!options.deferThinking || message.role !== "assistant"")
// passes non-assistant messages — including bashExecution — through unchanged.
// normalizeToolCalls is a secondary guard (returns non-assistant messages as-is).
switch (entry.type) {
case "message": {
const message = options.deferToolResultImages
? omitToolResultBase64Images(normalizeToolCalls(entry.message))
: normalizeToolCalls(entry.message);
if (!options.deferThinking || message.role !== "assistant") return message;
return {
...message,
content: message.content.map((block) => (
block.type === "thinking" && block.thinking.trim() !== ""
? { ...block, thinking: "", deferred: true }
: block
)),
};
}
case "compaction":
return {
role: "custom",
customType: "compaction",
content: entry.summary,
display: true,
details: {
tokensBefore: entry.tokensBefore,
firstKeptEntryId: entry.firstKeptEntryId,
},
timestamp: parseEntryTimestamp(entry.timestamp),
};
case "branch_summary":
if (!entry.summary) return null;
return {
role: "user",
content: `*The conversation briefly explored another branch and returned with this summary:*\n\n${entry.summary}`,
timestamp: parseEntryTimestamp(entry.timestamp),
};
case "custom_message":
return {
role: "custom",
customType: entry.customType,
content: entry.content,
display: entry.display,
details: entry.details,
timestamp: parseEntryTimestamp(entry.timestamp),
};
default:
return null;
}
}
-241
View File
@@ -1,241 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
const {
appendTitleRequestToTrailingUser,
buildSessionTitleAgentOptions,
generateSessionTitle,
parseGeneratedSessionTitle,
sanitizeTitleMessages,
} = await jiti.import("./session-title.ts");
function assistantMessage(text) {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "test",
provider: "test",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
test("cleans common session title response wrappers", () => {
assert.equal(parseGeneratedSessionTitle("标题:修复 SSE 重连。"), "修复 SSE 重连");
assert.equal(parseGeneratedSessionTitle('```json\n{"title":"整理 Session 文件夹"}\n```'), "整理 Session 文件夹");
assert.equal(parseGeneratedSessionTitle('"Improve worktree session grouping"'), "Improve worktree session grouping");
});
test("rejects responses without a usable title", () => {
assert.throws(() => parseGeneratedSessionTitle("```\n---\n```"), /usable session title/);
});
test("folds the title request into a trailing user message without mutating the source", () => {
const source = [
{ role: "assistant", content: [], timestamp: 1 },
{ role: "user", content: [{ type: "text", text: "Fix the running-session race" }], timestamp: 2 },
];
const prepared = appendTitleRequestToTrailingUser(source);
assert.deepEqual(prepared.map((message) => message.role), ["assistant", "user"]);
assert.match(prepared[1].content.at(-1).text, /Create a concise title/);
assert.equal(source[1].content.length, 1);
assert.notEqual(prepared[1], source[1]);
});
test("leaves a completed conversation unchanged before adding the title turn", () => {
const source = [
{ role: "user", content: "Fix it", timestamp: 1 },
{ role: "assistant", content: [], timestamp: 2 },
];
assert.equal(appendTitleRequestToTrailingUser(source), source);
});
test("waits for the source reply before sending the title prompt", async () => {
let sourceReplyFinished = false;
let providerRoles;
const sourceAgent = {
state: {
systemPrompt: "system",
model: { provider: "test", id: "test-model" },
thinkingLevel: "off",
tools: [],
messages: [{ role: "user", content: "Implement auto name", timestamp: 1 }],
},
waitForIdle: async () => {
sourceAgent.state.messages.push(assistantMessage("The implementation is complete"));
sourceReplyFinished = true;
},
convertToLlm: (messages) => messages,
streamFunction: (_model, context) => {
assert.equal(sourceReplyFinished, true);
providerRoles = context.messages.map((message) => message.role);
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: assistantMessage("Wait for Complete Agent Reply"),
});
});
return stream;
},
sessionId: "source-session-id",
};
const result = await generateSessionTitle({ agent: sourceAgent });
assert.equal(result.title, "Wait for Complete Agent Reply");
assert.deepEqual(providerRoles, ["user", "assistant", "user"]);
});
test("temporary title agent preserves the provider-facing prefix", async () => {
const model = { provider: "test", id: "cached-model" };
const messages = [{ role: "user", content: [{ type: "text", text: "Fix it" }] }];
const originalExecute = async () => ({ content: [], details: {} });
const tools = [{
name: "read",
label: "read",
description: "Read a file",
parameters: { type: "object", properties: {} },
execute: originalExecute,
}];
const convertToLlm = (value) => value;
const transformContext = async (value) => value;
const streamFunction = () => { throw new Error("not called"); };
const source = {
state: {
systemPrompt: "cached system prompt",
model,
thinkingLevel: "high",
tools,
messages,
},
convertToLlm,
transformContext,
streamFunction,
steeringMode: "one-at-a-time",
followUpMode: "one-at-a-time",
sessionId: "source-session-id",
transport: "sse",
toolExecution: "parallel",
};
const options = buildSessionTitleAgentOptions(source);
assert.equal(options.initialState.systemPrompt, source.state.systemPrompt);
assert.equal(options.initialState.model, model);
assert.equal(options.initialState.thinkingLevel, "high");
assert.equal(options.initialState.messages, messages);
assert.equal(options.convertToLlm, convertToLlm);
assert.equal(options.transformContext, transformContext);
assert.equal(options.streamFn, streamFunction);
assert.equal(options.sessionId, "source-session-id");
const withoutExecute = (tool) => Object.fromEntries(
Object.entries(tool).filter(([key]) => key !== "execute"),
);
assert.deepEqual(
options.initialState.tools.map(withoutExecute),
tools.map(withoutExecute),
);
assert.notEqual(options.initialState.tools[0].execute, originalExecute);
await assert.rejects(
options.initialState.tools[0].execute("call", {}, undefined, undefined),
/cannot be executed/,
);
});
test("keeps only tool calls with adjacent matching results", () => {
const messages = [
{ role: "user", content: "inspect both files", timestamp: 1 },
{
...assistantMessage("Inspecting files"),
content: [
{ type: "text", text: "Inspecting files" },
{ type: "toolCall", id: "call-complete", name: "read", arguments: { path: "a.txt" } },
{ type: "toolCall", id: "call-incomplete", name: "read", arguments: { path: "b.txt" } },
],
stopReason: "toolUse",
},
{
role: "toolResult",
toolCallId: "call-complete",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
isError: false,
timestamp: 2,
},
];
const sanitized = sanitizeTitleMessages(messages);
assert.deepEqual(
sanitized[1].content.filter((block) => block.type === "toolCall").map((block) => block.id),
["call-complete"],
);
assert.equal(sanitized[2], messages[2]);
assert.equal(messages[1].content.length, 3);
});
test("removes incomplete tool calls before invoking the title provider", async () => {
let providerMessages;
const sourceAgent = {
state: {
systemPrompt: "system",
model: { provider: "test", id: "test-model" },
thinkingLevel: "off",
tools: [],
messages: [
{ role: "user", content: "run a command", timestamp: 1 },
{
...assistantMessage(""),
content: [{
type: "toolCall",
id: "call-incomplete",
name: "bash",
arguments: { command: "sleep 10" },
}],
stopReason: "toolUse",
},
],
},
waitForIdle: async () => {},
convertToLlm: (messages) => messages,
streamFunction: (_model, context) => {
providerMessages = context.messages.map((message) => ({
role: message.role,
content: message.content,
}));
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: assistantMessage("Sanitized Tool Call History"),
});
});
return stream;
},
sessionId: "source-session-id",
};
const result = await generateSessionTitle({ agent: sourceAgent });
assert.equal(result.title, "Sanitized Tool Call History");
assert.deepEqual(providerMessages.map((message) => message.role), ["user"]);
assert.match(providerMessages[0].content, /Create a concise title/);
});
-253
View File
@@ -1,253 +0,0 @@
import {
Agent,
type AgentMessage,
type AgentOptions,
type AgentTool,
} from "@earendil-works/pi-agent-core";
import type { AgentSession } from "@earendil-works/pi-coding-agent";
const TITLE_TIMEOUT_MS = 90_000;
const MAX_TITLE_LENGTH = 80;
const TITLE_PROMPT = `Create a concise title for this session based on the conversation above.
Requirements:
- Match the primary language used by the user.
- Describe the user's concrete goal or the outcome, not the act of chatting.
- Use 4-12 words for space-separated languages, or 8-24 characters for CJK text when practical.
- Do not call any tools.
- Return only the title as plain text, with no quotes, label, markdown, or explanation.`;
export interface GeneratedSessionTitle {
title: string;
usage?: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
function createShadowTools(tools: AgentTool[]): AgentTool[] {
return tools.map((tool) => ({
...tool,
execute: async () => {
throw new Error("Tools cannot be executed while generating a session title");
},
}));
}
/**
* Build a temporary Agent configuration whose provider-facing prefix matches
* the source Agent. Tool implementations are replaced without changing their
* names, descriptions, or schemas, so a naming run cannot mutate the project.
*/
export function buildSessionTitleAgentOptions(source: Agent): AgentOptions {
const state = source.state;
return {
initialState: {
systemPrompt: state.systemPrompt,
model: state.model,
thinkingLevel: state.thinkingLevel,
tools: createShadowTools(state.tools),
messages: state.messages,
},
convertToLlm: source.convertToLlm,
transformContext: source.transformContext,
streamFn: source.streamFunction,
getApiKey: source.getApiKey,
onPayload: source.onPayload,
onResponse: source.onResponse,
steeringMode: source.steeringMode,
followUpMode: source.followUpMode,
sessionId: source.sessionId,
thinkingBudgets: source.thinkingBudgets,
transport: source.transport,
maxRetryDelayMs: source.maxRetryDelayMs,
toolExecution: source.toolExecution,
};
}
/**
* A running source session usually ends in the user message currently being
* answered. Fold the title request into a copy of that message so the title
* request does not send two consecutive user messages to the provider.
*/
export function appendTitleRequestToTrailingUser(messages: AgentMessage[]): AgentMessage[] {
const lastMessage = messages.at(-1);
if (!lastMessage || lastMessage.role !== "user") return messages;
const content = typeof lastMessage.content === "string"
? `${lastMessage.content}\n\n${TITLE_PROMPT}`
: [...lastMessage.content, { type: "text" as const, text: TITLE_PROMPT }];
return [
...messages.slice(0, -1),
{ ...lastMessage, content },
];
}
function stripWrappingQuotes(value: string): string {
const pairs: Array<[string, string]> = [
['"', '"'],
["'", "'"],
["`", "`"],
["\u201c", "\u201d"],
["\u300c", "\u300d"],
["\u300e", "\u300f"],
];
for (const [start, end] of pairs) {
if (value.startsWith(start) && value.endsWith(end) && value.length > start.length + end.length) {
return value.slice(start.length, -end.length).trim();
}
}
return value;
}
export function parseGeneratedSessionTitle(raw: string): string {
let value = raw.trim();
const fenced = value.match(/^```(?:json|text)?\s*([\s\S]*?)\s*```$/i);
if (fenced) value = fenced[1].trim();
if (value.startsWith("{")) {
try {
const parsed = JSON.parse(value) as { title?: unknown };
if (typeof parsed.title === "string") value = parsed.title.trim();
} catch {
// Fall back to plain-text cleanup below.
}
}
value = value.split(/\r?\n/, 1)[0] ?? "";
value = value.replace(/^(?:session\s+title|title|标题)\s*[::-]\s*/i, "");
value = stripWrappingQuotes(value).replace(/\s+/g, " ").trim();
value = value.replace(/[。.!]+$/u, "").trim();
if (!/[\p{L}\p{N}]/u.test(value)) {
throw new Error("The model did not return a usable session title");
}
const characters = Array.from(value);
if (characters.length > MAX_TITLE_LENGTH) {
value = characters.slice(0, MAX_TITLE_LENGTH).join("").trim();
}
return value;
}
function getAssistantResult(agent: Agent, historyLength: number): GeneratedSessionTitle {
const generatedMessages = agent.state.messages.slice(historyLength);
for (let i = generatedMessages.length - 1; i >= 0; i--) {
const message = generatedMessages[i];
if (message.role !== "assistant") continue;
if (message.stopReason === "error") {
throw new Error(message.errorMessage || "The title model request failed");
}
const text = message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
if (!text) continue;
return {
title: parseGeneratedSessionTitle(text),
...(message.usage ? {
usage: {
input: message.usage.input,
output: message.usage.output,
cacheRead: message.usage.cacheRead,
cacheWrite: message.usage.cacheWrite,
total: message.usage.totalTokens,
},
} : {}),
};
}
throw new Error("The model did not return a session title");
}
export function sanitizeTitleMessages(messages: AgentMessage[]): AgentMessage[] {
const sanitized: AgentMessage[] = [];
let expectedToolResultIds: Set<string> | undefined;
for (let index = 0; index < messages.length; index++) {
const message = messages[index];
if (message.role === "assistant") {
const followingToolResultIds = new Set<string>();
for (let resultIndex = index + 1; resultIndex < messages.length; resultIndex++) {
const resultMessage = messages[resultIndex];
if (resultMessage.role !== "toolResult") break;
followingToolResultIds.add(resultMessage.toolCallId);
}
expectedToolResultIds = new Set<string>();
const content = message.content.filter((block) => {
if (block.type !== "toolCall") return true;
if (!followingToolResultIds.has(block.id)) return false;
expectedToolResultIds!.add(block.id);
return true;
});
if (content.length > 0) {
sanitized.push({ ...message, content });
}
continue;
}
if (message.role === "toolResult") {
if (expectedToolResultIds?.delete(message.toolCallId)) {
sanitized.push(message);
}
continue;
}
expectedToolResultIds = undefined;
sanitized.push(message);
}
return sanitized;
}
export async function generateSessionTitle(source: AgentSession): Promise<GeneratedSessionTitle> {
const sourceAgent = source.agent;
await sourceAgent.waitForIdle();
const sanitizedMessages = sanitizeTitleMessages(sourceAgent.state.messages);
const historyLength = sanitizedMessages.length;
if (!sanitizedMessages.some((message) => message.role === "user")) {
throw new Error("The session has no user messages to name");
}
const options = buildSessionTitleAgentOptions(sourceAgent);
options.initialState!.messages = sanitizedMessages;
const continuesFromTrailingUser = sanitizedMessages.at(-1)?.role === "user";
if (continuesFromTrailingUser) {
options.initialState!.messages = appendTitleRequestToTrailingUser(sanitizedMessages);
}
const temporaryAgent = new Agent(options);
const runPromise = continuesFromTrailingUser
? temporaryAgent.continue()
: temporaryAgent.prompt(TITLE_PROMPT);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
runPromise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
temporaryAgent.abort();
reject(new Error("Session title generation timed out"));
}, TITLE_TIMEOUT_MS);
}),
]);
} catch (error) {
temporaryAgent.abort();
await runPromise.catch(() => {});
throw error;
} finally {
if (timeout) clearTimeout(timeout);
}
return getAssistantResult(temporaryAgent, historyLength);
}
-182
View File
@@ -1,182 +0,0 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
const { annotateSkillsWithInstallInfo, getGlobalSkillsLockPath } =
await jiti.import("./skill-lock.ts");
function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function makeSkill(name, filePath, scope) {
return {
name,
description: `${name} description`,
filePath,
baseDir: filePath.slice(0, -"/SKILL.md".length),
disableModelInvocation: false,
sourceInfo: { scope },
};
}
test("uses the CLI global lock location", () => {
assert.equal(
getGlobalSkillsLockPath({ homeDir: "/home/test", xdgStateHome: undefined }),
join("/home/test", ".agents", ".skill-lock.json"),
);
assert.equal(
getGlobalSkillsLockPath({ homeDir: "/home/test", xdgStateHome: "/state" }),
join("/state", "skills", ".skill-lock.json"),
);
});
test("annotates only lock entries that exist in the matching Pi scope", () => {
const root = mkdtempSync(join(tmpdir(), "pi-web-skill-lock-"));
try {
const cwd = join(root, "project");
const agentDir = join(root, "home", ".pi", "agent");
const globalLockPath = join(root, "global-lock.json");
const projectLockPath = join(cwd, "skills-lock.json");
const globalSkillPath = join(agentDir, "skills", "edge-tts", "SKILL.md");
const projectSkillPath = join(cwd, ".pi", "skills", "find-skills", "SKILL.md");
const manualSkillPath = join(agentDir, "skills", "manual", "SKILL.md");
const otherAgentSkillPath = join(root, "other-agent", "tts", "SKILL.md");
for (const path of [globalSkillPath, projectSkillPath, manualSkillPath, otherAgentSkillPath]) {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, "---\nname: test\n---\n", "utf8");
}
writeJson(globalLockPath, {
version: 3,
skills: {
"edge-tts": {
source: "https://github.com/aahl/skills.git",
sourceType: "github",
skillPath: "skills/edge-tts/SKILL.md",
skillFolderHash: "global-version",
},
tts: { source: "noizai/skills", sourceType: "github" },
},
});
writeJson(projectLockPath, {
version: 1,
skills: {
"find-skills": {
source: "vercel-labs/skills",
sourceType: "github",
skillPath: "skills/find-skills/SKILL.md",
computedHash: "project-version",
},
},
});
const annotated = annotateSkillsWithInstallInfo(
[
makeSkill("edge-tts", globalSkillPath, "user"),
makeSkill("find-skills", projectSkillPath, "project"),
makeSkill("manual", manualSkillPath, "user"),
makeSkill("tts", otherAgentSkillPath, "user"),
],
{ cwd, agentDir, globalLockPath, projectLockPath },
);
assert.deepEqual(annotated[0].install, {
package: "aahl/skills@edge-tts",
scope: "global",
source: "aahl/skills",
sourceType: "github",
skillsShUrl: "https://skills.sh/aahl/skills/edge-tts",
skillPath: "skills/edge-tts/SKILL.md",
versionHash: "global-version",
canCheckForUpdates: true,
});
assert.deepEqual(annotated[1].install, {
package: "vercel-labs/skills@find-skills",
scope: "project",
source: "vercel-labs/skills",
sourceType: "github",
skillsShUrl: "https://skills.sh/vercel-labs/skills/find-skills",
skillPath: "skills/find-skills/SKILL.md",
versionHash: "project-version",
canCheckForUpdates: true,
});
assert.equal(annotated[2].install, undefined);
assert.equal(annotated[3].install, undefined);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("ignores stale lock entries and malformed lock files", () => {
const root = mkdtempSync(join(tmpdir(), "pi-web-skill-lock-"));
try {
const cwd = join(root, "project");
const agentDir = join(root, "agent");
const missingPath = join(agentDir, "skills", "missing", "SKILL.md");
const projectSkillPath = join(cwd, ".pi", "skills", "broken", "SKILL.md");
const globalLockPath = join(root, "global-lock.json");
const projectLockPath = join(root, "project-lock.json");
mkdirSync(join(projectSkillPath, ".."), { recursive: true });
writeFileSync(projectSkillPath, "---\nname: broken\n---\n", "utf8");
writeJson(globalLockPath, {
version: 3,
skills: {
missing: { source: "owner/repo", sourceType: "github" },
},
});
writeFileSync(projectLockPath, "not json", "utf8");
const skills = annotateSkillsWithInstallInfo(
[
makeSkill("missing", missingPath, "user"),
makeSkill("broken", projectSkillPath, "project"),
],
{ cwd, agentDir, globalLockPath, projectLockPath },
);
assert.equal(skills[0].install, undefined);
assert.equal(skills[1].install, undefined);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("does not compare a project ref with the default skills.sh snapshot", () => {
const root = mkdtempSync(join(tmpdir(), "pi-web-skill-lock-"));
try {
const cwd = join(root, "project");
const agentDir = join(root, "agent");
const projectLockPath = join(cwd, "skills-lock.json");
const projectSkillPath = join(cwd, ".pi", "skills", "preview", "SKILL.md");
mkdirSync(join(projectSkillPath, ".."), { recursive: true });
writeFileSync(projectSkillPath, "---\nname: preview\n---\n", "utf8");
writeJson(projectLockPath, {
version: 1,
skills: {
preview: {
source: "owner/repo",
sourceType: "github",
skillPath: "skills/preview/SKILL.md",
ref: "preview",
computedHash: "project-version",
},
},
});
const [skill] = annotateSkillsWithInstallInfo(
[makeSkill("preview", projectSkillPath, "project")],
{ cwd, agentDir, projectLockPath, globalLockPath: join(root, "missing.json") },
);
assert.equal(skill.install.ref, "preview");
assert.equal(skill.install.canCheckForUpdates, false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
-147
View File
@@ -1,147 +0,0 @@
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { isAbsolute, join, relative, resolve, sep } from "path";
import type { SkillInfo, SkillInstallInfo, SkillInstallScope } from "@/lib/api-types";
interface SkillLockEntry {
source?: unknown;
sourceType?: unknown;
skillPath?: unknown;
ref?: unknown;
skillFolderHash?: unknown;
computedHash?: unknown;
}
interface SkillLockFile {
skills?: Record<string, SkillLockEntry>;
}
interface GlobalLockPathOptions {
homeDir?: string;
xdgStateHome?: string;
}
interface AnnotateSkillOptions {
cwd: string;
agentDir: string;
globalSkillRoots?: string[];
globalLockPath?: string;
projectLockPath?: string;
}
export function getGlobalSkillsLockPath({
homeDir = homedir(),
xdgStateHome = process.env.XDG_STATE_HOME,
}: GlobalLockPathOptions = {}): string {
return xdgStateHome
? join(xdgStateHome, "skills", ".skill-lock.json")
: join(homeDir, ".agents", ".skill-lock.json");
}
function readSkillLock(path: string): Record<string, SkillLockEntry> {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as SkillLockFile;
return parsed.skills && typeof parsed.skills === "object" ? parsed.skills : {};
} catch {
return {};
}
}
function isWithin(path: string, root: string): boolean {
const rel = relative(resolve(root), resolve(path));
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}
function findLockEntry(
entries: Record<string, SkillLockEntry>,
skillName: string,
): SkillLockEntry | undefined {
if (entries[skillName]) return entries[skillName];
const normalizedName = skillName.toLowerCase();
const key = Object.keys(entries).find((name) => name.toLowerCase() === normalizedName);
return key ? entries[key] : undefined;
}
function normalizeSource(source: string, sourceType?: string): string {
if (sourceType !== "github") return source.replace(/\/$/, "");
return source
.replace(/^git\+/, "")
.replace(/^https?:\/\/github\.com\//, "")
.replace(/^git@github\.com:/, "")
.replace(/\.git$/, "")
.replace(/\/$/, "");
}
function buildSkillsShUrl(source: string, skillName: string): string | undefined {
if (!source || source.includes("://") || source.startsWith("git@")) return undefined;
const sourcePath = source
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
if (!sourcePath) return undefined;
return `https://skills.sh/${sourcePath}/${encodeURIComponent(skillName)}`;
}
function getInstallInfo(
entries: Record<string, SkillLockEntry>,
skillName: string,
scope: SkillInstallScope,
): SkillInstallInfo | undefined {
const entry = findLockEntry(entries, skillName);
if (!entry || typeof entry.source !== "string" || !entry.source.trim()) return undefined;
const sourceType = typeof entry.sourceType === "string" ? entry.sourceType : undefined;
const source = normalizeSource(entry.source.trim(), sourceType);
if (!source) return undefined;
const skillPath = typeof entry.skillPath === "string" ? entry.skillPath : undefined;
const ref = typeof entry.ref === "string" ? entry.ref : undefined;
const rawVersionHash = scope === "global" ? entry.skillFolderHash : entry.computedHash;
const versionHash = typeof rawVersionHash === "string" && rawVersionHash
? rawVersionHash
: undefined;
const isGitHubSource =
sourceType === "github" && /^[\w.-]+\/[\w.-]+$/.test(source);
const hasComparableVersion = scope === "global" || !ref;
return {
package: `${source}@${skillName}`,
scope,
source,
sourceType,
skillsShUrl: sourceType === "local" ? undefined : buildSkillsShUrl(source, skillName),
...(skillPath && { skillPath }),
...(ref && { ref }),
...(versionHash && { versionHash }),
canCheckForUpdates: Boolean(
isGitHubSource && skillPath && versionHash && hasComparableVersion,
),
};
}
export function annotateSkillsWithInstallInfo(
skills: SkillInfo[],
{
cwd,
agentDir,
globalSkillRoots = [join(agentDir, "skills")],
globalLockPath = getGlobalSkillsLockPath(),
projectLockPath = join(cwd, "skills-lock.json"),
}: AnnotateSkillOptions,
): SkillInfo[] {
const globalEntries = readSkillLock(globalLockPath);
const projectEntries = readSkillLock(projectLockPath);
const projectSkillsRoot = join(cwd, ".pi", "skills");
return skills.map((skill) => {
if (!existsSync(skill.filePath)) return skill;
const install = globalSkillRoots.some((root) => isWithin(skill.filePath, root))
? getInstallInfo(globalEntries, skill.name, "global")
: isWithin(skill.filePath, projectSkillsRoot)
? getInstallInfo(projectEntries, skill.name, "project")
: undefined;
return install ? { ...skill, install } : skill;
});
}
-182
View File
@@ -1,182 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
const {
buildSkillUpdateArgs,
checkSkillUpdate,
checkSkillUpdates,
skillUpdateKey,
} = await jiti.import("./skill-updates.ts");
function install(overrides = {}) {
return {
package: "owner/repo@example-skill",
scope: "global",
source: "owner/repo",
sourceType: "github",
skillsShUrl: "https://skills.sh/owner/repo/example-skill",
skillPath: "skills/example-skill/SKILL.md",
versionHash: "current-hash",
canCheckForUpdates: true,
...overrides,
};
}
function jsonResponse(value, status = 200) {
return new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json" },
});
}
test("compares a global lock version with the remote Git tree", async () => {
const seen = [];
const upToDate = await checkSkillUpdate(install(), {
fetcher: async (url) => {
seen.push(url);
return jsonResponse({
sha: "root-hash",
tree: [{ type: "tree", path: "skills/example-skill", sha: "current-hash" }],
});
},
});
assert.equal(upToDate.state, "up-to-date");
assert.equal(upToDate.latestVersion, "current-hash");
assert.match(seen[0], /repos\/owner\/repo\/git\/trees\/HEAD/);
const available = await checkSkillUpdate(install(), {
fetcher: async () => jsonResponse({
sha: "root-hash",
tree: [{ type: "tree", path: "skills/example-skill", sha: "next-hash" }],
}),
});
assert.equal(available.state, "update-available");
assert.equal(available.currentVersion, "current-hash");
assert.equal(available.latestVersion, "next-hash");
});
test("uses the repository hash for a root global skill", async () => {
const result = await checkSkillUpdate(install({ skillPath: "SKILL.md" }), {
fetcher: async () => jsonResponse({ sha: "next-root", tree: [] }),
});
assert.equal(result.state, "update-available");
assert.equal(result.latestVersion, "next-root");
});
test("compares a project lock version with the skills.sh snapshot", async () => {
let requestedUrl = "";
const result = await checkSkillUpdate(install({ scope: "project" }), {
skillsApiBase: "https://skills.test",
fetcher: async (url) => {
requestedUrl = url;
return jsonResponse({ hash: "current-hash" });
},
});
assert.equal(result.state, "up-to-date");
assert.equal(
requestedUrl,
"https://skills.test/api/download/owner/repo/example-skill",
);
});
test("returns unsupported without making a remote request", async () => {
let called = false;
const result = await checkSkillUpdate(
install({ canCheckForUpdates: false, versionHash: undefined }),
{ fetcher: async () => { called = true; return jsonResponse({}); } },
);
assert.equal(result.state, "unsupported");
assert.equal(called, false);
});
test("returns a scoped error when the remote check fails", async () => {
const result = await checkSkillUpdate(install(), {
fetcher: async () => jsonResponse({}, 503),
});
assert.equal(result.state, "error");
assert.equal(result.message, "HTTP 503");
assert.equal(skillUpdateKey(install()), "global\0owner/repo@example-skill");
});
test("falls back to Git when the GitHub API is rate limited", async () => {
let resolved = false;
const result = await checkSkillUpdate(install(), {
fetcher: async () => jsonResponse({}, 403),
resolveGitTreeHash: async () => {
resolved = true;
return "next-hash";
},
});
assert.equal(resolved, true);
assert.equal(result.state, "update-available");
assert.equal(result.latestVersion, "next-hash");
});
test("builds Pi-only update commands for each scope", () => {
assert.deepEqual(buildSkillUpdateArgs(install()), [
"skills",
"add",
"owner/repo/skills/example-skill",
"--skill",
"example-skill",
"-y",
"--agent",
"pi",
"-g",
]);
assert.deepEqual(buildSkillUpdateArgs(install({ scope: "project" })), [
"skills",
"add",
"owner/repo/skills/example-skill",
"--skill",
"example-skill",
"-y",
"--agent",
"pi",
]);
assert.deepEqual(buildSkillUpdateArgs(install({ ref: "release/v2" })), [
"skills",
"add",
"owner/repo/skills/example-skill#release%2Fv2",
"--skill",
"example-skill",
"-y",
"--agent",
"pi",
"-g",
]);
});
test("reuses one remote request for skills from the same GitHub source", async () => {
let requests = 0;
const results = await checkSkillUpdates([
install(),
install({
package: "owner/repo@another-skill",
skillPath: "skills/another-skill/SKILL.md",
versionHash: "another-hash",
}),
], {
fetcher: async () => {
requests++;
return jsonResponse({
sha: "root-hash",
tree: [
{ type: "tree", path: "skills/example-skill", sha: "current-hash" },
{ type: "tree", path: "skills/another-skill", sha: "another-hash" },
],
});
},
});
assert.equal(requests, 1);
assert.deepEqual(results.map((item) => item.state), ["up-to-date", "up-to-date"]);
});
-263
View File
@@ -1,263 +0,0 @@
import { execFile } from "child_process";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { promisify } from "util";
import type {
SkillInstallInfo,
SkillUpdateResult,
} from "@/lib/api-types";
const CHECK_TIMEOUT_MS = 15_000;
const GIT_CHECK_TIMEOUT_MS = 30_000;
const DEFAULT_SKILLS_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh";
const execFileAsync = promisify(execFile);
type Fetcher = (input: string, init?: RequestInit) => Promise<Response>;
type GitTreeResolver = (install: SkillInstallInfo) => Promise<string>;
interface CheckOptions {
fetcher?: Fetcher;
skillsApiBase?: string;
githubToken?: string;
resolveGitTreeHash?: GitTreeResolver;
}
interface GitHubTreeEntry {
path?: unknown;
type?: unknown;
sha?: unknown;
}
interface GitHubTreeResponse {
sha?: unknown;
tree?: unknown;
}
interface SnapshotResponse {
hash?: unknown;
}
class HttpError extends Error {
constructor(readonly status: number) {
super(`HTTP ${status}`);
}
}
export function skillUpdateKey(install: Pick<SkillInstallInfo, "scope" | "package">): string {
return `${install.scope}\0${install.package}`;
}
export function buildSkillUpdateArgs(install: SkillInstallInfo): string[] {
const folder = skillFolder(install.skillPath ?? "");
const source = folder ? `${install.source}/${folder}` : install.source;
const ref = install.ref ? `#${encodeURIComponent(install.ref)}` : "";
const args = [
"skills",
"add",
`${source}${ref}`,
"--skill",
skillNameFromPackage(install.package),
"-y",
"--agent",
"pi",
];
if (install.scope === "global") args.push("-g");
return args;
}
function skillSlug(name: string): string {
return name
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function skillNameFromPackage(pkg: string): string {
const at = pkg.lastIndexOf("@");
return at >= 0 ? pkg.slice(at + 1) : pkg;
}
function skillFolder(skillPath: string): string {
let folder = skillPath.replace(/\\/g, "/");
if (folder.toLowerCase().endsWith("/skill.md")) folder = folder.slice(0, -9);
else if (folder.toLowerCase().endsWith("skill.md")) folder = folder.slice(0, -8);
return folder.replace(/\/$/, "");
}
function result(
install: SkillInstallInfo,
state: SkillUpdateResult["state"],
latestVersion?: string,
message?: string,
): SkillUpdateResult {
return {
package: install.package,
scope: install.scope,
state,
currentVersion: install.versionHash,
latestVersion,
message,
};
}
async function fetchJson(
url: string,
fetcher: Fetcher,
headers?: HeadersInit,
): Promise<unknown> {
const response = await fetcher(url, {
cache: "no-store",
headers,
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
});
if (!response.ok) throw new HttpError(response.status);
return response.json();
}
async function resolveGitTreeHash(install: SkillInstallInfo): Promise<string> {
const repository = `https://github.com/${install.source}.git`;
const ref = install.ref || "HEAD";
const folder = skillFolder(install.skillPath!);
const gitDir = await mkdtemp(join(tmpdir(), "pi-web-skill-check-"));
try {
await execFileAsync("git", ["init", "--bare", gitDir], {
timeout: GIT_CHECK_TIMEOUT_MS,
});
await execFileAsync("git", [
`--git-dir=${gitDir}`,
"fetch",
"--depth=1",
"--filter=blob:none",
"--no-tags",
repository,
ref,
], { timeout: GIT_CHECK_TIMEOUT_MS });
const revision = folder ? `FETCH_HEAD:${folder}` : "FETCH_HEAD^{tree}";
const { stdout } = await execFileAsync(
"git",
[`--git-dir=${gitDir}`, "rev-parse", revision],
{ timeout: GIT_CHECK_TIMEOUT_MS },
);
const hash = stdout.trim();
if (!/^[0-9a-f]{40}$/i.test(hash)) throw new Error("Invalid Git tree hash");
return hash;
} finally {
await rm(gitDir, { recursive: true, force: true });
}
}
async function checkGlobalSkill(
install: SkillInstallInfo,
options: Required<Pick<CheckOptions, "fetcher" | "resolveGitTreeHash">> & CheckOptions,
): Promise<SkillUpdateResult> {
const ref = install.ref || "HEAD";
const url = `https://api.github.com/repos/${install.source}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
const headers: Record<string, string> = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "pi-web",
};
if (options.githubToken) headers.Authorization = `Bearer ${options.githubToken}`;
const folder = skillFolder(install.skillPath!);
let latestVersion: string | undefined;
try {
const raw = (await fetchJson(url, options.fetcher, headers)) as GitHubTreeResponse;
latestVersion = typeof raw.sha === "string" && !folder ? raw.sha : undefined;
if (folder && Array.isArray(raw.tree)) {
const entry = (raw.tree as GitHubTreeEntry[]).find(
(item) => item.type === "tree" && item.path === folder,
);
if (entry && typeof entry.sha === "string") latestVersion = entry.sha;
}
} catch (error) {
if (!(error instanceof HttpError) || ![401, 403, 429].includes(error.status)) {
throw error;
}
latestVersion = await options.resolveGitTreeHash(install);
}
if (!latestVersion) {
return result(install, "error", undefined, "Remote skill path was not found.");
}
return result(
install,
latestVersion === install.versionHash ? "up-to-date" : "update-available",
latestVersion,
);
}
async function checkProjectSkill(
install: SkillInstallInfo,
options: Required<Pick<CheckOptions, "fetcher" | "skillsApiBase">> & CheckOptions,
): Promise<SkillUpdateResult> {
const [owner, repo] = install.source.split("/");
const name = skillSlug(skillNameFromPackage(install.package));
const url = `${options.skillsApiBase}/api/download/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(name)}`;
const raw = (await fetchJson(url, options.fetcher)) as SnapshotResponse;
const latestVersion = typeof raw.hash === "string" ? raw.hash : undefined;
if (!latestVersion) {
return result(install, "error", undefined, "skills.sh did not return a version hash.");
}
return result(
install,
latestVersion === install.versionHash ? "up-to-date" : "update-available",
latestVersion,
);
}
export async function checkSkillUpdate(
install: SkillInstallInfo,
options: CheckOptions = {},
): Promise<SkillUpdateResult> {
if (!install.canCheckForUpdates || !install.versionHash || !install.skillPath) {
return result(install, "unsupported", undefined, "This lock entry cannot be checked automatically.");
}
const resolvedOptions = {
...options,
fetcher: options.fetcher ?? fetch,
skillsApiBase: options.skillsApiBase ?? DEFAULT_SKILLS_API_BASE,
resolveGitTreeHash: options.resolveGitTreeHash ?? resolveGitTreeHash,
};
try {
return install.scope === "global"
? await checkGlobalSkill(install, resolvedOptions)
: await checkProjectSkill(install, resolvedOptions);
} catch (error) {
return result(
install,
"error",
undefined,
error instanceof Error ? error.message : String(error),
);
}
}
export async function checkSkillUpdates(
installs: SkillInstallInfo[],
options: CheckOptions = {},
): Promise<SkillUpdateResult[]> {
const fetcher = options.fetcher ?? fetch;
const requests = new Map<string, Promise<Response>>();
const cachedFetcher: Fetcher = async (input, init) => {
let request = requests.get(input);
if (!request) {
request = fetcher(input, init);
requests.set(input, request);
}
return (await request).clone();
};
return Promise.all(
installs.map((install) => checkSkillUpdate(install, {
...options,
fetcher: cachedFetcher,
})),
);
}
-32
View File
@@ -1,32 +0,0 @@
import { DefaultResourceLoader, getAgentDir } from "@earendil-works/pi-coding-agent";
import type { SkillInfo, SkillsResponse } from "@/lib/api-types";
import { annotateSkillsWithInstallInfo } from "@/lib/skill-lock";
import { getProjectTrustStatus, projectTrustReloadOptions } from "@/lib/project-trust";
import {
createAppSettingsManager,
getAppResourceLoaderOptions,
getManagedRuntimePaths,
isManagedRuntime,
} from "@/lib/app-runtime";
export async function loadSkillsWithInstallInfo(cwd: string): Promise<SkillsResponse> {
const agentDir = getAgentDir();
const loader = new DefaultResourceLoader({
...getAppResourceLoaderOptions(),
cwd,
agentDir,
settingsManager: createAppSettingsManager(cwd, agentDir),
});
await loader.reload(projectTrustReloadOptions(cwd, agentDir));
const { skills, diagnostics } = loader.getSkills();
const managedPaths = isManagedRuntime() ? getManagedRuntimePaths() : undefined;
return {
skills: annotateSkillsWithInstallInfo(skills as SkillInfo[], {
cwd,
agentDir,
...(managedPaths ? { globalSkillRoots: managedPaths.managedSkillRoots } : {}),
}),
diagnostics,
projectResourcesLoaded: getProjectTrustStatus(cwd, agentDir).trusted,
};
}
-239
View File
@@ -1,239 +0,0 @@
import { execFile } from "child_process";
import { existsSync, mkdirSync, realpathSync } from "fs";
import { basename, dirname, join, resolve } from "path";
import { promisify } from "util";
import { allowFileRoot, normalizeSlashes } from "./allowed-roots";
const execFileAsync = promisify(execFile);
// ============================================================================
// Project resolution: cwd → { projectRoot, branch }
//
// A worktree's `git rev-parse --git-common-dir` points at the *main* repo's
// .git directory, so its parent is the project root shared by all worktrees.
// Non-git directories resolve to themselves. Results are cached on globalThis
// (hot-reload safe) with a short TTL; add/remove worktree invalidates eagerly.
// ============================================================================
export interface ProjectInfo {
projectRoot: string;
/** Current branch of the cwd, null for non-git dirs or detached HEAD */
branch: string | null;
/** True when cwd is a linked worktree (not the main checkout) */
isWorktree: boolean;
/** True when cwd is the top-level directory of a checkout (main or linked).
* False for repo subdirectories and non-git dirs — the worktree switcher
* is only meaningful at the top level. */
isTopLevel: boolean;
}
export interface WorktreeInfo {
path: string;
branch: string | null;
isMain: boolean;
}
declare global {
var __piProjectCache: Map<string, { info: ProjectInfo; expiresAt: number }> | undefined;
}
const PROJECT_CACHE_TTL_MS = 60_000;
function getProjectCache(): Map<string, { info: ProjectInfo; expiresAt: number }> {
if (!globalThis.__piProjectCache) globalThis.__piProjectCache = new Map();
return globalThis.__piProjectCache;
}
export function invalidateProjectCache(): void {
globalThis.__piProjectCache?.clear();
}
/** Path equality that tolerates Windows quirks: git prints forward slashes
* while realpathSync uses backslashes, and drive letters may differ in case. */
function samePath(a: string, b: string): boolean {
if (process.platform === "win32") {
return normalizeSlashes(a).toLowerCase() === normalizeSlashes(b).toLowerCase();
}
return a === b;
}
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
// Pin the message locale so error-text matching (e.g. the dirty-worktree
// detection in the DELETE route) works regardless of system language.
env: { ...process.env, LC_ALL: "C" },
});
return stdout.trim();
}
/**
* addWorktree() places worktrees in `<repoRoot>-worktrees/<dir>`. When such a
* directory no longer exists (worktree removed), group its sessions back
* under the main repo instead of letting them dangle as a phantom project.
* The dir name is the sanitized branch name — close enough for display.
*/
function inferRemovedWorktree(cwd: string): ProjectInfo | null {
const parent = dirname(cwd);
if (!parent.endsWith("-worktrees")) return null;
const repoRoot = parent.slice(0, -"-worktrees".length);
if (!repoRoot || !existsSync(join(repoRoot, ".git"))) return null;
return { projectRoot: repoRoot, branch: basename(cwd), isWorktree: true, isTopLevel: true };
}
export async function resolveProject(cwd: string): Promise<ProjectInfo> {
const cache = getProjectCache();
const cached = cache.get(cwd);
if (cached && cached.expiresAt > Date.now()) return cached.info;
let info: ProjectInfo;
try {
if (!existsSync(cwd)) {
info = inferRemovedWorktree(cwd) ?? { projectRoot: cwd, branch: null, isWorktree: false, isTopLevel: false };
cache.set(cwd, { info, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
return info;
}
const out = await git(cwd, [
"rev-parse", "--path-format=absolute",
"--git-common-dir", "--git-dir", "--show-toplevel",
"--abbrev-ref", "HEAD",
]);
const [commonDir, gitDir, toplevel, ref] = out.split("\n").map((l) => l.trim());
// git prints resolved (symlink-free) paths; normalize cwd the same way
let realCwd = cwd;
try { realCwd = realpathSync(cwd); } catch { /* keep as-is */ }
// For a linked worktree, --git-dir differs from --git-common-dir.
// Only collapse *worktree toplevels* into the main repo. A session whose
// cwd is a subdirectory of a repo keeps its own project identity —
// grouping subdirs under the repo root would change where new sessions
// are created for existing users.
const isTopLevel = samePath(toplevel, realCwd);
const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
info = {
projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
branch: ref && ref !== "HEAD" ? ref : null,
isWorktree: isWorktreeTopLevel,
isTopLevel,
};
} catch {
info = { projectRoot: cwd, branch: null, isWorktree: false, isTopLevel: false };
}
cache.set(cwd, { info, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
return info;
}
// ============================================================================
// Worktree operations
//
// These take any directory inside the repo (a worktree, the main checkout, or
// a subdirectory) and resolve the main repo root themselves via the git
// common dir, so callers can pass session cwds directly.
// ============================================================================
/** Main repo root (parent of the shared .git dir), or throws for non-git dirs */
async function getRepoRoot(cwd: string): Promise<string> {
const commonDir = await git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
return dirname(commonDir);
}
export async function listWorktrees(cwd: string): Promise<WorktreeInfo[]> {
const out = await git(cwd, ["worktree", "list", "--porcelain"]);
const worktrees: WorktreeInfo[] = [];
let current: (Partial<WorktreeInfo> & { prunable?: boolean }) | null = null;
const flush = () => {
if (current?.path) {
// Prunable worktrees point at missing/broken gitdirs and cannot be
// browsed or selected usefully. Also skip vanished paths even if git has
// not marked them prunable yet.
if (!current.prunable && existsSync(current.path)) {
worktrees.push({
path: current.path,
branch: current.branch ?? null,
isMain: worktrees.length === 0,
});
}
}
current = null;
};
for (const line of out.split("\n")) {
if (line.startsWith("worktree ")) {
flush();
current = { path: line.slice("worktree ".length).trim() };
} else if (line.startsWith("branch ") && current) {
current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
} else if (line.startsWith("prunable") && current) {
current.prunable = true;
} else if (line.trim() === "") {
flush();
}
}
flush();
return worktrees;
}
function sanitizeBranchForDir(branch: string): string {
return branch.replace(/[\/\\:*?"<>|\s]+/g, "-").replace(/^-+|-+$/g, "");
}
export async function addWorktree(cwd: string, branch: string): Promise<{ path: string; branch: string }> {
const trimmed = branch.trim();
if (!trimmed) throw new Error("Branch name is required");
const dirName = sanitizeBranchForDir(trimmed);
if (!dirName) throw new Error(`Invalid branch name: ${branch}`);
const repoRoot = await getRepoRoot(cwd);
const baseDir = `${resolve(repoRoot)}-worktrees`;
const worktreePath = join(baseDir, dirName);
if (existsSync(worktreePath)) {
throw new Error(`Directory already exists: ${worktreePath}`);
}
mkdirSync(baseDir, { recursive: true });
// Reuse the branch if it already exists, otherwise create it at HEAD.
let branchExists = false;
try {
await git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${trimmed}`]);
branchExists = true;
} catch {
branchExists = false;
}
try {
if (branchExists) {
await git(repoRoot, ["worktree", "add", "--", worktreePath, trimmed]);
} else {
await git(repoRoot, ["worktree", "add", "-b", trimmed, "--", worktreePath]);
}
} catch (error) {
throw new Error(extractGitError(error));
}
allowFileRoot(worktreePath);
invalidateProjectCache();
return { path: worktreePath, branch: trimmed };
}
export async function removeWorktree(cwd: string, worktreePath: string, force = false): Promise<void> {
const worktrees = await listWorktrees(cwd);
const target = worktrees.find((w) => w.path === worktreePath);
if (!target) throw new Error(`Not a worktree of this repository: ${worktreePath}`);
if (target.isMain) throw new Error("Cannot remove the main worktree");
try {
await git(cwd, ["worktree", "remove", ...(force ? ["--force"] : []), worktreePath]);
} catch (error) {
throw new Error(extractGitError(error));
}
invalidateProjectCache();
}
function extractGitError(error: unknown): string {
const stderr = (error as { stderr?: string }).stderr;
if (typeof stderr === "string" && stderr.trim()) return stderr.trim();
return error instanceof Error ? error.message : String(error);
}