Skip to content
Merged
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
6 changes: 3 additions & 3 deletions apps/desktop/electron/main/agent-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ function slugFor(path: string): string {
/**
* Build a plugin directory from a pi extension file or directory (spec §3):
* copies the source under `src/`, writes a manifest that declares the entry
* files as `contributes.agentExtensions`, and a no-op `main.js`. A directory
* files as `contributes.agentExtensions`, and a CommonJS no-op `main.cjs`. A directory
* that ships a `package.json` also gets it (plus its lockfile) at the plugin
* root so {@link installExtensionDependencies} can resolve its dependencies
* there; `node_modules` itself is never copied — it is reinstalled.
Expand Down Expand Up @@ -373,7 +373,7 @@ export function generateImportedExtensionPlugin(
name: slug,
version: "0.0.0",
description: `Imported pi extension from ${resolved}`,
main: "main.js",
main: "main.cjs",
permissions: [...(entries.length ? ["agent.extension"] : []), ...(skills.length ? ["agent.prompt.inject"] : [])],
contributes: {
...(entries.length ? { agentExtensions: entries } : {}),
Expand All @@ -386,7 +386,7 @@ export function generateImportedExtensionPlugin(
};
writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
writeFileSync(
join(dir, "main.js"),
join(dir, "main.cjs"),
"// Generated by PI-Desktop: declarative skills and/or agent extensions.\nmodule.exports = {};\n",
"utf8",
);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/test/agent-extensions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ test("importing a pi extension directory or file generates a plugin holding agen
assert.deepEqual(manifest.permissions, ["agent.extension"]);
assert.deepEqual(manifest.contributes, { agentExtensions: ["src/index.ts"] });
assert.ok(existsSync(join(dir.path, "src", "lib", "util.ts")), "the whole directory is copied");
assert.match(readFileSync(join(dir.path, "main.js"), "utf8"), /module\.exports = \{\}/);
assert.match(readFileSync(join(dir.path, "main.cjs"), "utf8"), /module\.exports = \{\}/);

const file = join(root, "solo.ts");
writeFileSync(file, "export default function () {}\n");
Expand Down
47 changes: 45 additions & 2 deletions apps/desktop/test/imported-package-skills-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
mkdtempSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
Expand All @@ -20,7 +21,7 @@ const { generateImportedExtensionPlugin } = await import("../electron/main/agent
const { PluginRuntime } = await import("../electron/main/plugin-runtime.ts");
const hostEntry = fileURLToPath(new URL("../electron/main/plugin-host-process.mjs", import.meta.url));

function createHarness(t, { skillsOnly = false } = {}) {
function createHarness(t, { skillsOnly = false, packageType } = {}) {
const root = mkdtempSync(join(tmpdir(), "pi-imported-package-skills-"));
// The selected package itself lives under node_modules, as an npm global
// installation would. Its own nested dependency tree must still be excluded.
Expand All @@ -46,6 +47,7 @@ function createHarness(t, { skillsOnly = false } = {}) {
write("package.json", JSON.stringify({
name: "@fixture/package-skills",
version: "1.0.0",
...(packageType ? { type: packageType } : {}),
pi: {
...(!skillsOnly ? { extensions: ["index.ts"] } : {}),
skills: ["skills/direct.md", "skills/release", "skills/catalog"],
Expand All @@ -65,7 +67,7 @@ function createHarness(t, { skillsOnly = false } = {}) {
hostEntry,
spawnProcess: ({ entry }) => {
// Execute only the repository's real plugin host and the importer's
// generated no-op main.js. Fixture extension modules are only catalogued.
// generated no-op main.cjs. Fixture extension modules are only catalogued.
const child = fork(entry, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] });
return {
postMessage: (message) => { if (child.connected) child.send(message); },
Expand Down Expand Up @@ -152,3 +154,44 @@ test("a skill-only pi package loads in the plugin runtime without requiring exec
assert.ok(manifest.permissions.includes("agent.prompt.inject"));
assert.equal(manifest.permissions.includes("agent.extension"), false);
});

for (const packageType of ["module", "commonjs", undefined]) {
test(`imported ${packageType ?? "untyped"} packages initialize in the real plugin host`, async (t) => {
const { source, importRoot, runtime, bodies, paths } = createHarness(t, { packageType });
const imported = generateImportedExtensionPlugin(source, importRoot);
await runtime.loadFromPath(imported.path);
assertSkillCatalog(runtime, imported, bodies, paths);
assert.equal(runtime.getAgentExtensions().length, 1);
const manifest = runtime.getLoaded(imported.id).manifest;
assert.equal(manifest.main, "main.cjs");
assert.ok(existsSync(join(imported.path, manifest.main)));
assert.equal(existsSync(join(imported.path, "main.js")), false);
const original = readFileSync(join(source, "package.json"), "utf8");
for (const path of ["package.json", "src/package.json"]) {
assert.equal(readFileSync(join(imported.path, path), "utf8"), original);
}
});
}

test("re-importing an older ESM package creates a loadable copy without rewriting the existing plugin", async (t) => {
const { source, importRoot, runtime, bodies, paths } = createHarness(t, { packageType: "module" });
const previous = generateImportedExtensionPlugin(source, importRoot);
// Model the on-disk layout generated before explicit CommonJS wrappers.
const oldManifest = JSON.parse(readFileSync(join(previous.path, "manifest.json"), "utf8"));
oldManifest.main = "main.js";
writeFileSync(join(previous.path, "manifest.json"), JSON.stringify(oldManifest));
renameSync(join(previous.path, "main.cjs"), join(previous.path, "main.js"));
const oldFiles = new Map(["manifest.json", "main.js", "package.json", "src/package.json"]
.map((path) => [path, readFileSync(join(previous.path, path), "utf8")]));

const imported = generateImportedExtensionPlugin(source, importRoot);
assert.notEqual(imported.id, previous.id);
assert.notEqual(imported.path, previous.path);
for (const [path, contents] of oldFiles) {
assert.equal(readFileSync(join(previous.path, path), "utf8"), contents);
}
assert.equal(existsSync(join(previous.path, "main.cjs")), false);
await runtime.loadFromPath(imported.path);
assertSkillCatalog(runtime, imported, bodies, paths);
assert.equal(runtime.getAgentExtensions().length, 1);
});
25 changes: 24 additions & 1 deletion docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -7501,7 +7501,7 @@ identify the platform validation still needed.
| E — Tools & permissions | E2E-008a, E2E-014, E2E-015, E2E-016, E2E-017, E2E-018, E2E-019, E2E-024I, E2E-024K, E2E-040, E2E-049, E2E-074, E2E-093, E2E-097, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102d, E2E-102e, E2E-102g, E2E-103, E2E-105, E2E-106, E2E-107, E2E-111, E2E-112, E2E-113, E2E-114, E2E-115, E2E-116, E2E-119, E2E-121, E2E-122, E2E-142, E2E-145, E2E-147, E2E-155, E2E-158, E2E-166, E2E-181, E2E-PLUGIN-imported-pi-package-skills |
| F — Persistence | E2E-020, E2E-021, E2E-021a, E2E-036, E2E-037, E2E-038, E2E-040, E2E-042, E2E-047, E2E-048, E2E-051, E2E-054, E2E-056, E2E-061, E2E-062, E2E-064, E2E-066, E2E-068, E2E-071, E2E-072, E2E-073, E2E-082, E2E-084, E2E-096, E2E-098, E2E-102, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-102i, E2E-103, E2E-AGENTS-001, E2E-061a, E2E-073a, E2E-104, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-118, E2E-119, E2E-120, E2E-121, E2E-123, E2E-142, E2E-146, E2E-146a, E2E-148, E2E-151, E2E-158, E2E-160, E2E-168, E2E-171, E2E-177, E2E-178, E2E-183, E2E-186, E2E-005J, E2E-PLUGIN-session-orchestrator-real-workers |
| F — Persistence (project ordering) | E2E-251 |
| G — Plugins | E2E-022, E2E-022A, E2E-022B, E2E-022C, E2E-023, E2E-024, E2E-024B, E2E-024C, E2E-024D, E2E-024AA, E2E-024E, E2E-024W, E2E-024F, E2E-024G, E2E-024H, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M, E2E-024N, E2E-024O, E2E-024P, E2E-025, E2E-026, E2E-105, E2E-117, E2E-120, E2E-122, E2E-123, E2E-024Q, E2E-148, E2E-152, E2E-153, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-global-shortcut-owns-only-its-own-command, E2E-PLUGIN-permission-gate-for-real-time-capabilities, E2E-PLUGIN-background-audio-and-realtime-connection, E2E-PLUGIN-fs-root-follows-the-calling-session |
| G — Plugins | E2E-022, E2E-022A, E2E-022B, E2E-022C, E2E-023, E2E-024, E2E-024B, E2E-024C, E2E-024D, E2E-024AA, E2E-024E, E2E-024W, E2E-024F, E2E-024G, E2E-024H, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M, E2E-024N, E2E-024O, E2E-024P, E2E-025, E2E-026, E2E-105, E2E-117, E2E-120, E2E-122, E2E-123, E2E-024Q, E2E-148, E2E-152, E2E-153, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-imported-pi-package-wrapper, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-global-shortcut-owns-only-its-own-command, E2E-PLUGIN-permission-gate-for-real-time-capabilities, E2E-PLUGIN-background-audio-and-realtime-connection, E2E-PLUGIN-fs-root-follows-the-calling-session |
| H — Diagnostics | E2E-027, E2E-031, E2E-034, E2E-042, E2E-096, E2E-098, E2E-104, E2E-107, E2E-108, E2E-109, E2E-110, E2E-113, E2E-115, E2E-116, E2E-118, E2E-121, E2E-146, E2E-146a, E2E-155, E2E-159, E2E-176, E2E-194, E2E-195 |
| Security | E2E-028, E2E-029, E2E-030, E2E-024J, E2E-024K, E2E-024M, E2E-049, E2E-068, E2E-086, E2E-102c, E2E-102d, E2E-102e, E2E-105, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-113, E2E-115, E2E-116, E2E-117, E2E-119, E2E-121, E2E-122, E2E-123, E2E-142, E2E-148, E2E-151, E2E-153, E2E-158, E2E-187, E2E-196c, E2E-196b, E2E-196, E2E-PLUGIN-fs-root-follows-the-calling-session |
| Quality | E2E-032, E2E-033, E2E-039, E2E-043, E2E-044, E2E-045, E2E-046, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-050, E2E-053, E2E-055, E2E-056, E2E-057, E2E-058, E2E-059, E2E-060, E2E-061, E2E-062, E2E-063, E2E-064, E2E-065, E2E-066, E2E-067, E2E-068, E2E-069, E2E-070, E2E-071, E2E-072, E2E-073, E2E-074, E2E-075, E2E-076, E2E-077, E2E-078, E2E-079, E2E-080, E2E-081, E2E-082, E2E-083, E2E-084, E2E-085, E2E-086, E2E-092, E2E-093, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102e, E2E-103, E2E-AGENTS-001, E2E-021a, E2E-024N, E2E-059a, E2E-060b, E2E-060c, E2E-061a, E2E-073a, E2E-111, E2E-114, E2E-117, E2E-118, E2E-119, E2E-120, E2E-122, E2E-123, E2E-142, E2E-143, E2E-144, E2E-145, E2E-146, E2E-147, E2E-148, E2E-150, E2E-151, E2E-153, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-168, E2E-172, E2E-173, E2E-174, E2E-011g, E2E-176, E2E-177, E2E-178, E2E-179, E2E-180, E2E-181, E2E-182, E2E-183, E2E-186, E2E-187, E2E-194, E2E-195, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-196, E2E-201, E2E-204, E2E-202, E2E-203, E2E-205, E2E-206, E2E-207, E2E-208, E2E-209, E2E-210, E2E-218, E2E-219, E2E-250, E2E-252, E2E-102i, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation |
Expand Down Expand Up @@ -11709,6 +11709,29 @@ plugin-form fixtures in an isolated temporary directory at runtime.
- **Milestone**: Post-MVP (R7 v1)
- **Status**: Partially automated (`pnpm test:e2e:trusted-extensions`); the headless journey covers plugin discovery/projection, project scope, load state, and diagnostics, while native picker import and explicit enablement remain renderer/platform validation.

#### E2E-PLUGIN-imported-pi-package-wrapper: Imported package module types preserve plugin initialization

- **Preconditions**: Isolated local Pi packages declare `type: module`,
`type: commonjs`, or no `type`; each has an extension and skill contributions.
A fourth fixture represents an older imported ESM package with the generated
CommonJS `main.js` and matching manifest.
- **Steps**: Run `node --test apps/desktop/test/imported-package-skills-runtime.test.mjs`.
Generate each plugin through the production importer, load it through
`PluginRuntime` and the real child-process plugin host, read its skill catalog
and bodies, and inspect its declared extension. Re-import the older fixture.
- **Expected**: Each new manifest points to an existing `main.cjs`; initialization
succeeds for all three package types. Source and both copied `package.json`
files retain the same bytes. The repeated import has a distinct path/id and
loads successfully; the older manifest, wrapper, and package files remain
unchanged. There is no automatic migration of older imported plugins.
- **Specs linked**: `07-plugins/16-trusted-extensions.md` §3.2; ADR 0215.
- **Acceptance**: Quality
- **Status**: Automated import-to-plugin-host fixture. Run against the committed
task candidate after incorporating the latest `origin/main`; record the tested
candidate, base, result, and environment in delivery evidence. Native picker,
npm dependency installation, Windows execution, and third-party extension
execution during a provider turn are not covered by this fixture.

#### E2E-PLUGIN-imported-pi-package-skills: Explicit package import exposes skills through plugin grants

- **Preconditions**: A local fixture package under an npm-style
Expand Down
10 changes: 8 additions & 2 deletions docs/spec/07-plugins/16-trusted-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,17 @@ manifest that lists entries without the permission is invalid
Plugins → "Import pi extension" opens a native picker (main owns the path,
D344) for an explicit local file or directory. Main copies the selected
source under `<dataDir>/plugins/imported/<slug>/src/`, writes a generated
no-op `main.js` and a manifest with id `imported.<slug>` (a unique suffix is
CommonJS no-op `main.cjs` and a manifest with id `imported.<slug>` (a unique suffix is
added for repeated imports), and registers the directory through the existing
local-plugin flow. The confirmation before the picker remains the trust
decision; the generated manifest declares the permissions needed by its actual
contributions.
contributions. The manifest's `main` points to `main.cjs` regardless of the
source package's `type`; both copied package declarations retain their module
semantics. Existing imported directories are not rewritten on upgrade. To
repair an older import whose generated `main.js` fails under `type: module`,
remove that failed imported plugin and import its source again. Re-importing
without removal creates a separate plugin with a unique suffix and leaves the
old copy unchanged; it does not migrate its grants or activation scope.

For extension files and packages without `pi.skills`, entry discovery keeps
the existing `pi-coding-agent` rules: `package.json` `pi.extensions`, otherwise
Expand Down
Loading