Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion app/api/subagents/profiles/route.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test, { after } from "node:test";
Expand Down Expand Up @@ -48,6 +48,50 @@ function jsonRequest(method, body) {
});
}

test("profile PUT validates scoped MCP fields and old clients preserve stored role capabilities", async (t) => {
const cwd = await mkdtemp(join(tmpdir(), "pi-child-profile-put-")); allowFileRoot(cwd);
t.after(() => rm(cwd, { recursive: true, force: true }));
const capabilities = { codeMode: true, loadMcp: true, mcpServers: [{ scope: "global", name: "Case-ID" }] };
let response = await PUT(jsonRequest("PUT", { cwd, scope: "project", profile: profile(capabilities) }));
assert.equal(response.status, 200); assert.deepEqual((await response.json()).profile.mcpServers, capabilities.mcpServers);
response = await PUT(jsonRequest("PUT", { cwd, scope: "project", profile: profile({ description: "old client" }) }));
assert.equal(response.status, 200); const preserved = (await response.json()).profile;
assert.equal(preserved.codeMode, true); assert.equal(preserved.loadMcp, true); assert.deepEqual(preserved.mcpServers, capabilities.mcpServers);
for (const invalid of [{ codeMode: "true" }, { loadMcp: null }, { mcpServers: ["Case-ID"] }, { mcpServers: [{ name: "Case-ID" }] }, { mcpServers: [{ scope: "global", name: "Case-ID", config: {} }] }]) {
response = await PUT(jsonRequest("PUT", { cwd, scope: "project", profile: profile(invalid) })); assert.equal(response.status, 400);
}
response = await PUT(jsonRequest("PUT", { cwd, scope: "project", profile: profile({ codeMode: false, loadMcp: false, mcpServers: [] }) }));
assert.equal(response.status, 200); const disabled = (await response.json()).profile;
assert.equal(disabled.codeMode, false); assert.equal(disabled.loadMcp, false); assert.deepEqual(disabled.mcpServers, []);
});

test("model override permission defaults off, survives old PUT/clone/PATCH and rejects non-booleans", async (t) => {
const cwd = await mkdtemp(join(tmpdir(), "pi-profile-model-permission-")); allowFileRoot(cwd);
t.after(() => rm(cwd, { recursive: true, force: true }));
const put = (draft, extra = {}) => PUT(jsonRequest("PUT", { cwd, scope: "project", profile: draft, ...extra }));
let response = await put(profile());
assert.equal((await response.json()).profile.allowParentModelOverride, false);
response = await put(profile({ allowParentModelOverride: true }));
assert.equal((await response.json()).profile.allowParentModelOverride, true);
const path = join(cwd, ".pi", "agents", "api-test-agent.md");
await writeFile(path, (await readFile(path, "utf8")).replace("---\n", "---\nforeign_model_note: keep\n"));
response = await put(profile({ description: "old client omits the field" }));
assert.equal(response.status, 200); assert.equal((await response.json()).profile.allowParentModelOverride, true);
response = await put(profile({ name: "model-copy" }), { cloneFrom: { scope: "project", name: "api-test-agent" } });
assert.equal(response.status, 200); assert.equal((await response.json()).profile.allowParentModelOverride, true);
response = await PATCH(jsonRequest("PATCH", { cwd, scope: "project", name: "api-test-agent", enabled: false }));
assert.equal(response.status, 200); assert.equal((await response.json()).profile.allowParentModelOverride, true);
for (const invalid of ["true", null, 1, {}]) {
response = await put(profile({ allowParentModelOverride: invalid })); assert.equal(response.status, 400);
}
response = await put(profile({ allowParentModelOverride: false }));
assert.equal(response.status, 200); assert.equal((await response.json()).profile.allowParentModelOverride, false);
assert.match(await readFile(path, "utf8"), /foreign_model_note: keep/);
assert.match(await readFile(path, "utf8"), /allow_parent_model_override: false/);
const builtin = (await (await GET(new Request(`http://localhost/api/subagents/profiles?cwd=${encodeURIComponent(cwd)}`))).json()).profiles.find((item) => item.scope === "builtin");
assert.equal(builtin.allowParentModelOverride, false);
});

test("profiles route creates, lists, and deletes a project profile", async (t) => {
const cwd = await mkdtemp(join(tmpdir(), "pi-web-subagent-route-"));
allowFileRoot(cwd);
Expand Down
26 changes: 7 additions & 19 deletions app/api/subagents/profiles/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,19 @@ export async function PUT(req: Request) {
cwd?: unknown;
scope?: unknown;
profile?: Omit<SubagentProfile, "scope" | "filePath">;
cloneFrom?: { scope?: unknown; name?: unknown };
};
const cwd = await validateCwd(body.cwd);
const scope = validateScope(body.scope);
if (!body.profile || typeof body.profile.name !== "string") {
return NextResponse.json({ error: "profile required" }, { status: 400 });
}
return NextResponse.json({ profile: saveSubagentProfile(cwd, scope, body.profile) });
const cloneFrom = body.cloneFrom ? listSubagentProfileSources(cwd).find((source) => source.scope === body.cloneFrom?.scope && source.name === body.cloneFrom?.name) : undefined;
if (body.cloneFrom && !cloneFrom) throw new Error("Clone source not found");
return NextResponse.json({ profile: saveSubagentProfile(cwd, scope, body.profile, cloneFrom) });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: message === "Access denied" ? 403 : 400 });
return NextResponse.json({ error: message }, { status: message === "Access denied" ? 403 : message === "Cannot clone over an existing profile" ? 409 : 400 });
}
}

Expand All @@ -75,23 +78,8 @@ export async function PATCH(req: Request) {
writeDisabledBuiltInSubagent(source.name, !body.enabled);
return NextResponse.json({ profile: { ...source, enabled: body.enabled } });
}
const profile: Omit<SubagentProfile, "scope" | "filePath"> = {
name: source.name,
displayName: source.displayName,
description: source.description,
systemPrompt: source.systemPrompt,
tools: source.tools,
loadSkills: source.loadSkills,
loadExtensions: source.loadExtensions,
promptMode: source.promptMode,
model: source.model,
thinking: source.thinking,
maxTurns: source.maxTurns,
inheritContext: source.inheritContext,
runInBackground: source.runInBackground,
enabled: source.enabled,
};
return NextResponse.json({ profile: saveSubagentProfile(cwd, scope, { ...profile, enabled: body.enabled }) });
// The writer derives its target from cwd/scope/name, never from source.filePath.
return NextResponse.json({ profile: saveSubagentProfile(cwd, scope, { ...source, enabled: body.enabled }) });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: message === "Access denied" ? 403 : 400 });
Expand Down
118 changes: 118 additions & 0 deletions app/api/subagents/resources/route.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, readFile, rm, access } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import test, { after } from "node:test";
import { createJiti } from "jiti";

const sandbox = await mkdtemp(join(tmpdir(), "pi-resource-route-"));
const previous = process.env.PI_CODING_AGENT_DIR;
const previousHome = process.env.HOME;
process.env.HOME = join(sandbox, "home");
await mkdir(process.env.HOME);
process.env.PI_CODING_AGENT_DIR = join(sandbox, "agent");
after(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previous; await rm(sandbox, { recursive: true, force: true }); });
const jiti = createJiti(import.meta.url, { alias: { "@": process.cwd() } });
const { GET } = await jiti.import("./route.ts");
const profiles = await jiti.import("../profiles/route.ts");
const { allowFileRoot } = await jiti.import("../../../../lib/file-access.ts");
const { parseFrontmatter } = await jiti.import("../../../../lib/frontmatter.ts");
function request(cwd) { return new Request(`http://localhost/api/subagents/resources?cwd=${encodeURIComponent(cwd)}`); }
function profileRequest(method, body) { return new Request("http://localhost/api/subagents/profiles", { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); }

test("resource GET is guarded, static, and returns configured/default/project resources without trust", async () => {
const cwd = join(sandbox, "project"); await mkdir(cwd); allowFileRoot(cwd);
const extDir = join(cwd, ".pi", "extensions"); await mkdir(extDir, { recursive: true });
await writeFile(join(extDir, "static.ts"), "throw new Error('catalog must not execute a module'); export default () => {};\n");
const skillsDir = join(process.env.PI_CODING_AGENT_DIR, "configured"); await mkdir(skillsDir, { recursive: true });
await writeFile(join(skillsDir, "skill.md"), "---\nname: configured-name\ndescription: Fixture\n---\nFixture");
await writeFile(join(process.env.PI_CODING_AGENT_DIR, "settings.json"), JSON.stringify({ skills: ["./configured/skill.md"] }));
const response = await GET(request(cwd)); assert.equal(response.status, 200);
const catalog = await response.json();
assert.ok(catalog.extensions.some((entry) => entry.metadata.scope === "project" && entry.name === "static"));
assert.ok(catalog.skills.some((entry) => entry.name === "configured-name"));
assert.equal((await GET(new Request("http://localhost/api/subagents/resources"))).status, 400);
const forbidden = join(sandbox, "forbidden"); await mkdir(forbidden);
assert.equal((await GET(request(forbidden))).status, 403);
await writeFile(join(process.env.PI_CODING_AGENT_DIR, "settings.json"), "{");
const failed = await GET(request(cwd)); assert.equal(failed.status, 400); assert.match((await failed.json()).error, /settings/);
await writeFile(join(process.env.PI_CODING_AGENT_DIR, "settings.json"), "{}");
assert.equal((await GET(request(cwd))).status, 200, "retry after correcting the static file");
});

test("resource GET never executes an untrusted project's npmCommand during global missing-package root lookup", async (t) => {
const cwd = join(sandbox, "command-project"); await mkdir(join(cwd, ".pi"), { recursive: true }); allowFileRoot(cwd);
const marker = join(sandbox, "project-command.marker");
const script = join(cwd, "npm.cjs");
await writeFile(script, `require('fs').appendFileSync(${JSON.stringify(marker)}, 'executed\\n'); throw Error('untrusted project command');`);
const hostLog = join(sandbox, "host-command.log");
const hostScript = join(sandbox, "host-npm.cjs");
await writeFile(hostScript, `require('fs').appendFileSync(${JSON.stringify(hostLog)}, JSON.stringify(process.argv.slice(2))+'\\n'); if(process.argv.includes('root')) console.log(${JSON.stringify(join(sandbox, "host-modules"))}); else throw Error('network forbidden');`);
const agentDir = process.env.PI_CODING_AGENT_DIR;
const settingsPath = join(agentDir, "settings.json");
await mkdir(agentDir, { recursive: true });
const previousSettings = await readFile(settingsPath, "utf8").catch((error) => { if (error.code === "ENOENT") return undefined; throw error; });
t.after(() => previousSettings === undefined ? rm(settingsPath, { force: true }) : writeFile(settingsPath, previousSettings));
await writeFile(settingsPath, JSON.stringify({ packages: ["npm:@fixture/route-missing@1.0.0"], npmCommand: [process.execPath, hostScript] }));
await writeFile(join(cwd, ".pi", "visible.ts"), "export default () => {};\n");
await writeFile(join(cwd, ".pi", "settings.json"), JSON.stringify({ npmCommand: [process.execPath, script], extensions: ["./visible.ts"] }));
const { ProjectTrustStore } = await import("@earendil-works/pi-coding-agent");
new ProjectTrustStore(agentDir).set(cwd, false);
const response = await GET(request(cwd)); assert.equal(response.status, 200);
const catalog = await response.json();
await assert.rejects(access(marker), "project command marker must be absent");
assert.ok(catalog.extensions.some((entry) => entry.name === "visible" && entry.metadata.scope === "project"), "untrusted project declarations remain statically editable");
assert.ok(catalog.diagnostics.some((entry) => entry.message.includes("route-missing") && entry.message.includes("not installed")));
const hostCalls = await readFile(hostLog, "utf8"); assert.match(hostCalls, /root/); assert.doesNotMatch(hostCalls, /install|view|update/);
});

test("builtin clone rejects an occupied target with 409 without changing any bytes", async () => {
const cwd = join(sandbox, "builtin-clone"); await mkdir(cwd); allowFileRoot(cwd);
const listing = await profiles.GET(new Request(`http://localhost/api/subagents/profiles?cwd=${encodeURIComponent(cwd)}`));
const builtin = (await listing.json()).profiles.find((entry) => entry.scope === "builtin" && entry.name === "explore");
assert.ok(builtin);
const existing = await profiles.PUT(profileRequest("PUT", { cwd, scope: "global", profile: { ...builtin, name: "occupied", systemPrompt: "KEEP ORIGINAL PROMPT" } }));
assert.equal(existing.status, 200);
const path = (await existing.json()).profile.filePath;
const before = await readFile(path);
const denied = await profiles.PUT(profileRequest("PUT", { cwd, scope: "global", profile: { ...builtin, name: "occupied" }, cloneFrom: { scope: "builtin", name: "explore" } }));
assert.equal(denied.status, 409);
assert.deepEqual(await readFile(path), before);
const created = await profiles.PUT(profileRequest("PUT", { cwd, scope: "global", profile: { ...builtin, name: "new-builtin-copy" }, cloneFrom: { scope: "builtin", name: "explore" } }));
assert.equal(created.status, 200);
assert.equal((await created.json()).profile.systemPrompt, builtin.systemPrompt);
});

test("PUT/PATCH/clone deliver independent normalized selections and round-trip foreign YAML + ext tools", async () => {
const cwd = join(sandbox, "profiles"); await mkdir(cwd); allowFileRoot(cwd);
const path = join(cwd, ".pi", "agents", "original.md"); await mkdir(join(path, ".."), { recursive: true });
await writeFile(path, "---\nname: original\nskills: 'one, unknown'\nextensions: [foo, './other.ts']\nload_skills: true\nload_extensions: true\ntools: 'read, ext:foo/lookup'\nforeign: {kept: true}\nisolation: off\npersist_session: false\n---\nFixture prompt\n");
const listing = await profiles.GET(new Request(`http://localhost/api/subagents/profiles?cwd=${encodeURIComponent(cwd)}`));
const original = (await listing.json()).profiles.find((p) => p.name === "original");
assert.deepEqual(original.skills, ["one", "unknown"]); assert.deepEqual(original.extensions, ["foo", "./other.ts"]);
const unrelated = join(sandbox, "do-not-write.md"); await writeFile(unrelated, "KEEP");
const patched = await profiles.PATCH(profileRequest("PATCH", { cwd, scope: "project", name: "original", enabled: false, filePath: unrelated, profile: { filePath: unrelated } }));
assert.equal(patched.status, 200); assert.deepEqual((await patched.json()).profile.extensions, original.extensions);
const raw = parseFrontmatter(await readFile(path, "utf8")).data;
assert.equal(raw.skills, "one, unknown"); assert.deepEqual(raw.foreign, { kept: true }); assert.match(raw.tools, /ext:foo\/lookup/);
assert.equal(raw.enabled, false);
assert.equal(await readFile(unrelated, "utf8"), "KEEP");
const cloned = await profiles.PUT(profileRequest("PUT", { cwd, scope: "global", profile: { ...original, name: "copied" }, cloneFrom: { scope: "project", name: "original" } }));
assert.equal(cloned.status, 200);
const saved = (await cloned.json()).profile;
const cloneYaml = parseFrontmatter(await readFile(saved.filePath, "utf8")).data;
assert.equal(cloneYaml.name, "copied"); assert.deepEqual(cloneYaml.foreign, raw.foreign); assert.equal(cloneYaml.skills, raw.skills);
const updated = await profiles.PUT(profileRequest("PUT", { cwd, scope: "global", profile: { ...saved, skills: true, extensions: [] } }));
assert.equal(updated.status, 200); assert.equal((await updated.json()).profile.skills, true);
const savedYaml = parseFrontmatter(await readFile(saved.filePath, "utf8")).data;
assert.equal(savedYaml.skills, true); assert.deepEqual(savedYaml.extensions, []); assert.match(savedYaml.tools, /ext:foo\/lookup/);
const forged = await profiles.PUT(profileRequest("PUT", { cwd, scope: "project", profile: { ...original, name: "derived-target", filePath: unrelated } }));
assert.equal(forged.status, 200);
assert.equal((await forged.json()).profile.filePath, join(cwd, ".pi", "agents", "derived-target.md"));
assert.equal(await readFile(unrelated, "utf8"), "KEEP");
const before = await readFile(path);
const conflict = await profiles.PUT(profileRequest("PUT", { cwd, scope: "project", profile: original, cloneFrom: { scope: "project", name: "original" } }));
assert.equal(conflict.status, 409); assert.deepEqual(await readFile(path), before);
const invalid = await profiles.PUT(profileRequest("PUT", { cwd, scope: "project", profile: { ...original, skills: "invalid API" } }));
assert.equal(invalid.status, 400);
});
19 changes: 19 additions & 0 deletions app/api/subagents/resources/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { existsSync } from "node:fs";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { readSubagentResourceCatalog } from "@/lib/subagent-resource-catalog";

export const dynamic = "force-dynamic";

/** Files/SDK metadata only: no services, model runtime, extension imports, or installation. */
export async function GET(req: Request) {
try {
const cwd = new URL(req.url).searchParams.get("cwd");
if (!cwd || !existsSync(cwd)) throw new Error("Valid cwd required");
if (!isExistingFilePathAllowed(cwd, await getAllowedFileRoots())) return NextResponse.json({ error: "Access denied" }, { status: 403 });
return NextResponse.json(await readSubagentResourceCatalog(cwd, getAgentDir()));
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
}
}
Loading
Loading