From dbea78ee0f744322e49272f63f3f0da19cdcfaba Mon Sep 17 00:00:00 2001 From: lyx2145181 <41118458+lyx2145181@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:03:51 +0800 Subject: [PATCH 1/6] feat(subagents): scope resources and late tools Why: Subagents need resource selection before extension import, while approved late registrations must remain usable under SDK 1.0's hard tool allowlist. Safety: Resolve restricted resources before import and apply real project trust. Enforce a frozen, winner-specific policy for direct and nested execution. Handle queued readiness failures immediately and recheck cancellation before delegated prompts, including cancellation before listener setup. This is not a filesystem or extension-JavaScript sandbox. Compatibility: Preserve foreign YAML, unedited selections and legacy boolean clients. Keep v1 hard allowlists; restore v2 from its creation snapshot, not the current profile. Refresh non-resource scoped settings without persisting transient setters or widening resource and builtin permissions. Validation: Exported and checked this staged tree independently of the pending UI. Typecheck and ESLint passed. Isolated offline suite: 2339 passed; separately mocked plugin-update suite: 5 passed. No build, real provider request, MCP connection or deployment was run. --- app/api/subagents/profiles/route.ts | 26 +- app/api/subagents/resources/route.test.mjs | 118 ++++ app/api/subagents/resources/route.ts | 19 + docs/agents/subagent-resources.md | 39 ++ docs/agents/subagents.md | 2 +- lib/project-trust.test.mjs | 24 + lib/project-trust.ts | 6 +- lib/rpc-manager-shutdown.test.mjs | 24 +- lib/rpc-manager.test.mjs | 12 +- lib/rpc-manager.ts | 52 +- lib/subagent-resource-catalog.ts | 218 ++++++++ lib/subagent-resource-selection.test.mjs | 91 ++++ lib/subagent-resource-selection.ts | 44 ++ lib/subagent-resources.integration.test.mjs | 509 ++++++++++++++++++ lib/subagent-resources.ts | 191 +++++++ ...ubagent-runtime-ready.integration.test.mjs | 250 +++++++++ lib/subagent-runtime.ts | 108 ++-- lib/subagent-tool-policy.integration.test.mjs | 269 +++++++++ lib/subagent-tool-policy.test.mjs | 82 +++ lib/subagent-tool-policy.ts | 82 +++ lib/subagents.test.mjs | 10 +- lib/subagents.ts | 156 +++--- 22 files changed, 2179 insertions(+), 153 deletions(-) create mode 100644 app/api/subagents/resources/route.test.mjs create mode 100644 app/api/subagents/resources/route.ts create mode 100644 docs/agents/subagent-resources.md create mode 100644 lib/subagent-resource-catalog.ts create mode 100644 lib/subagent-resource-selection.test.mjs create mode 100644 lib/subagent-resource-selection.ts create mode 100644 lib/subagent-resources.integration.test.mjs create mode 100644 lib/subagent-resources.ts create mode 100644 lib/subagent-runtime-ready.integration.test.mjs create mode 100644 lib/subagent-tool-policy.integration.test.mjs create mode 100644 lib/subagent-tool-policy.test.mjs create mode 100644 lib/subagent-tool-policy.ts diff --git a/app/api/subagents/profiles/route.ts b/app/api/subagents/profiles/route.ts index c1959a2ecc..9ca3ac54ba 100644 --- a/app/api/subagents/profiles/route.ts +++ b/app/api/subagents/profiles/route.ts @@ -46,16 +46,19 @@ export async function PUT(req: Request) { cwd?: unknown; scope?: unknown; profile?: Omit; + 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 }); } } @@ -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 = { - 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 }); diff --git a/app/api/subagents/resources/route.test.mjs b/app/api/subagents/resources/route.test.mjs new file mode 100644 index 0000000000..2f94b196c6 --- /dev/null +++ b/app/api/subagents/resources/route.test.mjs @@ -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); +}); diff --git a/app/api/subagents/resources/route.ts b/app/api/subagents/resources/route.ts new file mode 100644 index 0000000000..77250369e0 --- /dev/null +++ b/app/api/subagents/resources/route.ts @@ -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 }); + } +} diff --git a/docs/agents/subagent-resources.md b/docs/agents/subagent-resources.md new file mode 100644 index 0000000000..7033cd3f30 --- /dev/null +++ b/docs/agents/subagent-resources.md @@ -0,0 +1,39 @@ +# Subagent resource selections + +Subagent profiles use the existing YAML aliases and SDK resource loader. Selections control resource loading and tool authorization; they are **not a filesystem or extension-JavaScript sandbox**. + +## Profile and API contract + +- `skills` and `extensions` accept booleans, CSV strings or string arrays in profile YAML. API responses normalize them to `boolean | string[]`; legacy clients sending only `loadSkills` / `loadExtensions` still work. `load_*: false` closes a resource dimension; `true` does not erase an existing whitelist. Unedited aliases and foreign frontmatter survive saves. +- `true` selects SDK-enabled resources, `false` selects none, and arrays select concrete files or unambiguous names. `*` may coexist with explicit entries. Unknown or ambiguous selections never fall back to All. Explicit paths may select a normally disabled discovered entry, but extensions must be files and skills must be Markdown files, not directories or package specifications. +- `GET /api/subagents/resources?cwd=` returns `{skills, extensions, diagnostics}`. Items include concrete `path`, canonical `identity`, matching `names` / `pathAliases`, `enabled` and SDK source `metadata`. Listing creates no session or model runtime, imports no extension and installs nothing. +- Profile PUT accepts optional `cloneFrom: {scope, name}`. The source is resolved server-side; cloning preserves foreign frontmatter and extension tool selectors and refuses an existing target with 409. Enabled PATCH updates only that flag. The writer derives the target path; client `filePath` is never a write target. + +## Static discovery and restricted loading + +`lib/subagent-resource-catalog.ts` reuses public `DefaultPackageManager.resolve`, installed package paths and `loadSkills`. Missing-package checks also work offline, where the SDK skips `onMissing`. Readiness checks use local manifests and SDK paths; no installation or remote version query is attempted. Static inspection excludes project `npmCommand` and other non-resource overrides. Legacy local npm-location queries can use the trusted host/global command only. + +`lib/subagent-resources.ts` preserves the original cwd, agentDir, model runtime and session manager boundaries: + +- All/All keeps the normal SDK loader. Restricted selections use scope-separated `SettingsManager.fromStorage`, with packages/extensions/skills/prompts/themes cleared on every storage read and write. Setters and flush remain memory-only. Explicit reload refreshes non-resource source settings while retaining transient overrides; resource and tool permissions remain frozen. Storage observes serialized changes, not setter intent: a no-op setter does not pin a baseline value. Arrays are atomic; changed/deleted nested leaves remain local overrides. +- Restricted extensions use `noExtensions: true` plus approved canonical `additionalExtensionPaths`. Filtering happens **before import**, not after executing excluded factories. Skills use `noSkills`, explicit paths and `skillsOverride`. +- Loaded entries regain their SDK source metadata, including tool/command metadata, so existing `ext:` selectors still work. Tool selectors grant execution, not extension loading. +- Creation, restore and reload consult actual project trust. Project ownership includes discovered scope, canonical containment and selected symlink aliases. Explicit project extension files outside normal discovery require a genuine trust decision; no manager is artificially marked trusted. +- Auto-discovered project skills wait for SDK trust. Explicit static Markdown selection is separate from executable project resources. Dynamic skills in Selected mode require both approved canonical identity and effective SDK name; a same-name foreign skill is not admitted. Restricted subagents do not discover additional prompts/themes. + +## Snapshots and late-registered tools + +New children persist `resourceSnapshot` v2 with the resource selections, prompt, frozen builtin tools and `{mode, selectors, deny}` extension tool policy. Deny wins. Cold restoration does not reread the current profile or widen shell permissions. Invalid or missing snapshots on identified subagents fail closed. V1 keeps its original hard tool allowlist and is not migrated. + +SDK 1.0 treats `options.tools` as a permanent allowlist, so it cannot admit approved tools registered later during `session_start`. V2 instead uses `noTools: "builtin"`, excludes unauthorized builtins/reserved control names and applies a shared authorization predicate to the actual registration winner and approved extension roster. Direct and nested `tool_call` execution both pass that guard; inactive deferred tools are still nested-callable, so active pruning alone is insufficient. + +The wrapper reconciles unauthorized active tools after its single bind, after both reload paths and before public tool/state reads. Binding includes `resources_discover`, which occurs after `session_start`. Reconciliation removes names only; it never force-activates optional tools. Delegated prompts wait for readiness, check cancellation again before starting, and handle queued startup failures without leaving rejected promises unobserved. + +**SDK limitation:** there is no public registration-policy hook. Denied tools may remain in `getAllTools()` or briefly become active between boundaries, but execution is guarded. This is not hard registry filtering or a sandbox for trusted extension JavaScript. No private registry mutation or duplicate bind is used. + +## Regression coverage + +- Resource integration tests use temporary HOME/agentDir/cwd and separate module/factory markers to verify pre-import exclusion, scope preservation, trust transitions, dynamic skills and cold restoration. Rejecting npm fixtures prohibit installation and remote commands. +- Tool-policy integration tests use delayed mock registration and canned in-process provider responses to exercise direct/nested authorization, actual collision winners, readiness, reload, legacy snapshots and strict parsing. +- Controller regressions cover cancellation during readiness and queued startup rejection. Route/profile tests cover clone conflicts and foreign frontmatter round-tripping. +- All fixtures are local and isolated; they require no real browser extension, provider request or MCP connection. diff --git a/docs/agents/subagents.md b/docs/agents/subagents.md index 0c73289c44..6f4f08caf8 100644 --- a/docs/agents/subagents.md +++ b/docs/agents/subagents.md @@ -7,4 +7,4 @@ - Built-in profiles (`general-purpose`, `explore`, `plan`) are switched off by name in that file's `disabledBuiltIns`, never by copying them to a `.md` file (ADR 0005). `builtInProfiles()` stamps `enabled` onto them so the panel, the `Agent` tool description and `resolveSubagentProfile` agree. Writes are minimal edits that keep names they did not touch, including ones no built-in claims; reading fails *open* (the feature switch has already failed closed). `PATCH /api/subagents/profiles` with `scope: "builtin"` writes it; `PUT`/`DELETE` refuse that scope. A same-name file replaces the built-in outright and is switched off through its own frontmatter. Only the switch is live for a built-in; the rest of the form is read-only. - `notifyParent` skips a background run's completion notification when the parent already collected that result with `get_subagent_result`, which marks it consumed. The mark names the run (`sessionId` + `completedAt`), never just the session, since `resume` reuses the session id; `resume` keeps the mark. While the parent `isRunning()` (often still inside that poll) the notification is held and the mark re-checked; an idle parent is notified at once. - The notification is a `custom` message, which pi's `convertToLlm` replays as a plain `user` turn, so `subagentNotificationText()` prefixes `SUBAGENT_NOTIFICATION_PREFIX`; otherwise compaction files the report under the user's Goal / Constraints. Keep the prefix in code, never in a profile prompt, so the model cannot drop it. A `resume`d run's notification adds a line saying it supersedes the earlier report, which it would otherwise repeat word for word. Foreground `Agent` and `get_subagent_result` results are `toolResult`s and keep the bare `subagentFinalText()`. -- Profile files (`~/.pi/agent/agents/*.md`, project `.pi/agents/*.md`) are shared with other runtimes: a save round-trips every frontmatter key outside `MANAGED_FRONTMATTER_KEYS` (`lib/subagents.ts`; e.g. `name`, `allowed_subagents`, `exclude_extensions`, `disallowed_tools`) and carries foreign `ext:` tool selectors through. The pi-subagents aliases `skills` / `extensions` are seeded on first save and kept in step with `load_skills` / `load_extensions` while they hold a boolean (or `none` / `all`); a whitelist such as `extensions: pi-advisor-flow` is never rewritten, and the flags fall back to the aliases when `load_skills` / `load_extensions` are absent. +- Profile files (`~/.pi/agent/agents/*.md`, project `.pi/agents/*.md`) are shared with other runtimes: saves and copies preserve foreign frontmatter, `ext:` selectors and unedited resource aliases. `skills` / `extensions` selections are applied before SDK imports. V2 snapshots freeze builtin and extension tool authority while admitting approved late registrations; v1 hard allowlists remain unchanged. Creation, cold restore and reload share the resource/trust policy. See [subagent-resources.md](subagent-resources.md) for the API, scope-preserving settings, execution limits and picker behavior. diff --git a/lib/project-trust.test.mjs b/lib/project-trust.test.mjs index 84731605c6..6a8c7b24da 100644 --- a/lib/project-trust.test.mjs +++ b/lib/project-trust.test.mjs @@ -45,6 +45,30 @@ test("clean projects stay on the normal trusted load path", async (t) => { assert.equal(projectTrustReloadOptions(cwd, agentDir), undefined); }); +test("additional explicit project resources use the same exact/inherited trust decisions without changing clean-folder defaults", async (t) => { + const { root, cwd, agentDir } = await createProjectFixture(t); + const store = new ProjectTrustStore(agentDir); + const status = () => getProjectTrustStatus(cwd, agentDir, { additionalProjectResources: true }); + assert.equal(status().requiresTrust, true); assert.equal(status().trusted, false); + assert.equal(getProjectTrustStatus(cwd, agentDir).trusted, true); + store.set(root, true); + assert.equal(status().trusted, true); assert.equal(status().inherited, true); + store.set(cwd, false); + assert.equal(status().trusted, false); assert.equal(status().decision, false); assert.equal(status().inherited, false); + assert.equal(getProjectTrustStatus(cwd, agentDir).trusted, true, "normal clean-folder behavior is unchanged even with an explicit false"); + store.set(cwd, true); + assert.equal(status().trusted, true); assert.equal(status().decisionPath, cwd); + assert.equal(projectTrustReloadOptions(cwd, agentDir), undefined); +}); + +test("additional project resources fail closed on unreadable trust while clean-folder status remains compatible", async (t) => { + const { cwd, agentDir } = await createProjectFixture(t); + await writeFile(join(agentDir, "trust.json"), "{"); + assert.equal(getProjectTrustStatus(cwd, agentDir).trusted, true); + assert.ok(getProjectTrustStatus(cwd, agentDir).decisionError); + assert.throws(() => getProjectTrustStatus(cwd, agentDir, { additionalProjectResources: true })); +}); + 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"); diff --git a/lib/project-trust.ts b/lib/project-trust.ts index 305bc549b1..1a7424cb3c 100644 --- a/lib/project-trust.ts +++ b/lib/project-trust.ts @@ -44,8 +44,10 @@ function describeDecision( * unreadable `trust.json` before, and callers that only need `trusted` still * must not, so that failure is reported in `decisionError` instead. */ -export function getProjectTrustStatus(cwd: string, agentDir: string): ProjectTrustStatus { - const requiresTrust = Boolean(cwd) && hasTrustRequiringProjectResources(cwd); +export function getProjectTrustStatus(cwd: string, agentDir: string, options: { additionalProjectResources?: boolean } = {}): ProjectTrustStatus { + // Explicit subagent project extensions need the same decision as SDK-discovered resources. + // Callers without additional resources retain the existing clean-folder semantics. + const requiresTrust = Boolean(cwd) && (options.additionalProjectResources === true || hasTrustRequiringProjectResources(cwd)); const trustStore = new ProjectTrustStore(agentDir); if (!requiresTrust) { if (!cwd) return { requiresTrust: false, trusted: true, decision: null, inherited: false }; diff --git a/lib/rpc-manager-shutdown.test.mjs b/lib/rpc-manager-shutdown.test.mjs index c73d11c0a7..abc71a253c 100644 --- a/lib/rpc-manager-shutdown.test.mjs +++ b/lib/rpc-manager-shutdown.test.mjs @@ -11,7 +11,7 @@ const { registerSessionLivenessProvider } = await jiti.import("./session-livenes const nextTurn = () => new Promise((resolve) => setImmediate(resolve)); -function makePromptInner(prompt) { +function makePromptInner(prompt, activeToolNames = []) { return { sessionId: "session-1", isBashRunning: false, @@ -22,6 +22,8 @@ function makePromptInner(prompt) { systemPrompt: "", agent: { state: {} }, getContextUsage: () => null, + // Match the public SDK API: report the current loadout, returning a fresh array. + getActiveToolNames: () => [...activeToolNames], getSteeringMessages: () => [], getFollowUpMessages: () => [], prompt, @@ -31,12 +33,14 @@ function makePromptInner(prompt) { test("get_state waits for extension resources before returning the system prompt", async (t) => { let finishBinding; - const inner = makePromptInner(() => Promise.resolve()); + const activeToolNames = ["read"]; + const inner = makePromptInner(() => Promise.resolve(), activeToolNames); inner.systemPrompt = "before extensions"; inner.bindExtensions = () => new Promise((resolve) => { finishBinding = () => { // Binding extensions can rewrite the prompt, so get_state must read it afterwards. inner.systemPrompt = "after extensions"; + activeToolNames.push("browser_mock"); resolve(); }; }); @@ -57,6 +61,7 @@ test("get_state waits for extension resources before returning the system prompt const state = await statePromise; assert.equal(state.systemPrompt, "after extensions"); + assert.deepEqual(state.activeToolNames, ["read", "browser_mock"]); }); test("get_state reports the prompt of a session that has not run anything yet", async (t) => { @@ -75,6 +80,21 @@ test("get_state reports the prompt of a session that has not run anything yet", const state = await wrapper.send({ type: "get_state" }); assert.equal(state.systemPrompt, "Pi rendered prompt"); + assert.deepEqual(state.activeToolNames, []); +}); + +test("get_state reports the current SDK active loadout rather than a stale snapshot", async (t) => { + const activeToolNames = ["read", "browser_mock"]; + const inner = makePromptInner(() => Promise.resolve(), activeToolNames); + const wrapper = new AgentSessionWrapper(inner); + t.after(() => wrapper.destroy()); + + const first = await wrapper.send({ type: "get_state" }); + assert.deepEqual(first.activeToolNames, ["read", "browser_mock"]); + activeToolNames.splice(1, 1); + const next = await wrapper.send({ type: "get_state" }); + assert.deepEqual(next.activeToolNames, ["read"]); + assert.deepEqual(first.activeToolNames, ["read", "browser_mock"]); }); test("get_state keeps reporting the replayed prompt once the transcript has one", async (t) => { diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index 65baa14abf..ed48ae5d67 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -25,7 +25,8 @@ test("RPC session startup preloads extension-registered providers before restori 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, /const createServices = subagentResources[\s\S]*?createSubagentSessionServices\(options, subagentResources\)[\s\S]*?: createAgentSessionServices;/); + assert.match(startupSource, /const services = await createServices\(/); assert.match(startupSource, /createAgentSessionFromServices\(/); assert.doesNotMatch(startupSource, /await createAgentSession\(/); }); @@ -60,8 +61,9 @@ test("built-in subagents persist their selected resource policy", async () => { assert.match(subagentSource, /dependencies\.registerSession\(inner, \{/); assert.match(subagentSource, /noExtensions: !profile\.loadExtensions/); assert.match(subagentSource, /noSkills: !profile\.loadSkills/); - assert.match(subagentSource, /excludeTools: \[\.\.\.SUBAGENT_CONTROL_TOOL_NAMES\]/); - assert.match(subagentSource, /withSubagentExtensionTools\(profile\.tools, extensionToolNames\)/); + assert.match(subagentSource, /noTools: "builtin"/); + assert.match(subagentSource, /excludeTools: subagentToolExclusions\(builtinTools\)/); + assert.match(subagentSource, /await ready;[\s\S]*?waitUntilReady/); assert.match(subagentSource, /resourceSnapshot:/); assert.match(startupSource, /readSubagentSessionResources\(/); assert.match(startupSource, /resourceLoaderOptions: subagentResources/); @@ -69,7 +71,7 @@ test("built-in subagents persist their selected resource policy", async () => { assert.match(startupSource, /noExtensions: !subagentResources\.loadExtensions/); assert.match(startupSource, /noSkills: !subagentResources\.loadSkills/); assert.match(startupSource, /excludeTools: \[\.\.\.SUBAGENT_CONTROL_TOOL_NAMES\]/); - assert.match(startupSource, /let toolsOption: string\[\] \| undefined = subagentResources\?\.tools/); + assert.match(startupSource, /let toolsOption: string\[\] \| undefined = subagentResources\?\.version === 2 \? undefined : subagentResources\?\.tools/); assert.match(source, /createSubagentController\(/); assert.match(source, /suppressCompletionNotifications: true/); assert.match(source, /suppressCompletionNotifications: Boolean\(subagentResources\)/); @@ -694,7 +696,7 @@ test("normal sessions restore persisted tool selections before loading resources assert.match(startupSource, /readSessionToolSelection\(sessionManager\.getEntries\(\)/); assert.match(startupSource, /const selectedToolNames = subagentResources\?\.tools \?\? persistedToolNames \?\? requestedToolNames/); assert.match(startupSource, /appendSessionToolSelection\(sessionManager, requestedToolNames\)/); - assert.ok(startupSource.indexOf("const chatOnly") < startupSource.indexOf("createAgentSessionServices(")); + assert.ok(startupSource.indexOf("const chatOnly") < startupSource.indexOf("const services = await createServices(")); assert.match(startupSource, /chatOnly\s*\? \{ \.\.\.CHAT_ONLY_RESOURCE_LOADER_OPTIONS/); assert.match(startupSource, /const trustReloadOptions = subagentResources[\s\S]*?subagentLoadsResources[\s\S]*?projectTrustReloadOptions\(sessionCwd, agentDir\)/); assert.match(registrationSource, /if \(!wrapper\.isChatOnly\(\)\) wrapper\.beginExtensionBinding\(\)/); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 989c372cba..4782ab772e 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1,5 +1,5 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { createAgentSessionFromServices, createAgentSessionServices, getAgentDir, initTheme, SessionManager, SettingsManager, Theme } from "@earendil-works/pi-coding-agent"; +import { createAgentSessionFromServices, createAgentSessionServices, getAgentDir, initTheme, SessionManager, SettingsManager, Theme, type AgentSession } from "@earendil-works/pi-coding-agent"; import { KeybindingsManager as TuiKeybindingsManager, TUI_KEYBINDINGS } from "@earendil-works/pi-tui"; import { randomUUID } from "crypto"; import { existsSync, realpathSync, writeFileSync } from "fs"; @@ -14,6 +14,8 @@ import { } from "./project-command-env"; import { cacheSessionPath, getLatestModelChange, invalidateSessionListCache, readLatestSessionEntryId, resolveSessionPath } from "./session-reader"; import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust"; +import { createSubagentSessionServices } from "./subagent-resources"; +import { reconcileSubagentActiveTools, subagentToolExclusions } from "./subagent-tool-policy"; import { notifySessionComplete } from "./web-push"; import { hasActiveSessionLivenessProvider } from "./session-liveness"; import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent"; @@ -36,6 +38,8 @@ import { readSubagentRun, readSubagentSessionResources, SUBAGENT_CONTROL_TOOL_NAMES, + SUBAGENT_META_TYPE, + type SubagentSessionResources, } from "./subagents"; import { createSubagentController } from "./subagent-runtime"; import { isBuiltInSubagentsEnabled } from "./subagent-settings"; @@ -121,6 +125,8 @@ type AgentSessionWrapperOptions = { chatOnly?: boolean; onAgentRunComplete?: AgentRunCompleteListener; suppressCompletionNotifications?: boolean; + /** Validated creation snapshot, supplied by child registration/cold restore; never a live profile. */ + subagentResources?: SubagentSessionResources | null; /** Connects the session's MCP servers before a prompt starts a run, and lets go of them when it closes (lib/mcp-host.ts). */ mcpHost?: Pick; }; @@ -300,6 +306,7 @@ export class AgentSessionWrapper { private readonly chatOnly: boolean; private readonly onAgentRunComplete?: AgentRunCompleteListener; private readonly suppressCompletionNotifications: boolean; + private readonly subagentResources?: SubagentSessionResources; private readonly mcpHost?: Pick; private mcpHostDisposed = false; // The MCP wait of the prompt being admitted; Stop ends it. @@ -327,6 +334,7 @@ export class AgentSessionWrapper { this.chatOnly = options.chatOnly ?? false; this.onAgentRunComplete = options.onAgentRunComplete; this.suppressCompletionNotifications = options.suppressCompletionNotifications ?? false; + this.subagentResources = options.subagentResources ? structuredClone(options.subagentResources) : undefined; this.mcpHost = options.mcpHost; } @@ -481,6 +489,8 @@ export class AgentSessionWrapper { } else { this.inner.extensionRunner.setUIContext?.(uiContext, "rpc"); } + // bindExtensions also awaits resources_discover, which runs AFTER session_start. + this.reconcileSubagentToolPolicy(); this.extensionsBound = true; console.log(`[pi-web] session_start dispatched to extensions for session ${this.inner.sessionId}`); })().catch((err) => { @@ -491,6 +501,17 @@ export class AgentSessionWrapper { return this.extensionBindingPromise; } + private reconcileSubagentToolPolicy(): void { + const resources = this.subagentResources; + if (resources?.version !== 2) return; // Normal sessions and v1 retain their existing loadout rules. + const sdk = this.inner as unknown as Pick; + reconcileSubagentActiveTools({ + getActiveTools: () => sdk.getActiveToolNames(), + getAllTools: () => sdk.getAllTools(), + setActiveTools: (names) => sdk.setActiveToolsByName(names), + }, resources.builtinTools, resources.toolPolicy, () => sdk.resourceLoader.getExtensions().extensions); + } + private async waitForExtensionsBound(): Promise { try { if (this.extensionBindingPromise) await this.extensionBindingPromise; @@ -522,6 +543,7 @@ export class AgentSessionWrapper { || type === "steer" || type === "follow_up" || type === "get_commands" + || type === "get_tools" || type === "get_state"; } @@ -864,6 +886,7 @@ export class AgentSessionWrapper { } case "get_state": { + this.reconcileSubagentToolPolicy(); const model = this.inner.model; const contextUsage = this.inner.getContextUsage(); return { @@ -894,6 +917,7 @@ export class AgentSessionWrapper { // per-run changes again once the run ends. systemPrompt: this.exactSystemPrompt?.() ?? (this.inner.agent.state?.systemPrompt || this.inner.systemPrompt || ""), thinkingLevel: this.inner.agent.state?.thinkingLevel ?? "off", + activeToolNames: this.inner.getActiveToolNames(), extensionStatuses: this.getExtensionStatuses(), extensionWidgets: this.getExtensionWidgets(), }; @@ -1092,6 +1116,7 @@ export class AgentSessionWrapper { } case "get_tools": { + this.reconcileSubagentToolPolicy(); // A hidden tool is withdrawn: pi ignores it when setting the active tools. const all: ToolInfo[] = this.inner.getAllTools().filter((t) => t.exposure !== "hidden"); const active = new Set(this.inner.getActiveToolNames()); @@ -1161,6 +1186,7 @@ export class AgentSessionWrapper { if (typeof this.inner.bindExtensions !== "function") { this.inner.extensionRunner.setUIContext?.(this.createExtensionUiContext(), "rpc"); } + this.reconcileSubagentToolPolicy(); invalidateModelsCache(); return { success: true }; } @@ -1903,6 +1929,7 @@ export class AgentSessionWrapper { this.inner.extensionRunner.setUIContext?.(this.createExtensionUiContext(), "rpc"); }, }); + this.reconcileSubagentToolPolicy(); }, }; } @@ -1968,9 +1995,11 @@ const SUBAGENT_CONTROLLER = createSubagentController({ ? { exactSystemPrompt: () => options.exactSystemPrompt! } : {}), chatOnly: options?.chatOnly, + subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries() as unknown as SessionEntry[]), suppressCompletionNotifications: true, }); registerRpcWrapper(wrapper); + return wrapper.waitUntilReady(); }, reopenSession: async (sessionId, sessionFile) => (await startRpcSession(sessionId, sessionFile, undefined)).session, @@ -2305,6 +2334,9 @@ export async function startRpcSession( sessionManager.getEntries() as unknown as SessionEntry[], ) : null; + if (sessionFile && !subagentResources && sessionManager.getEntries().some((entry) => entry.type === "custom" && entry.customType === SUBAGENT_META_TYPE)) { + throw new Error("Cannot restore subagent: missing or invalid resource snapshot"); + } const persistedToolNames = subagentResources ? undefined : readSessionToolSelection(sessionManager.getEntries() as unknown as SessionEntry[]); @@ -2324,7 +2356,7 @@ export async function startRpcSession( // Determine which tools to pass based on requested toolNames. // Since v0.68.0, session creation expects string[] tool names instead of Tool[] instances. - let toolsOption: string[] | undefined = subagentResources?.tools; + let toolsOption: string[] | undefined = subagentResources?.version === 2 ? undefined : subagentResources?.tools; if (!subagentResources && selectedToolNames !== undefined) { // toolNames === [] -> "all off" (an empty allow-list disables every tool). // Otherwise DO NOT pass a builtin-only allow-list: passing CODING_TOOL_NAMES @@ -2347,7 +2379,7 @@ export async function startRpcSession( : chatOnly ? undefined : projectTrustReloadOptions(sessionCwd, agentDir); - const settingsManager = SettingsManager.create(sessionCwd, agentDir); + const settingsManager = SettingsManager.create(sessionCwd, agentDir, subagentResources ? { projectTrusted: getProjectTrustStatus(sessionCwd, agentDir).trusted } : undefined); // Chat-only sessions and subagents that replace Pi's prompt send an exact // system prompt. The prompt is resolved at prompt time through this inline // extension: it may read the session's context files, which exist only @@ -2360,7 +2392,10 @@ export async function startRpcSession( const builtins = subagentResources || chatOnly ? undefined : await createPiWebBuiltinExtensions({ agentDir }); - const services = await createAgentSessionServices({ + const createServices = subagentResources + ? (options: Parameters[0]) => createSubagentSessionServices(options, subagentResources) + : createAgentSessionServices; + const services = await createServices({ cwd: sessionCwd, agentDir, settingsManager, @@ -2438,9 +2473,15 @@ export async function startRpcSession( ...(initial?.thinkingLevel ? { thinkingLevel: initial.thinkingLevel } : {}), ...(scope.scopedModels.length > 0 ? { scopedModels: [...scope.scopedModels] } : {}), ...(toolsOption !== undefined ? { tools: toolsOption } : {}), - ...(subagentResources ? { excludeTools: [...SUBAGENT_CONTROL_TOOL_NAMES] } : {}), + ...(subagentResources?.version === 2 + ? { noTools: "builtin", excludeTools: subagentToolExclusions(subagentResources.builtinTools) } + : subagentResources ? { excludeTools: [...SUBAGENT_CONTROL_TOOL_NAMES] } : {}), }); + if (subagentResources?.version === 2) { + inner.setActiveToolsByName([...new Set([...subagentResources.builtinTools, ...inner.getActiveToolNames()])]); + } + // A pinned selection replaces only the coding tools of the SDK's initial loadout, which // already holds the extension tools pi activates on registration and whatever // `defaultTools` names (such as `+codemode`), so installed extensions stay usable in @@ -2469,6 +2510,7 @@ export async function startRpcSession( }); }, suppressCompletionNotifications: Boolean(subagentResources), + subagentResources, ...(builtins?.mcpHost ? { mcpHost: builtins.mcpHost } : {}), }); const realSessionId = inner.sessionId as string; diff --git a/lib/subagent-resource-catalog.ts b/lib/subagent-resource-catalog.ts new file mode 100644 index 0000000000..32ef8fbd29 --- /dev/null +++ b/lib/subagent-resource-catalog.ts @@ -0,0 +1,218 @@ +import { DefaultPackageManager, SettingsManager, loadSkills, type PathMetadata, type ResourceDiagnostic } from "@earendil-works/pi-coding-agent"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { satisfies, validRange } from "semver"; +import { parseNpmSource } from "./npm-source"; +import type { SubagentResourceSelection } from "./subagent-resource-selection"; + +export interface SubagentResourceItem { + name: string; + path: string; + identity: string; + names: string[]; + pathAliases?: string[]; + metadata: PathMetadata; + enabled: boolean; + description?: string; +} +export interface SubagentResourceCatalog { + skills: SubagentResourceItem[]; + extensions: SubagentResourceItem[]; + diagnostics: ResourceDiagnostic[]; +} +/** Stable deduplication keeps distinct SDK collision winner/loser evidence. */ +export function uniqueResourceDiagnostics(diagnostics: ResourceDiagnostic[]): ResourceDiagnostic[] { + const seen = new Set(); + return diagnostics.filter((diagnostic) => { + const key = JSON.stringify([diagnostic.type, diagnostic.path, diagnostic.message, diagnostic.collision?.resourceType, diagnostic.collision?.name, diagnostic.collision?.winnerPath, diagnostic.collision?.loserPath]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function resourceIdentity(path: string): string { + try { return realpathSync(path); } catch { return resolve(path); } +} +export function explicitResourcePath(value: string, cwd: string): string | undefined { + if (!value.startsWith("~") && !value.includes("/") && !value.includes("\\")) return undefined; + const path = value.replaceAll("\\", "/"); + return resolve(cwd, path.startsWith("~/") ? join(homedir(), path.slice(2)) : path); +} + +/** Only concrete entry files reach additionalExtensionPaths. The SDK resolves manifests. */ +function concreteExtension(path: string): string | undefined { + try { + if (statSync(path).isFile()) return path; + for (const file of ["index.ts", "index.js"]) { + const entry = join(path, file); + if (existsSync(entry) && statSync(entry).isFile()) return entry; + } + } catch { /* diagnosed by catalog/selection */ } + return undefined; +} +function extensionNames(path: string, metadata: PathMetadata): string[] { + const file = basename(path).replace(/\.[^.]+$/, ""); + const names = [file === "index" ? basename(dirname(path)) : file]; + if (metadata.origin === "package") { + const sourceName = parseNpmSource(metadata.source)?.name; + if (sourceName) names.push(sourceName.replace(/^@[^/]+\//, "")); + if (metadata.packageRoot) { + try { + const manifest = JSON.parse(readFileSync(join(metadata.packageRoot, "package.json"), "utf8")); + if (typeof manifest.name === "string") names.push(manifest.name.replace(/^@[^/]+\//, "")); + } catch { /* a local package need not have a manifest */ } + } + } + return [...new Set(names.map((name) => name.toLowerCase()))]; +} +export function assertResourceSettingsReadable(manager: SettingsManager): void { + const errors = manager.drainErrors(); + if (errors.length) throw new Error(errors.map(({ scope, error }) => `${scope} settings: ${error.message}`).join("; ")); +} + +/** Readiness only, not package resolution: all locations come from public SDK metadata. */ +function unavailablePackageReason(source: string, installedPath: string | undefined, resolved: boolean): string | undefined { + if (!installedPath || !existsSync(installedPath)) return "not installed"; + try { + const stats = statSync(installedPath); + const npm = parseNpmSource(source); + if (!stats.isDirectory() && (npm || !stats.isFile())) return "not ready: installation is not a regular package path"; + // A resolved resource already passed SDK readiness (including inherited package deltas). + if (npm && !resolved) { + const manifest = JSON.parse(readFileSync(join(installedPath, "package.json"), "utf8").replace(/^\uFEFF/, "")); + if (typeof manifest.version !== "string" || !manifest.version) return "not ready: installed version is unavailable"; + const range = npm.version ? validRange(npm.version) : null; + if (range && !satisfies(manifest.version, range)) return "not ready: installed version does not match the configured range"; + } + } catch { + return "not ready: installation or package manifest is unreadable"; + } + return undefined; +} + +/** Read both scopes without adopting project executable settings (npmCommand, shell, etc.). */ +function catalogInspectionSettings(source: SettingsManager): SettingsManager { + assertResourceSettingsReadable(source); + const project = source.getProjectSettings(); + const scopes = { + global: source.getGlobalSettings(), + project: Object.fromEntries(["packages", "extensions", "skills", "prompts", "themes"].map((key) => [key, project[key as keyof typeof project]])), + }; + // Read-only snapshots. SDK scope visibility here is inspection, never execution approval. + // Legacy npm location queries may use the trusted host/global command only, not a project override. + return SettingsManager.fromStorage({ withLock(scope, fn) { fn(JSON.stringify(scopes[scope])); } }); +} + +/** Static inspection only: no project command or extension is executed; never passed to services. */ +export async function readSubagentResourceCatalog(cwd: string, agentDir: string, source = SettingsManager.create(cwd, agentDir)): Promise { + const manager = catalogInspectionSettings(source); + assertResourceSettingsReadable(manager); + const diagnostics: ResourceDiagnostic[] = []; + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager: manager }); + const skippedSources = new Set(); + const paths = await packageManager.resolve(async (source) => { + skippedSources.add(source); + return "skip"; + }); + // PI_OFFLINE skips installation *before* onMissing runs. Inspect public configured paths + // independently so missing/incomplete installs produce the same diagnostics offline. + const packageResources = [...paths.extensions, ...paths.skills, ...paths.prompts, ...paths.themes]; + for (const pkg of packageManager.listConfiguredPackages()) { + const resource = packageResources.find((entry) => entry.metadata.origin === "package" && entry.metadata.source === pkg.source && entry.metadata.scope === pkg.scope); + const installedPath = resource?.metadata.packageRoot ?? resource?.metadata.baseDir ?? pkg.installedPath; + const reason = unavailablePackageReason(pkg.source, installedPath, resource !== undefined); + if (!reason) continue; + diagnostics.push({ type: "warning", path: installedPath, message: `Package unavailable locally (${reason}, ${pkg.scope} scope): ${pkg.source}` }); + skippedSources.delete(pkg.source); + } + // Keep SDK callback evidence for any effective source not represented by a declaration. + for (const source of skippedSources) diagnostics.push({ type: "warning", message: `Package unavailable locally (not ready): ${source}` }); + assertResourceSettingsReadable(manager); + assertResourceSettingsReadable(source); + const extensions: SubagentResourceItem[] = []; + for (const resource of paths.extensions) { + const path = concreteExtension(resource.path); + if (!path) { + diagnostics.push({ type: "warning", path: resource.path, message: "No concrete extension entry file; select a specific file instead" }); + continue; + } + const names = extensionNames(path, resource.metadata); + extensions.push({ ...resource, path, identity: resourceIdentity(path), name: names[0], names }); + } + // Parse each SDK root independently to retain the losing sources, then report SDK collisions. + const skills: SubagentResourceItem[] = []; + for (const resource of paths.skills) { + const loaded = loadSkills({ cwd, agentDir, skillPaths: [resource.path], includeDefaults: false }); + diagnostics.push(...loaded.diagnostics); + for (const skill of loaded.skills) { + if (skills.some((entry) => entry.identity === resourceIdentity(skill.filePath))) continue; + skills.push({ name: skill.name, names: [skill.name.toLowerCase()], description: skill.description, path: skill.filePath, identity: resourceIdentity(skill.filePath), metadata: resource.metadata, enabled: resource.enabled }); + if (skill.name !== basename(skill.baseDir)) diagnostics.push({ type: "warning", path: skill.filePath, message: `Skill name ${skill.name} differs from its directory; selection uses the SDK name, not third-party full-text preloading` }); + } + } + const all = loadSkills({ cwd, agentDir, skillPaths: paths.skills.filter((r) => r.enabled).map((r) => r.path), includeDefaults: false }); + // Per-root parsing already supplied validation warnings; this pass adds only cross-root collisions. + diagnostics.push(...all.diagnostics.filter((diagnostic) => diagnostic.type === "collision")); + for (const entries of [skills, extensions]) { + for (const name of new Set(entries.flatMap((entry) => entry.names))) { + const owners = new Set(entries.filter((entry) => entry.names.includes(name)).map((entry) => entry.identity)); + if (owners.size > 1) diagnostics.push({ type: "warning", message: `Ambiguous resource name: ${name}. Select a concrete file; no fallback to all.` }); + } + } + for (const entry of [...skills, ...extensions]) { + entry.pathAliases = [entry.path, entry.identity, `./${relative(cwd, entry.path).replaceAll("\\", "/")}`]; + const homeRelative = relative(homedir(), entry.path); + if (!homeRelative.startsWith("..") && !homeRelative.startsWith("/")) entry.pathAliases.push(`~/${homeRelative.replaceAll("\\", "/")}`); + if (entry.path !== entry.identity) diagnostics.push({ type: "warning", path: entry.path, message: "Symlink resource uses SDK real-file identity; third-party name/discovery equivalence is not guaranteed" }); + } + return { skills, extensions, diagnostics: uniqueResourceDiagnostics(diagnostics) }; +} + +/** Names never broaden on ambiguity; wildcard and paths can coexist. */ +export function selectCatalogResources(entries: SubagentResourceItem[], selection: SubagentResourceSelection, cwd: string): { items: SubagentResourceItem[]; diagnostics: ResourceDiagnostic[] } { + if (typeof selection === "boolean") return { items: selection ? entries.filter((entry) => entry.enabled) : [], diagnostics: [] }; + const items: SubagentResourceItem[] = selection.includes("*") ? entries.filter((entry) => entry.enabled) : []; + const diagnostics: ResourceDiagnostic[] = []; + for (const value of selection) { + if (value === "*") continue; + const path = explicitResourcePath(value, cwd); + const matches = entries.filter((entry) => path ? entry.identity === resourceIdentity(path) : entry.names.includes(value.toLowerCase())); + const identities = new Set(matches.map((entry) => entry.identity)); + if (identities.size !== 1) { + diagnostics.push({ type: "warning", path: value, message: identities.size ? `Ambiguous resource: ${value}; select a concrete file` : `Unknown resource: ${value}; retained, not loaded` }); + continue; + } + items.push(matches[0]); + } + return { items: [...new Map(items.map((item) => [item.identity, item])).values()], diagnostics }; +} + +/** Explicit paths outside discovery are allowed only as concrete local files, never package specs. */ +export function addExplicitResources(catalog: SubagentResourceCatalog, selections: { skills: SubagentResourceSelection; extensions: SubagentResourceSelection }, cwd: string, agentDir: string): void { + for (const kind of ["skills", "extensions"] as const) { + const selection = selections[kind]; + if (!Array.isArray(selection)) continue; + for (const value of selection) { + const path = explicitResourcePath(value, cwd); + if (!path || catalog[kind].some((entry) => entry.identity === resourceIdentity(path))) continue; + const metadata: PathMetadata = { source: "explicit", scope: "temporary", origin: "top-level", baseDir: dirname(path) }; + if (kind === "extensions") { + try { + if (!statSync(path).isFile()) continue; + const names = extensionNames(path, metadata); + catalog.extensions.push({ path, identity: resourceIdentity(path), name: names[0], names, enabled: true, metadata }); + } catch { /* selectCatalogResources reports unknown */ } + } else if (path.toLowerCase().endsWith(".md")) { + try { + // A .md directory would make the SDK recurse, leaking its children through '*'. + if (!statSync(path).isFile()) continue; + const loaded = loadSkills({ cwd, agentDir, skillPaths: [path], includeDefaults: false }); + catalog.diagnostics.push(...loaded.diagnostics); + for (const skill of loaded.skills) catalog.skills.push({ path: skill.filePath, identity: resourceIdentity(skill.filePath), name: skill.name, names: [skill.name.toLowerCase()], enabled: true, metadata }); + } catch { /* selectCatalogResources reports unknown; never expand directories */ } + } + } + } +} diff --git a/lib/subagent-resource-selection.test.mjs b/lib/subagent-resource-selection.test.mjs new file mode 100644 index 0000000000..59c0f987ed --- /dev/null +++ b/lib/subagent-resource-selection.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm } 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 dir = await mkdtemp(join(tmpdir(), "pi-resource-profile-")); +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; +const originalHome = process.env.HOME; +process.env.HOME = join(dir, "home"); +await mkdir(process.env.HOME); +process.env.PI_CODING_AGENT_DIR = join(dir, "agent"); +after(async () => { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = originalAgentDir; await rm(dir, { recursive: true, force: true }); }); +const jiti = createJiti(import.meta.url); +const { parseResourceSelection, profileResourceSelection, resourceMatchCandidates, matchingResourceEntries } = await jiti.import("./subagent-resource-selection.ts"); +const { listSubagentProfileSources, saveSubagentProfile, readSubagentSessionResources, SUBAGENT_META_TYPE } = await jiti.import("./subagents.ts"); +const { parseFrontmatter } = await jiti.import("./frontmatter.ts"); + +for (const [input, expected] of [[true, true], [false, false], ["all", true], ["none", false], ["", []], [[], []], ["One, two", ["One", "two"]], [["*", "/a.ts", "unknown"], ["*", "/a.ts", "unknown"]]]) { + test(`normalizes alias ${JSON.stringify(input)} without broadening`, () => assert.deepEqual(parseResourceSelection(input), expected)); +} +test("raw matching retains aliases and ambiguity; identity guard and slash/case rules stay unchanged", () => { + const first = { path: "/A/one.md", identity: "/real/one.md", names: ["one"], pathAliases: ["./one.md", "/A/one.md"] }; + const alias = { path: "/alias/one.md", identity: first.identity, names: ["one"] }; + const other = { path: "/B/one.md", identity: "/real/other.md", names: ["one"] }; + assert.deepEqual(resourceMatchCandidates("ONE", [first, alias]), [first, alias]); + assert.deepEqual(matchingResourceEntries("ONE", [first, alias]), [first, alias]); + assert.deepEqual(resourceMatchCandidates("one", [first, alias, other]), [first, alias, other]); + assert.deepEqual(matchingResourceEntries("one", [first, alias, other]), []); + assert.deepEqual(matchingResourceEntries(".\\one.md", [first, other]), [first]); + assert.deepEqual(resourceMatchCandidates("/a/one.md", [first]), [], "path case stays significant"); + assert.deepEqual(resourceMatchCandidates("/real/one.md", [alias]), [alias], "fallback includes canonical identity"); + assert.deepEqual(resourceMatchCandidates("/real/one.md", [first]), [], "supplied aliases remain authoritative"); + assert.deepEqual(matchingResourceEntries("unknown", [first]), []); +}); + +test("legacy false closes and legacy true does not erase an alias whitelist", () => { + assert.deepEqual(profileResourceSelection("one,two", true), ["one", "two"]); + assert.equal(profileResourceSelection(["one"], false), false); +}); + +async function storedProfile(cwd, name, yaml) { + const path = join(cwd, ".pi", "agents", `${name}.md`); + await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, `---\n${yaml}\n---\nOriginal prompt.\n`); + return { path, profile: listSubagentProfileSources(cwd).find((p) => p.name === name) }; +} + +test("unchanged CSV/array lists and foreign metadata survive saves, PATCH-style spread, and clones across scopes", async () => { + const cwd = join(dir, "project"); await mkdir(cwd); + const { path, profile } = await storedProfile(cwd, "csv", "name: csv\nskills: 'One, unknown'\nextensions: [foo, './local.ts', '*']\nload_skills: true\nload_extensions: true\ntools: 'read, ext:foo/search'\nforeign: {nested: [keep, 9]}\nallowed_subagents: [plan]\nisolation: off\npersist_session: false"); + assert.deepEqual(profile.skills, ["One", "unknown"]); assert.deepEqual(profile.extensions, ["foo", "./local.ts", "*"]); + const saved = saveSubagentProfile(cwd, "project", { ...profile, description: "Edited", enabled: false }); + const raw = parseFrontmatter(await readFile(path, "utf8")).data; + assert.equal(raw.skills, "One, unknown"); assert.deepEqual(raw.extensions, ["foo", "./local.ts", "*"]); + assert.deepEqual(raw.foreign, { nested: ["keep", 9] }); assert.deepEqual(raw.allowed_subagents, ["plan"]); + assert.match(raw.tools, /ext:foo\/search/); assert.deepEqual(saved.extensions, profile.extensions); + const clone = saveSubagentProfile(cwd, "global", { ...saved, name: "clone", displayName: "Clone" }, profile); + const cloned = parseFrontmatter(await readFile(clone.filePath, "utf8")).data; + assert.equal(cloned.name, "clone"); assert.equal(cloned.skills, raw.skills); assert.deepEqual(cloned.extensions, raw.extensions); + assert.deepEqual(cloned.foreign, raw.foreign); assert.match(cloned.tools, /ext:foo\/search/); + assert.equal(listSubagentProfileSources(cwd).find((p) => p.name === "clone").scope, "global"); +}); + +test("old boolean clients can disable and re-enable without losing dormant whitelists; all writes YAML true", async () => { + const cwd = join(dir, "legacy"); await mkdir(cwd); + const { path, profile } = await storedProfile(cwd, "legacy", "skills: ['one', 'unknown']\nextensions: foo, unknown\nload_skills: false\nload_extensions: false"); + assert.equal(profile.skills, false); assert.equal(profile.extensions, false); + saveSubagentProfile(cwd, "project", { ...profile, description: "Unrelated save" }); + let raw = parseFrontmatter(await readFile(path, "utf8")).data; + assert.deepEqual(raw.skills, ["one", "unknown"]); assert.equal(raw.extensions, "foo, unknown"); + const legacy = { ...profile }; delete legacy.skills; delete legacy.extensions; + saveSubagentProfile(cwd, "project", { ...legacy, loadSkills: true, loadExtensions: true }); + const readback = listSubagentProfileSources(cwd).find((p) => p.name === "legacy"); + assert.deepEqual(readback.skills, ["one", "unknown"]); assert.deepEqual(readback.extensions, ["foo", "unknown"]); + saveSubagentProfile(cwd, "project", { ...readback, skills: true, extensions: false }); + raw = parseFrontmatter(await readFile(path, "utf8")).data; + assert.equal(raw.skills, true); assert.equal(raw.extensions, false); assert.equal(raw.load_extensions, false); + saveSubagentProfile(cwd, "project", { ...readback, skills: [], extensions: ["unknown"] }); + raw = parseFrontmatter(await readFile(path, "utf8")).data; + assert.deepEqual(raw.skills, []); assert.deepEqual(raw.extensions, ["unknown"]); + assert.throws(() => saveSubagentProfile(cwd, "project", { ...readback, skills: "bad API" }), /boolean or string\[\]/); +}); + +test("old snapshots stay boolean-compatible and new selected snapshots restore independently", () => { + const entries = (snapshot) => [{ type: "custom", customType: SUBAGENT_META_TYPE, data: { version: 1, parentSessionId: "parent", parentSessionPath: "/parent.jsonl", resourceSnapshot: { version: 1, appendSystemPrompt: [], tools: ["read"], ...snapshot } } }]; + assert.equal(readSubagentSessionResources(entries({ loadSkills: true, loadExtensions: false })).loadSkills, true); + assert.deepEqual(readSubagentSessionResources(entries({ loadSkills: true, loadExtensions: true, skills: ["one"], extensions: [] })).skills, ["one"]); + assert.deepEqual(readSubagentSessionResources(entries({ loadSkills: true, loadExtensions: true, skills: ["one"], extensions: [] })).extensions, []); + assert.equal(readSubagentSessionResources(entries({ loadSkills: true, skills: { invalid: true } })), null); +}); diff --git a/lib/subagent-resource-selection.ts b/lib/subagent-resource-selection.ts new file mode 100644 index 0000000000..d5ff876b2e --- /dev/null +++ b/lib/subagent-resource-selection.ts @@ -0,0 +1,44 @@ +/** Profile YAML aliases, not a second resource configuration format. Safe to use in the editor. */ +export type SubagentResourceSelection = boolean | string[]; + +export function parseResourceSelection(value: unknown, fallback: SubagentResourceSelection = false): SubagentResourceSelection { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const text = value.trim().toLowerCase(); + if (["true", "all"].includes(text)) return true; + if (["false", "none"].includes(text)) return false; + return value.split(",").map((entry) => entry.trim()).filter(Boolean); + } + if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean); + return fallback; +} + +/** A legacy false is a kill switch; a legacy true must not erase a whitelist. */ +export function profileResourceSelection(alias: unknown, legacy: unknown, fallback = false): SubagentResourceSelection { + if (legacy === false) return false; + return parseResourceSelection(alias, typeof legacy === "boolean" ? legacy : fallback); +} + +export function resourceSelectionEnabled(selection: SubagentResourceSelection): boolean { + return selection !== false; +} + +export function sameResourceSelection(a: SubagentResourceSelection, b: SubagentResourceSelection): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** Raw candidates, including ambiguous aliases. Paths are slash-normalized but case-sensitive. */ +export function resourceMatchCandidates(value: string, items: T[]): T[] { + const normalized = value.replaceAll("\\", "/"); + return items.filter((item) => (item.pathAliases ?? [item.path, item.identity]).some((path) => path.replaceAll("\\", "/") === normalized) || item.names.includes(value.toLowerCase())); +} + +/** Client catalog matching uses server-provided aliases; one canonical identity is unambiguous. */ +export function matchingResourceEntries(value: string, items: T[]): T[] { + const matches = resourceMatchCandidates(value, items); + return new Set(matches.map((item) => item.identity)).size === 1 ? matches : []; +} + +export function resourceSelectionMode(selection: SubagentResourceSelection): "all" | "none" | "selected" { + return selection === true ? "all" : selection === false ? "none" : "selected"; +} diff --git a/lib/subagent-resources.integration.test.mjs b/lib/subagent-resources.integration.test.mjs new file mode 100644 index 0000000000..8c24f11f85 --- /dev/null +++ b/lib/subagent-resources.integration.test.mjs @@ -0,0 +1,509 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm, access, symlink } 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-sdk-")); +const oldHome = process.env.HOME; +const oldAgent = process.env.PI_CODING_AGENT_DIR; +process.env.HOME = join(sandbox, "home"); +process.env.PI_CODING_AGENT_DIR = join(sandbox, "agent"); +await mkdir(process.env.HOME, { recursive: true }); +await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true }); +after(async () => { + if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome; + if (oldAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = oldAgent; + await rm(sandbox, { recursive: true, force: true }); +}); +const { DefaultPackageManager, SettingsManager, ModelRuntime, SessionManager, ProjectTrustStore, createAgentSessionFromServices } = await import("@earendil-works/pi-coding-agent"); +const { fauxProvider } = await import("@earendil-works/pi-ai"); +const jiti = createJiti(import.meta.url); +const { createSubagentMemorySettings, createSubagentSessionServices } = await jiti.import("./subagent-resources.ts"); +const { readSubagentResourceCatalog, selectCatalogResources, addExplicitResources, uniqueResourceDiagnostics } = await jiti.import("./subagent-resource-catalog.ts"); +const { saveSubagentProfile, readSubagentSessionResources, SUBAGENT_META_TYPE } = await jiti.import("./subagents.ts"); +const { createSubagentController } = await jiti.import("./subagent-runtime.ts"); + +async function file(path, contents) { await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, contents); return path; } +async function exists(path) { try { await access(path); return true; } catch { return false; } } +function skill(name) { return `---\nname: ${name}\ndescription: Skill ${name}\n---\nInstructions for ${name}.\n`; } +async function fixture(t) { + const dir = await mkdtemp(join(sandbox, "case-")); + const cwd = join(dir, "cwd"), agentDir = join(dir, "agent"); + await mkdir(cwd); await mkdir(agentDir); + const npmLog = join(dir, "npm.log"); + const npm = await file(join(dir, "npm.cjs"), `require('fs').appendFileSync(${JSON.stringify(npmLog)}, JSON.stringify(process.argv.slice(2))+'\\n'); if(process.argv.includes('root')) console.log(${JSON.stringify(join(dir, "global-node-modules"))}); else throw Error('installation/network forbidden');`); + const markers = {}; + const ext = async (path, name, dynamic) => { + const marker = join(dir, `${name}.marker`); markers[name] = marker; + return file(path, `import fs from 'node:fs';\nfs.appendFileSync(${JSON.stringify(marker)}, 'top\\n');\nexport default function(pi) { fs.appendFileSync(${JSON.stringify(marker)}, 'factory\\n'); pi.registerTool({name:${JSON.stringify(name)},label:${JSON.stringify(name)},description:'Marker',parameters:{type:'object',properties:{}},execute:async()=>({content:[],details:undefined})});${dynamic ? `pi.on('resources_discover',()=>({skillPaths:[${JSON.stringify(dynamic)}]}));` : ""} }`); + }; + const dynamic = await file(join(dir, "dynamic", "SKILL.md"), skill("dynamic")); + const allowed = await ext(join(agentDir, "extensions", "allowed.ts"), "allowed", dynamic); + const excluded = await ext(join(agentDir, "extensions", "excluded.ts"), "excluded"); + const project = await ext(join(cwd, ".pi", "extensions", "project", "index.ts"), "project"); + const one = await file(join(agentDir, "skills", "directory-one", "SKILL.md"), skill("effective-one")); + const two = await file(join(cwd, ".agents", "skills", "two", "SKILL.md"), skill("two")); + const packageRoot = join(agentDir, "npm", "node_modules", "@fixture", "short-package"); + const packaged = await ext(join(packageRoot, "src", "index.ts"), "package_tool"); + await file(join(packageRoot, "package.json"), JSON.stringify({ name: "@fixture/short-package", version: "1.0.0", pi: { extensions: ["src/index.ts"] } })); + const global = { cacheWarming: "off", transport: "websocket", defaultTools: ["read"], shellPath: "/global-shell", extensions: [], skills: [], packages: ["npm:@fixture/short-package@1.0.0", "npm:@fixture/nonselected-missing@1.0.0"], npmCommand: [process.execPath, npm] }; + const local = { cacheWarming: "idle", transport: "sse", defaultTools: ["+grep"], shellCommandPrefix: "project-prefix" }; + await file(join(agentDir, "settings.json"), JSON.stringify(global)); + await file(join(cwd, ".pi", "settings.json"), JSON.stringify(local)); + const faux = fauxProvider({ models: [{ id: "resource-faux" }] }); + const runtime = await ModelRuntime.create({ authPath: join(dir, "auth.json"), modelsPath: null, refreshOnCreate: false }); + runtime.registerNativeProvider(faux.provider); + t.after(() => runtime.dispose?.()); + const trust = new ProjectTrustStore(agentDir); + const services = async (skills, extensions) => createSubagentSessionServices({ cwd, agentDir, modelRuntime: runtime, settingsManager: SettingsManager.create(cwd, agentDir, { projectTrusted: trust.get(cwd) === true }), resourceLoaderOptions: { noPromptTemplates: true, noThemes: true, noContextFiles: true } }, { skills, extensions, loadSkills: skills !== false, loadExtensions: extensions !== false }); + const count = async (name, phase = "factory") => (await exists(markers[name])) ? (await readFile(markers[name], "utf8")).split("\n").filter((line) => line === phase).length : 0; + return { cwd, agentDir, dir, global, local, one, two, allowed, excluded, project, dynamic, packaged, markers, services, count, runtime, faux, trust, npmLog, ext }; +} + +test("scoped transient settings preserve global-only semantics and never write resource configuration", async () => { + const global = { cacheWarming: "off", shellPath: "global", transport: "websocket", packages: ["npm:missing"], extensions: ["global.ts"], skills: ["global.md"] }; + const project = { cacheWarming: "idle", shellPath: "project", transport: "sse", extensions: ["project.ts"], themes: ["x"] }; + const source = SettingsManager.fromStorage({ withLock(scope, fn) { fn(JSON.stringify(scope === "global" ? global : project)); } }); + const transient = createSubagentMemorySettings(source); + for (const scope of ["Global", "Project"]) { + const actual = transient[`get${scope}Settings`](), expected = source[`get${scope}Settings`](); + for (const key of ["packages", "extensions", "skills", "prompts", "themes"]) { assert.deepEqual(actual[key], []); delete actual[key]; delete expected[key]; } + assert.deepEqual(actual, expected); + } + assert.equal(transient.getCacheWarmingMode(), source.getCacheWarmingMode()); + assert.equal(transient.getTransport(), source.getTransport()); + transient.setDefaultModel("transient"); transient.setPackages(["npm:forbidden"]); transient.setProjectExtensionPaths(["forbidden.ts"]); + await transient.flush(); await transient.reload(); + assert.equal(transient.getDefaultModel(), "transient"); + assert.deepEqual(transient.getPackages(), []); assert.deepEqual(transient.getExtensionPaths(), []); + assert.equal(source.getDefaultModel(), undefined); + const broken = SettingsManager.fromStorage({ withLock(_scope, fn) { fn("{"); } }); + assert.throws(() => createSubagentMemorySettings(broken), /settings/); +}); + +test("transient setter deltas survive refreshed scopes, deletions, nested siblings and trust cycles", async () => { + const global = { transport: "websocket", shellPath: "old-shell", modelThinkingLevels: { "p/remove": "low", "p/keep": "high" }, terminal: { showImages: true, imageWidthCells: 40 } }; + const project = { shellCommandPrefix: "old-prefix", transport: "sse" }; + const source = SettingsManager.fromStorage({ withLock(scope, fn) { fn(JSON.stringify(scope === "global" ? global : project)); } }); + const transient = createSubagentMemorySettings(source); + transient.setDefaultModel("local-model"); + transient.setTransport("websocket"); // No-op global setter does not pin the source baseline. + transient.setShellPath(undefined); + transient.removeModelThinkingLevel("p", "remove"); + transient.setShowImages(false); + transient.setCompactionEnabled(false); // No initial object: future source siblings must still refresh. + await transient.flush(); + global.transport = "auto"; global.shellPath = "new-shell"; + global.modelThinkingLevels = { "p/remove": "medium", "p/keep": "low", "p/new": "high" }; + global.terminal = { showImages: true, imageWidthCells: 90, trueColor: false }; + global.compaction = { enabled: true, reserveTokens: 2345 }; + global.defaultModel = "disk-model"; + project.shellCommandPrefix = "new-prefix"; + for (const settings of [global, project]) for (const key of ["packages", "extensions", "skills", "prompts", "themes"]) settings[key] = ["forbidden"]; + await source.reload(); await transient.reload(); + assert.equal(transient.getGlobalSettings().transport, "auto"); + assert.equal(transient.getShellCommandPrefix(), "new-prefix"); + assert.equal(transient.getDefaultModel(), "local-model"); + assert.equal(transient.getGlobalSettings().shellPath, undefined); + assert.deepEqual(transient.getGlobalSettings().modelThinkingLevels, { "p/keep": "low", "p/new": "high" }); + assert.deepEqual(transient.getGlobalSettings().terminal, { showImages: false, imageWidthCells: 90, trueColor: false }); + assert.deepEqual(transient.getGlobalSettings().compaction, { enabled: false, reserveTokens: 2345 }); + transient.removeModelThinkingLevel("p", "keep"); transient.removeModelThinkingLevel("p", "new"); + await transient.flush(); + global.modelThinkingLevels = { "p/later": "high" }; + source.setProjectTrusted(false); await source.reload(); transient.setProjectTrusted(false); await transient.reload(); + assert.equal(transient.getTransport(), "auto"); + assert.equal(transient.getShellCommandPrefix(), undefined); + assert.equal(transient.getGlobalSettings().modelThinkingLevels, undefined, "whole-object deletion is not resurrected"); + project.shellCommandPrefix = "regranted-prefix"; + source.setProjectTrusted(true); await source.reload(); transient.setProjectTrusted(true); await transient.reload(); + assert.equal(transient.getShellCommandPrefix(), "regranted-prefix"); + assert.equal(transient.getDefaultModel(), "local-model"); + for (const scope of [transient.getGlobalSettings(), transient.getProjectSettings()]) for (const key of ["packages", "extensions", "skills", "prompts", "themes"]) assert.deepEqual(scope[key], []); + assert.equal(source.getDefaultModel(), "disk-model", "memory setters never reach the source"); + assert.equal(source.getGlobalSettings().shellPath, "new-shell"); +}); + +test("explicit loader reload refreshes non-resource settings equally for All/All and restricted resources", async (t) => { + const f = await fixture(t); + const initialGlobal = { ...f.global, packages: [] }; + await file(join(f.agentDir, "settings.json"), JSON.stringify(initialGlobal)); + const all = await f.services(true, true); + const restricted = await f.services(false, false); + const globalPath = join(f.agentDir, "settings.json"), projectPath = join(f.cwd, ".pi", "settings.json"); + const beforeGlobal = await readFile(globalPath), beforeProject = await readFile(projectPath); + restricted.settingsManager.setDefaultModel("local-model"); + restricted.settingsManager.setCompactionEnabled(false); + restricted.settingsManager.setShellPath(undefined); + restricted.settingsManager.setPackages(["npm:forbidden"]); + await restricted.settingsManager.flush(); + assert.deepEqual(await readFile(globalPath), beforeGlobal); assert.deepEqual(await readFile(projectPath), beforeProject); + const changedGlobal = { ...initialGlobal, transport: "auto", shellPath: "/new-shell", compaction: { enabled: true, reserveTokens: 1234 }, prompts: ["absent.md"], themes: ["absent.json"] }; + const changedProject = { ...f.local, shellCommandPrefix: "new-prefix", transport: "websocket" }; + await file(globalPath, JSON.stringify(changedGlobal)); await file(projectPath, JSON.stringify(changedProject)); + for (const services of [all, restricted]) await services.resourceLoader.reload(); + for (const services of [all, restricted]) assert.equal(services.settingsManager.getTransport(), "auto", "untrusted project cannot override refreshed global transport"); + f.trust.set(f.cwd, true); + for (const services of [all, restricted]) await services.resourceLoader.reload(); + for (const services of [all, restricted]) { + assert.equal(services.settingsManager.getTransport(), "websocket"); + assert.equal(services.settingsManager.getProjectSettings().shellCommandPrefix, "new-prefix"); + assert.equal(services.settingsManager.getCacheWarmingMode(), "off"); + } + changedGlobal.transport = "sse"; changedProject.shellCommandPrefix = "second-prefix"; + await file(globalPath, JSON.stringify(changedGlobal)); await file(projectPath, JSON.stringify(changedProject)); + for (const services of [all, restricted]) await services.resourceLoader.reload(); + assert.equal(restricted.settingsManager.getGlobalSettings().transport, "sse"); + assert.equal(restricted.settingsManager.getShellCommandPrefix(), "second-prefix"); + assert.equal(restricted.settingsManager.getDefaultModel(), "local-model"); + assert.equal(restricted.settingsManager.getGlobalSettings().shellPath, undefined); + assert.deepEqual(restricted.settingsManager.getGlobalSettings().compaction, { enabled: false, reserveTokens: 1234 }); + for (const scope of [restricted.settingsManager.getGlobalSettings(), restricted.settingsManager.getProjectSettings()]) for (const key of ["packages", "extensions", "skills", "prompts", "themes"]) assert.deepEqual(scope[key], []); + assert.deepEqual(restricted.resourceLoader.getPrompts().prompts, []); + assert.deepEqual(restricted.resourceLoader.getThemes().themes, []); + f.trust.set(f.cwd, false); + for (const services of [all, restricted]) await services.resourceLoader.reload(); + assert.equal(restricted.settingsManager.getTransport(), "sse"); + assert.equal(restricted.settingsManager.getShellCommandPrefix(), undefined); + assert.equal(restricted.settingsManager.getDefaultModel(), "local-model"); + assert.equal(await readFile(globalPath, "utf8"), JSON.stringify(changedGlobal)); + assert.equal(await readFile(projectPath, "utf8"), JSON.stringify(changedProject)); +}); + +test("explicit Markdown directories never expand skills, including wildcard + path", async (t) => { + const f = await fixture(t); + const directory = join(f.dir, "leak.md"); + const child = await file(join(directory, "nested", "SKILL.md"), skill("must-not-leak")); + const directoryAlias = join(f.dir, "alias.md"); await symlink(directory, directoryAlias, "dir"); + const markdown = await file(join(f.dir, "standalone.md"), skill("explicit-file")); + for (const selection of [[directory], ["*", directory, directoryAlias], [markdown]]) { + const catalog = await readSubagentResourceCatalog(f.cwd, f.agentDir); + addExplicitResources(catalog, { skills: selection, extensions: false }, f.cwd, f.agentDir); + assert.ok(!catalog.skills.some((entry) => entry.path === child), "directory children never enter the wildcard catalog"); + const services = await f.services(selection, false); + await services.resourceLoader.reload(); + assert.ok(!services.resourceLoader.getSkills().skills.some((entry) => entry.name === "must-not-leak")); + if (selection[0] === markdown) assert.deepEqual(services.resourceLoader.getSkills().skills.map((entry) => entry.name), ["explicit-file"]); + else assert.ok(services.resourceLoader.getSkills().diagnostics.some((entry) => entry.path === directory && entry.message.includes("Unknown resource"))); + } +}); + +test("skill diagnostics are stable and unique without losing collision losers or SDK winner evidence", async (t) => { + const f = await fixture(t); + const roots = await Promise.all(["winner", "loser-one", "loser-two"].map((name) => file(join(f.dir, name, "SKILL.md"), skill("Bad_Name")))); + await file(join(f.agentDir, "settings.json"), JSON.stringify({ ...f.global, skills: roots })); + const catalog = await readSubagentResourceCatalog(f.cwd, f.agentDir); + for (const path of roots) { + assert.ok(catalog.skills.some((entry) => entry.path === path), "per-root parsing retains a selectable loser"); + assert.equal(catalog.diagnostics.filter((entry) => entry.path === path && entry.message.includes("invalid characters")).length, 1); + } + const collisions = catalog.diagnostics.filter((entry) => entry.type === "collision" && entry.collision.name === "Bad_Name"); + assert.equal(collisions.length, 2); + assert.deepEqual(collisions.map((entry) => entry.collision.loserPath), roots.slice(1)); + assert.ok(collisions.every((entry) => entry.collision.winnerPath === roots[0])); + assert.deepEqual(uniqueResourceDiagnostics([...collisions, ...collisions]), collisions); + assert.deepEqual((await readSubagentResourceCatalog(f.cwd, f.agentDir)).diagnostics, catalog.diagnostics); + const services = await f.services([roots[1]], false); + assert.deepEqual(services.resourceLoader.getSkills().skills.map((entry) => entry.filePath), [roots[1]]); + for (const boundary of ["create", "reload"]) { + if (boundary === "reload") await services.resourceLoader.reload(); + const diagnostics = services.resourceLoader.getSkills().diagnostics; + assert.equal(diagnostics.filter((entry) => entry.path === roots[1] && entry.message.includes("invalid characters")).length, 1, "SDK runtime + static catalog warning is shown once"); + assert.deepEqual(diagnostics.filter((entry) => entry.type === "collision" && entry.collision.name === "Bad_Name"), collisions); + assert.deepEqual(uniqueResourceDiagnostics(diagnostics), diagnostics); + } +}); + +test("catalog executes neither module top-level nor factories and skips missing packages; names/collisions remain diagnostic", async (t) => { + const f = await fixture(t); + const alias = join(f.agentDir, "extensions", "alias.ts"); await symlink(f.allowed, alias); + const catalog = await readSubagentResourceCatalog(f.cwd, f.agentDir); + for (const name of Object.keys(f.markers)) { assert.equal(await f.count(name, "top"), 0); assert.equal(await f.count(name), 0); } + assert.ok(catalog.diagnostics.some((d) => d.message.includes("not installed"))); + assert.ok(catalog.diagnostics.some((d) => d.message.includes("differs from its directory"))); + assert.deepEqual(selectCatalogResources(catalog.skills, ["EFFECTIVE-ONE"], f.cwd).items.map((x) => x.path), [f.one]); + assert.deepEqual(selectCatalogResources(catalog.extensions, ["SHORT-PACKAGE"], f.cwd).items.map((x) => x.path), [f.packaged]); + assert.deepEqual(selectCatalogResources(catalog.extensions, ["project"], f.cwd).items.map((x) => x.path), [f.project]); + assert.equal(selectCatalogResources(catalog.extensions, ["unknown"], f.cwd).items.length, 0); + await f.ext(join(f.agentDir, "extensions", "nested", "allowed.ts"), "collision"); + await f.ext(join(f.agentDir, "extensions", "other", "allowed.ts"), "collision_other"); + await file(join(f.agentDir, "settings.json"), JSON.stringify({ ...f.global, extensions: ["./extensions/nested/allowed.ts", "./extensions/other/allowed.ts"] })); + const collision = await readSubagentResourceCatalog(f.cwd, f.agentDir); + assert.equal(selectCatalogResources(collision.extensions, ["allowed"], f.cwd).items.length, 0, JSON.stringify(collision.extensions)); + const npmCalls = await readFile(f.npmLog, "utf8"); assert.doesNotMatch(npmCalls, /install|view|update/); +}); + +test("offline catalog diagnoses missing and incomplete scoped packages even when SDK onMissing is never called", async (t) => { + const f = await fixture(t); + const previousOffline = process.env.PI_OFFLINE; + const originalResolve = DefaultPackageManager.prototype.resolve; + let onMissingCalls = 0; + DefaultPackageManager.prototype.resolve = function (onMissing) { + return originalResolve.call(this, async (source) => { + onMissingCalls += 1; + return onMissing ? onMissing(source) : "skip"; + }); + }; + t.after(() => { + DefaultPackageManager.prototype.resolve = originalResolve; + if (previousOffline === undefined) delete process.env.PI_OFFLINE; else process.env.PI_OFFLINE = previousOffline; + }); + const partialRoot = join(f.agentDir, "npm", "node_modules", "@fixture", "partial"); + await mkdir(partialRoot, { recursive: true }); + const mismatchRoot = join(f.agentDir, "npm", "node_modules", "@fixture", "mismatch"); + await file(join(mismatchRoot, "package.json"), JSON.stringify({ name: "@fixture/mismatch", version: "0.5.0" })); + const corruptRoot = join(f.agentDir, "npm", "node_modules", "@fixture", "corrupt"); + await file(join(corruptRoot, "package.json"), "{"); + const emptyRoot = join(f.agentDir, "npm", "node_modules", "@fixture", "empty"); + await file(join(emptyRoot, "package.json"), JSON.stringify({ name: "@fixture/empty", version: "1.0.0", pi: {} })); + const installedButDisabled = "npm:@fixture/short-package@1.0.0"; + await file(join(f.agentDir, "settings.json"), JSON.stringify({ ...f.global, packages: [ + { source: installedButDisabled, extensions: [], skills: [], prompts: [], themes: [] }, + "npm:@fixture/nonselected-missing@1.0.0", "./absent-local", "npm:@fixture/partial@1.0.0", + "npm:@fixture/mismatch@1.0.0", "npm:@fixture/corrupt@1.0.0", "npm:@fixture/empty@latest", + ] })); + await file(join(f.cwd, ".pi", "settings.json"), JSON.stringify({ ...f.local, packages: ["npm:@fixture/project-missing@1.0.0"] })); + + process.env.PI_OFFLINE = "1"; + const offline = await readSubagentResourceCatalog(f.cwd, f.agentDir); + assert.equal(onMissingCalls, 0, "real SDK offline skip happens before the supplied callback"); + const packageDiagnostics = (catalog) => catalog.diagnostics.filter((d) => d.message.startsWith("Package unavailable locally")); + const offlineDiagnostics = packageDiagnostics(offline); + assert.equal(offlineDiagnostics.length, 6); + assert.equal(offlineDiagnostics.filter((d) => d.message.includes("nonselected-missing")).length, 1); + assert.ok(offlineDiagnostics.some((d) => d.message.includes("not installed, project scope") && d.message.includes("project-missing"))); + assert.ok(offlineDiagnostics.some((d) => d.message.includes("not installed, user scope") && d.message.includes("absent-local"))); + assert.ok(offlineDiagnostics.some((d) => d.path === partialRoot && d.message.includes("not ready"))); + assert.ok(offlineDiagnostics.some((d) => d.path === mismatchRoot && d.message.includes("configured range"))); + assert.ok(offlineDiagnostics.some((d) => d.path === corruptRoot && d.message.includes("manifest is unreadable"))); + assert.ok(!offlineDiagnostics.some((d) => d.message.includes("@fixture/empty") || d.message.includes("@fixture/short-package")), "empty/disabled installed packages are not missing"); + + // Only this isolated process changes its flag; the rejecting fixture npm command prevents network/install. + delete process.env.PI_OFFLINE; + const online = await readSubagentResourceCatalog(f.cwd, f.agentDir); + assert.ok(onMissingCalls > 0); + assert.deepEqual(packageDiagnostics(online), offlineDiagnostics, "readiness diagnostics do not depend on the offline callback"); + for (const name of Object.keys(f.markers)) { assert.equal(await f.count(name, "top"), 0); assert.equal(await f.count(name), 0); } + assert.doesNotMatch(await readFile(f.npmLog, "utf8"), /install|view|update/); + t.diagnostic(JSON.stringify({ phase: "offline-static-catalog", offlineOnMissingCalls: 0, unavailablePackages: offlineDiagnostics.length, sameDiagnosticsOnline: true, moduleTopLevel: 0, extensionFactory: 0, installOrNetworkCalls: 0 })); +}); + +test("strict startup catalog does not execute project npmCommand before the missing-package callback", async (t) => { + const f = await fixture(t); + const marker = join(f.dir, "project-npm.marker"); + const script = await file(join(f.cwd, "npm.cjs"), `require('fs').appendFileSync(${JSON.stringify(marker)}, 'executed\\n'); throw Error('untrusted project command');`); + await file(join(f.cwd, ".pi", "settings.json"), JSON.stringify({ ...f.local, npmCommand: [process.execPath, script] })); + f.trust.set(f.cwd, false); + const services = await f.services(false, [f.allowed]); + assert.equal(await exists(marker), false); + assert.equal(services.settingsManager.isProjectTrusted(), false); + assert.equal(await f.count("allowed", "top"), 1); assert.equal(await f.count("allowed"), 1); + assert.equal(await f.count("project", "top"), 0); assert.equal(await f.count("project"), 0); + assert.doesNotMatch(await readFile(f.npmLog, "utf8"), /install|view|update/); +}); + +test("untrusted outside file symlink cannot import a real project extension", async (t) => { + const f = await fixture(t); + const arbitrary = await f.ext(join(f.cwd, "arbitrary.ts"), "arbitrary"); + const alias = join(f.dir, "outside-link.ts"); await symlink(arbitrary, alias); + f.trust.set(f.cwd, false); + const services = await f.services(false, [alias]); + assert.equal(await f.count("arbitrary", "top"), 0); assert.equal(await f.count("arbitrary"), 0); + assert.equal(services.resourceLoader.getExtensions().extensions.length, 0); + await services.resourceLoader.reload(); + assert.equal(await f.count("arbitrary", "top"), 0); assert.equal(await f.count("arbitrary"), 0); +}); + +test("user-scope discovery and project selection aliases cannot disguise a project extension's canonical ownership", async (t) => { + const f = await fixture(t); + const arbitrary = await f.ext(join(f.cwd, "arbitrary.ts"), "arbitrary"); + const userAlias = join(f.agentDir, "extensions", "user-alias.ts"); await symlink(arbitrary, userAlias); + const projectAlias = join(f.cwd, "global-alias.ts"); await symlink(f.allowed, projectAlias); + f.trust.set(f.cwd, false); + const catalog = await readSubagentResourceCatalog(f.cwd, f.agentDir); + assert.ok(catalog.extensions.some((entry) => entry.path === userAlias && entry.metadata.scope === "user")); + const services = await f.services(false, [userAlias, projectAlias]); + assert.equal(await f.count("arbitrary", "top"), 0); assert.equal(await f.count("arbitrary"), 0); + assert.equal(await f.count("allowed", "top"), 0); assert.equal(await f.count("allowed"), 0, "a project-owned alias is gated even when identity matching found the user entry first"); + assert.equal(services.resourceLoader.getExtensions().extensions.length, 0); +}); + +test("untrusted symlink cwd cannot import an extension selected by its real absolute project path", async (t) => { + const f = await fixture(t); + const arbitrary = await f.ext(join(f.cwd, "arbitrary.ts"), "arbitrary"); + const cwd = join(f.dir, "cwd-link"); await symlink(f.cwd, cwd, "dir"); + f.trust.set(f.cwd, false); + const services = await createSubagentSessionServices({ cwd, agentDir: f.agentDir, modelRuntime: f.runtime, settingsManager: SettingsManager.create(cwd, f.agentDir, { projectTrusted: false }), resourceLoaderOptions: { noPromptTemplates: true, noThemes: true, noContextFiles: true } }, { skills: false, extensions: [arbitrary], loadSkills: false, loadExtensions: true }); + assert.equal(await f.count("arbitrary", "top"), 0); assert.equal(await f.count("arbitrary"), 0); + assert.equal(services.settingsManager.isProjectTrusted(), false); + assert.equal(services.resourceLoader.getExtensions().extensions.length, 0); +}); + +test("bare project explicit extension needs a genuine trust decision on creation and every reload", async (t) => { + const f = await fixture(t); + await rm(join(f.cwd, ".pi"), { recursive: true, force: true }); + await rm(join(f.cwd, ".agents"), { recursive: true, force: true }); + const arbitrary = await f.ext(join(f.cwd, "arbitrary.ts"), "arbitrary"); + const { getProjectTrustStatus } = await jiti.import("./project-trust.ts"); + f.trust.set(f.cwd, false); + assert.equal(getProjectTrustStatus(f.cwd, f.agentDir).requiresTrust, false); + assert.equal(getProjectTrustStatus(f.cwd, f.agentDir).trusted, true, "normal-agent clean-folder semantics are unchanged"); + const services = await f.services(false, [arbitrary]); + assert.equal(await f.count("arbitrary", "top"), 0); assert.equal(await f.count("arbitrary"), 0); + assert.equal(services.settingsManager.isProjectTrusted(), false); + f.trust.set(f.cwd, null); + await services.resourceLoader.reload(); + assert.equal(await f.count("arbitrary", "top"), 0); assert.equal(await f.count("arbitrary"), 0, "absence of protected standard directories is not execution approval"); + f.trust.set(f.cwd, true); + await services.resourceLoader.reload(); + assert.equal(services.settingsManager.isProjectTrusted(), true); + assert.equal(await f.count("arbitrary", "top"), 1); assert.equal(await f.count("arbitrary"), 1); + f.trust.set(f.cwd, false); + await services.resourceLoader.reload(); + assert.equal(services.settingsManager.isProjectTrusted(), false); + assert.equal(services.resourceLoader.getExtensions().extensions.length, 0); + assert.equal(await f.count("arbitrary", "top"), 1); assert.equal(await f.count("arbitrary"), 1, "denied reload does not execute again"); + t.diagnostic(JSON.stringify({ phase: "bare-project-explicit-trust", initialFalseTop: 0, initialFalseFactory: 0, missingDecisionFactory: 0, genuineTrueFactory: 1, deniedReloadAdditionalExecutions: 0 })); +}); + +test("strict pre-import SDK loading: excluded top-level/factory 0, metadata retained, all skill dynamic discovery stays available", async (t) => { + const f = await fixture(t); + const services = await f.services(true, ["ALLOWED", "short-package"]); + const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(f.cwd), model: f.faux.getModel("resource-faux"), tools: ["read", "allowed", "package_tool"] }); + t.after(() => session.dispose()); + await session.bindExtensions({ onError: (error) => { throw Error(error.message); } }); + assert.equal(await f.count("allowed"), 1); assert.equal(await f.count("package_tool"), 1); + assert.equal(await f.count("excluded", "top"), 0); assert.equal(await f.count("excluded"), 0); + assert.equal(await f.count("project", "top"), 0); + assert.ok(services.resourceLoader.getSkills().skills.some((x) => x.name === "dynamic")); + assert.ok(!services.resourceLoader.getSkills().skills.some((x) => x.name === "two"), "automatic project skill still requires trust"); + const packaged = services.resourceLoader.getExtensions().extensions.find((x) => x.path === f.packaged); + assert.equal(packaged.sourceInfo.origin, "package"); assert.equal(packaged.sourceInfo.source, "npm:@fixture/short-package@1.0.0"); + const { selectSubagentExtensionTools } = await jiti.import("./subagents.ts"); + // Existing ext: matcher receives the original SDK source metadata. + assert.deepEqual(selectSubagentExtensionTools([packaged], ["ext:short-package/package_tool"]), ["package_tool"]); + await file(join(f.agentDir, "skills", "new", "SKILL.md"), skill("new")); + await session.reload(); + assert.ok(services.resourceLoader.getSkills().skills.some((x) => x.name === "new")); + assert.ok(services.resourceLoader.getSkills().skills.some((x) => x.name === "dynamic")); + assert.equal(await f.count("excluded", "top"), 0); assert.equal(await f.count("excluded"), 0); + assert.doesNotMatch(await readFile(f.npmLog, "utf8"), /install|view|update/); + t.diagnostic(JSON.stringify({ sdk: "1.0.0", phase: "strict-create-bind-reload", approvedFactory: await f.count("allowed"), excludedTopLevel: await f.count("excluded", "top"), excludedFactory: await f.count("excluded"), missingPackageInstallCalls: 0, dynamicSkillAvailable: true })); +}); + +test("none and selected skills enforce dynamic identity after binding/extendResources; explicit Markdown remains editable without trust", async (t) => { + const f = await fixture(t); + for (const selection of [false, ["effective-one"], [f.two], []]) { + const services = await f.services(selection, [f.allowed]); + const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(f.cwd), model: f.faux.getModel("resource-faux") }); + await session.bindExtensions({ onError: (error) => { throw Error(error.message); } }); + services.resourceLoader.extendResources({ skillPaths: [{ path: f.dynamic, metadata: { source: "extension", scope: "temporary", origin: "top-level" } }] }); + const expected = selection === false || selection.length === 0 ? [] : selection[0] === f.two ? ["two"] : ["effective-one"]; + assert.deepEqual(services.resourceLoader.getSkills().skills.map((x) => x.name), expected); + await session.reload(); + assert.deepEqual(services.resourceLoader.getSkills().skills.map((x) => x.name), expected); + session.dispose(); + } + assert.equal(await f.count("excluded", "top"), 0); +}); + +test("extension paths cannot bypass project trust on create/reload false → true → false; wildcard + path loads concrete approved files only", async (t) => { + const f = await fixture(t); + const outside = await f.ext(join(f.dir, "outside.ts"), "outside"); + const services = await f.services(false, ["*", outside, f.project.replaceAll("/", "\\")]); + assert.equal(await f.count("project", "top"), 0); + assert.equal(await f.count("outside"), 1); + assert.equal(services.settingsManager.isProjectTrusted(), false); + f.trust.set(f.cwd, true); + await services.resourceLoader.reload(); + assert.equal(services.settingsManager.isProjectTrusted(), true); + assert.equal(services.settingsManager.getTransport(), "sse"); + assert.equal(services.settingsManager.getCacheWarmingMode(), "off"); + assert.equal(services.settingsManager.getProjectSettings().shellCommandPrefix, "project-prefix"); + assert.equal(services.settingsManager.getGlobalSettings().shellPath, "/global-shell"); + assert.equal(await f.count("project"), 1); + f.trust.set(f.cwd, false); + const before = await f.count("project"); await services.resourceLoader.reload(); + assert.equal(services.settingsManager.isProjectTrusted(), false); + assert.equal(await f.count("project"), before); + assert.ok(!services.resourceLoader.getExtensions().extensions.some((x) => x.path === f.project)); + assert.doesNotMatch(await readFile(f.npmLog, "utf8"), /install|view|update/); +}); + +test("real controller creation snapshots resource selections; cold rpc restoration and explicit reload never re-read a widened profile", async (t) => { + const f = await fixture(t); + // Profiles and sessions use the SDK's agentDir environment, not a real user's files. + const previousAgent = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = f.agentDir; + t.after(() => { process.env.PI_CODING_AGENT_DIR = previousAgent; }); + await file(f.allowed, `export default function(pi) { pi.on('session_start', async () => { await new Promise(r => setTimeout(r, 20)); pi.registerTool({name:'browser_mock',label:'Browser mock',description:'Late mock, no browser',parameters:{type:'object',properties:{}},execute:async()=>({content:[],details:undefined})}); }); }`); + saveSubagentProfile(f.cwd, "project", { name: "whitelist", displayName: "Whitelist", description: "Fixture", systemPrompt: "Fixture prompt", tools: ["read"], loadSkills: true, loadExtensions: true, skills: ["effective-one"], extensions: [f.allowed], inheritContext: false, runInBackground: false, promptMode: "append", enabled: true }); + const parentManager = SessionManager.create(f.cwd, join(f.dir, "sessions")); + parentManager.appendMessage({ role: "user", content: "Fixture", timestamp: Date.now() }); + const parent = { inner: { sessionManager: parentManager, modelRuntime: f.runtime, model: f.faux.getModel("resource-faux"), agent: { state: { thinkingLevel: "off" } } }, sessionFile: parentManager.getSessionFile(), cwd: f.cwd, isAlive: () => true }; + let created, childWrapper; + const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts"); + const controller = createSubagentController({ getSession: (id) => id === parentManager.getSessionId() ? parent : childWrapper, registerSession(inner) { + created = inner; + childWrapper = new AgentSessionWrapper(inner, { suppressCompletionNotifications: true, subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries()) }); + t.after(() => childWrapper.destroy()); + childWrapper.beginExtensionBinding(); + inner.prompt = async () => { assert.ok(inner.getActiveToolNames().includes("browser_mock"), "controller waits for the single wrapper bind before prompt"); }; + return childWrapper.waitUntilReady(); + }, reopenSession: async () => { throw Error("not used"); }, resolveSessionPath: async () => null, invalidateSessionList() {}, isBuiltInSubagentsEnabled: () => true }); + const run = await controller.extensionRuntime.start({ parentContext: parent.inner, parentToolCallId: "fixture-call", profile: "whitelist", task: "fixture", description: "Fixture" }); + const firstTools = await childWrapper.send({ type: "get_tools" }); + assert.equal(firstTools.find((tool) => tool.name === "browser_mock")?.active, true); + assert.ok((await childWrapper.send({ type: "get_state" })).activeToolNames.includes("browser_mock")); + assert.equal((await run.completion).status, "completed"); + // SDK does not flush metadata-only sessions until an assistant entry exists. No provider request. + created.sessionManager.appendMessage({ role: "assistant", content: [{ type: "text", text: "Fixture" }], api: "faux", provider: "faux", model: "resource-faux", 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() }); + const entries = created.sessionManager.getEntries(); + const snapshot = entries.find((e) => e.type === "custom" && e.customType === SUBAGENT_META_TYPE).data.resourceSnapshot; + assert.equal(snapshot.version, 2); + assert.deepEqual(snapshot.builtinTools, ["read"]); + assert.deepEqual(snapshot.toolPolicy, { mode: "implicitAll", selectors: [], deny: [] }); + assert.ok(created.getActiveToolNames().includes("browser_mock")); + assert.deepEqual(snapshot.skills, ["effective-one"]); assert.deepEqual(snapshot.extensions, [f.allowed]); + assert.ok(snapshot.appendSystemPrompt.some((text) => text.includes("Fixture prompt"))); + assert.deepEqual(readSubagentSessionResources(entries).skills, ["effective-one"]); + const childFile = created.sessionFile; + childWrapper.destroy(); + // Widen the profile and shell/default loadout on disk. + await file(join(f.agentDir, "settings.json"), JSON.stringify({ ...f.global, defaultTools: ["powershell", "write", "+codemode"] })); + // Widen the profile on disk. Restore must use the persisted snapshot, not the profile. + await file(join(f.cwd, ".pi", "agents", "whitelist.md"), "---\nskills: true\nextensions: true\n---\nWidened.\n"); + // Inject only a no-refresh in-process model runtime; real startRpcSession/services/loader/session stay intact. + const originalCreate = ModelRuntime.create; + ModelRuntime.create = async () => f.runtime; + t.after(() => { ModelRuntime.create = originalCreate; }); + const { startRpcSession } = await jiti.import("./rpc-manager.ts"); + const { session: wrapper } = await startRpcSession(run.run.sessionId, childFile, f.cwd); + t.after(() => wrapper.destroy()); + assert.equal((await wrapper.send({ type: "get_tools" })).find((tool) => tool.name === "browser_mock")?.active, true); + assert.ok((await wrapper.send({ type: "get_state" })).activeToolNames.includes("browser_mock")); + await wrapper.waitUntilReady(); + assert.ok(wrapper.inner.getActiveToolNames().includes("browser_mock")); + assert.ok(wrapper.inner.getActiveToolNames().includes("read")); + assert.match(wrapper.inner.systemPrompt, /Fixture prompt/); + assert.doesNotMatch(wrapper.inner.systemPrompt, /Widened/); + assert.ok(!wrapper.inner.getAllTools().some((tool) => ["powershell", "write", "Agent", "get_subagent_result", "steer_subagent"].includes(tool.name))); + assert.deepEqual(wrapper.inner.resourceLoader.getSkills().skills.map((x) => x.name), ["effective-one"]); + assert.equal(await f.count("excluded", "top"), 0); + await wrapper.send({ type: "reload" }); + assert.ok(wrapper.inner.getActiveToolNames().includes("browser_mock")); + assert.equal((await wrapper.send({ type: "get_tools" })).find((tool) => tool.name === "browser_mock")?.active, true); + assert.ok((await wrapper.send({ type: "get_state" })).activeToolNames.includes("browser_mock")); + assert.ok(!wrapper.inner.getAllTools().some((tool) => ["powershell", "write"].includes(tool.name))); + assert.match(wrapper.inner.systemPrompt, /Fixture prompt/); + assert.doesNotMatch(wrapper.inner.systemPrompt, /Widened/); + assert.deepEqual(wrapper.inner.resourceLoader.getSkills().skills.map((x) => x.name), ["effective-one"]); + assert.equal(await f.count("excluded", "top"), 0); assert.equal(await f.count("excluded"), 0); + assert.doesNotMatch(await readFile(f.npmLog, "utf8"), /install|view|update/); + t.diagnostic(JSON.stringify({ phase: "controller-create-cold-rpc-reload", restoredSkills: wrapper.inner.resourceLoader.getSkills().skills.map((x) => x.name), excludedTopLevel: await f.count("excluded", "top"), excludedFactory: await f.count("excluded"), missingPackageInstallCalls: 0, providerRequests: 0 })); + const invalid = SessionManager.create(f.cwd, join(f.dir, "invalid-sessions")); + invalid.appendCustomEntry(SUBAGENT_META_TYPE, { version: 1, parentSessionId: "parent", parentSessionPath: "/parent.jsonl", resourceSnapshot: { version: 1, tools: [], appendSystemPrompt: [], loadExtensions: true, extensions: { invalid: true } } }); + invalid.appendMessage({ role: "assistant", content: [], api: "faux", provider: "faux", model: "resource-faux", 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() }); + await assert.rejects(startRpcSession(invalid.getSessionId(), invalid.getSessionFile(), f.cwd), /invalid resource snapshot/); +}); diff --git a/lib/subagent-resources.ts b/lib/subagent-resources.ts new file mode 100644 index 0000000000..30ebca5d73 --- /dev/null +++ b/lib/subagent-resources.ts @@ -0,0 +1,191 @@ +import { createAgentSessionServices, SettingsManager, type CreateAgentSessionServicesOptions, type Skill } from "@earendil-works/pi-coding-agent"; +import { isPathWithinRoots } from "./path-security"; +import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust"; +import { addExplicitResources, assertResourceSettingsReadable, explicitResourcePath, readSubagentResourceCatalog, resourceIdentity, selectCatalogResources, uniqueResourceDiagnostics, type SubagentResourceItem } from "./subagent-resource-catalog"; +import type { SubagentResourceSelection } from "./subagent-resource-selection"; +import { createSubagentToolPolicyExtension, type SubagentToolPolicy } from "./subagent-tool-policy"; + +const RESOURCE_KEYS = ["packages", "extensions", "skills", "prompts", "themes"] as const; +function clearResources(settings: ReturnType): string { + const copy = structuredClone(settings); + for (const key of RESOURCE_KEYS) copy[key] = []; + return JSON.stringify(copy); +} + +type SettingsObject = Record; +const isSettingsObject = (value: unknown): value is SettingsObject => typeof value === "object" && value !== null && !Array.isArray(value); + +/** A scope-preserving SDK storage: fresh source baseline plus memory-only setter deltas. */ +export function createSubagentMemorySettings(source: SettingsManager): SettingsManager { + assertResourceSettingsReadable(source); + // Store only changed JSON leaves (arrays are atomic); undefined is a deletion tombstone. + // SDK owns merging, migration, setters and write ordering. No effective/merged settings are persisted. + // A no-op setter does not pin a baseline value: public storage cannot observe mutation intent. + const overlays = { global: new Map(), project: new Map() }; + return SettingsManager.fromStorage({ + withLock(scope, fn) { + const current: SettingsObject = JSON.parse(clearResources(scope === "global" ? source.getGlobalSettings() : source.getProjectSettings())); + const overlay = overlays[scope]; + for (const { path, value } of overlay.values()) { + let target = current; + for (const key of path.slice(0, -1)) { + if (!isSettingsObject(target[key])) target[key] = {}; + target = target[key] as SettingsObject; + } + const key = path[path.length - 1]; + if (value === undefined) delete target[key]; else target[key] = structuredClone(value); + } + const next = fn(JSON.stringify(current)); + if (next === undefined) return; + const recordChanges = (before: SettingsObject, after: SettingsObject, parent: string[] = []) => { + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (JSON.stringify(before[key]) === JSON.stringify(after[key])) continue; + const path = [...parent, key]; + if (isSettingsObject(after[key]) && (before[key] === undefined || isSettingsObject(before[key]))) { + recordChanges(isSettingsObject(before[key]) ? before[key] : {}, after[key], path); + } else { + // Replacing/deleting a parent supersedes older child changes. + for (const [id, change] of overlay) if (path.every((part, i) => change.path[i] === part)) overlay.delete(id); + overlay.set(JSON.stringify(path), { path, value: after[key] }); + } + } + }; + recordChanges(current, JSON.parse(clearResources(JSON.parse(next)))); + }, + }, { projectTrusted: source.isProjectTrusted() }); +} + +export interface SubagentResourcePolicy { + loadSkills: boolean; + loadExtensions: boolean; + skills?: SubagentResourceSelection; + extensions?: SubagentResourceSelection; + builtinTools?: string[]; + toolPolicy?: SubagentToolPolicy; +} + +/** Any discovered project source or selected project alias makes the canonical file project-owned. */ +function projectExtensionIdentities(entries: SubagentResourceItem[], selection: SubagentResourceSelection, cwd: string): Set { + const roots = new Set([cwd, resourceIdentity(cwd)]); + const projectPath = (path: string) => isPathWithinRoots(path, roots) || isPathWithinRoots(resourceIdentity(path), roots); + const identities = new Set(entries.filter((entry) => entry.metadata.scope === "project" || projectPath(entry.path) || projectPath(entry.identity)).map((entry) => entry.identity)); + // Selection can name a project symlink whose real file already has a user-scope catalog entry. + if (Array.isArray(selection)) for (const value of selection) { + const path = explicitResourcePath(value, cwd); + if (path && projectPath(path)) identities.add(resourceIdentity(path)); + } + return identities; +} + +/** Reuse original services/model runtime/cwd. Only restricted resources get a transient SDK loader. */ +export async function createSubagentSessionServices(options: CreateAgentSessionServicesOptions & { agentDir: string; settingsManager: SettingsManager }, policy: SubagentResourcePolicy) { + const { cwd, agentDir, settingsManager: source } = options; + // Compose host factories in one place on both create and cold restore. Reload reuses + // this factory but obtains the newly approved, trust-checked roster from the loader. + let policyLoader: Awaited>["resourceLoader"] | undefined; + if (policy.toolPolicy) options = { + ...options, + resourceLoaderOptions: { + ...options.resourceLoaderOptions, + extensionFactories: [ + ...options.resourceLoaderOptions?.extensionFactories ?? [], + createSubagentToolPolicyExtension(policy.builtinTools ?? [], policy.toolPolicy, () => { + if (!policyLoader) throw new Error("Subagent resource roster not ready"); + return policyLoader.getExtensions().extensions; + }), + ], + }, + }; + assertResourceSettingsReadable(source); + const selections = { skills: policy.skills ?? policy.loadSkills, extensions: policy.extensions ?? policy.loadExtensions }; + if (selections.skills === true && selections.extensions === true) { + const services = await createAgentSessionServices(options); + policyLoader = services.resourceLoader; + assertResourceSettingsReadable(source); + const reload = services.resourceLoader.reload.bind(services.resourceLoader); + services.resourceLoader.reload = async () => { + source.setProjectTrusted(getProjectTrustStatus(cwd, agentDir).trusted); + await reload(projectTrustReloadOptions(cwd, agentDir)); + assertResourceSettingsReadable(source); + }; + return services; + } + + const settingsManager = createSubagentMemorySettings(source); + const extensionPaths: string[] = []; + const skillPaths: string[] = []; + let approvedSkills: SubagentResourceItem[] = []; + let approvedExtensions: SubagentResourceItem[] = []; + let diagnostics: Awaited>["diagnostics"] = []; + const prepare = async () => { + // Resolve statically before deciding trust: explicit project files also require a real decision. + // Inspection excludes project executable settings and is never passed to services. + const catalog = await readSubagentResourceCatalog(cwd, agentDir); + addExplicitResources(catalog, selections, cwd, agentDir); + const skills = selectCatalogResources(catalog.skills, selections.skills, cwd); + const extensions = selectCatalogResources(catalog.extensions, selections.extensions, cwd); + const projectIdentities = projectExtensionIdentities(catalog.extensions, selections.extensions, cwd); + const trusted = getProjectTrustStatus(cwd, agentDir, { additionalProjectResources: extensions.items.some((entry) => projectIdentities.has(entry.identity)) }).trusted; + source.setProjectTrusted(trusted); + await source.reload(); + assertResourceSettingsReadable(source); + // Explicit reload boundary: both scopes read the refreshed source plus local setter deltas. + await settingsManager.reload(); + settingsManager.setProjectTrusted(trusted); + assertResourceSettingsReadable(settingsManager); + const explicitSkills = new Set(selectCatalogResources(catalog.skills, Array.isArray(selections.skills) ? selections.skills.filter((entry) => entry !== "*") : false, cwd).items.map((entry) => entry.identity)); + approvedSkills = skills.items.filter((entry) => trusted || explicitSkills.has(entry.identity) || entry.metadata.scope !== "project"); + approvedExtensions = extensions.items.filter((entry) => trusted || !projectIdentities.has(entry.identity)); + diagnostics = [...catalog.diagnostics, ...skills.diagnostics, ...extensions.diagnostics]; + for (const entry of extensions.items.filter((item) => !approvedExtensions.includes(item))) diagnostics.push({ type: "warning", path: entry.path, message: "Project extension not loaded: project trust required" }); + // Load the identity we approved, not an alias that can be retargeted before SDK import. + extensionPaths.splice(0, extensionPaths.length, ...approvedExtensions.map((entry) => entry.identity)); + skillPaths.splice(0, skillPaths.length, ...approvedSkills.map((entry) => entry.path)); + }; + const skillsOverride = (base: { skills: Skill[]; diagnostics: typeof diagnostics }) => ({ + skills: selections.skills === false ? [] : selections.skills === true || (Array.isArray(selections.skills) && selections.skills.includes("*")) ? base.skills : base.skills.filter((skill) => approvedSkills.some((entry) => entry.identity === resourceIdentity(skill.filePath) && entry.name === skill.name)), + diagnostics: uniqueResourceDiagnostics([...base.diagnostics, ...diagnostics]), + }); + await prepare(); + const services = await createAgentSessionServices({ + ...options, settingsManager, + // Current real trust is already resolved. Do not bootstrap-import temporary project paths. + resourceLoaderReloadOptions: undefined, + resourceLoaderOptions: { + ...options.resourceLoaderOptions, + noExtensions: true, noSkills: true, + additionalExtensionPaths: extensionPaths, additionalSkillPaths: skillPaths, + skillsOverride, + }, + }); + const loader = services.resourceLoader; + policyLoader = loader; + const restoreSources = () => { + for (const extension of loader.getExtensions().extensions) { + const entry = approvedExtensions.find((item) => item.identity === resourceIdentity(extension.path)); + if (!entry) continue; // inline host factories have no catalog source + extension.sourceInfo = { ...entry.metadata, path: extension.path }; + for (const tool of extension.tools.values()) tool.sourceInfo = extension.sourceInfo; + for (const command of extension.commands.values()) command.sourceInfo = extension.sourceInfo; + } + for (const skill of loader.getSkills().skills) { + const entry = approvedSkills.find((item) => item.identity === resourceIdentity(skill.filePath)); + if (entry) skill.sourceInfo = { ...entry.metadata, path: skill.filePath }; + } + }; + restoreSources(); + const reload = loader.reload.bind(loader); + loader.reload = async () => { + await prepare(); + await reload(); + restoreSources(); + assertResourceSettingsReadable(settingsManager); + }; + const extend = loader.extendResources.bind(loader); + loader.extendResources = (paths) => { + // SDK skillsOverride runs on resources_discover and every extendResources call too. + extend({ skillPaths: paths.skillPaths }); + restoreSources(); + }; + return services; +} diff --git a/lib/subagent-runtime-ready.integration.test.mjs b/lib/subagent-runtime-ready.integration.test.mjs new file mode 100644 index 0000000000..e4b6b953f8 --- /dev/null +++ b/lib/subagent-runtime-ready.integration.test.mjs @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { createJiti } from "jiti"; + +const dir = await mkdtemp(join(tmpdir(), "pi-runtime-ready-")); +const oldHome = process.env.HOME, oldAgent = process.env.PI_CODING_AGENT_DIR; +process.env.HOME = join(dir, "home"); process.env.PI_CODING_AGENT_DIR = join(dir, "agent"); +await mkdir(process.env.HOME); await mkdir(process.env.PI_CODING_AGENT_DIR); +await mkdir(join(process.env.PI_CODING_AGENT_DIR, "agents")); +await writeFile(join(process.env.PI_CODING_AGENT_DIR, "agents", "settings.json"), JSON.stringify({ maxConcurrent: 1 })); +after(async () => { + if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome; + if (oldAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = oldAgent; + delete globalThis.__piRuntimeReadyFixture; + await rm(dir, { recursive: true, force: true }); +}); +const { ModelRuntime, SessionManager } = await import("@earendil-works/pi-coding-agent"); +const { fauxProvider, fauxAssistantMessage, fauxText, fauxToolCall } = await import("@earendil-works/pi-ai"); +const jiti = createJiti(import.meta.url); +const { createSubagentController } = await jiti.import("./subagent-runtime.ts"); +const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts"); +const { readSubagentSessionResources } = await jiti.import("./subagents.ts"); + +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 15)); + +async function fixture(t) { + const cwd = await mkdtemp(join(dir, "cwd-")); + const hooks = { gates: new Map(), prompts: 0, providers: 0, tools: 0, binds: 0 }; + globalThis.__piRuntimeReadyFixture = hooks; + const extension = join(await mkdtemp(join(process.env.PI_CODING_AGENT_DIR, "extension-")), "fixture.ts"); + await writeFile(extension, `export default function(pi) { + pi.registerTool({name:'mock_probe',label:'Probe',description:'Fixture only',parameters:{type:'object',properties:{}},execute:async()=>{ + globalThis.__piRuntimeReadyFixture.tools++; return {content:[],details:undefined}; + }}); + pi.on('session_start',async (_event,ctx)=>{ + const gate=globalThis.__piRuntimeReadyFixture.gates.get(ctx.sessionManager.getSessionId()); + if(gate) { gate.entered.resolve(); await gate.promise; } + }); + }`); + await mkdir(join(cwd, ".pi", "agents"), { recursive: true }); + await writeFile(join(cwd, ".pi", "agents", "ready.md"), `---\ntools: read, ext:fixture\nextensions: [${JSON.stringify(extension)}]\n---\nFixture.`); + const faux = fauxProvider({ models: [{ id: "ready-faux" }] }); + const runtime = await ModelRuntime.create({ authPath: join(cwd, "auth.json"), modelsPath: null, refreshOnCreate: false }); + runtime.registerNativeProvider(faux.provider); + const response = () => { hooks.providers++; return fauxAssistantMessage([fauxText("fixture done")]); }; + faux.setResponses(Array.from({ length: 20 }, () => response)); + const manager = SessionManager.inMemory(cwd); + const parent = { + cwd, sessionFile: join(cwd, "parent.jsonl"), isAlive: () => true, isRunning: () => false, + waitUntilReady: async () => {}, + inner: { sessionManager: manager, modelRuntime: runtime, model: faux.getModel("ready-faux"), agent: { state: {} } }, + }; + const wrappers = new Map([[manager.getSessionId(), parent]]); + const plans = []; + const controller = createSubagentController({ + getSession: (id) => wrappers.get(id), + registerSession(inner) { + const plan = plans.shift() ?? {}; + if (plan.binding) hooks.gates.set(inner.sessionId, plan.binding); + const originalPrompt = inner.prompt.bind(inner), originalBind = inner.bindExtensions.bind(inner); + inner.prompt = async (...args) => { + hooks.prompts++; + await originalPrompt(...args); + if (plan.rejectAfterPrompt) throw Error("fixture prompt rejected after turn-limit abort"); + }; + inner.bindExtensions = (...args) => { hooks.binds++; return originalBind(...args); }; + const wrapper = new AgentSessionWrapper(inner, { subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries()) }); + wrappers.set(inner.sessionId, wrapper); + wrapper.beginExtensionBinding(); + // One registration boundary and the same wrapper's bind boundary, never a second bind. + return plan.voidRegistration ? undefined : plan.registration ?? Promise.resolve(); + }, + reopenSession: async (id) => wrappers.get(id), resolveSessionPath: async () => null, + invalidateSessionList: () => {}, isBuiltInSubagentsEnabled: () => true, + }); + t.after(async () => { + for (const [id, wrapper] of wrappers) if (id !== manager.getSessionId()) await wrapper.destroy(); + runtime.dispose?.(); + }); + const request = { parentContext: parent.inner, parentToolCallId: "call", profile: "ready", task: "Fixture", description: "Fixture" }; + return { controller, hooks, plans, request, wrappers, faux }; +} + +for (const phase of ["registration", "binding", "resume"]) { + for (const cancel of ["parent", "Stop"]) { + test(`${phase}: ${cancel} while waiting ready prevents every prompt/provider/tool; next run works`, async (t) => { + const f = await fixture(t), gate = deferred(), signal = new AbortController(); + let execution; + if (phase === "resume") { + const first = await f.controller.extensionRuntime.start(f.request); + assert.equal((await first.completion).status, "completed"); + f.hooks.prompts = 0; f.hooks.providers = 0; + const wrapper = f.wrappers.get(first.run.sessionId); + const ready = wrapper.waitUntilReady.bind(wrapper); + const entered = deferred(); + wrapper.waitUntilReady = async () => { await ready(); entered.resolve(); await gate.promise; }; + execution = await f.controller.extensionRuntime.resume({ ...f.request, sessionId: first.run.sessionId, signal: signal.signal }); + await entered.promise; + } else { + if (phase === "registration") f.plans.push({ registration: gate.promise }); + else { gate.entered = deferred(); f.plans.push({ binding: gate }); } + execution = await f.controller.extensionRuntime.start({ ...f.request, signal: signal.signal }); + if (phase === "binding") await gate.entered.promise; + } + assert.equal((await f.controller.get(execution.run.sessionId)).status, "running"); + assert.equal(f.wrappers.get(execution.run.sessionId).isRunning(), false, "SDK remains idle during ready"); + if (cancel === "parent") signal.abort(); else await f.controller.abort(execution.run.sessionId); + gate.resolve(); + const result = await execution.completion; + assert.equal(result.status, "aborted"); assert.equal(result.error, undefined); + assert.equal(f.hooks.prompts, 0); assert.equal(f.hooks.providers, 0); assert.equal(f.hooks.tools, 0); + const entries = f.wrappers.get(result.sessionId).inner.sessionManager.getEntries(); + assert.equal(entries.at(-1).data.status, "aborted"); + assert.equal((await f.controller.get(result.sessionId)).status, "aborted"); + const next = await f.controller.extensionRuntime.start(f.request); + assert.equal((await next.completion).status, "completed"); + assert.equal(f.hooks.prompts, 1); assert.equal(f.hooks.providers, 1); + assert.equal(f.hooks.binds, 2, "one bind per created child, resume does not bind again"); + }); + } +} + +test("queued registration rejects immediately without unhandled rejection, fails on dequeue; cancelled rejection and next run are safe", async (t) => { + const f = await fixture(t), firstGate = deferred(), unhandled = []; + const onUnhandled = (error) => unhandled.push(error); + process.on("unhandledRejection", onUnhandled); + t.after(() => process.removeListener("unhandledRejection", onUnhandled)); + f.plans.push({ registration: firstGate.promise }); + const first = await f.controller.extensionRuntime.start(f.request); + const states = []; + // The rejection originates at registration, not after the job is dequeued. + // Produce it inside registerSession so no fixture-owned naked rejection exists. + f.plans.push({ get registration() { return Promise.reject(Error("registration failed")); } }); + const failed = await f.controller.extensionRuntime.start({ ...f.request, onUpdate: (run) => states.push(run.status) }); + assert.equal(failed.run.status, "queued"); + f.plans.push({ get registration() { return Promise.reject(Error("cancelled registration failed")); } }); + const cancelled = await f.controller.extensionRuntime.start(f.request); + await f.controller.abort(cancelled.run.sessionId); + assert.equal((await cancelled.completion).status, "aborted"); + const next = await f.controller.extensionRuntime.start(f.request); + await tick(); assert.deepEqual(unhandled, []); assert.equal(f.hooks.prompts, 0); + firstGate.resolve(); assert.equal((await first.completion).status, "completed"); + const failure = await failed.completion; + assert.equal(failure.status, "failed"); assert.match(failure.error, /registration failed/); + assert.ok(states.includes("queued")); assert.ok(states.includes("failed")); + assert.equal((await f.controller.get(failure.sessionId)).status, "failed"); + assert.equal(f.wrappers.get(failure.sessionId).inner.sessionManager.getEntries().at(-1).data.status, "failed"); + assert.equal((await next.completion).status, "completed"); + await tick(); assert.deepEqual(unhandled, []); + assert.equal(f.hooks.prompts, 2); assert.equal(f.hooks.providers, 2); assert.equal(f.hooks.tools, 0); assert.equal(f.hooks.binds, 4); + t.diagnostic(JSON.stringify({ unhandledRejection: 0, failedAndCancelledPrompts: 0, nextWorks: true, externalProviderRequests: 0, browserStarts: 0, mcpStarts: 0 })); +}); + +for (const mode of ["create", "resume"]) { + for (const runInBackground of [false, true]) { + test(`${mode}: pre-listener onUpdate abort ${runInBackground ? "preserves background queue" : "settles queued foreground before first ready"}`, async (t) => { + const f = await fixture(t), signal = new AbortController(), gate = deferred(); + let resumedId; + if (mode === "resume") { + const seed = await f.controller.extensionRuntime.start(f.request); + assert.equal((await seed.completion).status, "completed"); + resumedId = seed.run.sessionId; + f.hooks.prompts = 0; f.hooks.providers = 0; + } + gate.entered = deferred(); f.plans.push({ binding: gate }); + const first = await f.controller.extensionRuntime.start(f.request); + let firstSettled = false; + void first.completion.then(() => { firstSettled = true; }); + try { + await gate.entered.promise; + const firstBefore = await f.controller.get(first.run.sessionId); + const states = []; + const request = { + ...f.request, signal: signal.signal, runInBackground, + onUpdate(run) { + states.push(run.status); + // Only the initial queued update: abort before listener installation. + if (states.length === 1) { assert.equal(run.status, "queued"); signal.abort(); } + }, + }; + const second = mode === "create" + ? await f.controller.extensionRuntime.start(request) + : await f.controller.extensionRuntime.resume({ ...request, sessionId: resumedId }); + let secondSettled = false; + void second.completion.then(() => { secondSettled = true; }); + assert.equal(signal.signal.aborted, true); + if (runInBackground) { + await tick(); + assert.equal(secondSettled, false); + assert.equal((await f.controller.get(second.run.sessionId)).status, "queued"); + } else { + let timeout; + const result = await Promise.race([ + second.completion, + new Promise((_, reject) => { timeout = setTimeout(() => reject(Error("queued cancellation waited for first ready")), 500); }), + ]).finally(() => clearTimeout(timeout)); + assert.equal(result.status, "aborted"); assert.equal(result.error, undefined); + assert.equal((await f.controller.get(result.sessionId)).status, "aborted"); + assert.equal(f.wrappers.get(result.sessionId).inner.sessionManager.getEntries().at(-1).data.status, "aborted"); + assert.ok(!states.includes("running")); assert.equal(states.at(-1), "aborted"); + } + // The cancellation must not release, finish or alter the earlier running slot. + assert.equal(firstSettled, false); + assert.deepEqual(await f.controller.get(first.run.sessionId), firstBefore); + assert.equal(f.hooks.prompts, 0); assert.equal(f.hooks.providers, 0); assert.equal(f.hooks.tools, 0); + gate.resolve(); assert.equal((await first.completion).status, "completed"); + assert.equal((await second.completion).status, runInBackground ? "completed" : "aborted"); + assert.equal(f.hooks.prompts, runInBackground ? 2 : 1); + assert.equal(f.hooks.providers, runInBackground ? 2 : 1); + t.diagnostic(JSON.stringify({ mode, runInBackground, foregroundCancelledBeforeFirstReady: !runInBackground, promptsBeforeFirstReady: 0, externalProviderRequests: 0 })); + } finally { + // Release the real binding before fixture wrapper cleanup, even on regression failure. + gate.resolve(); await first.completion; + } + }); + } +} + +test("an already cancelled foreground request cannot prompt", async (t) => { + const f = await fixture(t), signal = new AbortController(); signal.abort(); + const execution = await f.controller.extensionRuntime.start({ ...f.request, signal: signal.signal }); + assert.equal((await execution.completion).status, "aborted"); assert.equal(f.hooks.prompts, 0); assert.equal(f.hooks.providers, 0); +}); + +test("background ready wait ignores parent cancellation and supports void registration", async (t) => { + const f = await fixture(t), signal = new AbortController(), gate = deferred(); + gate.entered = deferred(); f.plans.push({ binding: gate, voidRegistration: true }); + const execution = await f.controller.extensionRuntime.start({ ...f.request, runInBackground: true, signal: signal.signal }); + await gate.entered.promise; signal.abort(); gate.resolve(); + assert.equal((await execution.completion).status, "completed"); + assert.equal(f.hooks.prompts, 1); assert.equal(f.hooks.providers, 1); assert.equal(f.hooks.binds, 1); +}); + +test("maxTurnsReached prompt rejection still completes rather than becoming a cancellation", async (t) => { + const f = await fixture(t); + f.plans.push({ rejectAfterPrompt: true }); + f.faux.setResponses(Array.from({ length: 4 }, () => () => fauxAssistantMessage([fauxToolCall("mock_probe", {})], { stopReason: "toolUse" }))); + const execution = await f.controller.extensionRuntime.start({ ...f.request, maxTurns: 1 }); + const result = await execution.completion; + assert.equal(result.status, "completed", JSON.stringify(result)); + assert.equal(f.hooks.prompts, 1); assert.equal(f.hooks.tools, 2); +}); diff --git a/lib/subagent-runtime.ts b/lib/subagent-runtime.ts index 68c8bb7e23..221fff3093 100644 --- a/lib/subagent-runtime.ts +++ b/lib/subagent-runtime.ts @@ -1,7 +1,6 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { createAgentSessionFromServices, - createAgentSessionServices, getAgentDir, initTheme, SessionManager, @@ -20,12 +19,9 @@ import { import { readSubagentRun, resolveSubagentProfile, - SUBAGENT_CONTROL_TOOL_NAMES, SUBAGENT_META_TYPE, SUBAGENT_STATUS_TYPE, SUBAGENT_RESULT_TYPE, - selectSubagentExtensionTools, - withSubagentExtensionTools, type SubagentMetadata, type SubagentResultMetadata, type SubagentRunInfo, @@ -34,7 +30,9 @@ import type { SessionEntry } from "./types"; import { buildSubagentPromptPlan } from "./subagent-prompt"; import { createExactSystemPromptExtension } from "./exact-system-prompt"; import { appendSubagentInputFiles, loadSubagentInputFiles } from "./subagent-input"; -import { projectTrustReloadOptions } from "./project-trust"; +import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust"; +import { createSubagentSessionServices } from "./subagent-resources"; +import { subagentToolExclusions, subagentToolPolicy } from "./subagent-tool-policy"; import { resolveShellTools } from "./powershell-settings"; import { isBuiltInSubagentsEnabled, readSubagentSettings } from "./subagent-settings"; import { SubagentQueue } from "./subagent-queue"; @@ -55,7 +53,7 @@ export interface SubagentRuntimeDependencies { registerSession( inner: AgentSessionLike, options?: { exactSystemPrompt?: string; chatOnly?: boolean }, - ): void; + ): void | Promise; reopenSession(sessionId: string, sessionFile: string): Promise; resolveSessionPath(sessionId: string): Promise; invalidateSessionList(): void; @@ -76,6 +74,12 @@ type StoredSubagentExecution = { cancelQueued?: () => boolean; }; +/** Check admission after every readiness await; aborting an idle SDK session alone cannot stop a later prompt. */ +function throwIfSubagentCancelled(stored: StoredSubagentExecution, signal?: AbortSignal): void { + if (signal?.aborted) stored.abortRequested = true; + if (stored.abortRequested) throw new Error("Subagent cancelled before prompting"); +} + declare global { var __piSubagentRuns: Map | undefined; var __piSubagentQueue: SubagentQueue | undefined; @@ -223,7 +227,7 @@ export function createSubagentController( const agentDir = getAgentDir(); const parentModelRuntime = (parent.inner as unknown as { modelRuntime: ModelRuntime }).modelRuntime; - const settingsManager = SettingsManager.create(childCwd, agentDir); + const settingsManager = SettingsManager.create(childCwd, agentDir, { projectTrusted: getProjectTrustStatus(childCwd, agentDir).trusted }); const inheritedParentContext = inheritContext ? `The following is the active conversation context from the parent session. Use it only as background for the delegated task:\n${parentContextText(parent)}` : undefined; @@ -239,7 +243,9 @@ export function createSubagentController( }); const { chatOnly, appendSystemPrompt, delegatedTask } = promptPlan; if (!chatOnly) initTheme(); - const services = await createAgentSessionServices({ + const builtinTools = resolveShellTools(profile.tools, settingsManager.getDefaultTools()); + const toolPolicy = subagentToolPolicy(profile); + const services = await createSubagentSessionServices({ cwd: childCwd, agentDir, modelRuntime: parentModelRuntime, @@ -265,21 +271,7 @@ export function createSubagentController( ...((profile.loadExtensions || profile.loadSkills) ? { resourceLoaderReloadOptions: projectTrustReloadOptions(childCwd, agentDir) } : {}), - }); - - const extensionToolNames = profile.loadExtensions - ? profile.extensionTools?.length - ? selectSubagentExtensionTools( - services.resourceLoader.getExtensions().extensions, - profile.extensionTools, - profile.disallowedExtensionTools, - ) - : services.resourceLoader.getExtensions().extensions.flatMap((extension) => [...extension.tools.keys()]) - : []; - const activeTools = resolveShellTools( - withSubagentExtensionTools(profile.tools, extensionToolNames), - settingsManager.getDefaultTools(), - ); + }, { ...profile, builtinTools, toolPolicy }); const sessionManager = isolatedWorktree ? SessionManager.create(childCwd, undefined, { parentSession: parent.sessionFile }) @@ -296,12 +288,15 @@ export function createSubagentController( runInBackground, createdAt, resourceSnapshot: { - version: 1, + version: 2, appendSystemPrompt: [...appendSystemPrompt], - tools: [...activeTools], + builtinTools: [...builtinTools], + toolPolicy, loadSkills: profile.loadSkills, - loadExtensions: profile.loadExtensions, - ...(promptPlan.exactSystemPrompt !== undefined ? { exactSystemPrompt: promptPlan.exactSystemPrompt } : {}), + loadExtensions: profile.loadExtensions, + skills: profile.skills ?? profile.loadSkills, + extensions: profile.extensions ?? profile.loadExtensions, + ...(promptPlan.exactSystemPrompt !== undefined ? { exactSystemPrompt: promptPlan.exactSystemPrompt } : {}), }, ...(isolatedWorktree ? { worktreePath: isolatedWorktree.path, worktreeBranch: isolatedWorktree.branch } : {}), }; @@ -315,15 +310,23 @@ export function createSubagentController( sessionManager, model: requestedModel ?? parentModel, ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}), - tools: activeTools, - excludeTools: [...SUBAGENT_CONTROL_TOOL_NAMES], + noTools: "builtin", + excludeTools: subagentToolExclusions(builtinTools), }); - dependencies.registerSession(inner, { + // Public loadout API, not tools' immutable hard allowlist. Preserve extension + // exposure/defaultActive; never force hidden/codemode/deferred tools active. + inner.setActiveToolsByName([...new Set([...builtinTools, ...inner.getActiveToolNames()])]); + // Handle rejection at registration, even if this run remains queued or is cancelled. + // Keep the error as data until execution so a failed bind never admits a prompt. + const ready = Promise.resolve(dependencies.registerSession(inner, { ...(promptPlan.exactSystemPrompt !== undefined ? { exactSystemPrompt: promptPlan.exactSystemPrompt } : {}), chatOnly, - }); + })).then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); const initialRun: SubagentRunInfo = { sessionId: inner.sessionId, @@ -360,7 +363,7 @@ export function createSubagentController( const stored: StoredSubagentExecution = { run: initialRun, completion, - abortRequested: false, + abortRequested: !runInBackground && request.signal?.aborted === true, }; getSubagentRuns().set(initialRun.sessionId, stored); request.onUpdate?.(initialRun); @@ -374,22 +377,18 @@ export function createSubagentController( if (!runInBackground) request.signal?.addEventListener("abort", handleParentAbort, { once: true }); const execute = async (): Promise => { - if (stored.abortRequested) { - const result: SubagentRunInfo = { ...initialRun, status: "aborted", completedAt: new Date().toISOString() }; - sessionManager.appendCustomEntry(SUBAGENT_RESULT_TYPE, { version: 1, status: "aborted", completedAt: result.completedAt }); - await cleanupWorktree(parent.cwd, isolatedWorktree); - stored.run = result; - request.onUpdate?.(result); - getSubagentRuns().delete(initialRun.sessionId); - dependencies.invalidateSessionList(); - return result; - } stored.run = { ...stored.run, status: "running" }; sessionManager.appendCustomEntry(SUBAGENT_STATUS_TYPE, { version: 1, status: "running" }); request.onUpdate?.(stored.run); dependencies.invalidateSessionList(); let result: SubagentRunInfo; try { + throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); + const registration = await ready; + throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); + if (!registration.ok) throw registration.error; + await dependencies.getSession(inner.sessionId)?.waitUntilReady?.(); + throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); await inner.prompt(delegatedTask, { source: "rpc" }); const text = inner.getLastAssistantText()?.trim(); const aborted = stored.abortRequested && !maxTurnsReached; @@ -438,6 +437,8 @@ export function createSubagentController( const finishQueuedAbort = async () => { if (stored.run.status !== "queued") return; + unsubscribeTurns(); + request.signal?.removeEventListener("abort", handleParentAbort); const result: SubagentRunInfo = { ...initialRun, status: "aborted", completedAt: new Date().toISOString() }; const cleanupError = await cleanupWorktree(parent.cwd, isolatedWorktree); const finalResult = cleanupError ? { ...result, worktreeCleanupError: cleanupError } : result; @@ -463,6 +464,9 @@ export function createSubagentController( finishQueuedAbort, ); stored.cancelQueued = queued.cancel; + // Synchronous update callbacks may abort before the listener/cancel hook exists. + if (!runInBackground && request.signal?.aborted) stored.abortRequested = true; + if (stored.abortRequested) stored.cancelQueued(); void queued.promise.then(resolveCompletion, (error) => { resolveCompletion({ ...initialRun, status: "failed", completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) }); }); @@ -509,7 +513,7 @@ export function createSubagentController( const manager = wrapper.inner.sessionManager; let resolveCompletion!: (run: SubagentRunInfo) => void; const completion = new Promise((resolve) => { resolveCompletion = resolve; }); - const stored: StoredSubagentExecution = { run: initialRun, completion, abortRequested: false }; + const stored: StoredSubagentExecution = { run: initialRun, completion, abortRequested: !runInBackground && request.signal?.aborted === true }; getSubagentRuns().set(request.sessionId, stored); request.onUpdate?.(initialRun); dependencies.invalidateSessionList(); @@ -521,19 +525,14 @@ export function createSubagentController( if (!runInBackground) request.signal?.addEventListener("abort", handleParentAbort, { once: true }); const execute = async (): Promise => { - if (stored.abortRequested) { - const result: SubagentRunInfo = { ...initialRun, status: "aborted", completedAt: new Date().toISOString() }; - manager.appendCustomEntry(SUBAGENT_RESULT_TYPE, { version: 1, status: "aborted", completedAt: result.completedAt }); - stored.run = result; - getSubagentRuns().delete(request.sessionId); - resolveCompletion(result); - return result; - } stored.run = { ...stored.run, status: "running" }; manager.appendCustomEntry(SUBAGENT_STATUS_TYPE, { version: 1, status: "running" }); request.onUpdate?.(stored.run); let result: SubagentRunInfo; try { + throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); + await wrapper!.waitUntilReady(); + throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); await wrapper!.inner.prompt(request.task, { source: "rpc" }); const text = wrapper!.inner.getLastAssistantText()?.trim(); const providerError = stored.abortRequested ? undefined : lastAssistantError(manager); @@ -569,6 +568,7 @@ export function createSubagentController( }; const finishQueuedAbort = () => { if (stored.run.status !== "queued") return; + request.signal?.removeEventListener("abort", handleParentAbort); const result: SubagentRunInfo = { ...initialRun, status: "aborted", completedAt: new Date().toISOString() }; manager.appendCustomEntry(SUBAGENT_RESULT_TYPE, { version: 1, status: "aborted", completedAt: result.completedAt }); stored.run = result; @@ -584,6 +584,9 @@ export function createSubagentController( dependencies.invalidateSessionList(); }, finishQueuedAbort); stored.cancelQueued = queued.cancel; + // Synchronous update callbacks may abort before the listener/cancel hook exists. + if (!runInBackground && request.signal?.aborted) stored.abortRequested = true; + if (stored.abortRequested) stored.cancelQueued(); void queued.promise.then(resolveCompletion, (error) => resolveCompletion({ ...initialRun, status: "failed", completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) })); return { run: stored.run, completion }; } @@ -650,7 +653,8 @@ export function createSubagentController( if (!stored.cancelQueued?.()) throw new Error("Subagent is no longer queued"); return; } - if (!wrapper?.isAlive() || !wrapper.isRunning()) throw new Error("Subagent is not running"); + // The controller owns a running slot while binding, before the SDK is streaming. + if (!wrapper?.isAlive() || (!wrapper.isRunning() && stored?.run.status !== "running")) throw new Error("Subagent is not running"); if (stored) stored.abortRequested = true; await wrapper.inner.abort(); } diff --git a/lib/subagent-tool-policy.integration.test.mjs b/lib/subagent-tool-policy.integration.test.mjs new file mode 100644 index 0000000000..19dfb74a4d --- /dev/null +++ b/lib/subagent-tool-policy.integration.test.mjs @@ -0,0 +1,269 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, access, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { createJiti } from "jiti"; + +const dir = await mkdtemp(join(tmpdir(), "pi-late-tools-sdk-")); +const oldHome = process.env.HOME, oldAgent = process.env.PI_CODING_AGENT_DIR; +process.env.HOME = join(dir, "home"); process.env.PI_CODING_AGENT_DIR = join(dir, "agent"); +await mkdir(process.env.HOME); await mkdir(process.env.PI_CODING_AGENT_DIR); +after(async () => { + if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome; + if (oldAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = oldAgent; + await rm(dir, { recursive: true, force: true }); +}); +const { AgentSession, ModelRuntime, SettingsManager, SessionManager, createAgentSessionFromServices } = await import("@earendil-works/pi-coding-agent"); +const { fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } = await import("@earendil-works/pi-ai"); +const jiti = createJiti(import.meta.url); +const { createSubagentSessionServices } = await jiti.import("./subagent-resources.ts"); +const { subagentToolExclusions, subagentToolPolicy } = await jiti.import("./subagent-tool-policy.ts"); +const { listSubagentProfiles, readSubagentSessionResources, SUBAGENT_META_TYPE } = await jiti.import("./subagents.ts"); +const { AgentSessionWrapper, startRpcSession } = await jiti.import("./rpc-manager.ts"); + +async function exists(path) { try { await access(path); return true; } catch { return false; } } +async function setup(t, selectors, deny = [], discovery = false) { + const root = await mkdtemp(join(dir, "case-")), cwd = join(root, "cwd"), agentDir = join(root, "agent"); + await mkdir(cwd); await mkdir(agentDir); await mkdir(join(agentDir, "extensions")); + const marker = join(root, "denied.marker"), allowedMarker = join(root, "browser.marker"); + const path = join(agentDir, "extensions", "browser.ts"); + await writeFile(path, `import fs from 'node:fs'; +export default function(pi) { + ${discovery ? `const registerLate = () => pi.registerTool({name:'discovery_denied',label:'Denied discovery',description:'Denied discovery',parameters:{type:'object',properties:{}},execute:async()=>{fs.appendFileSync(${JSON.stringify(marker)}, 'DISCOVERY EXECUTED');return {content:[],details:undefined};}}); + pi.on('resources_discover', () => { registerLate(); return {}; }); + pi.registerCommand('fixture-register-denied', {handler:async()=>{registerLate(); pi.setActiveTools([...pi.getActiveTools(), 'discovery_denied']);}}); + pi.registerCommand('fixture-reload', {handler:async(_args,ctx)=>{await ctx.reload();}});` : ""} + pi.on('session_start', async () => { + await new Promise(r => setTimeout(r, 25)); + const tool = (name, exposure, execute) => pi.registerTool({name,label:name,description:name,exposure,parameters:{type:'object',properties:{target:{type:'string'},file:{type:'string'}}},execute}); + const denied = async () => { fs.appendFileSync(${JSON.stringify(marker)}, 'executed\\n'); return {content:[],details:undefined}; }; + tool('browser_mock','direct',async (_id,args,_signal,_update,ctx) => { + if (args.target) { + try { return await ctx.executeTool(args.target, args.file ? {path:args.file,content:'DENIED'} : {}); } + catch(error) { return {content:[{type:'text',text:error.message}],details:undefined}; } + } + fs.appendFileSync(${JSON.stringify(allowedMarker)}, 'executed\\n'); + return {content:[{type:'text',text:'browser mock ok'}],details:undefined}; + }); + tool('denied_deferred','deferred',denied); + tool('denied_codemode','codemode',denied); + tool('optional_hidden','hidden',denied); + for(const name of ['Agent','get_subagent_result','steer_subagent','write','bash','powershell']) tool(name,'direct',denied); + }); +}`); + const faux = fauxProvider({ models: [{ id: "late-faux" }] }); + const runtime = await ModelRuntime.create({ authPath: join(root, "auth.json"), modelsPath: null, refreshOnCreate: false }); + runtime.registerNativeProvider(faux.provider); t.after(() => runtime.dispose?.()); + const toolPolicy = { mode: selectors === undefined ? "implicitAll" : "selectors", selectors: selectors ?? [], deny }; + const resources = { version: 2, builtinTools: ["read"], tools: ["read"], toolPolicy, loadExtensions: true, loadSkills: false, extensions: [path], skills: false, appendSystemPrompt: ["Fixture"] }; + const services = await createSubagentSessionServices({ cwd, agentDir, modelRuntime: runtime, settingsManager: SettingsManager.create(cwd, agentDir), resourceLoaderOptions: { noPromptTemplates: true, noThemes: true, noContextFiles: true } }, resources); + const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: faux.getModel("late-faux"), noTools: "builtin", excludeTools: subagentToolExclusions(resources.builtinTools) }); + session.setActiveToolsByName([...resources.builtinTools, ...session.getActiveToolNames()]); + const snapshot = structuredClone(resources); delete snapshot.tools; + session.sessionManager.appendCustomEntry(SUBAGENT_META_TYPE, { version: 1, parentSessionId: "parent", parentSessionPath: "/parent", resourceSnapshot: snapshot }); + const wrapper = new AgentSessionWrapper(session, { subagentResources: readSubagentSessionResources(session.sessionManager.getEntries()) }); + t.after(() => wrapper.destroy()); + let binds = 0; + const bind = session.bindExtensions.bind(session); + session.bindExtensions = (...args) => { binds++; return bind(...args); }; + wrapper.beginExtensionBinding(); + return { root, cwd, agentDir, path, marker, allowedMarker, faux, runtime, session, wrapper, resources, binds: () => binds }; +} + +async function calls(f, calls) { + f.faux.setResponses([ + () => fauxAssistantMessage(calls.map(([name, args]) => fauxToolCall(name, args)), { stopReason: "toolUse" }), + () => fauxAssistantMessage([fauxText("done")]), + ]); + await f.session.prompt("fixture tools only"); + return f.session.messages.filter((message) => message.role === "toolResult").slice(-calls.length); +} + +test("late approved browser is visible on first public read, directly and nested callable; denied execution markers stay zero", async (t) => { + const f = await setup(t, undefined, ["ext:browser/denied_deferred", "ext:browser/denied_codemode"]); + // No explicit wait before the first Tools page read: send waits for the existing bind. + const tools = await f.wrapper.send({ type: "get_tools" }); + assert.equal(tools.find((tool) => tool.name === "browser_mock")?.active, true); + assert.ok((await f.wrapper.send({ type: "get_state" })).activeToolNames.includes("browser_mock")); + assert.equal(f.binds(), 1); + assert.ok(!f.session.getActiveToolNames().some((name) => ["denied_deferred", "denied_codemode", "optional_hidden"].includes(name))); + // Deferred/codemode remain registered and nested-callable despite being inactive. + assert.ok(f.session.getCallableToolNames().includes("denied_deferred")); + const direct = await calls(f, [["browser_mock", {}]]); assert.equal(direct[0].isError, false); + const nested = await calls(f, [["browser_mock", { target: "browser_mock" }]]); assert.equal(nested[0].isError, false); + assert.equal((await readFile(f.allowedMarker, "utf8")).trim().split("\n").length, 2); + for (const target of ["denied_deferred", "denied_codemode", "write", "bash", "powershell", "Agent", "get_subagent_result", "steer_subagent"]) { + const results = await calls(f, [[target, { path: join(f.root, "write.marker"), content: "DENIED" }], ["browser_mock", { target, file: join(f.root, "write.marker") }]]); + assert.equal(results[0].isError, true, target); + assert.equal(results[1].isError, true, target); + assert.match(results[1].nestedCalls.calls[0].error, /denied|not found|not callable|not available/i, target); + } + assert.equal(await exists(f.marker), false); assert.equal(await exists(join(f.root, "write.marker")), false); + assert.ok(!f.session.getAllTools().some((tool) => ["write", "bash", "powershell", "Agent", "get_subagent_result", "steer_subagent"].includes(tool.name))); + t.diagnostic(JSON.stringify({ sdk: "1.0.0", lateBrowserActiveOnFirstGetTools: true, singleBind: f.binds(), browserDirectAndNested: 2, deniedDirectAndNestedMarker: 0, externalProviderRequests: 0, browserDaemonStarts: 0 })); +}); + +test("implicit All deny, cancelled explicit allow, None and late source collision never widen", async (t) => { + for (const [selectors, deny] of [[undefined, ["ext:browser"]], [["ext:browser"], ["EXT:BROWSER/*"]], [[], []]]) { + const f = await setup(t, selectors, deny); + assert.equal((await f.wrapper.send({ type: "get_tools" })).find((tool) => tool.name === "browser_mock")?.active, false); + const [result] = await calls(f, [["browser_mock", {}]]); assert.equal(result.isError, true); + assert.equal(await exists(f.allowedMarker), false); + await f.wrapper.send({ type: "reload" }); + assert.equal((await f.wrapper.send({ type: "get_tools" })).find((tool) => tool.name === "browser_mock")?.active, false); + } + const f = await setup(t, ["ext:winner"]); + const collision = join(f.agentDir, "extensions", "winner.ts"); + await writeFile(collision, `import fs from 'node:fs'; export default function(pi) { pi.on('session_start',()=>pi.registerTool({name:'browser_mock',label:'Collision',description:'Collision',parameters:{type:'object',properties:{}},execute:async()=>{fs.appendFileSync(${JSON.stringify(f.marker)},'WINNER');return {content:[],details:undefined};}})); }`); + f.resources.extensions.push(collision); + // Recreate under the same creation policy; neither the winning name nor another grant authorizes it. + const services = await createSubagentSessionServices({ cwd: f.cwd, agentDir: f.agentDir, modelRuntime: f.runtime, settingsManager: SettingsManager.create(f.cwd, f.agentDir) }, f.resources); + const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(f.cwd), model: f.faux.getModel("late-faux"), noTools: "builtin", excludeTools: subagentToolExclusions(["read"]) }); + t.after(() => session.dispose()); await session.bindExtensions({}); + assert.equal(session.getAllTools().find((tool) => tool.name === "browser_mock").sourceInfo.path, f.path, "SDK's first registered source wins, not the selected same-name loser"); + assert.ok(!session.getActiveToolNames().includes("browser_mock")); + f.session = session; + assert.equal((await calls(f, [["browser_mock", {}]]))[0].isError, true); + assert.equal(await exists(f.marker), false); + assert.equal(await exists(f.allowedMarker), false); +}); + +test("parse-time fully denied allow stays explicit; tools none stays None even with loaded extensions", async (t) => { + const f = await setup(t); + await mkdir(join(f.cwd, ".pi", "agents"), { recursive: true }); + for (const [name, tools, deny] of [["cancelled", "read, ext:browser", "ext:browser"], ["none", "none", ""]]) { + await writeFile(join(f.cwd, ".pi", "agents", `${name}.md`), `---\ntools: ${tools}\nload_extensions: true\ndisallowed_tools: ${deny}\n---\nFixture`); + const profile = listSubagentProfiles(f.cwd).find((profile) => profile.name === name); + const policy = subagentToolPolicy(profile); + assert.equal(policy.mode, name === "none" ? "none" : "selectors"); + assert.deepEqual(policy.selectors, name === "none" ? [] : ["ext:browser"]); + } +}); + +test("v1 cold restore/reload retains hardallow and never adds late browser; malformed v2 refuses restore", async (t) => { + const f = await setup(t); + await f.wrapper.waitUntilReady(); + const manager = SessionManager.create(f.cwd, join(f.root, "sessions")); + const meta = { version: 1, parentSessionId: "parent", parentSessionPath: "/parent", resourceSnapshot: { version: 1, tools: ["read"], appendSystemPrompt: [], loadSkills: false, loadExtensions: true, extensions: [f.path] } }; + manager.appendCustomEntry(SUBAGENT_META_TYPE, meta); + manager.appendMessage(fauxAssistantMessage([fauxText("persist fixture")])); + const originalCreate = ModelRuntime.create, oldAgent = process.env.PI_CODING_AGENT_DIR; + ModelRuntime.create = async () => f.runtime; process.env.PI_CODING_AGENT_DIR = f.agentDir; + t.after(() => { ModelRuntime.create = originalCreate; process.env.PI_CODING_AGENT_DIR = oldAgent; }); + const { session: wrapper } = await startRpcSession(manager.getSessionId(), manager.getSessionFile(), f.cwd); + t.after(() => wrapper.destroy()); + assert.ok(!(await wrapper.send({ type: "get_tools" })).some((tool) => tool.name === "browser_mock")); + await wrapper.send({ type: "reload" }); + assert.ok(!(await wrapper.send({ type: "get_tools" })).some((tool) => tool.name === "browser_mock")); + const invalid = SessionManager.create(f.cwd, join(f.root, "invalid")); + invalid.appendCustomEntry(SUBAGENT_META_TYPE, { ...meta, resourceSnapshot: { ...f.resources, toolPolicy: { mode: "implicitAll" } } }); + invalid.appendMessage(fauxAssistantMessage([fauxText("persist invalid")])); + assert.equal(readSubagentSessionResources(invalid.getEntries()), null); + await assert.rejects(startRpcSession(invalid.getSessionId(), invalid.getSessionFile(), f.cwd), /invalid resource snapshot/); +}); + +test("v2 cold restore freezes explicit denial and builtins despite wider profile/default settings", async (t) => { + const f = await setup(t, ["ext:browser"], ["ext:browser/browser_mock"]); + await f.wrapper.waitUntilReady(); + const manager = SessionManager.create(f.cwd, join(f.root, "sessions")); + const snapshot = structuredClone(f.resources); delete snapshot.tools; + manager.appendCustomEntry(SUBAGENT_META_TYPE, { version: 1, parentSessionId: "parent", parentSessionPath: "/parent", profile: "widened", resourceSnapshot: snapshot }); + manager.appendMessage(fauxAssistantMessage([fauxText("persist fixture")])); + await mkdir(join(f.cwd, ".pi", "agents"), { recursive: true }); + await writeFile(join(f.cwd, ".pi", "agents", "widened.md"), "---\ntools: all, ext:*\nextensions: true\n---\nNew profile."); + await writeFile(join(f.agentDir, "settings.json"), JSON.stringify({ defaultTools: ["powershell", "write", "+codemode"] })); + const originalCreate = ModelRuntime.create, oldAgent = process.env.PI_CODING_AGENT_DIR; + ModelRuntime.create = async () => f.runtime; process.env.PI_CODING_AGENT_DIR = f.agentDir; + t.after(() => { ModelRuntime.create = originalCreate; process.env.PI_CODING_AGENT_DIR = oldAgent; }); + const { session: wrapper } = await startRpcSession(manager.getSessionId(), manager.getSessionFile(), f.cwd); + t.after(() => wrapper.destroy()); + for (const reload of [false, true]) { + if (reload) await wrapper.send({ type: "reload" }); + const tools = await wrapper.send({ type: "get_tools" }); + assert.equal(tools.find((tool) => tool.name === "browser_mock")?.active, false); + assert.ok(!tools.some((tool) => ["powershell", "write"].includes(tool.name))); + assert.deepEqual((await wrapper.send({ type: "get_state" })).activeToolNames, ["read"]); + } +}); + +test("v2 reload rechecks real project trust for late tools and restores source metadata", async (t) => { + const f = await setup(t, ["ext:project-browser"]); + await f.wrapper.waitUntilReady(); + const { ProjectTrustStore } = await import("@earendil-works/pi-coding-agent"); + const trust = new ProjectTrustStore(f.agentDir); + await mkdir(join(f.cwd, ".pi", "extensions"), { recursive: true }); + const project = join(f.cwd, ".pi", "extensions", "project-browser.ts"); + await writeFile(project, await readFile(f.path, "utf8")); + f.resources.extensions = [project]; + trust.set(f.cwd, true); + const services = await createSubagentSessionServices({ cwd: f.cwd, agentDir: f.agentDir, modelRuntime: f.runtime, settingsManager: SettingsManager.create(f.cwd, f.agentDir, { projectTrusted: true }) }, f.resources); + const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(f.cwd), model: f.faux.getModel("late-faux"), noTools: "builtin", excludeTools: subagentToolExclusions(["read"]) }); + t.after(() => session.dispose()); session.setActiveToolsByName(["read"]); + await session.bindExtensions({ onError: (error) => { throw Error(error.message ?? error.error); } }); + assert.ok(session.getActiveToolNames().includes("browser_mock")); + assert.equal(session.getAllTools().find((tool) => tool.name === "browser_mock").sourceInfo.scope, "project"); + trust.set(f.cwd, false); await session.reload(); + assert.ok(!session.getAllTools().some((tool) => tool.name === "browser_mock")); + trust.set(f.cwd, true); await session.reload(); + assert.ok(session.getActiveToolNames().includes("browser_mock")); + assert.equal(session.getAllTools().find((tool) => tool.name === "browser_mock").sourceInfo.scope, "project"); +}); + +test("resources_discover tools reconcile after ready, cold restore, both reload paths and each public read without rebinding", async (t) => { + const f = await setup(t, ["ext:browser/browser_mock"], [], true); + const verify = async (wrapper) => { + // Assert the completion boundary itself, BEFORE a public read can repair the loadout. + assert.ok(wrapper.inner.getAllTools().some((tool) => tool.name === "discovery_denied")); + assert.ok(!wrapper.inner.getActiveToolNames().includes("discovery_denied")); + assert.ok(wrapper.inner.getActiveToolNames().includes("browser_mock")); + const tools = await wrapper.send({ type: "get_tools" }); + assert.equal(tools.find((tool) => tool.name === "discovery_denied")?.active, false); + assert.equal(tools.find((tool) => tool.name === "browser_mock")?.active, true); + const state = await wrapper.send({ type: "get_state" }); + assert.ok(!state.activeToolNames.includes("discovery_denied")); + assert.ok(state.activeToolNames.includes("browser_mock")); + assert.equal(await exists(f.marker), false); + }; + await f.wrapper.waitUntilReady(); await verify(f.wrapper); + assert.equal(f.binds(), 1); + + // A late registration/activation AFTER readiness is reconciled by each read boundary. + for (const type of ["get_tools", "get_state"]) { + await f.session.prompt("/fixture-register-denied"); + assert.ok(f.session.getActiveToolNames().includes("discovery_denied")); + const response = await f.wrapper.send({ type }); + if (type === "get_tools") assert.equal(response.find((tool) => tool.name === "discovery_denied")?.active, false); + else assert.ok(!response.activeToolNames.includes("discovery_denied")); + await verify(f.wrapper); + } + await f.wrapper.send({ type: "reload" }); await verify(f.wrapper); + await f.session.prompt("/fixture-reload"); await verify(f.wrapper); + assert.equal(f.binds(), 1, "reload uses the existing bindings, never a second bind"); + + // Persist only the original validated creation snapshot, not current profile/settings. + const manager = SessionManager.create(f.cwd, join(f.root, "discovery-sessions")); + const metadata = f.session.sessionManager.getEntries().find((entry) => entry.type === "custom" && entry.customType === SUBAGENT_META_TYPE).data; + manager.appendCustomEntry(SUBAGENT_META_TYPE, structuredClone(metadata)); + manager.appendMessage(fauxAssistantMessage([fauxText("persist fixture")])); + const originalCreate = ModelRuntime.create, originalBind = AgentSession.prototype.bindExtensions; + const previousAgent = process.env.PI_CODING_AGENT_DIR; + let coldBinds = 0; + ModelRuntime.create = async () => f.runtime; process.env.PI_CODING_AGENT_DIR = f.agentDir; + AgentSession.prototype.bindExtensions = function (...args) { + if (this.sessionId === manager.getSessionId()) coldBinds++; + return originalBind.apply(this, args); + }; + t.after(() => { ModelRuntime.create = originalCreate; AgentSession.prototype.bindExtensions = originalBind; process.env.PI_CODING_AGENT_DIR = previousAgent; }); + const { session: cold } = await startRpcSession(manager.getSessionId(), manager.getSessionFile(), f.cwd); + t.after(() => cold.destroy()); + await cold.waitUntilReady(); await verify(cold); + await cold.send({ type: "reload" }); await verify(cold); + assert.equal(coldBinds, 1); + f.session = cold.inner; + assert.equal((await calls(f, [["browser_mock", {}]]))[0].isError, false); + assert.equal((await calls(f, [["browser_mock", { target: "browser_mock" }]]))[0].isError, false); + const [denied] = await calls(f, [["discovery_denied", {}]]); + assert.equal(denied.isError, true); assert.equal(await exists(f.marker), false); + t.diagnostic(JSON.stringify({ phase: "post-resources-discover", readyDeniedInactive: true, coldDeniedInactive: true, reloadDeniedInactive: true, latePublicReadsReconciled: true, initialBinds: f.binds(), coldBinds, deniedMarker: 0, externalProviderRequests: 0 })); +}); diff --git a/lib/subagent-tool-policy.test.mjs b/lib/subagent-tool-policy.test.mjs new file mode 100644 index 0000000000..9c57030f4f --- /dev/null +++ b/lib/subagent-tool-policy.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { createJiti } from "jiti"; + +const sandbox = await mkdtemp(join(tmpdir(), "pi-tool-policy-")); +const oldHome = process.env.HOME, oldAgent = process.env.PI_CODING_AGENT_DIR; +process.env.HOME = join(sandbox, "home"); process.env.PI_CODING_AGENT_DIR = join(sandbox, "agent"); +await mkdir(process.env.HOME); await mkdir(process.env.PI_CODING_AGENT_DIR); +after(async () => { + if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome; + if (oldAgent === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = oldAgent; + await rm(sandbox, { recursive: true, force: true }); +}); +const jiti = createJiti(import.meta.url); +const { allowedSubagentTools, isSubagentToolPolicy, createSubagentToolPolicyExtension, subagentToolPolicy, subagentToolExclusions } = await jiti.import("./subagent-tool-policy.ts"); +const { readSubagentSessionResources, SUBAGENT_META_TYPE } = await jiti.import("./subagents.ts"); +const source = (path, source = "auto", origin = "top-level") => ({ path, source, origin, scope: "user" }); +const a = { path: "/extensions/allowed.ts", sourceInfo: source("/extensions/allowed.ts"), tools: new Map([["same", {}]]) }; +const b = { path: "/extensions/denied.ts", sourceInfo: source("/extensions/denied.ts"), tools: new Map([["same", {}]]) }; +const policy = (selectors, deny = []) => ({ mode: selectors === undefined ? "implicitAll" : "selectors", selectors: selectors ?? [], deny }); + +test("actual source winner, unknown source, reserved and builtin exclusions fail closed", () => { + const tools = [ + { name: "same", sourceInfo: b.sourceInfo }, + { name: "unknown", sourceInfo: source("/not-loaded.ts") }, + { name: "Agent", sourceInfo: a.sourceInfo }, + { name: "bash", sourceInfo: a.sourceInfo }, + { name: "read", sourceInfo: source("builtin:read", "builtin") }, + ]; + assert.deepEqual([...allowedSubagentTools(["read"], policy(["ext:allowed"]), tools, [a, b])], ["read"]); + assert.deepEqual([...allowedSubagentTools([], policy(undefined, ["ext:denied"]), tools, [a, b])], []); + assert.deepEqual([...allowedSubagentTools([], policy(["ext:denied"], ["ext:denied"]), tools, [a, b])], []); + assert.deepEqual([...allowedSubagentTools([], policy([]), tools, [a, b])], []); + assert.deepEqual([...allowedSubagentTools([], policy(undefined), tools, [a, b])], ["same"]); + assert.ok(subagentToolExclusions(["read"]).includes("powershell")); +}); + +test("winner uses existing scoped aliases, longest match, case and ambiguity rules", () => { + const extensions = [ + { path: "/a/pkg/index.ts", sourceInfo: source("/a/pkg/index.ts", "npm:@a/pkg@1.0.0", "package"), tools: new Map() }, + { path: "/b/pkg/index.ts", sourceInfo: source("/b/pkg/index.ts", "npm:@b/pkg@1.0.0", "package"), tools: new Map() }, + ]; + const tools = [{ name: "Search", sourceInfo: extensions[0].sourceInfo }]; + assert.deepEqual([...allowedSubagentTools([], policy(["ext:pkg"]), tools, extensions)], []); + assert.deepEqual([...allowedSubagentTools([], policy(["EXT:@A/PKG/Search"]), tools, extensions)], ["Search"]); + assert.deepEqual([...allowedSubagentTools([], policy(["ext:@a/pkg/search"]), tools, extensions)], []); + assert.deepEqual([...allowedSubagentTools([], policy(["ext:@a/pkg/Search"], ["ext:@a/pkg"]), tools, extensions)], []); +}); + +test("policy factory shares predicate, never activates inactive tools, and errors block nested execution", () => { + const handlers = {}, tools = [{ name: "same", sourceInfo: b.sourceInfo }]; + let active = ["same"], broken = false; + const pi = { on(name, fn) { handlers[name] = fn; }, getActiveTools: () => active, setActiveTools(names) { active = names; }, getAllTools() { if (broken) throw Error("bad source"); return tools; } }; + createSubagentToolPolicyExtension([], policy(["ext:allowed"]), () => [a, b])(pi); + for (const boundary of ["session_start", "before_agent_start", "turn_start", "turn_end"]) { + active = ["same"]; handlers[boundary](); assert.deepEqual(active, []); + } + assert.equal(handlers.tool_call({ toolName: "same", parentToolCallId: "parent" }).block, true); + broken = true; + assert.equal(handlers.tool_call({ toolName: "same" }).block, true); + active = ["same"]; handlers.turn_start(); assert.deepEqual(active, []); + assert.deepEqual(subagentToolPolicy({ loadExtensions: true, extensionTools: [] }), { mode: "none", selectors: [], deny: [] }); +}); + +test("v2 snapshots strictly validate policy and frozen builtins; v1 is not promoted", () => { + const entries = (snapshot) => [{ type: "custom", customType: SUBAGENT_META_TYPE, data: { version: 1, parentSessionId: "p", parentSessionPath: "/p", resourceSnapshot: snapshot } }]; + const snapshot = { version: 2, builtinTools: ["powershell"], toolPolicy: policy(["ext:allowed"]), loadExtensions: true, loadSkills: false, extensions: ["allowed"], appendSystemPrompt: ["frozen"] }; + assert.equal(readSubagentSessionResources(entries(snapshot)).version, 2); + for (const toolPolicy of [null, {}, { ...policy(), mode: "ALL" }, { ...policy(), selectors: ["ext:*"] }, { ...policy([]), deny: [3] }, { ...policy([]), extra: true }, { ...policy([]), selectors: ["ext: "] }]) { + assert.equal(isSubagentToolPolicy(toolPolicy), false); + assert.equal(readSubagentSessionResources(entries({ ...snapshot, toolPolicy })), null); + } + for (const changes of [{ builtinTools: undefined }, { toolPolicy: undefined }, { builtinTools: ["browser_mock"] }, { builtinTools: ["Agent"] }, { loadExtensions: false }, { loadSkills: undefined }, { extensions: {} }, { exactSystemPrompt: false }]) assert.equal(readSubagentSessionResources(entries({ ...snapshot, ...changes })), null); + const snapshotV1 = { version: 1, tools: ["read"], appendSystemPrompt: [], loadExtensions: true, loadSkills: false }; + const persistedV1 = entries(snapshotV1), originalV1 = structuredClone(persistedV1); + const legacy = readSubagentSessionResources(persistedV1); + assert.deepEqual(legacy, { tools: ["read"], appendSystemPrompt: [], loadExtensions: true, loadSkills: false }); + assert.deepEqual(persistedV1, originalV1, "reading never migrates or widens persisted legacy authority"); +}); diff --git a/lib/subagent-tool-policy.ts b/lib/subagent-tool-policy.ts new file mode 100644 index 0000000000..f0b493b7af --- /dev/null +++ b/lib/subagent-tool-policy.ts @@ -0,0 +1,82 @@ +import type { ExtensionAPI, ExtensionFactory, ResourceLoader } from "@earendil-works/pi-coding-agent"; +import { isSubagentToolPolicy, selectSubagentExtensionTools, SUBAGENT_BUILTIN_TOOL_NAMES, SUBAGENT_BUILTIN_TOOLS, SUBAGENT_CONTROL_TOOL_NAMES, SUBAGENT_CONTROL_TOOLS, type SubagentToolPolicy } from "./subagents"; + +// Compatibility exports; the persisted configuration contract owns these definitions. +export { isSubagentToolPolicy, SUBAGENT_BUILTIN_TOOL_NAMES, type SubagentToolPolicy } from "./subagents"; + +export function subagentToolPolicy(profile: { loadExtensions: boolean; extensionTools?: string[]; disallowedExtensionTools?: string[] }): SubagentToolPolicy { + return { + mode: !profile.loadExtensions || profile.extensionTools?.length === 0 ? "none" : profile.extensionTools !== undefined ? "selectors" : "implicitAll", + selectors: profile.loadExtensions ? [...profile.extensionTools ?? []] : [], + deny: [...profile.disallowedExtensionTools ?? []], + }; +} + +export function subagentToolExclusions(builtinTools: readonly string[]): string[] { + return [...SUBAGENT_BUILTIN_TOOL_NAMES.filter((name) => !builtinTools.includes(name)), ...SUBAGENT_CONTROL_TOOL_NAMES]; +} + +type Tool = Pick[number], "name" | "sourceInfo">; +type Extension = ReturnType["extensions"][number]; + +/** Resolve selectors against the approved roster, but grant only the actual registry winner. */ +export function allowedSubagentTools(builtinTools: readonly string[], policy: SubagentToolPolicy, tools: readonly Tool[], extensions: readonly Extension[]): Set { + if (!isSubagentToolPolicy(policy)) return new Set(); + const roster = extensions.map((extension) => ({ ...extension, tools: new Map() })); + const allowed = new Set(); + for (const tool of tools) { + if (SUBAGENT_CONTROL_TOOLS.has(tool.name)) continue; + const builtin = SUBAGENT_BUILTIN_TOOLS.has(tool.name); + if (builtin && !builtinTools.includes(tool.name)) continue; + const source = tool.sourceInfo; + if (!source) continue; + if (builtin && source.path === `builtin:${tool.name}` && source.source === "builtin") { + allowed.add(tool.name); + continue; + } + const owners = roster.filter((extension) => extension.path === source.path + && extension.sourceInfo?.source === source.source + && extension.sourceInfo?.origin === source.origin + && extension.sourceInfo?.scope === source.scope); + if (owners.length === 1) owners[0].tools.set(tool.name, undefined); + } + const selectors = policy.mode === "implicitAll" ? ["ext:*"] : policy.mode === "selectors" ? policy.selectors : []; + for (const name of selectSubagentExtensionTools(roster, selectors, policy.deny)) allowed.add(name); + return allowed; +} + +/** Public-API reconciliation shared by lifecycle handlers and completed wrapper boundaries. */ +export function reconcileSubagentActiveTools( + api: Pick, + builtinTools: readonly string[], + policy: SubagentToolPolicy, + getExtensions: () => readonly Extension[], +): void { + let allowed: Set; + try { allowed = allowedSubagentTools(builtinTools, policy, api.getAllTools(), getExtensions()); } + catch { allowed = new Set(); } + const active = api.getActiveTools(); + const next = active.filter((name) => allowed.has(name)); + if (next.length !== active.length) api.setActiveTools(next); +} + +/** No registration-policy hook exists in SDK 1.0: active pruning is not registry filtering. */ +export function createSubagentToolPolicyExtension(builtinTools: readonly string[], policy: SubagentToolPolicy, getExtensions: () => readonly Extension[]): ExtensionFactory { + // Freeze creation authority, including against later mutations of the caller's profile. + const base = [...builtinTools]; + const frozen = structuredClone(policy); + return (pi) => { + const permitted = () => allowedSubagentTools(base, frozen, pi.getAllTools(), getExtensions()); + const prune = () => reconcileSubagentActiveTools(pi, base, frozen, getExtensions); + pi.on("session_start", prune); + pi.on("before_agent_start", prune); + pi.on("turn_start", prune); + pi.on("turn_end", prune); + pi.on("tool_call", (event) => { + try { + if (permitted().has(event.toolName)) return; + } catch { /* Policy/source errors block execution, including nested calls. */ } + return { block: true, reason: `Subagent tool policy denied ${event.toolName}` }; + }); + }; +} diff --git a/lib/subagents.test.mjs b/lib/subagents.test.mjs index bdb9d5c598..481c529066 100644 --- a/lib/subagents.test.mjs +++ b/lib/subagents.test.mjs @@ -304,8 +304,8 @@ test("a denied extension covers selectors scoped below it", async () => { await write("scoped", "read, ext:codegraph/codegraph_search", "ext:codegraph"); await write("star", "read, ext:codegraph/*", "ext:codegraph"); assert.deepEqual(load("control").extensionTools, ["ext:codegraph/codegraph_search"]); - assert.deepEqual(load("scoped").extensionTools ?? [], []); - assert.deepEqual(load("star").extensionTools ?? [], []); + assert.deepEqual(load("scoped").extensionTools, ["ext:codegraph/codegraph_search"]); + assert.deepEqual(load("star").extensionTools, ["ext:codegraph/*"]); // A tool-scoped deny narrows a whole-extension allow only at spawn time, where the // loaded extensions supply the tool names: the raw selector stays for the runtime to @@ -316,7 +316,7 @@ test("a denied extension covers selectors scoped below it", async () => { // A global deny removes every extension tool whatever the allow side says. await write("deny-all", "read, ext:codegraph/*", "ext:*"); - assert.deepEqual(load("deny-all").extensionTools ?? [], []); + assert.deepEqual(load("deny-all").extensionTools, ["ext:codegraph/*"]); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -364,8 +364,8 @@ test("an uppercase EXT: selector round-trips and a denied extension blocks its s const allowed = listSubagentProfiles(cwd).find((item) => item.name === "allowed"); assert.deepEqual(allowed.extensionTools, ["ext:codegraph/*"]); assert.deepEqual(denied.tools, ["read"]); - // an empty extension selection is omitted from the profile - assert.deepEqual(denied.extensionTools ?? [], []); + // Keep explicit allow intent even when deny cancels it, never imply All. + assert.deepEqual(denied.extensionTools, ["ext:codegraph/*"]); } finally { await rm(cwd, { recursive: true, force: true }); } diff --git a/lib/subagents.ts b/lib/subagents.ts index 4c6891f32b..1ddd62888b 100644 --- a/lib/subagents.ts +++ b/lib/subagents.ts @@ -4,6 +4,7 @@ import { dump as stringifyYaml } from "js-yaml"; import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs"; import { basename, dirname, join, resolve } from "path"; import { parseFrontmatter } from "./frontmatter"; +import { profileResourceSelection, resourceSelectionEnabled, sameResourceSelection, type SubagentResourceSelection } from "./subagent-resource-selection"; import { parseNpmSource } from "./npm-source"; import { writePrivateFileAtomicSync } from "./atomic-file"; import { isExistingPathWithinRoots } from "./path-security"; @@ -15,6 +16,26 @@ export const SUBAGENT_META_TYPE = "pi-web:subagent"; export const SUBAGENT_STATUS_TYPE = "pi-web:subagent-status"; export const SUBAGENT_RESULT_TYPE = "pi-web:subagent-result"; export const SUBAGENT_CONTROL_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as const; +export const SUBAGENT_BUILTIN_TOOL_NAMES = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"] as const; +export const SUBAGENT_BUILTIN_TOOLS = new Set(SUBAGENT_BUILTIN_TOOL_NAMES); +export const SUBAGENT_CONTROL_TOOLS = new Set(SUBAGENT_CONTROL_TOOL_NAMES); + +/** Persisted creation authority. Explicit empty selectors mean None; deny always wins. */ +export interface SubagentToolPolicy { + mode: "implicitAll" | "selectors" | "none"; + selectors: string[]; + deny: string[]; +} + +export function isSubagentToolPolicy(value: unknown): value is SubagentToolPolicy { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const policy = value as Record; + const selectors = (entries: unknown): entries is string[] => Array.isArray(entries) && entries.every((entry) => typeof entry === "string" && /^ext:.+/i.test(entry) && entry.slice(4).trim().length > 0); + return Object.keys(policy).every((key) => ["mode", "selectors", "deny"].includes(key)) + && typeof policy.mode === "string" && ["implicitAll", "selectors", "none"].includes(policy.mode) + && selectors(policy.selectors) && selectors(policy.deny) + && (policy.mode === "selectors" || policy.selectors.length === 0); +} export type SubagentStatus = SubagentSessionStatus; export type SubagentScope = "builtin" | "global" | "workspace" | "project"; @@ -31,6 +52,8 @@ export interface SubagentProfile { disallowedExtensionTools?: string[]; loadSkills: boolean; loadExtensions: boolean; + skills?: SubagentResourceSelection; + extensions?: SubagentResourceSelection; model?: string; thinking?: ThinkingLevel; maxTurns?: number; @@ -60,23 +83,26 @@ export interface SubagentMetadata { worktreeBranch?: string; } -export interface SubagentResourceSnapshot { - version: 1; - appendSystemPrompt: string[]; - tools: string[]; - loadSkills: boolean; - loadExtensions: boolean; - exactSystemPrompt?: string; -} +export type SubagentResourceSnapshot = SubagentResourceSnapshotBase & ( + | { version: 1; tools: string[] } + | { version: 2; builtinTools: string[]; toolPolicy: SubagentToolPolicy } +); -export interface SubagentSessionResources { +interface SubagentResourceSnapshotBase { appendSystemPrompt: string[]; - tools: string[]; loadSkills: boolean; loadExtensions: boolean; + skills?: SubagentResourceSelection; + extensions?: SubagentResourceSelection; exactSystemPrompt?: string; } +/** Legacy reads retain their unversioned shape; only v2 carries predicate authority. */ +export type SubagentSessionResources = SubagentResourceSnapshotBase & { tools: string[] } & ( + | { version?: undefined; builtinTools?: never; toolPolicy?: never } + | { version: 2; builtinTools: string[]; toolPolicy: SubagentToolPolicy } +); + export interface SubagentResultMetadata { version: 1; status: Exclude; @@ -112,9 +138,9 @@ export interface SubagentRunInfo { resumed?: boolean; } -const DEFAULT_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"]; -const BUILTIN_TOOLS = new Set(DEFAULT_TOOLS); -const SUBAGENT_CONTROL_TOOLS = new Set(SUBAGENT_CONTROL_TOOL_NAMES); +const DEFAULT_TOOLS = SUBAGENT_BUILTIN_TOOL_NAMES.filter((name) => name !== "powershell"); +// Preserve the original profile/v1 shell vocabulary; v2 freezes resolved shell names. +const BUILTIN_TOOLS = new Set(DEFAULT_TOOLS); const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); /** @@ -144,13 +170,6 @@ const MANAGED_FRONTMATTER_KEYS = new Set([ const FRONTMATTER_OPEN_RE = /^(?:\uFEFF)?---[ \t]*(?:\r\n|\n|\r)/; -/** - * The UI exposes two booleans (`load_skills` / `load_extensions`); pi-subagents reads - * the aliases `skills` / `extensions`, which also accept a whitelist. Aliases are - * carried through by `unmanagedFrontmatter` and only rewritten once we own them. - */ -const OWNED_ALIAS_VALUES = new Set(["none", "all", "true", "false"]); - const BUILTIN_PROFILES: SubagentProfile[] = [ { name: "general-purpose", @@ -204,11 +223,6 @@ function booleanValue(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } -function resourceBoolean(value: unknown, fallback: boolean): boolean { - if (typeof value === "boolean") return value; - return Array.isArray(value) || typeof value === "string" ? true : fallback; -} - function stringList(value: unknown): string[] { const values = Array.isArray(value) ? value @@ -265,21 +279,16 @@ function composeToolsField(tools: string[], storedTools: unknown): string { return combined.length > 0 ? combined.join(", ") : "none"; } -/** - * Keep the alias in step with the boolean the UI owns. A boolean (or a "none" / - * "all" spelling) is ours to rewrite; a whitelist such as `extensions: - * pi-advisor-flow` expresses scoping the UI cannot show, so it stays as authored. - */ -function syncFlagAlias( - frontmatter: Record, - alias: string, - storedValue: unknown, - flag: boolean, -): void { - const owned = storedValue === undefined - || typeof storedValue === "boolean" - || (typeof storedValue === "string" && OWNED_ALIAS_VALUES.has(storedValue.trim().toLowerCase())); - if (owned) frontmatter[alias] = flag; +/** Keep unchanged CSV/array values, including a dormant list behind load_*: false. */ +function saveResourceAlias(stored: Record, alias: "skills" | "extensions", selection: SubagentResourceSelection | undefined, flag: boolean): unknown { + const raw = stored[alias]; + if (selection === undefined) { + // Old boolean clients can disable/re-enable but cannot edit a stored list. + if (Array.isArray(raw) || (typeof raw === "string" && !["all", "none", "true", "false"].includes(raw.trim().toLowerCase()))) return raw; + return flag; + } + if ((Array.isArray(raw) || (typeof raw === "string" && !["all", "none", "true", "false"].includes(raw.trim().toLowerCase()))) && sameResourceSelection(selection, profileResourceSelection(raw, stored[`load_${alias}`]))) return raw; + return selection; } function parseProfileFile(filePath: string, scope: SubagentScope): SubagentProfile | null { try { @@ -291,31 +300,23 @@ function parseProfileFile(filePath: string, scope: SubagentScope): SubagentProfi const maxTurnsValue = typeof data?.max_turns === "number" ? Math.floor(data.max_turns) : undefined; const tools = parseTools(data?.tools, DEFAULT_TOOLS); const disallowedTools = new Set(parseTools(data?.disallowed_tools, [])); - // The deny list is also handed to the runtime, which resolves both sides against the - // loaded extensions. This parse-time filter is only the cheap literal fast path: it - // cannot see extension aliases (`ext:codegraph` vs `ext:@scope/pi-codegraph`), so it - // must never be the only gate. + // Keep raw allow intent: cancelling every explicit selector must never become All. + // Alias-aware deny precedence is resolved against the loaded roster at execution time. const disallowedExtensionTools = parseExtensionToolSelectors(data?.disallowed_tools); - const deniedKeys = new Set( - disallowedExtensionTools.map((tool) => normalizeExtensionSelector(tool).toLowerCase()), - ); - const extensionTools = parseExtensionToolSelectors(data?.tools) - .filter((tool) => { - const allowed = normalizeExtensionSelector(tool).toLowerCase(); - return ![...deniedKeys].some((denied) => ( - denied === "*" || allowed === denied || allowed.startsWith(`${denied}/`) - )); - }); + const extensionTools = parseExtensionToolSelectors(data?.tools); + const explicitNone = rawToolValues(data?.tools).some((tool) => tool.toLowerCase() === "none"); return { name, displayName: stringValue(data?.display_name) ?? name, description: stringValue(data?.description) ?? name, systemPrompt: rest.trim(), tools: tools.filter((tool) => !disallowedTools.has(tool)), - ...(extensionTools.length > 0 ? { extensionTools } : {}), + ...(extensionTools.length > 0 || explicitNone ? { extensionTools: explicitNone ? [] : extensionTools } : {}), ...(disallowedExtensionTools.length > 0 ? { disallowedExtensionTools } : {}), - loadSkills: resourceBoolean(data?.load_skills ?? data?.skills, false), - loadExtensions: resourceBoolean(data?.load_extensions ?? data?.extensions, extensionTools.length > 0), + skills: profileResourceSelection(data?.skills, data?.load_skills), + extensions: profileResourceSelection(data?.extensions, data?.load_extensions, extensionTools.length > 0), + loadSkills: resourceSelectionEnabled(profileResourceSelection(data?.skills, data?.load_skills)), + loadExtensions: resourceSelectionEnabled(profileResourceSelection(data?.extensions, data?.load_extensions, extensionTools.length > 0)), ...(stringValue(data?.model) ? { model: stringValue(data?.model) } : {}), ...(thinkingValue && THINKING_LEVELS.has(thinkingValue) ? { thinking: thinkingValue } : {}), ...(maxTurnsValue && maxTurnsValue > 0 ? { maxTurns: maxTurnsValue } : {}), @@ -426,6 +427,7 @@ export function saveSubagentProfile( cwd: string, scope: SubagentWritableScope, profile: Omit, + cloneFrom?: Pick, ): SubagentProfile { const name = assertProfileName(profile.name); const tools = [...new Set(profile.tools.filter((tool) => BUILTIN_TOOLS.has(tool)))]; @@ -443,8 +445,11 @@ export function saveSubagentProfile( const description = profile.description.trim() || name; const systemPrompt = profile.systemPrompt.trim(); const model = profile.model?.trim() || undefined; - const loadSkills = profile.loadSkills === true; - const loadExtensions = profile.loadExtensions === true; + for (const selection of [profile.skills, profile.extensions]) { + if (selection !== undefined && typeof selection !== "boolean" && !(Array.isArray(selection) && selection.every((entry) => typeof entry === "string"))) throw new Error("skills/extensions must be boolean or string[]"); + } + const loadSkills = profile.skills !== undefined ? resourceSelectionEnabled(profile.skills) : profile.loadSkills === true; + const loadExtensions = profile.extensions !== undefined ? resourceSelectionEnabled(profile.extensions) : profile.loadExtensions === true; const promptMode = profile.promptMode === "replace" ? "replace" : "append"; const dir = assertWritableProfileDirectory(cwd, scope); mkdirSync(dir, { recursive: true }); @@ -452,7 +457,9 @@ export function saveSubagentProfile( throw new Error("Agent profile directory is outside the project root"); } const filePath = join(dir, `${name}.md`); - const stored = readStoredFrontmatter(filePath); + if (cloneFrom && existsSync(filePath)) throw new Error("Cannot clone over an existing profile"); + const stored = readStoredFrontmatter(cloneFrom?.filePath ?? filePath); + if (cloneFrom?.filePath && stored.name !== undefined) stored.name = name; const managed: Record = { description, display_name: displayName, @@ -464,8 +471,8 @@ export function saveSubagentProfile( run_in_background: profile.runInBackground, prompt_mode: promptMode, }; - syncFlagAlias(managed, "skills", stored.skills, loadSkills); - syncFlagAlias(managed, "extensions", stored.extensions, loadExtensions); + managed.skills = saveResourceAlias(stored, "skills", profile.skills, loadSkills); + managed.extensions = saveResourceAlias(stored, "extensions", profile.extensions, loadExtensions); if (model) managed.model = model; if (profile.thinking) managed.thinking = profile.thinking; if (maxTurns) managed.max_turns = maxTurns; @@ -481,6 +488,8 @@ export function saveSubagentProfile( writePrivateFileAtomicSync(filePath, `---\n${yaml}\n---\n\n${systemPrompt}\n`); return { ...profile, + skills: profileResourceSelection(managed.skills, loadSkills), + extensions: profileResourceSelection(managed.extensions, loadExtensions), name, displayName, description, @@ -541,9 +550,30 @@ export function readSubagentSessionResources( const snapshot = data.resourceSnapshot; const loadSkills = isRecord(snapshot) && snapshot.loadSkills === true; const loadExtensions = isRecord(snapshot) && snapshot.loadExtensions === true; + if (isRecord(snapshot) && snapshot.version === 2) { + if (typeof snapshot.loadSkills !== "boolean" || typeof snapshot.loadExtensions !== "boolean" + || ![snapshot.skills, snapshot.extensions].every((value) => value === undefined || typeof value === "boolean" || (Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim().length > 0))) + || !Array.isArray(snapshot.appendSystemPrompt) || !snapshot.appendSystemPrompt.every((item) => typeof item === "string") + || !Array.isArray(snapshot.builtinTools) || !snapshot.builtinTools.every((item) => typeof item === "string" && SUBAGENT_BUILTIN_TOOLS.has(item)) + || !isSubagentToolPolicy(snapshot.toolPolicy) + || (!loadExtensions && snapshot.toolPolicy.mode !== "none") + || (snapshot.exactSystemPrompt !== undefined && typeof snapshot.exactSystemPrompt !== "string")) return null; + return { + version: 2, + builtinTools: [...new Set(snapshot.builtinTools as string[])], + tools: [...new Set(snapshot.builtinTools as string[])], + toolPolicy: structuredClone(snapshot.toolPolicy), + appendSystemPrompt: [...snapshot.appendSystemPrompt], + loadSkills, loadExtensions, + ...(snapshot.skills !== undefined ? { skills: profileResourceSelection(snapshot.skills, loadSkills) } : {}), + ...(snapshot.extensions !== undefined ? { extensions: profileResourceSelection(snapshot.extensions, loadExtensions) } : {}), + ...(typeof snapshot.exactSystemPrompt === "string" ? { exactSystemPrompt: snapshot.exactSystemPrompt } : {}), + }; + } if ( isRecord(snapshot) && snapshot.version === 1 + && [snapshot.skills, snapshot.extensions].every((value) => value === undefined || typeof value === "boolean" || (Array.isArray(value) && value.every((entry) => typeof entry === "string"))) && Array.isArray(snapshot.appendSystemPrompt) && snapshot.appendSystemPrompt.every((item) => typeof item === "string") && Array.isArray(snapshot.tools) @@ -559,6 +589,8 @@ export function readSubagentSessionResources( tools: [...new Set(snapshot.tools)], loadSkills, loadExtensions, + ...(snapshot.skills !== undefined ? { skills: profileResourceSelection(snapshot.skills, snapshot.loadSkills) } : {}), + ...(snapshot.extensions !== undefined ? { extensions: profileResourceSelection(snapshot.extensions, snapshot.loadExtensions) } : {}), ...(typeof snapshot.exactSystemPrompt === "string" ? { exactSystemPrompt: snapshot.exactSystemPrompt } : {}), }; } From 5f3448c8f347f4ac3a3263904989dc817208bef6 Mon Sep 17 00:00:00 2001 From: lyx2145181 <41118458+lyx2145181@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:06:19 +0800 Subject: [PATCH 2/6] feat(subagents): add compact resource pickers Why: Boolean switches cannot show or edit resource whitelists. Compact summaries and an on-demand picker keep the profile editor readable without changing its draft and Save workflow. Safety: Search, navigation and retry leave selections unchanged. The single tri-state bulk control selects the full enabled catalog as explicit paths, not future-enabled All, and preserves unknown or ambiguous entries. Share catalog matching, use native DOM contracts, clamp fixed positions to the visual viewport and avoid refocusing on responsive width changes. Keep built-in profiles read-only and distinguish explicit from automatic focus restoration. Validation: Exported and checked this staged tree independently. Typecheck and ESLint passed. Isolated offline suite: 2366 passed; separately mocked plugin-update suite: 5 passed. Post-fix browser acceptance stopped after two harness environment failures; no browser pass or physical keyboard/pinch-zoom verification is claimed. No real profile save, build or deployment was run. --- app/settings.css | 172 +++++ components/AgentResourceControls.test.mjs | 727 ++++++++++++++++++++ components/AgentResourceControls.tsx | 776 ++++++++++++++++++++++ components/AgentsConfig.test.mjs | 5 +- components/AgentsConfig.tsx | 26 +- docs/agents/subagent-resources.md | 12 + lib/i18n/messages/en.ts | 17 + lib/i18n/messages/zh-CN.ts | 18 + lib/i18n/messages/zh-TW.ts | 18 + 9 files changed, 1763 insertions(+), 8 deletions(-) create mode 100644 components/AgentResourceControls.test.mjs create mode 100644 components/AgentResourceControls.tsx diff --git a/app/settings.css b/app/settings.css index 66e08803fb..81f299ced7 100644 --- a/app/settings.css +++ b/app/settings.css @@ -1780,6 +1780,178 @@ font-size: 12px; } +.agents-resources { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + color: var(--text-muted); + font-size: 12px; + overflow-wrap: anywhere; +} +.agents-resources-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + min-width: 0; +} +.agents-resource-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-panel); +} +.agents-resource-summary-label { + flex: 1 1 90px; + min-width: 0; + color: var(--text); + font-weight: 600; +} +.agents-resource-summary-status { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; + min-width: 0; +} +.agents-resource-count { color: var(--text-muted); white-space: nowrap; } +.agents-resource-wildcard-mark { + padding: 0 4px; + border-radius: 4px; + background: var(--bg-selected); + color: var(--text); + font-family: var(--font-mono); +} +.agents-resource-warning { color: #d97706; font-weight: 600; } +.agents-resource-choose { margin-left: auto; } + +/* The Resources picker overlay (portal). It is anchored to the active Choose button and + sits outside the settings scroll container, so it is never clipped or pushes + the rest of the profile editor down; it only scrolls its own list. */ +.agents-resource-picker { + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); +} +.agents-resource-picker:focus { outline: none; } +.agents-resource-picker-header { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); +} +.agents-resource-picker-header strong { min-width: 0; color: var(--text); font-size: 12px; } +.agents-resource-picker-count { margin-left: auto; min-width: 0; color: var(--text-muted); font-size: 11px; white-space: nowrap; } +.agents-resource-picker-close { + flex-shrink: 0; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 5px; + background: none; + color: var(--text-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; +} +.agents-resource-picker-close:hover { background: var(--bg-hover); color: var(--text); } +/* The picker's master bulk control: a tri-state checkbox-styled toggle that + selects every enabled row or clears them, without a second text button. */ +.agents-resource-picker-bulk { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 5px; + background: none; + color: var(--text-muted); + cursor: pointer; +} +.agents-resource-picker-bulk:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } +.agents-resource-picker-bulk:disabled { opacity: 0.45; cursor: default; } +.agents-resource-bulk-box { fill: none; stroke: currentColor; stroke-width: 1.3; } +.agents-resource-bulk-check { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.agents-resource-bulk-dash { fill: currentColor; } +.agents-resource-picker-bulk[data-state="all"] { color: var(--accent); } +.agents-resource-picker-bulk[data-state="all"] .agents-resource-bulk-box { fill: var(--accent); stroke: var(--accent); } +.agents-resource-picker-bulk[data-state="all"] .agents-resource-bulk-check { stroke: var(--bg); } +.agents-resource-picker-bulk[data-state="partial"] { color: var(--accent); } +.agents-resource-picker-bulk[data-state="partial"] .agents-resource-bulk-dash { fill: var(--accent); } +.agents-resource-picker-search { flex-shrink: 0; padding: 8px 10px; border-bottom: 1px solid var(--border); } +.agents-resource-picker-search input { + box-sizing: border-box; + width: 100%; + min-width: 0; + height: 30px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--text); + font-size: 12px; + outline: none; +} +.agents-resource-picker-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px 0; } +.agents-resource-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; padding: 5px 10px; } +.agents-resource-row + .agents-resource-row { border-top: 1px solid var(--border); } +.agents-resource-row-main { display: flex; flex: 1 1 140px; align-items: center; gap: 7px; min-width: 0; cursor: pointer; } +.agents-resource-row-main:has(input:disabled) { cursor: default; color: var(--text-dim); } +.agents-resource-row-main input { flex-shrink: 0; margin: 0; } +.agents-resource-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agents-resource-expand { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 3px; + padding: 2px 6px; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-dim); + font-size: 10px; + cursor: pointer; +} +.agents-resource-expand:hover { background: var(--bg-hover); color: var(--text); } +.agents-resource-detail { + display: flex; + flex: 1 1 100%; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 4px 0 2px 21px; +} +.agents-resource-detail-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; min-width: 0; } +.agents-resource-detail-label { flex-shrink: 0; color: var(--text-dim); font-size: 10px; } +.agents-resource-source, .agents-resource-path { + min-width: 0; + max-width: 100%; + color: var(--text-dim); + font-size: 10px; + overflow-wrap: anywhere; + white-space: normal; +} +.agents-resource-unknown-note { flex: 1 1 100%; color: #d97706; font-size: 10px; } +.agents-resource-row.is-unknown { color: #d97706; } +.agents-resource-row.is-wildcard .agents-resource-name { color: var(--text-muted); } +@media (max-width: 640px) { .agents-resources-grid { grid-template-columns: minmax(0, 1fr); } } + .settings-general-error { margin: 8px 0 0; color: #ef4444; diff --git a/components/AgentResourceControls.test.mjs b/components/AgentResourceControls.test.mjs new file mode 100644 index 0000000000..182b5e5671 --- /dev/null +++ b/components/AgentResourceControls.test.mjs @@ -0,0 +1,727 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true }); +const React = await jiti.import("react"); +const { renderToStaticMarkup } = await jiti.import("react-dom/server"); +const { I18nProvider } = await jiti.import("@/hooks/useI18n.tsx"); +const { + AgentResourceControls, + ResourcePickerList, + ResourceSummary, + bindResourcePickerDismissal, + classifyResourceSelection, + closeResourcePicker, + filterResourceItems, + focusResourcePickerTrigger, + isResourceItemChecked, + isResourcePickerOutside, + observeResourcePickerVisibility, + openResourcePickerDialog, + removeResourceEntry, + resourceDisplayName, + resourceBulkState, + resourcePickerLayout, + resourceSelectionBase, + resourceSelectionStatus, + selectAllResourceItems, + toggleResourceItem, +} = await jiti.import("./AgentResourceControls.tsx"); +const { matchingResourceEntries: matchingEntries, resourceMatchCandidates } = await jiti.import("../lib/subagent-resource-selection.ts"); + +const source = await readFile(new URL("./AgentResourceControls.tsx", import.meta.url), "utf8"); +const agents = await readFile(new URL("./AgentsConfig.tsx", import.meta.url), "utf8"); +const css = await readFile(new URL("../app/settings.css", import.meta.url), "utf8"); + +const h = React.createElement; +const render = (element) => renderToStaticMarkup(h(I18nProvider, null, element)); +const noop = () => {}; + +function item(path, names, overrides = {}) { + return { + name: names[0], + path, + identity: overrides.identity ?? path, + names, + pathAliases: overrides.pathAliases ?? [path, `~${path}`, `./${path.slice(1)}`], + metadata: { source: "configured", scope: "global", origin: "top-level", baseDir: "/base", ...overrides.metadata }, + enabled: overrides.enabled ?? true, + }; +} + +/** A node of a minimal DOM tree, with a synchronous `emit` for the listener tests. */ +class FakeNode { + constructor(name, parent = null) { + this.name = name; + this.parent = parent; + this.listeners = []; + this.isConnected = true; + this.focused = []; + } + + addEventListener(type, listener) { this.listeners.push({ type, listener }); } + removeEventListener(type, listener) { this.listeners = this.listeners.filter((entry) => !(entry.type === type && entry.listener === listener)); } + focus(options) { this.focused.push(options); } + getClientRects() { return [{}]; } + contains(other) { for (let node = other; node; node = node.parent) if (node === this) return true; return false; } + + emit(type, init = {}) { + const event = Object.assign(init, { type }); + for (const entry of [...this.listeners]) if (entry.type === type) entry.listener.call(this, event); + return event; + } +} + +/** Window/DOM seams live only in this test file; production uses the native types. */ +class PointerWindowMock extends EventTarget { + constructor(coarse = false, width = 1024) { + super(); + this.coarse = coarse; + this.innerWidth = width; + this.queries = []; + } + + matchMedia(query) { + this.queries.push(query); + return { matches: this.coarse, media: query }; + } +} + +function fakeDocument() { + const doc = new FakeNode("document"); + doc.body = new FakeNode("body", doc); + doc.defaultView = new PointerWindowMock(); + doc.activeElement = doc.body; + return doc; +} + +function escapeEvent() { + return { + key: "Escape", + isComposing: false, + keyCode: 0, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.propagationStopped = true; }, + }; +} + +test("editor resolves aliases to unique real identities, preserves unknown/colliding names, and supports slash variants", () => { + const first = item("/first.ts", ["first"]), second = item("/second.ts", ["second"]); + assert.deepEqual(matchingEntries("FIRST", [first, second]), [first]); + assert.deepEqual(matchingEntries("\\first.ts", [first]), [first]); + assert.deepEqual(matchingEntries("~/first.ts", [first]), [first]); + assert.deepEqual(matchingEntries("./first.ts", [first]), [first]); + assert.deepEqual(matchingEntries("missing", [first]), []); + assert.deepEqual(matchingEntries("first", [first, item("/other.ts", ["first"])]), []); + assert.deepEqual(matchingEntries("first", [first, item("/alias.ts", ["first"], { identity: "/first.ts" })]).length, 2); +}); + +test("a summary counts explicit entries and flags unknown/ambiguous only once the catalog is ready", () => { + const entries = [item("/a.ts", ["alpha"]), item("/b.ts", ["beta"])]; + assert.deepEqual(classifyResourceSelection(true, entries, true), { mode: "all", selectedCount: 0, hasWildcard: false, unknown: [], ambiguous: [] }); + assert.equal(classifyResourceSelection(false, entries, true).mode, "none"); + const selected = classifyResourceSelection(["alpha", "missing", "*"], entries, true); + assert.deepEqual([selected.mode, selected.selectedCount, selected.hasWildcard], ["selected", 2, true]); + assert.deepEqual(selected.unknown, ["missing"]); + // Before the catalog arrives a draft is never mislabelled unknown and kept. + const pending = classifyResourceSelection(["missing"], [], false); + assert.deepEqual([pending.unknown, pending.selectedCount], [[], 1]); + const ambiguous = classifyResourceSelection(["dup"], [item("/one.ts", ["dup"]), item("/two.ts", ["dup"])], true); + assert.deepEqual(ambiguous.ambiguous, ["dup"]); + assert.deepEqual(ambiguous.unknown, []); + assert.equal(resourceMatchCandidates("dup", [item("/one.ts", ["dup"]), item("/two.ts", ["dup"])]).length, 2); +}); + +test("checkbox edits keep the stored path, retain unknowns until explicit removal, and drop the wildcard on its own", () => { + const first = item("/a.ts", ["alpha"]), second = item("/b.ts", ["beta"]); + assert.deepEqual(resourceSelectionBase(true, [first, second]), ["/a.ts", "/b.ts"]); + assert.deepEqual(resourceSelectionBase(false, [first, second]), []); + // A wildcard first edit expands to the enabled paths plus the explicit entries, + // keeping an unknown and never adding a future catalog entry as selected. + assert.deepEqual(resourceSelectionBase(["*", "unknown"], [first, second]), ["unknown", "/a.ts", "/b.ts"]); + assert.deepEqual(resourceSelectionBase(["*", "/a.ts"], [first, second]), ["/a.ts", "/b.ts"]); + assert.deepEqual(toggleResourceItem([], first, true, [first, second]), ["/a.ts"]); + assert.equal(isResourceItemChecked(["/a.ts"], first, [first, second]), true); + assert.equal(isResourceItemChecked(false, first, [first, second]), false); + assert.equal(isResourceItemChecked(true, first, [first, second]), true); + assert.deepEqual(toggleResourceItem(["/a.ts"], first, false, [first, second]), []); + // All minus one stays an explicit list of the rest, never a display name. + assert.deepEqual(toggleResourceItem(true, first, false, [first, second]), ["/b.ts"]); + // A row can be unchecked while the wildcard is present: the wildcard is dropped + // and converted in one click, and an unknown entry survives the edit. + assert.deepEqual(toggleResourceItem(["*"], first, false, [first, second]), ["/b.ts"]); + assert.deepEqual(toggleResourceItem(["*", "unknown"], first, false, [first, second]), ["unknown", "/b.ts"]); + assert.deepEqual(toggleResourceItem(["*", "unknown"], second, true, [first, second]), ["unknown", "/a.ts", "/b.ts"]); + assert.deepEqual(toggleResourceItem(["alpha"], first, false, [first, second]), []); + assert.deepEqual(removeResourceEntry(["/a.ts", "missing", "*"], "missing"), ["/a.ts", "*"]); + assert.deepEqual(removeResourceEntry(["/a.ts", "missing", "*"], "*"), ["/a.ts", "missing"]); + assert.deepEqual(removeResourceEntry(["*"], "*"), []); + assert.equal(removeResourceEntry(true, "*"), true); +}); + +test("a packaged skill keeps its own effective name while a packaged extension shows the package name", () => { + const review = item("/pkg/skills/review-code/SKILL.md", ["review-code"], { metadata: { origin: "package", source: "npm:dev-skills@1.0.0" } }); + const write = item("/pkg/skills/write-tests/SKILL.md", ["write-tests"], { metadata: { origin: "package", source: "npm:dev-skills@1.0.0" } }); + const extension = item("/pkg/node_modules/pi-browser-harness/dist/index.js", ["dist", "pi-browser-harness"], { + metadata: { origin: "package", source: "npm:pi-browser-harness@1.0.0", packageRoot: "/pkg/node_modules/pi-browser-harness" }, + }); + assert.equal(resourceDisplayName(review, "skills"), "review-code"); + assert.equal(resourceDisplayName(write, "skills"), "write-tests"); + assert.equal(resourceDisplayName(extension, "extensions"), "pi-browser-harness"); + assert.equal(resourceDisplayName(item("/x.ts", ["plain"]), "extensions"), "plain"); + assert.equal(resourceDisplayName(item("/x.ts", ["plain"]), "skills"), "plain"); + // The identity/path offered to the draft does not change with the shown name. + assert.deepEqual(toggleResourceItem([], extension, true, [extension]), [extension.path]); +}); + +test("search filters on the shown name and aliases without touching the selection", () => { + const entries = [item("/a.ts", ["alpha"]), item("/b.ts", ["beta"])]; + assert.deepEqual(filterResourceItems(entries, "ALP", "extensions").map((entry) => entry.path), ["/a.ts"]); + assert.deepEqual(filterResourceItems(entries, "beta", "skills"), [entries[1]]); + assert.deepEqual(filterResourceItems(entries, " ", "skills"), entries); +}); + +test("picker layout strictly clamps both edges, handles an off-screen anchor and a short viewport", () => { + const below = resourcePickerLayout({ top: 100, bottom: 130, left: 20, width: 200 }, { width: 800, height: 600 }); + assert.deepEqual([below.above, below.top, below.left, below.width, below.maxHeight, below.offscreen], [false, 136, 8, 420, 380, false]); + + const flipped = resourcePickerLayout({ top: 520, bottom: 550, left: 20, width: 200 }, { width: 800, height: 600 }); + assert.deepEqual([flipped.above, flipped.top, flipped.maxHeight, flipped.offscreen], [true, 134, 380, false]); + + const tight = resourcePickerLayout({ top: 260, bottom: 290, left: 20, width: 200 }, { width: 800, height: 600 }); + assert.equal(tight.maxHeight, 296); + + const phone = resourcePickerLayout({ top: 300, bottom: 340, left: 0, width: 374 }, { width: 390, height: 780 }); + assert.deepEqual([phone.left, phone.width], [8, 374]); + assert.ok(phone.maxHeight <= 380); + + // The reported bug: an anchor above the viewport must not put the header at -104. + const offTop = resourcePickerLayout({ top: -150, bottom: -110, left: 20, width: 200 }, { width: 800, height: 600 }); + assert.equal(offTop.offscreen, true); + assert.equal(offTop.above, false); + assert.equal(offTop.top, 8); + assert.ok((offTop.top ?? 0) >= 8 && (offTop.top ?? 0) + offTop.maxHeight <= 592, "the panel stays inside the viewport"); + + const offBottom = resourcePickerLayout({ top: 700, bottom: 730, left: 20, width: 200 }, { width: 800, height: 600 }); + assert.equal(offBottom.offscreen, true); + assert.equal(offBottom.above, true); + assert.equal(offBottom.top, 212); + assert.ok(offBottom.top >= 8 && offBottom.top + offBottom.maxHeight <= 592, "the flipped panel stays inside the viewport"); + + // A short viewport: no forced 96px minimum that crosses the bottom edge. + const short = resourcePickerLayout({ top: 80, bottom: 110, left: 20, width: 200 }, { width: 400, height: 200 }); + assert.equal(short.offscreen, false); + assert.equal(short.maxHeight, 76); + assert.ok((short.top ?? 0) + short.maxHeight <= 192); + + // A visualViewport shift changes the visible bounds, NOT the fixed anchor coordinates. + const shifted = resourcePickerLayout({ top: 250, bottom: 280, left: 20, width: 200 }, { width: 800, height: 600, offsetTop: 100 }); + assert.deepEqual([shifted.offscreen, shifted.top, shifted.maxHeight], [false, 286, 380]); + + // Partially past the top edge is still on-screen; the panel clamps below the anchor. + const partial = resourcePickerLayout({ top: -10, bottom: 30, left: 20, width: 200 }, { width: 800, height: 600 }); + assert.deepEqual([partial.offscreen, partial.top], [false, 36]); +}); + +test("offset viewport boundaries clamp in layout coordinates, including near-bottom, margins and narrow zoom", () => { + const viewport = { width: 500, height: 600, offsetLeft: 100, offsetTop: 100 }; + const aligned = resourcePickerLayout({ top: 250, bottom: 280, left: 520, width: 52 }, viewport); + assert.equal(aligned.top, 286); + assert.equal(aligned.left + aligned.width, 572, "align to the actual trigger right without subtracting offsetLeft"); + const rightEdge = resourcePickerLayout({ top: 250, bottom: 280, left: 580, width: 52 }, viewport); + assert.equal(rightEdge.left + rightEdge.width, 592); + const nearBottom = resourcePickerLayout({ top: 650, bottom: 680, left: 520, width: 52 }, viewport); + assert.deepEqual([nearBottom.above, nearBottom.top, nearBottom.maxHeight], [true, 264, 380]); + assert.equal(nearBottom.top + nearBottom.maxHeight, 644, "flipped bottom remains one gap above trigger top"); + assert.equal("bottom" in nearBottom, false, "both opening directions use fixed top, not a second viewport reference"); + + const margin = resourcePickerLayout({ top: 115, bottom: 145, left: -200, width: 52 }, viewport, { margin: 20, gap: 10, maxWidth: 240 }); + assert.deepEqual([margin.left, margin.width, margin.top], [120, 240, 155]); + for (const width of [180, 120, 16, 10]) { + const narrowViewport = { ...viewport, width }; + const narrow = resourcePickerLayout({ top: 250, bottom: 280, left: 160, width: 52 }, narrowViewport); + assert.ok(narrow.width <= width, "no minimum width overflows a zoom-narrowed visual viewport"); + assert.ok(narrow.left >= viewport.offsetLeft); + assert.ok(narrow.left + narrow.width <= viewport.offsetLeft + width); + } + for (const anchor of [ + { top: -100, bottom: -70, left: -100, width: 52 }, + { top: 900, bottom: 930, left: 900, width: 52 }, + ]) { + const offscreen = resourcePickerLayout(anchor, viewport); + assert.equal(offscreen.offscreen, true); + assert.ok(offscreen.top >= 108); + assert.ok(offscreen.top + offscreen.maxHeight <= 692); + assert.ok(offscreen.left >= 108 && offscreen.left + offscreen.width <= 592); + } +}); + +test("each picker aligns to its own button rather than the shared grid", () => { + // User's side-by-side rows: clicking extensions must not put its picker under skills. + const viewport = { width: 1160, height: 840 }; + const skills = resourcePickerLayout({ top: 540, bottom: 569, left: 632, width: 52 }, viewport); + const extensions = resourcePickerLayout({ top: 540, bottom: 569, left: 1052, width: 52 }, viewport); + assert.equal(skills.left + skills.width, 684); + assert.equal(extensions.left + extensions.width, 1104); + assert.equal(extensions.left - skills.left, 420); + assert.equal(extensions.top, 575); + const shifted = resourcePickerLayout({ top: 540, bottom: 569, left: 1052, width: 52 }, { ...viewport, width: 1000, offsetLeft: 100 }); + assert.equal(shifted.left + shifted.width, 1092, "right edge is clamped inside the shifted viewport"); + const phone = resourcePickerLayout({ top: 440, bottom: 469, left: 326, width: 52 }, { width: 390, height: 844 }); + assert.equal(phone.left, 8); + assert.equal(phone.left + phone.width, 382); +}); + +test("the default block mounts both summary rows but never the list or an overlay", () => { + const html = render(h(AgentResourceControls, { + cwd: "/repo", trustKey: "global", profileKey: "global:a", + skills: ["one", "unknown"], extensions: false, disabled: false, onChange: noop, + })); + assert.match(html, /data-kind="skills"/); + assert.match(html, /data-kind="extensions"/); + assert.match(html, /Load skills/); + assert.match(html, /Load extensions/); + assert.match(html, /data-kind="skills"[\s\S]*?2 enabled/); + assert.match(html, /data-kind="extensions"[\s\S]*?Disabled/); + assert.match(html, /Choose…/); + assert.doesNotMatch(html, / catalog\.skills\.some/); +}); + +test("one picker at a time, closed by a new profile, cwd, trust or readonly", () => { + assert.match(source, /const \[picker, setPicker\] = useState\(null\)/); + assert.match(source, /open=\{openKind === kind\}/); + assert.match(source, /key=\{openKind\}/); + assert.match(source, /setPicker\(null\)/); + assert.match(source, /\}, \[cwd, trustKey, profileKey, disabled\]\)/); +}); + +test("catalog error/retry and late-response cancellation never replace a draft", () => { + assert.match(source, /controller\.signal\.aborted/); + assert.match(source, /return \(\) => controller\.abort\(\)/); + assert.match(source, /\[cwd, trustKey, retry\]/); + assert.match(source, /response\.ok[\s\S]*?Array\.isArray\(data\.skills\)/); + assert.match(source, /role="alert"[\s\S]*?setRetry/); + assert.doesNotMatch(source, /setDraft/); + assert.match(agents, /skills: profile\.skills \?\? profile\.loadSkills/); + assert.match(agents, /extensions: profile\.extensions \?\? profile\.loadExtensions/); + assert.match(agents, /extensionTools: profile\.extensionTools/); + assert.match(agents, /setCloneFrom\(\{ name: selected\.name, scope: selected\.scope \}\)/); + assert.match(agents, /profileKey=\{selectedKey \?\? "create"\}/); +}); diff --git a/components/AgentResourceControls.tsx b/components/AgentResourceControls.tsx new file mode 100644 index 0000000000..b205212fab --- /dev/null +++ b/components/AgentResourceControls.tsx @@ -0,0 +1,776 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Ref } from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "@/hooks/useI18n"; +import type { SubagentResourceItem } from "@/lib/subagent-resource-catalog"; +import { matchingResourceEntries as matchingEntries, resourceMatchCandidates, resourceSelectionMode, type SubagentResourceSelection } from "@/lib/subagent-resource-selection"; +import { parseNpmSource } from "@/lib/npm-source"; +import { listenForStackedDialogEscape } from "@/lib/stacked-dialog"; +import { ConfigButton, ConfigEmptyState, ConfigScopeTag } from "./SettingsUi"; + +export type SubagentResourceKind = "skills" | "extensions"; + +/** Trigger coordinates from getBoundingClientRect(), in the layout viewport. */ +export interface ResourcePickerRect { + top: number; + bottom: number; + left: number; + width: number; +} + +export interface ResourcePickerViewport { + width: number; + height: number; + /** Visible viewport origin in layout-viewport coordinates (`visualViewport.offsetTop`). */ + offsetTop?: number; + /** `visualViewport.offsetLeft`. */ + offsetLeft?: number; +} + +export interface ResourcePickerLayout { + above: boolean; + /** Fixed-position top in layout-viewport coordinates, for both opening directions. */ + top: number; + left: number; + width: number; + maxHeight: number; + /** True when the anchor has no vertical overlap with the viewport (scrolled away or section hidden). */ + offscreen: boolean; +} + +/** + * Where a resource picker sits relative to its trigger: right-aligned below it when + * there is room, flipped above it near the bottom edge, always inside the + * viewport's width (a 390px phone included) and never wider than `maxWidth`. + * Both edges are strictly clamped, so a header/search never lands at a + * negative offset after the anchor scrolls off the top; an off-screen anchor + * reports `offscreen` and the caller closes. `offsetTop`/`offsetLeft` carry a + * `visualViewport` shift. Pure, so the boundary rules are testable without a DOM. + */ +export function resourcePickerLayout( + anchor: ResourcePickerRect, + viewport: ResourcePickerViewport, + options: { margin?: number; gap?: number; maxWidth?: number; preferredHeight?: number; minVisible?: number } = {}, +): ResourcePickerLayout { + const margin = options.margin ?? 8; + const gap = options.gap ?? 6; + const maxWidth = options.maxWidth ?? 420; + const preferredHeight = options.preferredHeight ?? 380; + const minVisible = options.minVisible ?? 160; + const offsetTop = viewport.offsetTop ?? 0; + const offsetLeft = viewport.offsetLeft ?? 0; + + // Keep the trigger, visible boundaries and fixed top/left in the same + // layout-viewport coordinates, as ChatWindow's quote popover does. + // Shrink the margins too when zoom leaves less than two margins of room. + const horizontalMargin = Math.min(margin, viewport.width / 2); + const verticalMargin = Math.min(margin, viewport.height / 2); + const viewLeft = offsetLeft + horizontalMargin; + const viewRight = offsetLeft + viewport.width - horizontalMargin; + const viewTop = offsetTop + verticalMargin; + const viewBottom = offsetTop + viewport.height - verticalMargin; + const width = Math.max(0, Math.min(maxWidth, viewRight - viewLeft)); + const left = Math.max(viewLeft, Math.min(anchor.left + anchor.width - width, viewRight - width)); + const offscreen = anchor.bottom <= viewTop || anchor.top >= viewBottom; + + const belowTop = anchor.bottom + gap; + const spaceBelow = viewBottom - belowTop; + const spaceAbove = anchor.top - gap - viewTop; + const above = spaceBelow < minVisible && spaceAbove > spaceBelow; + const maxHeight = Math.max(0, Math.min(preferredHeight, viewBottom - viewTop, above ? spaceAbove : spaceBelow)); + const desiredTop = above ? anchor.top - gap - maxHeight : belowTop; + const top = Math.max(viewTop, Math.min(desiredTop, viewBottom - maxHeight)); + return { above, top, left, width, maxHeight, offscreen }; +} + +export interface ResourceSelectionSummary { + mode: "all" | "none" | "selected"; + /** Explicit entries, excluding the wildcard, so the wildcard reads as its own mark. */ + selectedCount: number; + hasWildcard: boolean; + unknown: string[]; + ambiguous: string[]; +} + +/** + * What a summary row reads: its mode, how many entries were chosen, and which + * chosen entries no longer match the catalog. `catalogReady` is false while the + * catalog is loading or failed, so a draft is never mislabelled unknown just + * because the list has not arrived; those entries are kept. + */ +export function classifyResourceSelection( + value: SubagentResourceSelection, + items: SubagentResourceItem[], + catalogReady: boolean, +): ResourceSelectionSummary { + const selected = Array.isArray(value) ? value : []; + const unknown: string[] = []; + const ambiguous: string[] = []; + if (catalogReady) { + for (const entry of selected) { + if (entry === "*") continue; + const matches = resourceMatchCandidates(entry, items); + if (matches.length === 0) unknown.push(entry); + else if (new Set(matches.map((match) => match.identity)).size > 1) ambiguous.push(entry); + } + } + return { + mode: resourceSelectionMode(value), + selectedCount: selected.filter((entry) => entry !== "*").length, + hasWildcard: selected.includes("*"), + unknown, + ambiguous, + }; +} + +/** Whether a catalog entry is currently checked, with All and * reading as every enabled entry. */ +export function isResourceItemChecked(value: SubagentResourceSelection, item: SubagentResourceItem, items: SubagentResourceItem[]): boolean { + if (value === true) return item.enabled; + if (value === false) return false; + if (value.includes("*")) return item.enabled; + return value.some((entry) => matchingEntries(entry, items).some((match) => match.identity === item.identity)); +} + +/** + * The explicit list a first checkbox edit starts from. All expands to every + * enabled catalog entry (so unchecking one keeps the rest) and None starts + * empty. An explicit list containing the wildcard also expands to that enabled + * set plus its own entries, so one checkbox click can drop a row (and the + * wildcard with it) instead of asking for a separate wildcard removal. The + * stored value stays the catalog `path`, never a display name. + */ +export function resourceSelectionBase(value: SubagentResourceSelection, items: SubagentResourceItem[]): string[] { + if (value === true) return items.filter((item) => item.enabled).map((item) => item.path); + if (value === false) return []; + const explicit = value.filter((entry) => entry !== "*"); + if (!value.includes("*")) return [...explicit]; + const base = [...explicit]; + for (const item of items) { + if (!item.enabled) continue; + if (base.some((entry) => matchingEntries(entry, items).some((match) => match.identity === item.identity))) continue; + base.push(item.path); + } + return base; +} + +/** One checkbox change: drop every entry that resolved to this identity, then add the chosen one. */ +export function toggleResourceItem(value: SubagentResourceSelection, item: SubagentResourceItem, checked: boolean, items: SubagentResourceItem[]): string[] { + const remaining = resourceSelectionBase(value, items).filter((entry) => !matchingEntries(entry, items).some((match) => match.identity === item.identity)); + return checked ? [...remaining, item.path] : remaining; +} + +/** The state of the picker's master bulk control: every enabled row, none, or some. */ +export function resourceBulkState(value: SubagentResourceSelection, items: SubagentResourceItem[]): "all" | "none" | "partial" { + const enabled = items.filter((item) => item.enabled); + if (enabled.length === 0) return "none"; + const checked = enabled.filter((item) => isResourceItemChecked(value, item, items)).length; + if (checked === 0) return "none"; + return checked === enabled.length ? "all" : "partial"; +} + +/** + * One click selects every currently enabled catalog entry as an explicit path, + * keeping the draft's own entries (unknown/ambiguous included). It stays an + * array even when it covers the whole catalog, so a later-added resource is + * never silently enabled; the explicit "disable all" action writes `false`. + */ +export function selectAllResourceItems(value: SubagentResourceSelection, items: SubagentResourceItem[]): string[] { + const base = resourceSelectionBase(value, items); + for (const item of items) { + if (!item.enabled) continue; + if (base.some((entry) => matchingEntries(entry, items).some((match) => match.identity === item.identity))) continue; + base.push(item.path); + } + return base; +} + +/** Drops one chosen entry by its exact value, unknown and wildcard included. */ +export function removeResourceEntry(value: SubagentResourceSelection, entry: string): SubagentResourceSelection { + return Array.isArray(value) ? value.filter((item) => item !== entry) : value; +} + +/** + * The name a row shows. Only an extension takes its package/plugin name, since + * its entry file often sits in a build folder (`dist`, `src`); a skill always + * keeps its SDK effective name, so two skills from one package stay distinct. + * Display-only: it never changes the saved `path`/`identity`. + */ +export function resourceDisplayName(item: SubagentResourceItem, kind: SubagentResourceKind): string { + if (kind !== "extensions" || item.metadata.origin !== "package") return item.name; + const npm = parseNpmSource(item.metadata.source); + if (npm?.name) return npm.name.replace(/^@[^/]+\//, ""); + const lower = item.name.toLowerCase(); + const packaged = item.names.find((name) => name !== lower && !["src", "dist", "build", "lib", "out", "esm", "cjs", "index"].includes(name)); + return packaged ?? item.name; +} + +/** Search matches the shown name, the matching aliases, the source and the full path. */ +export function filterResourceItems(items: SubagentResourceItem[], search: string, kind: SubagentResourceKind): SubagentResourceItem[] { + const query = search.trim().toLowerCase(); + if (!query) return items; + return items.filter((item) => `${item.name} ${resourceDisplayName(item, kind)} ${item.path} ${item.metadata.source}`.toLowerCase().includes(query)); +} + +/** + * Whether a mousedown/focus target should close an open picker. Inside the + * panel or on the summary block (its two Choose buttons) does not, so focusing + * the other summary switches dimension instead of closing and reopening. + */ +export function isResourcePickerOutside( + target: Node | null, + panel: HTMLElement | null, + anchor: HTMLElement | null, +): boolean { + if (!target) return false; + if (panel?.contains(target)) return false; + if (anchor?.contains(target)) return false; + return true; +} + +/** + * Closes the picker when a press or a focus move lands outside the panel and + * the summary block: an outside `mousedown` (mouse, touch) or an outside + * `focusin` (Tab to a page control, a section switch). Focus that dropped to + * `body` from a click is ignored. A native Tab that reaches the document + * boundary (browser chrome) fires no `focusin` on a new control, so it is + * checked after the browser moves focus: still outside means dismiss. The check + * is deferred, so a kind switch that briefly drops focus clears it before it + * runs; a hidden section is handled by the visibility observer. + */ +export function bindResourcePickerDismissal( + doc: Document, + panel: HTMLElement | null, + anchor: HTMLElement, + onDismiss: () => void, +): () => void { + const timers = new Set>(); + const handle = (event: Event) => { + const target = event.target as Node | null; + if (event.type === "focusin" && (!target || target === doc.body)) return; + if (isResourcePickerOutside(target, panel, anchor)) onDismiss(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + const timer = setTimeout(() => { + timers.delete(timer); + if (isResourcePickerOutside(doc.activeElement, panel, anchor)) onDismiss(); + }, 0); + timers.add(timer); + }; + doc.addEventListener("mousedown", handle); + doc.addEventListener("focusin", handle); + doc.addEventListener("keydown", handleKeyDown); + return () => { + for (const timer of timers) clearTimeout(timer); + timers.clear(); + doc.removeEventListener("mousedown", handle); + doc.removeEventListener("focusin", handle); + doc.removeEventListener("keydown", handleKeyDown); + }; +} + +/** + * Focuses a picker's trigger on an explicit close, when it is still on the page + * and visible (`isConnected` alone would focus a trigger hidden with its + * section). Returns whether focus moved. + */ +export function focusResourcePickerTrigger(trigger: HTMLElement | null): boolean { + if (!trigger?.isConnected || trigger.getClientRects().length === 0) return false; + trigger.focus({ preventScroll: true }); + return true; +} + +/** + * An explicit close (Escape, the × button): return focus to this picker's own + * trigger, then close. An outside press/focus or an automatic close never goes + * through here, so it keeps the user's new target instead of stealing it back. + */ +export function closeResourcePicker(trigger: HTMLElement | null, onClose: () => void): void { + focusResourcePickerTrigger(trigger); + onClose(); +} + +/** + * Opens the picker overlay: the shared capture-phase Escape listener (so one + * Escape closes only the picker and never Settings) and initial focus (the + * search box on a fine pointer, the panel on a coarse one). Cleanup only stops + * the listener — it never moves focus — so an outside press/focus or an + * automatic close (hidden section, scrolled-away anchor, mode/profile/cwd + * change) keeps whatever the user focused. The caller's Escape handler uses + * `closeResourcePicker()` when it wants the explicit restore. + */ +export function openResourcePickerDialog( + doc: Document, + onEscape: () => void, + panel: HTMLElement | null, + search: HTMLInputElement | null, +): () => void { + const stopEscape = listenForStackedDialogEscape(doc, onEscape); + const coarse = doc.defaultView?.matchMedia("(pointer: coarse)").matches; + (coarse ? panel : search)?.focus({ preventScroll: true }); + return stopEscape; +} + +/** + * Closes the picker when its anchor stops intersecting the viewport: the + * settings section switched away (`hidden`, so the section host stays mounted + * but `display:none`) or the grid scrolled fully off. A browser API, not a new + * framework; absent, the `focusin`/scroll handlers still cover the cases. + */ +export function observeResourcePickerVisibility( + win: Window & typeof globalThis, + anchor: HTMLElement, + onHidden: () => void, +): () => void { + const Observer = win.IntersectionObserver; + if (!Observer) return () => {}; + const observer = new Observer((entries) => { + if (entries.some((entry) => !entry.isIntersecting)) onHidden(); + }); + observer.observe(anchor); + return () => observer.disconnect(); +} + +/** + * The status word a summary row and the picker header share: a boolean All or a + * wildcard reads as "all", an empty selection as "none", and anything else as + * its explicit count. An array that happens to cover the whole catalog stays a + * count, so a later-added resource is never silently enabled. + */ +export function resourceSelectionStatus(summary: ResourceSelectionSummary): "all" | "none" | "selected" { + if (summary.hasWildcard || summary.mode === "all") return "all"; + if (summary.selectedCount === 0) return "none"; + return "selected"; +} + +/** One summary row: label, a read-only status, any unknown/ambiguous warning, and Choose…. */ +export function ResourceSummary({ kind, label, value, items, catalogReady, disabled, open, triggerRef, onChoose }: { + kind: SubagentResourceKind; label: string; value: SubagentResourceSelection; items: SubagentResourceItem[]; catalogReady: boolean; + disabled: boolean; open: boolean; triggerRef?: Ref; + onChoose: () => void; +}) { + const { t } = useI18n(); + const summary = useMemo(() => classifyResourceSelection(value, items, catalogReady), [value, items, catalogReady]); + const warningCount = summary.unknown.length + summary.ambiguous.length; + const status = resourceSelectionStatus(summary); + const statusText = status === "selected" ? t("agents.resource.selectedCount", { count: summary.selectedCount }) : t(`agents.resource.${status}`); + return ( +
+ {label} + + {statusText} + {summary.hasWildcard && *} + {warningCount > 0 && ( + + {t("agents.resource.warning", { count: warningCount })} + + )} + + + {t("agents.resource.choose")} + +
+ ); +} + +function ExpandToggle({ expanded, label, onToggle }: { expanded: boolean; label: string; onToggle: () => void }) { + const { t } = useI18n(); + const text = t("agents.resource.details"); + return ( + + ); +} + +function ResourceItemRow({ item, kind, value, items, disabled, expanded, onToggleExpand, onToggle }: { + item: SubagentResourceItem; kind: SubagentResourceKind; value: SubagentResourceSelection; items: SubagentResourceItem[]; disabled: boolean; + expanded: boolean; onToggleExpand: () => void; onToggle: (checked: boolean) => void; +}) { + const { t } = useI18n(); + const name = resourceDisplayName(item, kind); + const checked = isResourceItemChecked(value, item, items); + return ( +
+ + + {expanded && ( +
+ + {item.metadata.scope} + {t("agents.resource.source")} + {item.metadata.source} + + + {t("agents.resource.path")} + {item.path} + +
+ )} +
+ ); +} + +/** + * The picker's list body. Presentational (no portal or effects), so it also + * renders in tests: search filters catalog rows, unknown/ambiguous draft + * entries stay visible below them, and each catalog row expands to its source + * and full path on demand. + */ +export function ResourcePickerList({ kind, value, items, catalogReady, disabled, search, expanded, onToggleExpand, onChange }: { + kind: SubagentResourceKind; value: SubagentResourceSelection; items: SubagentResourceItem[]; catalogReady: boolean; disabled: boolean; + search: string; expanded: ReadonlySet; + onToggleExpand: (key: string) => void; + onChange: (value: SubagentResourceSelection) => void; +}) { + const { t } = useI18n(); + const summary = useMemo(() => classifyResourceSelection(value, items, catalogReady), [value, items, catalogReady]); + const visible = useMemo(() => filterResourceItems(items, search, kind), [items, search, kind]); + const unknownRows = [ + ...summary.unknown.map((entry) => ({ entry, ambiguous: false })), + ...summary.ambiguous.map((entry) => ({ entry, ambiguous: true })), + ]; + return ( +
+ {summary.hasWildcard && ( +
+ +
+ )} + {visible.map((item) => ( + onToggleExpand(item.identity)} + onToggle={(checked) => onChange(toggleResourceItem(value, item, checked, items))} + /> + ))} + {unknownRows.map(({ entry, ambiguous }) => ( +
+ + + {t(ambiguous ? "agents.resource.ambiguous" : "agents.resource.unknown")} + +
+ ))} + {visible.length === 0 && unknownRows.length === 0 && !summary.hasWildcard && ( + {catalogReady ? t("agents.resource.empty") : t("agents.loading")} + )} +
+ ); +} + +function ResourcePicker({ kind, label, value, items, catalogReady, disabled, summaryBoundary, trigger, onClose, onChange }: { + kind: SubagentResourceKind; label: string; value: SubagentResourceSelection; items: SubagentResourceItem[]; catalogReady: boolean; + disabled: boolean; summaryBoundary: HTMLElement; trigger: HTMLButtonElement; + onClose: () => void; + onChange: (value: SubagentResourceSelection) => void; +}) { + const { t } = useI18n(); + const panelRef = useRef(null); + const searchRef = useRef(null); + const [search, setSearch] = useState(""); + const [expanded, setExpanded] = useState>(() => new Set()); + const computeLayout = useCallback(() => { + if (typeof window === "undefined") return null; + const rect = trigger.getBoundingClientRect(); + const viewport = window.visualViewport; + return resourcePickerLayout( + { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }, + { + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + offsetTop: viewport?.offsetTop ?? 0, + offsetLeft: viewport?.offsetLeft ?? 0, + }, + ); + }, [trigger]); + // Laid out during the first render, so the panel is visible when focus moves in. + const [layout, setLayout] = useState(computeLayout); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const [portalTarget] = useState(() => (typeof document === "undefined" ? null : document.body)); + + const summary = useMemo(() => classifyResourceSelection(value, items, catalogReady), [value, items, catalogReady]); + const status = resourceSelectionStatus(summary); + const bulk = resourceBulkState(value, items); + const hasEnabled = items.some((item) => item.enabled); + + // Follow the active trigger while the settings pane, the page, or a phone keyboard + // resizes; a fully off-screen anchor (or no room at all) closes the picker. + const updateLayout = useCallback(() => { + const next = computeLayout(); + if (!next) return; + if (next.offscreen || next.maxHeight <= 0) { + onCloseRef.current(); + return; + } + setLayout(next); + }, [computeLayout]); + + useEffect(() => { + updateLayout(); + window.addEventListener("resize", updateLayout); + window.addEventListener("scroll", updateLayout, true); + window.visualViewport?.addEventListener("resize", updateLayout); + window.visualViewport?.addEventListener("scroll", updateLayout); + return () => { + window.removeEventListener("resize", updateLayout); + window.removeEventListener("scroll", updateLayout, true); + window.visualViewport?.removeEventListener("resize", updateLayout); + window.visualViewport?.removeEventListener("scroll", updateLayout); + }; + }, [updateLayout]); + + // A short list is smaller than maxHeight. Keep its actual bottom next to the + // trigger when flipped, still using only a layout-viewport top coordinate. + // ResizeObserver also follows filtering/expanded details without moving focus. + useLayoutEffect(() => { + const panel = panelRef.current; + if (!panel || !layout?.above) return; + const position = () => { + panel.style.top = `${layout.top + Math.max(0, layout.maxHeight - panel.getBoundingClientRect().height)}px`; + }; + position(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(position); + observer.observe(panel); + return () => observer.disconnect(); + }, [layout]); + + // Escape is an explicit close: it restores this picker's own trigger through + // closeResourcePicker. Cleanup never moves focus, so an outside focus change or + // an automatic close is not stolen back. + useEffect( + () => openResourcePickerDialog(document, () => closeResourcePicker(trigger, () => onCloseRef.current()), panelRef.current, searchRef.current), + [trigger], + ); + + // A press or a focus move outside closes; the summary block and the panel do not. + useEffect( + () => bindResourcePickerDismissal(document, panelRef.current, summaryBoundary, () => onCloseRef.current()), + [summaryBoundary], + ); + + // A hidden settings section (`hidden`, still mounted) or a scrolled-away + // anchor leaves the viewport, closing the picker without a focus change. + useEffect( + () => observeResourcePickerVisibility(window, trigger, () => onCloseRef.current()), + [trigger], + ); + + if (!portalTarget) return null; + + const style: CSSProperties = layout ? { + position: "fixed", + left: layout.left, + width: layout.width, + maxHeight: layout.maxHeight, + zIndex: 1050, + top: layout.top, + } : { position: "fixed", top: 0, left: 0, visibility: "hidden", zIndex: 1050 }; + + const toggleExpand = (key: string) => setExpanded((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + return createPortal( +
+
+ {label} + + {status === "selected" ? t("agents.resource.selectedCount", { count: summary.selectedCount }) : t(`agents.resource.${status}`)} + +
+
+ setSearch(event.target.value)} + /> +
+ +
, + portalTarget, + ); +} + +/** + * The compact Resources block: one summary row per kind (label, All/None/ + * Selected, the chosen count with any unknown/ambiguous warning, and Choose…), + * and, only while chosen, a portal overlay with search and checkboxes. It keeps + * one static SDK catalog for both rows, and drafts survive errors, retry, trust + * and profile changes. Checkboxes and the overlay are display/navigation only: + * opening, closing, searching and retrying never touch the draft. + */ +export function AgentResourceControls({ cwd, trustKey, profileKey, skills, extensions, disabled, onChange }: { + cwd: string; trustKey: string; profileKey?: string | null; + skills: SubagentResourceSelection; extensions: SubagentResourceSelection; disabled: boolean; + onChange: (kind: SubagentResourceKind, value: SubagentResourceSelection) => void; +}) { + const { t } = useI18n(); + const gridRef = useRef(null); + const skillsTriggerRef = useRef(null); + const extensionsTriggerRef = useRef(null); + const [catalog, setCatalog] = useState<{ skills: SubagentResourceItem[]; extensions: SubagentResourceItem[] } | null>(null); + const [diagnostics, setDiagnostics] = useState<{ message: string; path?: string }[]>([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [retry, setRetry] = useState(0); + const [picker, setPicker] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + setCatalog(null); + setDiagnostics([]); + void (async () => { + try { + const response = await fetch(`/api/subagents/resources?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store", signal: controller.signal }); + const data = await response.json(); + if (!response.ok || data.error || !Array.isArray(data.skills) || !Array.isArray(data.extensions) || !Array.isArray(data.diagnostics)) throw new Error(data.error ?? `HTTP ${response.status}`); + if (!controller.signal.aborted) { + setCatalog({ skills: data.skills, extensions: data.extensions }); + setDiagnostics(data.diagnostics); + } + } catch (cause) { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + })(); + return () => controller.abort(); + }, [cwd, trustKey, retry]); + + // A new folder, trust decision or profile closes the old overlay; the draft + // itself is untouched. Read-only mode cannot open one either. + useEffect(() => { + setPicker(null); + }, [cwd, trustKey, profileKey, disabled]); + + const catalogReady = catalog !== null; + const itemsFor = (kind: SubagentResourceKind) => (kind === "skills" ? catalog?.skills : catalog?.extensions) ?? []; + + const openKind = picker; + const trigger = openKind === "skills" ? skillsTriggerRef.current : openKind === "extensions" ? extensionsTriggerRef.current : null; + return ( +
+
+ {(["skills", "extensions"] as const).map((kind) => { + const label = t(kind === "skills" ? "agents.loadSkills" : "agents.loadExtensions"); + const value = kind === "skills" ? skills : extensions; + return ( + setPicker((current) => (current === kind ? null : kind))} + /> + ); + })} +
+ {loading && {t("agents.loading")}} + {error &&
{error} setRetry((value) => value + 1)}>{t("agents.resource.retry")}
} + {diagnostics.length > 0 &&
{t("agents.resource.diagnostics", { count: diagnostics.length })}{diagnostics.map((entry, index) =>
{entry.message}{entry.path && {entry.path}}
)}
} + {openKind && gridRef.current && trigger && ( + setPicker(null)} + onChange={(next) => onChange(openKind, next)} + /> + )} +
+ ); +} diff --git a/components/AgentsConfig.test.mjs b/components/AgentsConfig.test.mjs index 2f766f8137..f3221a1a3f 100644 --- a/components/AgentsConfig.test.mjs +++ b/components/AgentsConfig.test.mjs @@ -57,7 +57,7 @@ test("uses the shared sidebar action for new profiles", () => { }); test("sends the selected scope for saves and the source scope for deletes", () => { - assert.match(source, /JSON\.stringify\(\{ cwd, scope: targetScope, profile: draft \}\)/); + assert.match(source, /JSON\.stringify\(\{ cwd, scope: targetScope, profile: draft, \.\.\.\(creating && cloneFrom \? \{ cloneFrom \} : \{\}\) \}\)/); assert.match(source, /JSON\.stringify\(\{ cwd, scope: selected\.scope, name: selected\.name \}\)/); }); @@ -114,8 +114,7 @@ test("uses the same form controls for editable and readonly profiles", () => { assert.match(source, /(() => getLastSettingsSelection("agents", cwd)); const [draft, setDraft] = useState(EMPTY_PROFILE); const [mode, setMode] = useState("view"); + const [cloneFrom, setCloneFrom] = useState | null>(null); const [targetScope, setTargetScope] = useState("global"); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -282,6 +291,7 @@ export function AgentsConfig({ }, [cwd, trustKey]); const selectProfile = (profile: SubagentProfile) => { + setCloneFrom(null); setSelectedKey(profileKey(profile)); setDraft(editableProfile(profile)); setMode(isWritableScope(profile.scope) ? "edit" : "view"); @@ -290,6 +300,7 @@ export function AgentsConfig({ }; const beginCreate = () => { + setCloneFrom(null); let name = "custom-agent"; let suffix = 2; while (profiles.some((profile) => profile.name === name)) name = `custom-agent-${suffix++}`; @@ -303,6 +314,7 @@ export function AgentsConfig({ const beginDuplicate = () => { if (!selected) return; const name = duplicateProfileName(selected.name, profiles); + setCloneFrom({ name: selected.name, scope: selected.scope }); setSelectedKey(null); setDraft({ ...editableProfile(selected), @@ -322,7 +334,7 @@ export function AgentsConfig({ const response = await fetch("/api/subagents/profiles", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd, scope: targetScope, profile: draft }), + body: JSON.stringify({ cwd, scope: targetScope, profile: draft, ...(creating && cloneFrom ? { cloneFrom } : {}) }), }); const data = await response.json() as { profile?: SubagentProfile; error?: string }; if (!response.ok || data.error || !data.profile) throw new Error(data.error ?? `HTTP ${response.status}`); @@ -607,10 +619,14 @@ export function AgentsConfig({ -
- update("loadSkills", checked)} /> - update("loadExtensions", checked)} /> -
+ setDraft((current) => ({ ...current, [kind]: value, [kind === "skills" ? "loadSkills" : "loadExtensions"]: value !== false }))} + />
diff --git a/docs/agents/subagent-resources.md b/docs/agents/subagent-resources.md index 7033cd3f30..7a9f106d02 100644 --- a/docs/agents/subagent-resources.md +++ b/docs/agents/subagent-resources.md @@ -31,6 +31,18 @@ The wrapper reconciles unauthorized active tools after its single bind, after bo **SDK limitation:** there is no public registration-policy hook. Denied tools may remain in `getAllTools()` or briefly become active between boundaries, but execution is guarded. This is not hard registry filtering or a sandbox for trusted extension JavaScript. No private registry mutation or duplicate bind is used. +## Resource picker UI + +Settings › Sub-agents uses shared `SettingsUi` blocks and the existing profile draft/Save flow. Built-in profiles remain read-only. Each resource row shows All enabled, Disabled or a selected count, with a Choose button and visible unknown/ambiguous warnings; there is no mode dropdown or always-expanded list. + +- One checkbox picker opens beside the current button. Search filters the view, not the selection. Source/path details expand on demand, and packaged skills keep their effective SDK names. +- The header has one tri-state bulk toggle. Selecting all writes explicit paths for the complete enabled catalog, preserving unknown/ambiguous entries; clearing writes `false`. It never converts a complete list into future-enabled `true`/`*`. Loading, unavailable and read-only catalogs disable bulk editing. +- Unchecking one item from All or `*` narrows the draft to explicit current entries. Opening, searching, retrying and closing never change or save the draft. +- Escape and the close button restore the visible current trigger. Outside interaction, native Tab leaving the picker, hidden sections, offscreen anchors and profile/cwd/trust changes close without stealing focus. Both summary buttons remain available for one-click kind switching. +- Fixed positioning uses layout-viewport coordinates and clamps to the visual viewport, including its offsets. It flips above near the lower edge. Initial focus follows the pointer type: search for a fine pointer, panel for a coarse pointer; viewport resize does not refocus the picker. + +Component tests cover draft and matching rules, bulk scope, dismissal, positioning and focus policy. Browser acceptance uses isolated catalogs and in-memory storage; no real profile write is needed. + ## Regression coverage - Resource integration tests use temporary HOME/agentDir/cwd and separate module/factory markers to verify pre-import exclusion, scope preservation, trust transitions, dynamic skills and cold restoration. Rejecting npm fixtures prohibit installation and remote commands. diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 0bb5253766..12f6742493 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -97,6 +97,23 @@ export const enLocale: LocalePlugin = { "agents.prompt": "System instructions", "agents.tools": "Tools", "agents.resources": "Resources", + "agents.resource.all": "All enabled", + "agents.resource.none": "Disabled", + "agents.resource.selectedCount": "{count} enabled", + "agents.resource.warning": "{count} unknown or ambiguous; retained", + "agents.resource.choose": "Choose…", + "agents.resource.details": "Details", + "agents.resource.source": "Source", + "agents.resource.path": "Path", + "agents.resource.ambiguous": "Ambiguous name; pick a concrete file", + "agents.resource.search": "Search resources", + "agents.resource.retry": "Retry", + "agents.resource.empty": "No matching resources", + "agents.resource.unknown": "Unknown or ambiguous; retained, not loaded", + "agents.resource.wildcard": "* — all available resources (plus explicit paths)", + "agents.resource.selectAll": "Select all", + "agents.resource.clearAll": "Clear all", + "agents.resource.diagnostics": "Resource diagnostics ({count})", "agents.loadSkills": "Load skills", "agents.loadExtensions": "Load extensions", "agents.model": "Model override", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index eabdadb90b..b427f30bd2 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -97,6 +97,24 @@ export const zhCNLocale: LocalePlugin = { "agents.prompt": "系统指令", "agents.tools": "工具", "agents.resources": "资源", + "agents.resource.all": "全部启用", + "agents.resource.none": "禁用", + "agents.resource.selectedCount": "已启用 {count} 项", + "agents.resource.warning": "{count} 项未知或歧义;已保留", + "agents.resource.choose": "选择…", + "agents.resource.details": "详情", + "agents.resource.source": "来源", + "agents.resource.path": "路径", + "agents.resource.ambiguous": "名称有歧义;请选择具体文件", + "agents.resource.search": "搜索资源", + "agents.resource.retry": "重试", + "agents.resource.empty": "没有匹配的资源", + "agents.resource.unknown": "未知或名称有歧义;保留但不加载", + "agents.resource.wildcard": "* — 全部可用资源(加显式路径)", + "agents.resource.selectAll": "全选", + "agents.resource.clearAll": "清除全部", + "agents.resource.diagnostics": "资源诊断({count})", + "agents.loadSkills": "加载技能", "agents.loadExtensions": "加载扩展", "agents.model": "指定模型", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 3c18dc63cc..e109493e10 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -97,6 +97,24 @@ export const zhTWLocale: LocalePlugin = { "agents.prompt": "系統指令", "agents.tools": "工具", "agents.resources": "資源", + "agents.resource.all": "全部啟用", + "agents.resource.none": "停用", + "agents.resource.selectedCount": "已啟用 {count} 項", + "agents.resource.warning": "{count} 項未知或歧義;已保留", + "agents.resource.choose": "選擇…", + "agents.resource.details": "詳情", + "agents.resource.source": "來源", + "agents.resource.path": "路徑", + "agents.resource.ambiguous": "名稱有歧義;請選擇具體檔案", + "agents.resource.search": "搜尋資源", + "agents.resource.retry": "重試", + "agents.resource.empty": "沒有符合的資源", + "agents.resource.unknown": "未知或名稱有歧義;保留但不載入", + "agents.resource.wildcard": "* — 全部可用資源(加明確路徑)", + "agents.resource.selectAll": "全選", + "agents.resource.clearAll": "清除全部", + "agents.resource.diagnostics": "資源診斷({count})", + "agents.loadSkills": "載入技能", "agents.loadExtensions": "載入擴充功能", "agents.model": "指定模型", From a0eadaa2d5079471296dab1545479d99777c512b Mon Sep 17 00:00:00 2001 From: lyx2145181 <41118458+lyx2145181@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:42:00 +0800 Subject: [PATCH 3/6] fix(subagents): route delegated runs through wrappers Why: Direct SDK prompts bypass the wrapper's prompt and Stop lifecycle. Delegated runs must await completion and resume extension UI after Stop. Safety: Use wrapper admission, readiness and cancellation for create and resume. Reset extension UI cancellation before prompting without rebinding. Share MCP preparation with normal prompts. Compatibility: Keep v2 snapshots and legacy test-host fallbacks unchanged. Introduce no capability fields, profile APIs or host authorization changes. Validation: Exported and checked this staged tree independently. Typecheck, ESLint and whitespace checks passed. Isolated offline suite: 2367 passed; separately mocked plugin suite: 5 passed. Independent targeted tests: 41 passed. The lifecycle test also passed with pending v3 code; restoring the old runtime in a temporary copy reproduced an interrupted-UI Stop timeout. No browser validation, build, deployment or real provider/MCP request. --- docs/agents/subagent-resources.md | 2 +- lib/rpc-manager.ts | 85 ++++++++++---- ...ent-runtime-delegated.integration.test.mjs | 107 ++++++++++++++++++ lib/subagent-runtime.ts | 24 +++- 4 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 lib/subagent-runtime-delegated.integration.test.mjs diff --git a/docs/agents/subagent-resources.md b/docs/agents/subagent-resources.md index 7a9f106d02..24fa26f3dd 100644 --- a/docs/agents/subagent-resources.md +++ b/docs/agents/subagent-resources.md @@ -27,7 +27,7 @@ New children persist `resourceSnapshot` v2 with the resource selections, prompt, SDK 1.0 treats `options.tools` as a permanent allowlist, so it cannot admit approved tools registered later during `session_start`. V2 instead uses `noTools: "builtin"`, excludes unauthorized builtins/reserved control names and applies a shared authorization predicate to the actual registration winner and approved extension roster. Direct and nested `tool_call` execution both pass that guard; inactive deferred tools are still nested-callable, so active pruning alone is insufficient. -The wrapper reconciles unauthorized active tools after its single bind, after both reload paths and before public tool/state reads. Binding includes `resources_discover`, which occurs after `session_start`. Reconciliation removes names only; it never force-activates optional tools. Delegated prompts wait for readiness, check cancellation again before starting, and handle queued startup failures without leaving rejected promises unobserved. +The wrapper reconciles unauthorized active tools after its single bind, after both reload paths and before public tool/state reads. Binding includes `resources_discover`, which occurs after `session_start`. Reconciliation removes names only; it never force-activates optional tools. Delegated prompts wait for readiness, check cancellation again before starting, and handle queued startup failures without leaving rejected promises unobserved. They use the wrapper's complete prompt/admission/Stop boundary rather than calling the SDK prompt directly; Stop during extension UI is reset before a resumed prompt, without rebinding. **SDK limitation:** there is no public registration-policy hook. Denied tools may remain in `getAllTools()` or briefly become active between boundaries, but execution is guarded. This is not hard registry filtering or a sandbox for trusted extension JavaScript. No private registry mutation or duplicate bind is used. diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 4782ab772e..cd12532c3e 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -501,6 +501,63 @@ export class AgentSessionWrapper { return this.extensionBindingPromise; } + private resetExtensionUiForPrompt(): void { + if (this.extensionUiAbortController.signal.aborted) this.extensionUiAbortController = new AbortController(); + } + + /** Complete delegated run, not the send() preflight acknowledgement. One bind only. */ + async promptDelegated(message: string, signal?: AbortSignal): Promise { + await this.waitUntilReady(); + const check = () => { + if (signal?.aborted || !this.isAlive()) throw new Error("Subagent cancelled before prompting"); + }; + check(); + const releaseAdmission = await this.acquirePromptAdmission(); + try { + check(); + if (this.isRunning()) throw new Error("Subagent is already running"); + this.resetExtensionUiForPrompt(); + this.pendingPromptCount += 1; + const cancel = () => { + this.mcpPromptWait?.controller.abort(); + if (this.inner.isStreaming) void this.inner.abort(); + }; + signal?.addEventListener("abort", cancel, { once: true }); + try { + check(); + await this.prepareMcpPrompt(message); + check(); + this.reconcileSubagentToolPolicy(); + this.agentRunNeedsCompletion = true; + await this.inner.prompt(message, { source: "rpc" }); + } finally { + signal?.removeEventListener("abort", cancel); + this.pendingPromptCount = Math.max(0, this.pendingPromptCount - 1); + this.resetIdleTimer(); + this.notifyAgentRunCompleteIfIdle(); + } + } finally { releaseAdmission(); } + } + + async abortDelegated(): Promise { await this.send({ type: "abort" }); } + + private async prepareMcpPrompt(message: string): Promise { + const preparation = this.mcpHost && !this.inner.isStreaming + ? mcpPromptPreparation(message, this.extensionCommandCandidates()) : "none"; + if (!this.mcpHost || preparation === "none") return; + const controller = new AbortController(); + const waited = this.mcpHost.prepareForPrompt(controller.signal, { wait: preparation === "wait" }) + .catch((error: unknown) => { + console.error("[pi-web] MCP servers could not be prepared:", error instanceof Error ? error.message : error); + }).then(() => { + if (controller.signal.aborted) throw new Error(MCP_WAIT_STOPPED_MESSAGE); + }); + const wait = { controller, done: waited.then(() => undefined, () => undefined) }; + this.mcpPromptWait = wait; + try { await waited; } + finally { if (this.mcpPromptWait === wait) this.mcpPromptWait = null; } + } + private reconcileSubagentToolPolicy(): void { const resources = this.subagentResources; if (resources?.version !== 2) return; // Normal sessions and v1 retain their existing loadout rules. @@ -753,9 +810,7 @@ export class AgentSessionWrapper { if (this.inner.isBashRunning) { throw new Error("Cannot send a prompt while a shell command is running"); } - if (this.extensionUiAbortController.signal.aborted) { - this.extensionUiAbortController = new AbortController(); - } + this.resetExtensionUiForPrompt(); const promptImages = command.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined; const streamingBehavior = command.streamingBehavior as "steer" | "followUp" | undefined; let preflightAccepted = false; @@ -793,28 +848,8 @@ export class AgentSessionWrapper { // extension command before anything else and starts no run for it: another // extension's command skips this, and the built-in `/mcp`, which acts on the // registered servers, registers them without waiting (`mcpPromptPreparation()`). - const mcpPreparation = this.mcpHost && !this.inner.isStreaming - ? mcpPromptPreparation(typeof command.message === "string" ? command.message : "", this.extensionCommandCandidates()) - : "none"; - if (this.mcpHost && mcpPreparation !== "none") { - const controller = new AbortController(); - const waited = this.mcpHost.prepareForPrompt(controller.signal, { wait: mcpPreparation === "wait" }) - .catch((error: unknown) => { - console.error("[pi-web] MCP servers could not be prepared:", error instanceof Error ? error.message : error); - }) - .then(() => { - if (!controller.signal.aborted) return; - finishPrompt(); - throw new Error(MCP_WAIT_STOPPED_MESSAGE); - }); - const wait = { controller, done: waited.then(() => undefined, () => undefined) }; - this.mcpPromptWait = wait; - try { - await waited; - } finally { - if (this.mcpPromptWait === wait) this.mcpPromptWait = null; - } - } + try { await this.prepareMcpPrompt(typeof command.message === "string" ? command.message : ""); } + catch (error) { finishPrompt(); throw error; } let prompt: Promise; try { prompt = this.inner.prompt(command.message as string, { diff --git a/lib/subagent-runtime-delegated.integration.test.mjs b/lib/subagent-runtime-delegated.integration.test.mjs new file mode 100644 index 0000000000..abfc65fb7c --- /dev/null +++ b/lib/subagent-runtime-delegated.integration.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { createJiti } from "jiti"; + +const root = await mkdtemp(join(tmpdir(), "pi-runtime-delegated-")); +const previous = { HOME: process.env.HOME, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }; +process.env.HOME = join(root, "home"); +process.env.PI_CODING_AGENT_DIR = join(root, "agent"); +await mkdir(process.env.HOME); +await mkdir(process.env.PI_CODING_AGENT_DIR); +const keepAlive = setInterval(() => {}, 1000); +after(async () => { + clearInterval(keepAlive); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await rm(root, { recursive: true, force: true }); +}); +const sdk = await import("@earendil-works/pi-coding-agent"); +const { fauxProvider } = await import("@earendil-works/pi-ai"); +const jiti = createJiti(import.meta.url); +const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts"); +const { createSubagentController } = await jiti.import("./subagent-runtime.ts"); +const { readSubagentSessionResources } = await jiti.import("./subagents.ts"); + +async function until(predicate) { + for (let i = 0; i < 200; i++) { + if (predicate()) return; + await delay(5); + } + throw Error("fixture timed out"); +} + +// Generic delegated lifecycle fixture: no optional host capabilities or transport harness. +async function fixture(t) { + const dir = await mkdtemp(join(root, "case-")); + const cwd = join(dir, "cwd"), agentDir = join(dir, "agent"); + await mkdir(cwd); + await mkdir(agentDir); + await writeFile(join(agentDir, "settings.json"), JSON.stringify({ cacheWarming: "off" })); + const faux = fauxProvider({ models: [{ id: "child-faux" }] }); + const runtime = await sdk.ModelRuntime.create({ authPath: join(dir, "auth.json"), modelsPath: null, refreshOnCreate: false }); + runtime.registerNativeProvider(faux.provider); + t.after(() => runtime.dispose?.()); + return { cwd, agentDir, faux, runtime }; +} + +test("controller Stop then delegated resume issues a fresh real extension UI confirmation without rebinding", async (t) => { + const f = await fixture(t), parentManager = sdk.SessionManager.inMemory(f.cwd); + const extension = join(f.agentDir, "choose.ts"); + globalThis.__childUiConfirmed = 0; + t.after(() => { delete globalThis.__childUiConfirmed; }); + await writeFile(extension, `export default function(pi) { pi.registerCommand('choose', { description:'Fixture', handler:async(_args,ctx)=>{ if(await ctx.ui.confirm('Choose','Continue?')) globalThis.__childUiConfirmed++; } }); }`); + await mkdir(join(f.cwd, ".pi", "agents"), { recursive: true }); + await writeFile(join(f.cwd, ".pi", "agents", "ui-child.md"), `---\ntools: none\nextensions: [${JSON.stringify(extension)}]\n---\nFixture`); + const parent = { + cwd: f.cwd, sessionFile: join(f.cwd, "parent.jsonl"), isAlive: () => true, + isRunning: () => false, waitUntilReady: async () => {}, + inner: { sessionManager: parentManager, modelRuntime: f.runtime, model: f.faux.getModel("child-faux"), agent: { state: {} } }, + }; + const wrappers = new Map([[parentManager.getSessionId(), parent]]), events = []; + let binds = 0; + const controller = createSubagentController({ + getSession: (id) => wrappers.get(id), + registerSession(inner, options) { + const bind = inner.bindExtensions.bind(inner); + inner.bindExtensions = (...args) => { binds++; return bind(...args); }; + const wrapper = new AgentSessionWrapper(inner, { + ...options, subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries()), + }); + wrappers.set(inner.sessionId, wrapper); + wrapper.onEvent((event) => events.push(event)); + wrapper.beginExtensionBinding(); + t.after(() => wrapper.destroy()); + return wrapper.waitUntilReady(); + }, + reopenSession: async (id) => wrappers.get(id), resolveSessionPath: async () => null, + invalidateSessionList: () => {}, isBuiltInSubagentsEnabled: () => true, + }); + const request = { parentContext: parent.inner, parentToolCallId: "ui", profile: "ui-child", description: "UI", task: "/choose" }; + const first = await controller.extensionRuntime.start(request); + await until(() => events.some((event) => event.method === "confirm")); + let firstSettled = false; + void first.completion.then(() => { firstSettled = true; }); + await delay(10); + assert.equal(firstSettled, false, "delegated completion must await the outstanding UI response"); + const firstConfirmation = events.find((event) => event.method === "confirm"); + await controller.abort(first.run.sessionId); + assert.equal((await first.completion).status, "aborted"); + const errorsBefore = events.filter((event) => event.type === "extension_error").length; + const resumed = await controller.extensionRuntime.resume({ ...request, sessionId: first.run.sessionId }); + await until(() => events.filter((event) => event.method === "confirm").length === 2); + const nextConfirmation = events.findLast((event) => event.method === "confirm"); + assert.notEqual(nextConfirmation.id, firstConfirmation.id); + await wrappers.get(first.run.sessionId).send({ type: "extension_ui_response", id: nextConfirmation.id, confirmed: true }); + assert.equal((await resumed.completion).status, "completed"); + assert.equal(globalThis.__childUiConfirmed, 1); + assert.equal(binds, 1); + assert.equal(events.filter((event) => event.type === "extension_error").length, errorsBefore); + assert.equal(f.faux.state.callCount, 0); + t.diagnostic(JSON.stringify({ confirmationRequests: 2, confirmedResumed: 1, initialBinds: binds, resumeBinds: 0, providerRequests: 0 })); +}); diff --git a/lib/subagent-runtime.ts b/lib/subagent-runtime.ts index 221fff3093..9a5df91280 100644 --- a/lib/subagent-runtime.ts +++ b/lib/subagent-runtime.ts @@ -46,6 +46,8 @@ interface HostSession { isAlive(): boolean; isRunning(): boolean; waitUntilReady(): Promise; + promptDelegated?(message: string, signal?: AbortSignal): Promise; + abortDelegated?(): Promise; } export interface SubagentRuntimeDependencies { @@ -72,8 +74,20 @@ type StoredSubagentExecution = { completion: Promise; abortRequested: boolean; cancelQueued?: () => boolean; + promptController?: AbortController; }; +async function promptDelegated(wrapper: HostSession | undefined, inner: AgentSessionLike, message: string, stored: StoredSubagentExecution): Promise { + throwIfSubagentCancelled(stored); + if (wrapper?.promptDelegated) return wrapper.promptDelegated(message, (stored.promptController ??= new AbortController()).signal); + await inner.prompt(message, { source: "rpc" }); +} + +function abortDelegated(wrapper: HostSession | undefined, inner: AgentSessionLike, stored?: StoredSubagentExecution): Promise { + stored?.promptController?.abort(); + return wrapper?.abortDelegated ? wrapper.abortDelegated() : inner.abort(); +} + /** Check admission after every readiness await; aborting an idle SDK session alone cannot stop a later prompt. */ function throwIfSubagentCancelled(stored: StoredSubagentExecution, signal?: AbortSignal): void { if (signal?.aborted) stored.abortRequested = true; @@ -372,7 +386,7 @@ export function createSubagentController( const handleParentAbort = () => { stored.abortRequested = true; if (stored.run.status === "queued") stored.cancelQueued?.(); - else void inner.abort(); + else void abortDelegated(dependencies.getSession(inner.sessionId), inner, stored); }; if (!runInBackground) request.signal?.addEventListener("abort", handleParentAbort, { once: true }); @@ -389,7 +403,7 @@ export function createSubagentController( if (!registration.ok) throw registration.error; await dependencies.getSession(inner.sessionId)?.waitUntilReady?.(); throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); - await inner.prompt(delegatedTask, { source: "rpc" }); + await promptDelegated(dependencies.getSession(inner.sessionId), inner, delegatedTask, stored); const text = inner.getLastAssistantText()?.trim(); const aborted = stored.abortRequested && !maxTurnsReached; const providerError = aborted ? undefined : lastAssistantError(sessionManager); @@ -520,7 +534,7 @@ export function createSubagentController( const handleParentAbort = () => { stored.abortRequested = true; if (stored.run.status === "queued") stored.cancelQueued?.(); - else void wrapper!.inner.abort(); + else void abortDelegated(wrapper, wrapper!.inner, stored); }; if (!runInBackground) request.signal?.addEventListener("abort", handleParentAbort, { once: true }); @@ -533,7 +547,7 @@ export function createSubagentController( throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); await wrapper!.waitUntilReady(); throwIfSubagentCancelled(stored, runInBackground ? undefined : request.signal); - await wrapper!.inner.prompt(request.task, { source: "rpc" }); + await promptDelegated(wrapper, wrapper!.inner, request.task, stored); const text = wrapper!.inner.getLastAssistantText()?.trim(); const providerError = stored.abortRequested ? undefined : lastAssistantError(manager); result = { @@ -656,7 +670,7 @@ export function createSubagentController( // The controller owns a running slot while binding, before the SDK is streaming. if (!wrapper?.isAlive() || (!wrapper.isRunning() && stored?.run.status !== "running")) throw new Error("Subagent is not running"); if (stored) stored.abortRequested = true; - await wrapper.inner.abort(); + await abortDelegated(wrapper, wrapper.inner, stored); } return { From de9da4f903bf5932a2b1cdf7194420ae3b9c61cb Mon Sep 17 00:00:00 2001 From: lyx2145181 <41118458+lyx2145181@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:45:16 +0800 Subject: [PATCH 4/6] feat(subagents): enable scoped Code mode and MCP Why: Subagent roles need independent Code mode and MCP capabilities with explicit server selection, rather than inheriting a parent's loadout. Safety: Apply real scoped builtin switches and project trust. Check selected registration ownership before transport value resolution, and enforce actual-winner authorization for direct and nested tool calls. Use the child's frozen builtin selection for read-only MCP policy. Separate aggregate declaration from execution during SDK catalog lag; preserve explicit tool disablement without replaying an old active set. This is not a filesystem, network or extension-JavaScript sandbox. The roster observer depends on audited SDK 1.0 catalog-update timing. Compatibility: Default both capabilities off and preserve omitted fields from old clients. Freeze scoped server identities in v3 snapshots, not config or credentials. Keep v1/v2 authority unchanged without migration or capability promotion. Share existing factories, hosts and credential storage; include no UI changes. Validation: Exported and checked this staged tree independently. Typecheck, ESLint and whitespace checks passed with no warnings. Isolated offline suite: 2384 passed; separately mocked plugin suite: 5 passed. Independent capability, lifecycle and profile API tests: 22 passed. No browser validation, build, deployment or real provider/MCP request. --- app/api/subagents/profiles/route.test.mjs | 17 + docs/agents/mcp-runtime.md | 1 + docs/agents/subagent-resources.md | 17 +- docs/agents/subagents.md | 2 +- docs/agents/tools.md | 4 +- lib/builtin-extensions.ts | 94 +++- lib/mcp-command.ts | 9 +- lib/mcp-host.ts | 86 +++- lib/mcp-read-only-policy.ts | 4 +- lib/rpc-manager.test.mjs | 7 +- lib/rpc-manager.ts | 25 +- ...subagent-capabilities.integration.test.mjs | 464 ++++++++++++++++++ lib/subagent-prompt.ts | 4 +- lib/subagent-resources.integration.test.mjs | 5 +- lib/subagent-resources.ts | 54 +- lib/subagent-runtime.ts | 16 +- lib/subagent-tool-policy.test.mjs | 2 +- lib/subagent-tool-policy.ts | 28 +- lib/subagents.test.mjs | 2 + lib/subagents.ts | 41 +- 20 files changed, 823 insertions(+), 59 deletions(-) create mode 100644 lib/subagent-capabilities.integration.test.mjs diff --git a/app/api/subagents/profiles/route.test.mjs b/app/api/subagents/profiles/route.test.mjs index 6d433c3294..7868f02397 100644 --- a/app/api/subagents/profiles/route.test.mjs +++ b/app/api/subagents/profiles/route.test.mjs @@ -48,6 +48,23 @@ 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("profiles route creates, lists, and deletes a project profile", async (t) => { const cwd = await mkdtemp(join(tmpdir(), "pi-web-subagent-route-")); allowFileRoot(cwd); diff --git a/docs/agents/mcp-runtime.md b/docs/agents/mcp-runtime.md index f37de4acc0..ca751e7bb4 100644 --- a/docs/agents/mcp-runtime.md +++ b/docs/agents/mcp-runtime.md @@ -1,6 +1,7 @@ # MCP in sessions ## MCP host (`lib/mcp-host.ts`) +- V3 subagents can use the same host with a frozen scope/name selection, never a parent's connection set. Scoped transport admission checks the actual live registration owner before value resolution; late closures stay bound to their original load. Delegated prompts use the wrapper's preparation/cancellation boundary. See [subagent-resources.md](subagent-resources.md) for the profile, source and aggregate-resource contract; normal sessions retain the default unscoped path. - `createPiWebBuiltinExtensions()` returns `{ extensions, mcpHost }`; `startRpcSession()` passes the host to the wrapper. The host's inline extension reads the global and project `mcp.json` and registers servers with the SDK's MCP extension through `pi.registerMcpServer()`; the extension's own `loadConfig` stays empty (ADR 0006). The host stays inactive unless `/mcp` belongs to `builtin:mcp` (`isBuiltinMcpCommand()` in `lib/mcp-command.ts`, the composer's rule too): under `-builtin:mcp` or a third-party `/mcp`, another implementation would connect the servers through its own, unscrubbed transport. - **Nothing connects until a prompt** (ADR 0006). Before a prompt sent while no run is going (`!inner.isStreaming`), the wrapper calls `prepareForPrompt()`: the host re-registers only entries whose canonical JSON changed, then waits up to 10 s for servers with `direct` tools (the server's `exposure` or any `toolExposure` entry, `ConnectAttempt.declaresTools`, the SDK's `hasDirectTools()`) still connecting; one that outlasts a full wait is not waited for again. Other servers connect in the background, as in pi 1.0: their tools are in no request, the SDK's extension activates `codemode` / `tool_search` from the config before they connect, and its `tool_call` handler waits (abortably) for the servers a codemode script names or searches, or for all of them when `tool_search` runs. The extension is created with `startupWaitMs: 0`: its own first-prompt wait for `direct` servers ignores Stop, and since pi 1.0 it covers servers registered right before the prompt. `before_agent_start` also syncs, without waiting, for prompts that bypass the wrapper. pi runs an extension command at the start of `prompt()` and starts no run, so `mcpPromptPreparation()` matches the message against the session's extension commands by invocation name: another extension's command skips MCP; the built-in `/mcp` registers without waiting (`{ wait: false }`). Never skip the built-in `/mcp`: it acts only on registered servers (`/mcp login docs` as a first message would answer "No MCP server named"). - **Stop during that wait** rejects the prompt unsent with `MCP_WAIT_STOPPED_MESSAGE`, so the client restores the draft. Never move the wait into `before_agent_start`: pi emits it before the run has an abort signal, and `AgentSession.abort()` does nothing until a run is active. `abort` waits for the withdrawn prompt to unwind before it checks `isRunning()`. diff --git a/docs/agents/subagent-resources.md b/docs/agents/subagent-resources.md index 24fa26f3dd..d92a44cc96 100644 --- a/docs/agents/subagent-resources.md +++ b/docs/agents/subagent-resources.md @@ -23,14 +23,26 @@ Subagent profiles use the existing YAML aliases and SDK resource loader. Selecti ## Snapshots and late-registered tools -New children persist `resourceSnapshot` v2 with the resource selections, prompt, frozen builtin tools and `{mode, selectors, deny}` extension tool policy. Deny wins. Cold restoration does not reread the current profile or widen shell permissions. Invalid or missing snapshots on identified subagents fail closed. V1 keeps its original hard tool allowlist and is not migrated. +New children persist `resourceSnapshot` v3 with the resource selections, prompt, frozen builtin tools, `{mode, selectors, deny}` extension tool policy and the explicit Code mode/MCP capabilities below. Deny wins. Cold restoration does not reread the current profile or widen shell permissions. Invalid or missing snapshots on identified subagents fail closed. V1 keeps its original hard tool allowlist; v2 keeps its frozen extension policy. Neither gains the new host capabilities or is migrated. -SDK 1.0 treats `options.tools` as a permanent allowlist, so it cannot admit approved tools registered later during `session_start`. V2 instead uses `noTools: "builtin"`, excludes unauthorized builtins/reserved control names and applies a shared authorization predicate to the actual registration winner and approved extension roster. Direct and nested `tool_call` execution both pass that guard; inactive deferred tools are still nested-callable, so active pruning alone is insufficient. +SDK 1.0 treats `options.tools` as a permanent allowlist, so it cannot admit approved tools registered later during `session_start`. V2/v3 instead use `noTools: "builtin"`, exclude unauthorized builtins/reserved control names and apply a shared authorization predicate to the actual registration winner and approved extension roster. Direct and nested `tool_call` execution both pass that guard; inactive deferred tools are still nested-callable, so active pruning alone is insufficient. The wrapper reconciles unauthorized active tools after its single bind, after both reload paths and before public tool/state reads. Binding includes `resources_discover`, which occurs after `session_start`. Reconciliation removes names only; it never force-activates optional tools. Delegated prompts wait for readiness, check cancellation again before starting, and handle queued startup failures without leaving rejected promises unobserved. They use the wrapper's complete prompt/admission/Stop boundary rather than calling the SDK prompt directly; Stop during extension UI is reset before a resumed prompt, without rebinding. **SDK limitation:** there is no public registration-policy hook. Denied tools may remain in `getAllTools()` or briefly become active between boundaries, but execution is guarded. This is not hard registry filtering or a sandbox for trusted extension JavaScript. No private registry mutation or duplicate bind is used. +## Code mode and MCP capabilities + +Profiles add `code_mode` / `load_mcp` booleans and `mcp_servers: [{scope: "global" | "project", name: string}]`; the API uses `codeMode`, `loadMcp`, `mcpServers`. Defaults are false/false/[]; omission by an old client retains saved values. Disabling MCP retains dormant selections. Names preserve their original case; bare names, wildcard grants and config/credential objects are not server references. + +- V3 freezes both switches and the selected server identities, not `mcp.json` contents or a remote tool directory. Same-ID edits follow existing sync/reconnect and credential rules; a new ID is not admitted. Restore/reload uses the child's snapshot, never a parent's current loadout. +- The same builtin factory and `McpHost` serve normal and child sessions. Child host capabilities are independent of extension loading. Real scoped `-builtin:*`, operator/internals/sandbox failures and third-party replacement still apply; clearing transient resource settings cannot bypass them. Disabling role Code mode skips its self-test; disabling role MCP creates no host. +- Builtin Code mode uses `models: false` and only the authorized tool surface. External replacements require their existing extension loading/tool policy; the role switch grants them nothing and does not sandbox their JavaScript. MCP without builtin Code mode uses deferred discovery via tool_search, respecting its disable switch rather than changing `mcp.json`. +- MCP filtering occurs after SDK validation/trust/merge, matching scope, raw name and child source. A project override cannot replace a selected global identity, and a missing project selection cannot fall back to global. Each child owns its host/connections while reusing the existing credential store. +- Before the real transport factory resolves values or starts a process/network connection, scoped admission checks the live registration's owner and runtime config. Tool execution additionally verifies the actual builtin winner and admitted namespace, not a name prefix. The existing read-only MCP policy also applies to the child's frozen builtin selection. +- Aggregate resources have a separate transient execution gate while the SDK catalog trails its public registration roster. This does not prune temporarily unready aggregates or replay an old active set: an explicitly disabled tool stays disabled. The per-load public-roster observer is coupled to SDK 1.0's synchronous catalog update after the getter; its held-handler regression must be revisited on an SDK upgrade. +- Delegated create/resume waits for the single wrapper bind and MCP preparation, checks cancellation before the actual prompt and awaits full completion. Enabled capabilities missing their wrapper/readiness or MCP host fail before any provider request. Stop also cancels preparation; the next delegated prompt resets extension UI cancellation state. Existing idle/disposal rules remain in force. + ## Resource picker UI Settings › Sub-agents uses shared `SettingsUi` blocks and the existing profile draft/Save flow. Built-in profiles remain read-only. Each resource row shows All enabled, Disabled or a selected count, with a Choose button and visible unknown/ambiguous warnings; there is no mode dropdown or always-expanded list. @@ -48,4 +60,5 @@ Component tests cover draft and matching rules, bulk scope, dismissal, positioni - Resource integration tests use temporary HOME/agentDir/cwd and separate module/factory markers to verify pre-import exclusion, scope preservation, trust transitions, dynamic skills and cold restoration. Rejecting npm fixtures prohibit installation and remote commands. - Tool-policy integration tests use delayed mock registration and canned in-process provider responses to exercise direct/nested authorization, actual collision winners, readiness, reload, legacy snapshots and strict parsing. - Controller regressions cover cancellation during readiness and queued startup rejection. Route/profile tests cover clone conflicts and foreign frontmatter round-tripping. +- Capability integration uses fake transports and in-process provider responses for all switch combinations, source/owner conflicts, first/resumed actual requests, preparation cancellation, read-only/nested calls, legacy restoration, late transport closures and aggregate revocation/explicit-disable windows. It performs no external MCP or provider requests. - All fixtures are local and isolated; they require no real browser extension, provider request or MCP connection. diff --git a/docs/agents/subagents.md b/docs/agents/subagents.md index 6f4f08caf8..a2317e0785 100644 --- a/docs/agents/subagents.md +++ b/docs/agents/subagents.md @@ -7,4 +7,4 @@ - Built-in profiles (`general-purpose`, `explore`, `plan`) are switched off by name in that file's `disabledBuiltIns`, never by copying them to a `.md` file (ADR 0005). `builtInProfiles()` stamps `enabled` onto them so the panel, the `Agent` tool description and `resolveSubagentProfile` agree. Writes are minimal edits that keep names they did not touch, including ones no built-in claims; reading fails *open* (the feature switch has already failed closed). `PATCH /api/subagents/profiles` with `scope: "builtin"` writes it; `PUT`/`DELETE` refuse that scope. A same-name file replaces the built-in outright and is switched off through its own frontmatter. Only the switch is live for a built-in; the rest of the form is read-only. - `notifyParent` skips a background run's completion notification when the parent already collected that result with `get_subagent_result`, which marks it consumed. The mark names the run (`sessionId` + `completedAt`), never just the session, since `resume` reuses the session id; `resume` keeps the mark. While the parent `isRunning()` (often still inside that poll) the notification is held and the mark re-checked; an idle parent is notified at once. - The notification is a `custom` message, which pi's `convertToLlm` replays as a plain `user` turn, so `subagentNotificationText()` prefixes `SUBAGENT_NOTIFICATION_PREFIX`; otherwise compaction files the report under the user's Goal / Constraints. Keep the prefix in code, never in a profile prompt, so the model cannot drop it. A `resume`d run's notification adds a line saying it supersedes the earlier report, which it would otherwise repeat word for word. Foreground `Agent` and `get_subagent_result` results are `toolResult`s and keep the bare `subagentFinalText()`. -- Profile files (`~/.pi/agent/agents/*.md`, project `.pi/agents/*.md`) are shared with other runtimes: saves and copies preserve foreign frontmatter, `ext:` selectors and unedited resource aliases. `skills` / `extensions` selections are applied before SDK imports. V2 snapshots freeze builtin and extension tool authority while admitting approved late registrations; v1 hard allowlists remain unchanged. Creation, cold restore and reload share the resource/trust policy. See [subagent-resources.md](subagent-resources.md) for the API, scope-preserving settings, execution limits and picker behavior. +- Profile files (`~/.pi/agent/agents/*.md`, project `.pi/agents/*.md`) are shared with other runtimes: saves and copies preserve foreign frontmatter, `ext:` selectors and unedited resource aliases. `skills` / `extensions` selections are applied before SDK imports. V2/v3 snapshots freeze builtin and extension tool authority while admitting approved late registrations; v1 hard allowlists remain unchanged. V3 also freezes independent default-off Code mode/MCP capabilities and selected scoped server identities; older snapshots gain neither capability. Creation, cold restore and reload share the resource/trust policy. See [subagent-resources.md](subagent-resources.md) for the API, scope-preserving settings, execution limits and picker behavior. diff --git a/docs/agents/tools.md b/docs/agents/tools.md index eb87209e7a..cef6588eb3 100644 --- a/docs/agents/tools.md +++ b/docs/agents/tools.md @@ -12,11 +12,11 @@ The last preset the user explicitly selected is kept in browser `localStorage` a - A preset replaces only the coding tools. `resolveActiveToolNames()` carries every other tool that is active, registered and not hidden, so what an extension, `tool_search` or a `defaultTools` entry such as `+codemode` activated survives startup, `set_tools` and `reload`, a tool an extension switched off stays off, and a preset never turns `codemode` on. It adds nothing else: pi activates extension tools itself, so re-adding them would override an extension (or a branch's recorded loadout) that keeps a `direct` tool off. `reload` carries the set pi rebuilt after extensions restarted, not the previous one. - `navigateTree()` restores the target branch's loadout from its transcript (bringing back `write` under a Read-only pin, dropping `codemode`), so a normal session then reapplies its pin (the restored set when unpinned) and carries over from the branch it left the session's own tools: `codemode`, `tool_search` and pi-web's subagent tools. Other extension tools follow the target branch's loadout, as in the pi CLI, so one registered after that branch point stays off there until a reload. Chat-only and subagent wrappers keep what pi restored. -**Built-in extensions** (`lib/builtin-extensions.ts`, ADR 0006). The CLI's built-in `codemode`, `tool-search` and `mcp` are rebuilt from the factories the SDK root exports, under the CLI's names with `replaceable: true, builtin: true`, so `-builtin:`, project overrides, replacement by a third-party extension and `noExtensions` behave as in the CLI. Only normal sessions load them. +**Built-in extensions** (`lib/builtin-extensions.ts`, ADR 0006). The CLI's built-in `codemode`, `tool-search` and `mcp` are rebuilt from the factories the SDK root exports, under the CLI's names with `replaceable: true, builtin: true`, so `-builtin:`, project overrides, replacement by a third-party extension and `noExtensions` behave as in the CLI. Normal sessions load them by default. V3 subagents may independently opt into builtin Code mode (`models: false`) and an explicit MCP server selection; legacy snapshots do not gain them. See [subagent-resources.md](subagent-resources.md). - The MCP extension's `loadConfig` returns no servers (only `autoEnableCodemode`), so it connects nothing, and with `startupWaitMs: 0`, so its first-prompt wait for `direct` servers (which ignores Stop, and since pi 1.0 also sees servers registered right before the prompt) holds nothing; pi-web registers servers itself (**MCP host** in [mcp-runtime.md](mcp-runtime.md)). Servers connect through `createPiWebMcpTransportFactory()`; `openUrl` opens no browser on the server host. - MCP is off (the `mcp` entry registers nothing) when `PI_WEB_DISABLE_MCP` is anything but empty, `0` or `false`, or `lib/pi-sdk-internals.ts` cannot load; never fall back to the SDK's own transport. - `codemode` is offered only after a once-per-process self-test ran a script through the SDK's sandbox (`checkCodemodeSandbox()`). A built-in that cannot run keeps its `builtin:` entry with an empty factory, so a setting naming it means the same. -**Read-only MCP policy** (`lib/mcp-read-only-policy.ts`, normal sessions). While the pinned selection is read-only (it names tools, none of `bash`, `powershell`, `edit`, `write`; no pin is not read-only), its `tool_call` handler blocks every MCP tool (the MCP extension's, or any named `mcp__`) whose definition lacks `annotations.readOnlyHint: true`, codemode scripts' calls included. It reads the pin from the session entries, which `set_tools` writes before applying it and cannot change mid-run. The hint is the server's word: this guards against model mistakes, not a malicious server. +**Read-only MCP policy** (`lib/mcp-read-only-policy.ts`). Normal sessions use their pin; capability-enabled subagents supply their frozen builtin selection to the same policy. While the selection is read-only (it names tools, none of `bash`, `powershell`, `edit`, `write`; no pin is not read-only), its `tool_call` handler blocks every MCP tool (the MCP extension's, or any named `mcp__`) whose definition lacks `annotations.readOnlyHint: true`, codemode scripts' calls included. It reads the pin from the session entries, which `set_tools` writes before applying it and cannot change mid-run. The hint is the server's word: this guards against model mistakes, not a malicious server. **`defaultTools` switches.** Code mode's Always on is `+codemode` in the global `defaultTools`; Automatic writes nothing (ADR 0006). `/api/tools/settings` is the only writer of that key, for this and the PowerShell switch, and of the global `codemode.mode` and `codemode.inlineBudget` (Settings › MCP's Built-in tools switch and tool list budget), through `lib/global-settings-file.ts` (the lock pi's `SettingsManager` takes on that file). `lib/codemode-settings.ts` edits only entries naming `codemode` and appends `+codemode`, valid on plain and modifier-only lists; a modifier-only list left empty is removed, since `defaultTools: []` means no tools at all. `defaultTools` may hold `+name` / `-name` modifiers: read it resolved through `SettingsManager.getDefaultTools()`; `lib/powershell-settings.ts` resolves the raw global list by the same rule before editing, since appending a plain name to a modifier-only list drops pi's default tools. diff --git a/lib/builtin-extensions.ts b/lib/builtin-extensions.ts index 57ceb499d5..ad43ecd98e 100644 --- a/lib/builtin-extensions.ts +++ b/lib/builtin-extensions.ts @@ -14,6 +14,7 @@ import { type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { McpHost, type McpHostOptions } from "./mcp-host"; +import { CODEMODE_EXTENSION_PATH } from "./mcp-command"; import { createPiWebMcpTransportFactory } from "./mcp-transport"; import { loadPiSdkInternals, type PiSdkInternals, type PiSdkInternalsResult } from "./pi-sdk-internals"; import { mayReadProjectConfigNow } from "./project-trust"; @@ -343,8 +344,27 @@ function builtin(name: string, factory: ExtensionFactory): InlineExtension { return { name, factory, replaceable: true, builtin: true }; } +export interface PiWebBuiltinCapability { + codeMode: boolean; + loadMcp: boolean; + mcpServers: import("./subagents").SubagentMcpServerRef[]; + cwd: string; + /** Read real scoped source settings, before restricted resource clearing. */ + builtinEnabled(name: BuiltinExtensionName): Promise; + /** Presentation/discovery only; execution still passes the separate tool_call guard. */ + allowedTools?(tools: ReturnType): Set; +} + +export async function scopedBuiltinExtensionSwitches(source: SettingsManager, cwd: string, agentDir: string) { + return resolveBuiltinSwitches({ cwd, agentDir, projectTrusted: source.isProjectTrusted() }, { + global: source.getGlobalSettings().extensions, + project: source.getProjectSettings().extensions, + }, { global: join(agentDir, "settings.json"), project: join(cwd, CONFIG_DIR_NAME, "settings.json") }); +} + export interface PiWebBuiltinExtensionsOptions { agentDir: string; + capability?: PiWebBuiltinCapability; /** Timing overrides for tests. */ mcpHost?: Pick; } @@ -360,34 +380,96 @@ export interface PiWebBuiltinExtensions { export async function createPiWebBuiltinExtensions( options: PiWebBuiltinExtensionsOptions, ): Promise { - const [sandbox, mcp] = await Promise.all([checkCodemodeSandbox(), loadMcpRuntime()]); + const cap = options.capability ? { ...options.capability, mcpServers: structuredClone(options.capability.mcpServers) } : undefined; + const codeEnabled = !cap || (cap.codeMode && await cap.builtinEnabled("codemode")); + const mcpEnabled = !cap || cap.loadMcp; + const [sandbox, mcp] = await Promise.all([ + codeEnabled ? checkCodemodeSandbox() : Promise.resolve({ available: false, reason: "Role Code mode is off" }), + mcpEnabled ? loadMcpRuntime() : Promise.resolve({ available: false, reason: "Role MCP is off" }), + ]); + let codemodeAvailable = sandbox.available; const mcpHost = mcp.available ? new McpHost({ ...options.mcpHost, agentDir: options.agentDir, internals: mcp.internals, - codemodeAvailable: () => sandbox.available, + codemodeAvailable: () => codemodeAvailable, + ...(cap ? { selection: { cwd: cap.cwd, servers: structuredClone(cap.mcpServers) } } : {}), }) : undefined; const extensions = [ - builtin("codemode", sandbox.available ? createCodemodeExtension() : unavailableExtension), + builtin("codemode", sandbox.available ? createCodemodeExtension(cap ? { models: false } : undefined) : unavailableExtension), builtin("tool-search", createToolSearchExtension()), builtin( "mcp", mcp.available && mcpHost - ? createMcpExtension({ + ? (pi) => { + const createTransport = mcpHost.wrapTransportFactory(createPiWebMcpTransportFactory(mcp.internals), Boolean(cap)); + const observeRegistry = cap ? mcpHost.registrationObserverForLoad() : undefined; + // SDK consumes this roster synchronously when rebuilding its server/resource + // catalog, before awaiting close/connect. A preceding extension handler may + // delay that read after the registry changes; aggregates stay blocked then. + const api: ExtensionAPI = observeRegistry ? { ...pi, getMcpServers: () => { + const servers = pi.getMcpServers(); observeRegistry(servers); return servers; + } } : pi; + return createMcpExtension({ loadConfig: createMcpExtensionConfigLoader(mcp.internals, options.agentDir), - createTransport: mcpHost.wrapTransportFactory(createPiWebMcpTransportFactory(mcp.internals)), + createTransport, // The host already waited, and Stop ends its wait. The extension's own wait for // servers with `direct` tools, at a session's first prompt, ignores Stop. startupWaitMs: 0, // `/mcp login` already shows the address in the chat; a browser // opened on the server host is one a remote user never sees. openUrl: () => {}, - }) + })(api); + } : unavailableExtension, ), ]; + if (cap) { + for (const extension of extensions) { + if (typeof extension === "function") continue; + const name = extension.name as BuiltinExtensionName; + const factory = extension.factory; + extension.factory = async (pi) => { + if (!(name === "codemode" ? cap.codeMode : cap.loadMcp) || !await cap.builtinEnabled(name)) return; + let selectedFactory = factory; + if (name === "codemode") { + const currentSandbox = await checkCodemodeSandbox(); + codemodeAvailable = currentSandbox.available; + selectedFactory = currentSandbox.available ? createCodemodeExtension({ models: false }) : unavailableExtension; + } + const allowed = () => cap.allowedTools?.(pi.getAllTools()); + const api: ExtensionAPI = cap.allowedTools ? { + ...pi, + getAllTools: () => { const names = allowed(); return pi.getAllTools().filter((tool) => names?.has(tool.name)); }, + registerTool: (definition) => pi.registerTool({ + ...definition, + ...(definition.prepareLoadout ? { prepareLoadout: (loadout) => { + const names = allowed(); + return definition.prepareLoadout!({ ...loadout, declared: loadout.declared.filter((tool) => names?.has(tool.name)), callable: loadout.callable.filter((tool) => names?.has(tool.name)) }); + } } : {}), + execute: (id, params, signal, update, ctx) => { + const names = allowed(); + // ExtensionToolContext has prototype methods (executeTool) and getters. + // Preserve them; only narrow the public discovery view for this builtin. + const context = new Proxy({} as typeof ctx, { get(_target, property) { + if (property === "tools") return ctx.tools.filter((tool) => names?.has(tool.name)); + const value = Reflect.get(ctx, property, ctx); + return typeof value === "function" ? value.bind(ctx) : value; + } }); + return definition.execute(id, params, signal, update, context); + }, + }), + } : pi; + await selectedFactory(api); + if (name === "codemode" && codemodeAvailable) pi.on("session_start", () => { + const tool = pi.getAllTools().find((tool) => tool.name === "codemode"); + if (tool?.sourceInfo.path === CODEMODE_EXTENSION_PATH && tool.sourceInfo.source === "builtin") pi.setActiveTools([...new Set([...pi.getActiveTools(), "codemode"])]); + }); + }; + } + } if (mcpHost) extensions.push(mcpHost.extension()); return { extensions, mcpHost }; } diff --git a/lib/mcp-command.ts b/lib/mcp-command.ts index 0c4fc69d3c..b5c3f1af4a 100644 --- a/lib/mcp-command.ts +++ b/lib/mcp-command.ts @@ -15,8 +15,15 @@ // built-in's tools by the path, and the composer opens Settings › MCP for a // bare `/mcp` (ADR 0006). This module has no imports, so the browser can load it. -/** The `sourceInfo.path` of everything pi's built-in MCP extension registers. */ +/** + * The `sourceInfo.path` of each pi built-in extension's resources, as the SDK + * names them (`builtin:`). Identity checks compare against these instead + * of repeating the literal, so a rename stays in one place. `MCP_EXTENSION_PATH` + * is also the `/mcp` ownership marker above. + */ export const MCP_EXTENSION_PATH = "builtin:mcp"; +export const CODEMODE_EXTENSION_PATH = "builtin:codemode"; +export const TOOL_SEARCH_EXTENSION_PATH = "builtin:tool-search"; export const MCP_COMMAND_NAME = "mcp"; diff --git a/lib/mcp-host.ts b/lib/mcp-host.ts index 66829d3e25..dd47d94386 100644 --- a/lib/mcp-host.ts +++ b/lib/mcp-host.ts @@ -2,6 +2,7 @@ import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } fro import { join } from "node:path"; import { CONFIG_DIR_NAME, + type ToolInfo, type ExtensionAPI, type ExtensionContext, type InlineExtension, @@ -11,7 +12,7 @@ import { type McpTransportFactory, } from "@earendil-works/pi-coding-agent"; import type { McpHostInactiveInfo, McpScope, McpSessionState, McpSessionStatus } from "./api-types"; -import { isBuiltinMcpCommand, isMcpExtensionCommand } from "./mcp-command"; +import { CODEMODE_EXTENSION_PATH, isBuiltinMcpCommand, isMcpExtensionCommand, MCP_EXTENSION_PATH } from "./mcp-command"; import { canonicalJson, mcpConfigKey, mcpEntryConfigKey } from "./mcp-config-key"; import { scrubMcpLoadError } from "./mcp-json-error"; import { @@ -59,6 +60,8 @@ export { canonicalJson }; // for Settings › MCP, keyed by the entry as its file holds it. export const MCP_HOST_EXTENSION_NAME = "pi-web-mcp-host"; +export const MCP_HOST_EXTENSION_PATH = ``; +const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]); const DEFAULT_MCP_IDLE_MS = 10 * 60 * 1000; const PROMPT_WAIT_MS = 10_000; @@ -537,8 +540,10 @@ class HostInstance { private runActive = false; /** Prompts waiting in prepareForPrompt(); nor does it run while one waits. */ private preparing = 0; + /** Runtime-only roster the builtin MCP consumed for its own catalog. Never persisted. */ + private consumedRegistry: string | undefined; - constructor(private readonly pi: ExtensionAPI, private readonly options: Required) { + constructor(private readonly pi: ExtensionAPI, private readonly options: Required> & Pick) { pi.on("session_start", (_event, ctx) => { if (this.disposed) return; this.ctx = ctx; @@ -583,7 +588,15 @@ class HostInstance { */ attemptFor(entry: McpServerEntry): ConnectAttempt | undefined { const attempt = this.attempts.get(entry.name); - return attempt && !attempt.released && attempt.configKey === canonicalJson(entry.config) ? attempt : undefined; + if (!attempt || attempt.released || attempt.configKey !== canonicalJson(entry.config)) return undefined; + if (this.options.selection) { + if (!this.active || this.disposed || !this.ctx || entry.scope !== "extension" || entry.source !== MCP_HOST_EXTENSION_PATH) return undefined; + const owner = this.pi.getMcpServers().find((server) => server.name === entry.name); + if (owner?.extensionPath !== MCP_HOST_EXTENSION_PATH || canonicalJson(owner.config) !== attempt.configKey) return undefined; + const wanted = this.desiredServers(this.ctx).get(entry.name); + if (!wanted || wanted.scope !== attempt.scope || canonicalJson(wanted.config) !== attempt.configKey || wanted.target.sourcePath !== attempt.target.sourcePath) return undefined; + } + return attempt; } /** @@ -647,7 +660,8 @@ class HostInstance { const desired = this.desiredServers(this.ctx); for (const [name, attempt] of [...this.attempts]) { const wanted = desired.get(name); - if (wanted && canonicalJson(wanted.config) === attempt.configKey) continue; + if (wanted && canonicalJson(wanted.config) === attempt.configKey + && (!this.options.selection || (wanted.scope === attempt.scope && wanted.target.sourcePath === attempt.target.sourcePath))) continue; await this.unregister(name); } for (const [name, wanted] of desired) { @@ -898,12 +912,15 @@ class HostInstance { } for (const entry of loaded.servers) { if (entry.config.enabled === false) continue; + const selection = this.options.selection; + if (selection && (ctx.cwd !== selection.cwd || !selection.servers.some((ref) => ref.scope === entry.scope && ref.name === entry.name) + || entry.source !== (entry.scope === "project" ? projectPath : join(this.options.agentDir, "mcp.json")))) continue; const scope = entry.scope === "project" ? "project" : "global"; // Each entry is keyed in its own try: one the host cannot key never stops the others, // global servers included, from connecting. try { desired.set(entry.name, { - config: withReachableExposure(entry.config, this.options.codemodeAvailable()), + config: withReachableExposure(entry.config, this.options.codemodeAvailable() && (!selection || this.pi.getAllTools().some((tool) => tool.name === "codemode" && tool.sourceInfo.path === CODEMODE_EXTENSION_PATH && tool.sourceInfo.source === "builtin"))), scope, // The validator's copy of the entry (aliases resolved), which Settings keys it by too. target: { scope, sourcePath: entry.source, name: entry.name, configKey: mcpConfigKey(entry.config) }, @@ -915,6 +932,40 @@ class HostInstance { return desired; } + /** + * The exact registry roster the builtin MCP factory has consumed for its own + * server/resource catalog. `getMcpServers()` is a public roster query, not a + * consumed-catalog notification: in the audited SDK the factory reads it only + * at `session_start` and synchronously at the start of `mcp_servers_change`, + * before its first await. Execution compares against this so a catalog that + * still names a revoked server stays blocked. Re-audit those call sites on an + * SDK upgrade; do not present this as a stable SDK guarantee. + */ + observeRegistrations(servers: ReturnType): void { + if (!this.disposed) this.consumedRegistry = canonicalJson(servers); + } + + admitsTool(tool: Pick, phase: "declare" | "execute", input?: Record): boolean { + if (!this.options.selection || !this.active || this.disposed || tool.sourceInfo?.path !== MCP_EXTENSION_PATH || tool.sourceInfo.source !== "builtin") return false; + const registrations = this.pi.getMcpServers(); + const admitted = (server: typeof registrations[number]) => this.attemptFor({ name: server.name, config: server.config, scope: "extension", source: server.extensionPath }) !== undefined; + if (MCP_RESOURCE_TOOLS.has(tool.name)) { + // Structural for both phases: the aggregate reaches at least one admitted + // selected server. Declaration never consults the consumed roster, so a + // catalog lag cannot prune the tool (and no replay is needed to restore it). + if (!registrations.some(admitted)) return false; + if (phase === "declare") return true; + // Execution: the SDK catalog must have consumed this exact roster (no lag + // window), every current registration must be admitted, and a named server + // must itself be admitted. The phase is explicit, so a no-argument call is + // still an execution and cannot take the declaration shortcut. + if (!registrations.every(admitted) || canonicalJson(registrations) !== this.consumedRegistry) return false; + if (input?.server === undefined) return tool.name !== "read_mcp_resource"; + return typeof input.server === "string" && registrations.some((server) => server.name === input.server && admitted(server)); + } + return registrations.some((server) => admitted(server) && tool.namespace?.name === `mcp__${server.name.replaceAll("-", "_")}`); + } + private register(name: string, wanted: DesiredServer): void { const { config, scope, target } = wanted; const attempt = new ConnectAttempt(canonicalJson(config), scope, target, config); @@ -991,6 +1042,8 @@ export interface McpHostOptions { internals: Pick & Partial>; /** Whether codemode can run scripts; servers it cannot reach become `deferred`. */ codemodeAvailable: () => boolean; + /** Optional child authority; SDK merge/trust/validation precede exact scoped filtering. */ + selection?: { cwd: string; servers: import("./subagents").SubagentMcpServerRef[] }; /** * Whether the project's `.pi/mcp.json` may be read, asked on every sync. * Defaults to a fresh read of the folder and `trust.json` @@ -1011,8 +1064,9 @@ export interface McpHostOptions { * connects through. */ export class McpHost { - private readonly options: Required; + private readonly options: Required> & Pick; private current: HostInstance | undefined; + private pendingTransportBinding: { host?: HostInstance } | undefined; constructor(options: McpHostOptions) { this.options = { @@ -1031,14 +1085,20 @@ export class McpHost { factory: (pi) => { this.current?.dispose(); this.current = new HostInstance(pi, this.options); + if (this.pendingTransportBinding) this.pendingTransportBinding.host = this.current; + this.pendingTransportBinding = undefined; }, }; } - wrapTransportFactory(factory: McpTransportFactory): McpTransportFactory { + wrapTransportFactory(factory: McpTransportFactory, bindNextLoad = false): McpTransportFactory { + // A child MCP factory is built once per extension load, before the adjacent + // host factory. Late calls keep THAT instance, never a same-config replacement. + const binding: { host?: HostInstance } | undefined = bindNextLoad ? (this.pendingTransportBinding = {}) : undefined; return (entry, cwd, authProvider) => { - const host = this.current; + const host = binding ? binding.host : this.current; const attempt = host?.attemptFor(entry); + if (this.options.selection && !attempt) throw new Error(`Subagent MCP registration not admitted: ${entry.name}`); if (!attempt && host?.refuseAbandoned(entry)) { throw new Error(`MCP server "${entry.name}" was removed before it connected, so Pi Web did not start it`); } @@ -1055,6 +1115,12 @@ export class McpHost { }; } + /** Observe the builtin's public registry reads, bound to the same load as its transport. */ + registrationObserverForLoad(): (servers: ReturnType) => void { + const binding = this.pendingTransportBinding; + return (servers) => binding?.host?.observeRegistrations(servers); + } + /** * For a wrapper that starts closing: the host lets go of what it reported * before extensions hear `session_shutdown`, whose handlers run in order and @@ -1075,6 +1141,10 @@ export class McpHost { return this.current?.prepareForPrompt(signal, options.wait ?? true) ?? Promise.resolve(); } + admitsTool(tool: Pick, phase: "declare" | "execute", input?: Record): boolean { + try { return this.current?.admitsTool(tool, phase, input) ?? false; } catch { return false; } + } + serverStates(): McpHostServerStatus[] { return this.current?.serverStates() ?? []; } diff --git a/lib/mcp-read-only-policy.ts b/lib/mcp-read-only-policy.ts index bf0d2ec508..ce3ca4c214 100644 --- a/lib/mcp-read-only-policy.ts +++ b/lib/mcp-read-only-policy.ts @@ -33,7 +33,7 @@ export function readOnlyMcpBlockReason(toolName: string): string { return `This session uses a read-only tool selection, and the MCP server does not mark "${toolName}" as read-only (readOnlyHint), so the call was blocked. Switch the session to a preset that allows changes to use it.`; } -export function createReadOnlyMcpPolicyExtension(): InlineExtension { +export function createReadOnlyMcpPolicyExtension(subagentBuiltinTools?: readonly string[]): InlineExtension { return { name: READ_ONLY_MCP_POLICY_EXTENSION_NAME, hidden: true, @@ -43,7 +43,7 @@ export function createReadOnlyMcpPolicyExtension(): InlineExtension { const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName); if (!tool || !isMcpTool(tool) || tool.annotations?.readOnlyHint === true) return undefined; // Read last: most calls are not MCP calls, and the selection scan walks the session. - const selection = readSessionToolSelection(ctx.sessionManager.getEntries() as unknown as SessionEntry[]); + const selection = subagentBuiltinTools ?? readSessionToolSelection(ctx.sessionManager.getEntries() as unknown as SessionEntry[]); if (!isReadOnlySelection(selection)) return undefined; return { block: true, reason: readOnlyMcpBlockReason(event.toolName) }; }); diff --git a/lib/rpc-manager.test.mjs b/lib/rpc-manager.test.mjs index ed48ae5d67..7517c6b642 100644 --- a/lib/rpc-manager.test.mjs +++ b/lib/rpc-manager.test.mjs @@ -31,7 +31,7 @@ test("RPC session startup preloads extension-registered providers before restori assert.doesNotMatch(startupSource, /await createAgentSession\(/); }); -test("only normal sessions load the codemode, tool-search, and mcp built-ins", async () => { +test("normal startup loads default built-ins; subagent startup forwards only capability-scoped services' host", async () => { const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8"); const startupSource = source.slice(source.indexOf("export async function startRpcSession")); @@ -47,7 +47,7 @@ test("only normal sessions load the codemode, tool-search, and mcp built-ins", a // The Read-only policy for MCP tools rides along with the MCP extension. assert.match(startupSource, /\.\.\.\(builtins\?\.extensions \?\? \[\]\),\s*createReadOnlyMcpPolicyExtension\(\),/); // The wrapper connects the host's servers before a prompt starts a run. - assert.match(startupSource, /\.\.\.\(builtins\?\.mcpHost \? \{ mcpHost: builtins\.mcpHost \} : \{\}\),/); + assert.match(startupSource, /subagentResources && "mcpHost" in services && services\.mcpHost \? \{ mcpHost: services\.mcpHost as McpHost \} : builtins\?\.mcpHost \? \{ mcpHost: builtins\.mcpHost \} : \{\}/); }); test("built-in subagents persist their selected resource policy", async () => { @@ -71,7 +71,8 @@ test("built-in subagents persist their selected resource policy", async () => { assert.match(startupSource, /noExtensions: !subagentResources\.loadExtensions/); assert.match(startupSource, /noSkills: !subagentResources\.loadSkills/); assert.match(startupSource, /excludeTools: \[\.\.\.SUBAGENT_CONTROL_TOOL_NAMES\]/); - assert.match(startupSource, /let toolsOption: string\[\] \| undefined = subagentResources\?\.version === 2 \? undefined : subagentResources\?\.tools/); + assert.match(startupSource, /let toolsOption: string\[\] \| undefined = subagentResources\?\.toolPolicy \? undefined : subagentResources\?\.tools/); + assert.match(subagentSource, /resourceSnapshot: \{\s*version: 3,/); assert.match(source, /createSubagentController\(/); assert.match(source, /suppressCompletionNotifications: true/); assert.match(source, /suppressCompletionNotifications: Boolean\(subagentResources\)/); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index cd12532c3e..7120b7c4c6 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -14,7 +14,7 @@ import { } from "./project-command-env"; import { cacheSessionPath, getLatestModelChange, invalidateSessionListCache, readLatestSessionEntryId, resolveSessionPath } from "./session-reader"; import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust"; -import { createSubagentSessionServices } from "./subagent-resources"; +import { createSubagentSessionServices, subagentHostToolAdmission } from "./subagent-resources"; import { reconcileSubagentActiveTools, subagentToolExclusions } from "./subagent-tool-policy"; import { notifySessionComplete } from "./web-push"; import { hasActiveSessionLivenessProvider } from "./session-liveness"; @@ -128,7 +128,7 @@ type AgentSessionWrapperOptions = { /** Validated creation snapshot, supplied by child registration/cold restore; never a live profile. */ subagentResources?: SubagentSessionResources | null; /** Connects the session's MCP servers before a prompt starts a run, and lets go of them when it closes (lib/mcp-host.ts). */ - mcpHost?: Pick; + mcpHost?: Pick & Partial>; }; export const MCP_WAIT_STOPPED_MESSAGE = "Stopped while MCP servers were connecting; the message was not sent."; @@ -307,7 +307,7 @@ export class AgentSessionWrapper { private readonly onAgentRunComplete?: AgentRunCompleteListener; private readonly suppressCompletionNotifications: boolean; private readonly subagentResources?: SubagentSessionResources; - private readonly mcpHost?: Pick; + private readonly mcpHost?: Pick & Partial>; private mcpHostDisposed = false; // The MCP wait of the prompt being admitted; Stop ends it. private mcpPromptWait: { controller: AbortController; done: Promise } | null = null; @@ -542,12 +542,15 @@ export class AgentSessionWrapper { async abortDelegated(): Promise { await this.send({ type: "abort" }); } private async prepareMcpPrompt(message: string): Promise { + if (this.subagentResources?.version === 3 && this.subagentResources.loadMcp + && typeof this.mcpHost?.prepareForPrompt !== "function") throw new Error("Subagent MCP requires a prepared session host"); const preparation = this.mcpHost && !this.inner.isStreaming ? mcpPromptPreparation(message, this.extensionCommandCandidates()) : "none"; if (!this.mcpHost || preparation === "none") return; const controller = new AbortController(); const waited = this.mcpHost.prepareForPrompt(controller.signal, { wait: preparation === "wait" }) .catch((error: unknown) => { + if (this.subagentResources?.version === 3) throw error; console.error("[pi-web] MCP servers could not be prepared:", error instanceof Error ? error.message : error); }).then(() => { if (controller.signal.aborted) throw new Error(MCP_WAIT_STOPPED_MESSAGE); @@ -560,13 +563,14 @@ export class AgentSessionWrapper { private reconcileSubagentToolPolicy(): void { const resources = this.subagentResources; - if (resources?.version !== 2) return; // Normal sessions and v1 retain their existing loadout rules. + if (resources?.version !== 2 && resources?.version !== 3) return; // Normal sessions and v1 retain their existing loadout rules. const sdk = this.inner as unknown as Pick; reconcileSubagentActiveTools({ getActiveTools: () => sdk.getActiveToolNames(), getAllTools: () => sdk.getAllTools(), setActiveTools: (names) => sdk.setActiveToolsByName(names), - }, resources.builtinTools, resources.toolPolicy, () => sdk.resourceLoader.getExtensions().extensions); + }, resources.builtinTools, resources.toolPolicy, () => sdk.resourceLoader.getExtensions().extensions, + resources.version === 3 ? subagentHostToolAdmission(resources, this.mcpHost?.admitsTool ? { admitsTool: this.mcpHost.admitsTool.bind(this.mcpHost) } : undefined) : undefined); } private async waitForExtensionsBound(): Promise { @@ -2030,6 +2034,7 @@ const SUBAGENT_CONTROLLER = createSubagentController({ ? { exactSystemPrompt: () => options.exactSystemPrompt! } : {}), chatOnly: options?.chatOnly, + mcpHost: options?.mcpHost, subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries() as unknown as SessionEntry[]), suppressCompletionNotifications: true, }); @@ -2380,7 +2385,7 @@ export async function startRpcSession( appendSessionToolSelection(sessionManager, requestedToolNames); } const subagentLoadsResources = Boolean( - subagentResources?.loadExtensions || subagentResources?.loadSkills, + subagentResources?.loadExtensions || subagentResources?.loadSkills || (subagentResources?.version === 3 && (subagentResources.codeMode || subagentResources.loadMcp)), ); const chatOnly = selectedToolNames?.length === 0 && !subagentLoadsResources; const finishStartingSession = trackStartingSession(sessionCwd); @@ -2391,7 +2396,7 @@ export async function startRpcSession( // Determine which tools to pass based on requested toolNames. // Since v0.68.0, session creation expects string[] tool names instead of Tool[] instances. - let toolsOption: string[] | undefined = subagentResources?.version === 2 ? undefined : subagentResources?.tools; + let toolsOption: string[] | undefined = subagentResources?.toolPolicy ? undefined : subagentResources?.tools; if (!subagentResources && selectedToolNames !== undefined) { // toolNames === [] -> "all off" (an empty allow-list disables every tool). // Otherwise DO NOT pass a builtin-only allow-list: passing CODING_TOOL_NAMES @@ -2508,12 +2513,12 @@ export async function startRpcSession( ...(initial?.thinkingLevel ? { thinkingLevel: initial.thinkingLevel } : {}), ...(scope.scopedModels.length > 0 ? { scopedModels: [...scope.scopedModels] } : {}), ...(toolsOption !== undefined ? { tools: toolsOption } : {}), - ...(subagentResources?.version === 2 + ...(subagentResources?.toolPolicy ? { noTools: "builtin", excludeTools: subagentToolExclusions(subagentResources.builtinTools) } : subagentResources ? { excludeTools: [...SUBAGENT_CONTROL_TOOL_NAMES] } : {}), }); - if (subagentResources?.version === 2) { + if (subagentResources?.toolPolicy) { inner.setActiveToolsByName([...new Set([...subagentResources.builtinTools, ...inner.getActiveToolNames()])]); } @@ -2546,7 +2551,7 @@ export async function startRpcSession( }, suppressCompletionNotifications: Boolean(subagentResources), subagentResources, - ...(builtins?.mcpHost ? { mcpHost: builtins.mcpHost } : {}), + ...(subagentResources && "mcpHost" in services && services.mcpHost ? { mcpHost: services.mcpHost as McpHost } : builtins?.mcpHost ? { mcpHost: builtins.mcpHost } : {}), }); const realSessionId = inner.sessionId as string; registerRpcWrapper(wrapper); diff --git a/lib/subagent-capabilities.integration.test.mjs b/lib/subagent-capabilities.integration.test.mjs new file mode 100644 index 0000000000..b95a26a9d8 --- /dev/null +++ b/lib/subagent-capabilities.integration.test.mjs @@ -0,0 +1,464 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { createJiti } from "jiti"; + +const root = await mkdtemp(join(tmpdir(), "pi-child-capabilities-")); +const previous = { HOME: process.env.HOME, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }; +process.env.HOME = join(root, "home"); process.env.PI_CODING_AGENT_DIR = join(root, "agent"); +await mkdir(process.env.HOME); await mkdir(process.env.PI_CODING_AGENT_DIR); +const keepAlive = setInterval(() => {}, 1000); +after(async () => { clearInterval(keepAlive); for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } await rm(root, { recursive: true, force: true }); }); +const sdk = await import("@earendil-works/pi-coding-agent"); +const { fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } = await import("@earendil-works/pi-ai"); +const jiti = createJiti(import.meta.url); +const { createSubagentSessionServices } = await jiti.import("./subagent-resources.ts"); +const { AgentSessionWrapper, startRpcSession } = await jiti.import("./rpc-manager.ts"); +const { createSubagentController } = await jiti.import("./subagent-runtime.ts"); +const { readSubagentSessionResources, saveSubagentProfile, listSubagentProfiles, SUBAGENT_META_TYPE } = await jiti.import("./subagents.ts"); +const { subagentToolExclusions } = await jiti.import("./subagent-tool-policy.ts"); +const { loadPiSdkInternals } = await jiti.import("./pi-sdk-internals.ts"); +const { McpHost, MCP_HOST_EXTENSION_PATH } = await jiti.import("./mcp-host.ts"); +const internals = await loadPiSdkInternals(); assert.equal(internals.ok, true); +const connections = [], calls = [], paused = new Set(); +let resolutions = 0; +class FakeTransport { + listeners = new Set(); closeListeners = new Set(); closed = false; requests = []; + constructor(entry) { this.entry = structuredClone(entry); connections.push(this); } + onMessage(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); } + onClose(fn) { this.closeListeners.add(fn); return () => this.closeListeners.delete(fn); } + onError() { return () => {}; } + async start() {} + async close() { this.closed = true; for (const fn of this.closeListeners) fn(); } + async send(message) { + if (message.id === undefined || this.closed) return; + this.requests.push(message.method); + let result; + switch (message.method) { + case "initialize": result = { protocolVersion: "2025-06-18", capabilities: { tools: {}, resources: {} }, serverInfo: { name: "fake", version: "1" } }; break; + case "tools/list": + if (paused.has(this.entry.name)) return; + result = { tools: [ + { name: "inspect", description: "Fixture read", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } }, + { name: "mutate", description: "Fixture write", inputSchema: { type: "object", properties: {} } }, + ] }; break; + case "resources/list": result = { resources: [{ uri: "fixture://item", name: "item" }] }; break; + case "resources/templates/list": result = { resourceTemplates: [] }; break; + case "resources/read": calls.push([this.entry.name, "resource"]); result = { contents: [{ uri: "fixture://item", text: "fixture resource" }] }; break; + case "tools/call": calls.push([this.entry.name, message.params.name]); result = { content: [{ type: "text", text: "fixture executed" }] }; break; + default: result = {}; + } + queueMicrotask(() => { if (!this.closed) for (const fn of this.listeners) fn({ jsonrpc: "2.0", id: message.id, result }); }); + } +} +// Replace only the existing pi-web adapter result, never SDK internals/private methods. +const fakeInternals = { ...internals, createDefaultTransport: (entry) => { resolutions++; return new FakeTransport(entry); } }; +globalThis[Symbol.for("pi-web.piSdkInternals")] = Promise.resolve(fakeInternals); +const config = (exposure = "direct") => ({ url: "https://fixture.invalid/mcp", exposure }); +const snapshot = (overrides = {}) => ({ version: 3, builtinTools: ["read"], toolPolicy: { mode: "none", selectors: [], deny: [] }, appendSystemPrompt: ["Fixture"], loadSkills: false, loadExtensions: false, codeMode: false, loadMcp: false, mcpServers: [], ...overrides }); +const metadata = (resourceSnapshot) => ({ version: 1, parentSessionId: "parent", parentSessionPath: "/parent", resourceSnapshot }); +async function until(fn) { for (let i = 0; i < 200; i++) { if (fn()) return; await delay(5); } throw Error("fixture timed out"); } +async function fixture(t, resources = snapshot(), settings = {}, files = { selected: config(), excluded: config() }) { + const dir = await mkdtemp(join(root, "case-")), cwd = join(dir, "cwd"), agentDir = join(dir, "agent"); + await mkdir(cwd); await mkdir(agentDir); + await writeFile(join(agentDir, "settings.json"), JSON.stringify({ cacheWarming: "off", ...settings })); + await writeFile(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: files })); + const faux = fauxProvider({ models: [{ id: "child-faux" }] }); + const runtime = await sdk.ModelRuntime.create({ authPath: join(dir, "auth.json"), modelsPath: null, refreshOnCreate: false }); + runtime.registerNativeProvider(faux.provider); + const services = await createSubagentSessionServices({ cwd, agentDir, modelRuntime: runtime, settingsManager: sdk.SettingsManager.create(cwd, agentDir), resourceLoaderOptions: { noPromptTemplates: true, noThemes: true, noContextFiles: true } }, resources); + const manager = sdk.SessionManager.inMemory(cwd); manager.appendCustomEntry(SUBAGENT_META_TYPE, metadata(resources)); + const { session } = await sdk.createAgentSessionFromServices({ services, sessionManager: manager, model: faux.getModel("child-faux"), ...(resources.version === 1 ? { tools: resources.tools } : { noTools: "builtin", excludeTools: subagentToolExclusions(resources.builtinTools) }) }); + if (resources.version !== 1) session.setActiveToolsByName([...resources.builtinTools, ...session.getActiveToolNames()]); + const wrapper = new AgentSessionWrapper(session, { subagentResources: readSubagentSessionResources(manager.getEntries()), mcpHost: services.mcpHost }); + let binds = 0; const bind = session.bindExtensions.bind(session); session.bindExtensions = (...args) => { binds++; return bind(...args); }; + wrapper.beginExtensionBinding(); await wrapper.waitUntilReady(); + t.after(async () => { await wrapper.destroy(); runtime.dispose?.(); }); + return { cwd, agentDir, dir, services, session, wrapper, faux, runtime, resources, binds: () => binds }; +} +async function execute(f, toolCalls) { + f.faux.setResponses([() => fauxAssistantMessage(toolCalls.map(([name, args]) => fauxToolCall(name, args)), { stopReason: "toolUse" }), () => fauxAssistantMessage([fauxText("done")])]); + await f.wrapper.promptDelegated("fixture"); + return f.session.messages.filter((m) => m.role === "toolResult").slice(-toolCalls.length); +} + +test("default/off and old snapshots ignore global defaults, restore/reload never create host connections", async (t) => { + for (const resources of [snapshot(), { ...snapshot(), version: 2, codeMode: undefined, loadMcp: undefined, mcpServers: undefined }, { version: 1, tools: ["read"], appendSystemPrompt: [], loadSkills: false, loadExtensions: false }]) { + const before = resolutions, f = await fixture(t, resources, { defaultTools: ["+codemode", "+tool_search"] }); + assert.equal(f.services.mcpHost, undefined); + assert.ok(!f.session.getAllTools().some((tool) => ["codemode", "tool_search"].includes(tool.name))); + f.faux.setResponses([() => fauxAssistantMessage([fauxText("off")])]); await f.wrapper.promptDelegated("off"); + await f.wrapper.send({ type: "reload" }); assert.equal(resolutions, before); assert.equal(f.binds(), 1); + } +}); + +test("all four independent role combinations execute real builtin scripts/MCP; excluded marker stays zero", async (t) => { + for (const codeMode of [false, true]) for (const loadMcp of [false, true]) { + const before = connections.length; + const f = await fixture(t, snapshot({ codeMode, loadMcp, mcpServers: [{ scope: "global", name: "selected" }] })); + assert.equal(Boolean(f.services.mcpHost), loadMcp); + assert.equal(f.session.getActiveToolNames().includes("codemode"), codeMode); + if (codeMode) { + const [result] = await execute(f, [["codemode", { code: "return [6 * 7, typeof models]" }]]); + assert.equal(result.isError, false); assert.match(JSON.stringify(result.content), /42.*undefined/); + assert.ok(!f.session.agent.state.tools.find((tool) => tool.name === "codemode").description.includes("models.classify")); + } else { + f.faux.setResponses([() => fauxAssistantMessage([fauxText("ready")])]); await f.wrapper.promptDelegated("ready"); + } + if (loadMcp) { + await until(() => f.session.getAllTools().some((tool) => tool.name === "mcp__selected__inspect")); + assert.equal((await execute(f, [["mcp__selected__inspect", {}]]))[0].isError, false); + } + assert.deepEqual(connections.slice(before).map((transport) => transport.entry.name), loadMcp ? ["selected"] : []); + assert.ok(!calls.some(([server]) => server === "excluded")); + } +}); + +test("MCP without Code mode converts codemode to discoverable deferred; None and disabled tool-search never activate executable codemode", async (t) => { + const f = await fixture(t, snapshot({ loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] }), {}, { selected: config("codemode") }); + const result = await execute(f, [["tool_search", { query: "inspect" }]]); + assert.equal(result[0].isError, false); + assert.equal(f.session.getAllTools().find((tool) => tool.name === "mcp__selected__inspect").exposure, "deferred"); + assert.equal((await execute(f, [["mcp__selected__inspect", {}]]))[0].isError, false); + assert.ok(!f.session.getAllTools().some((tool) => tool.name === "codemode")); + const before = resolutions; + const none = await fixture(t, snapshot({ loadMcp: true })); + none.faux.setResponses([() => fauxAssistantMessage([fauxText("none")])]); await none.wrapper.promptDelegated("none"); + assert.equal(resolutions, before); + const disabled = await fixture(t, snapshot({ loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] }), { extensions: ["-builtin:tool-search"] }, { selected: config("codemode") }); + assert.ok(!disabled.session.getAllTools().some((tool) => ["tool_search", "codemode"].includes(tool.name))); + disabled.faux.setResponses([() => fauxAssistantMessage([fauxText("unreachable")])]); await disabled.wrapper.promptDelegated("unreachable"); + await until(() => disabled.session.getAllTools().some((tool) => tool.name === "mcp__selected__inspect")); + assert.equal(disabled.session.getAllTools().find((tool) => tool.name === "mcp__selected__inspect").exposure, "deferred"); +}); + +test("read-only MCP tools and resources are allowed, direct and nested mutation/foreign resources have zero execution", async (t) => { + const f = await fixture(t, snapshot({ codeMode: true, loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] })); + let before = calls.length; + const results = await execute(f, [["mcp__selected__mutate", {}], ["codemode", { code: "return await tools.mcp__selected__mutate({})" }], ["read_mcp_resource", { server: "excluded", uri: "fixture://item" }]]); + assert.ok(results.every((result) => result.isError)); assert.equal(calls.length, before); + const allowed = await execute(f, [["codemode", { code: "return await tools.mcp__selected__inspect({})" }], ["read_mcp_resource", { server: "selected", uri: "fixture://item" }], ["list_mcp_resources", {}]]); + assert.ok(allowed.every((result) => !result.isError)); assert.equal(calls.length, before + 2); + before = calls.length; + await writeFile(join(f.agentDir, "mcp.json"), JSON.stringify({ mcpServers: { excluded: config() } })); + // Do not prepare: test synchronous authorization during the revocation sync window. + f.faux.setResponses([() => fauxAssistantMessage([fauxToolCall("read_mcp_resource", { server: "selected", uri: "fixture://item" }), fauxToolCall("codemode", { code: "return await tools.mcp__selected__inspect({})" })], { stopReason: "toolUse" }), () => fauxAssistantMessage([fauxText("done")])]); + await f.session.prompt("revoked"); assert.equal(calls.length, before); +}); + +test("builtin switches read un-cleared scoped source; global disable, trusted project override, untrusted override, reload", async (t) => { + const f = await fixture(t, snapshot({ codeMode: true, loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] }), { extensions: ["-builtin:codemode", "-builtin:mcp"] }); + assert.ok(!f.session.getAllTools().some((tool) => tool.name === "codemode")); + f.faux.setResponses([() => fauxAssistantMessage([fauxText("disabled")])]); const before = resolutions; await f.wrapper.promptDelegated("disabled"); assert.equal(resolutions, before); + await mkdir(join(f.cwd, ".pi")); await writeFile(join(f.cwd, ".pi", "settings.json"), JSON.stringify({ extensions: ["+builtin:codemode", "+builtin:mcp"] })); + await f.wrapper.send({ type: "reload" }); assert.ok(!f.session.getAllTools().some((tool) => tool.name === "codemode")); + new sdk.ProjectTrustStore(f.agentDir).set(f.cwd, true); + await f.wrapper.send({ type: "reload" }); assert.ok(f.session.getActiveToolNames().includes("codemode")); + assert.equal((await execute(f, [["mcp__selected__inspect", {}]]))[0].isError, false); + new sdk.ProjectTrustStore(f.agentDir).set(f.cwd, false); + await f.wrapper.send({ type: "reload" }); assert.ok(!f.session.getAllTools().some((tool) => tool.name === "codemode")); +}); + +test("profile v3 strict validation, case-sensitive references, legacy save and clone preserve managed/foreign fields", async () => { + const cwd = await mkdtemp(join(root, "profiles-")); + const profile = { name: "original", displayName: "Original", description: "Fixture", systemPrompt: "Fixture", tools: ["read"], loadSkills: false, loadExtensions: false, inheritContext: false, runInBackground: false, promptMode: "append", enabled: true, codeMode: true, loadMcp: true, mcpServers: [{ scope: "project", name: "Case-ID" }] }; + const saved = saveSubagentProfile(cwd, "project", profile); + await writeFile(saved.filePath, (await readFile(saved.filePath, "utf8")).replace("---\n\n", "foreign: preserved\n---\n\n")); + const old = { ...profile }; delete old.codeMode; delete old.loadMcp; delete old.mcpServers; + const retained = saveSubagentProfile(cwd, "project", old); + assert.equal(retained.codeMode, true); assert.deepEqual(retained.mcpServers, profile.mcpServers); + const clone = saveSubagentProfile(cwd, "project", { ...old, name: "clone" }, retained); + assert.match(await readFile(clone.filePath, "utf8"), /foreign: preserved/); assert.equal(clone.loadMcp, true); + assert.deepEqual(listSubagentProfiles(cwd).find((p) => p.name === "clone").mcpServers, profile.mcpServers); + for (const invalid of [{ codeMode: "true" }, { loadMcp: 1 }, { mcpServers: ["Case-ID"] }, { mcpServers: [{ scope: "project", name: "Case-ID", hash: "no" }] }]) assert.throws(() => saveSubagentProfile(cwd, "project", { ...profile, ...invalid })); + for (const invalid of [{ version: 4 }, { codeMode: undefined }, { loadMcp: undefined }, { mcpServers: undefined }, { mcpServers: [{ scope: "extension", name: "x" }] }]) assert.equal(readSubagentSessionResources([{ type: "custom", customType: SUBAGENT_META_TYPE, data: metadata({ ...snapshot(), ...invalid }) }]), null); + assert.deepEqual(readSubagentSessionResources([{ type: "custom", customType: SUBAGENT_META_TYPE, data: metadata(snapshot({ mcpServers: profile.mcpServers })) }]).mcpServers, profile.mcpServers); +}); + +test("SDK merge winner scope, shadowing, lost-project no fallback, namespace collision and case remain exact", async (t) => { + const f = await fixture(t, snapshot({ loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] })); + await mkdir(join(f.cwd, ".pi")); + await writeFile(join(f.cwd, ".pi", "mcp.json"), JSON.stringify({ mcpServers: { selected: config() } })); + const trust = new sdk.ProjectTrustStore(f.agentDir); trust.set(f.cwd, true); + let before = resolutions; + f.faux.setResponses([() => fauxAssistantMessage([fauxText("shadow")])]); await f.wrapper.promptDelegated("shadow"); + assert.equal(resolutions, before, "selected global shadowed by trusted project is not connected separately or promoted"); + const g = await fixture(t, snapshot({ loadMcp: true, mcpServers: [{ scope: "project", name: "selected" }] })); + await mkdir(join(g.cwd, ".pi")); await writeFile(join(g.cwd, ".pi", "mcp.json"), JSON.stringify({ mcpServers: { selected: config() } })); + new sdk.ProjectTrustStore(g.agentDir).set(g.cwd, true); + assert.equal((await execute(g, [["mcp__selected__inspect", {}]]))[0].isError, false); + before = resolutions; + await rm(join(g.cwd, ".pi", "mcp.json")); + g.faux.setResponses([() => fauxAssistantMessage([fauxText("lost")])]); await g.wrapper.promptDelegated("lost"); + assert.equal(resolutions, before, "lost selected project never falls back to same-name global"); + const collision = await fixture(t, snapshot({ loadMcp: true, mcpServers: [{ scope: "global", name: "a_b" }] }), {}, { "a-b": config(), a_b: config() }); + before = resolutions; collision.faux.setResponses([() => fauxAssistantMessage([fauxText("collision")])]); await collision.wrapper.promptDelegated("collision"); assert.equal(resolutions, before); + const caseSensitive = await fixture(t, snapshot({ loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] }), {}, { Selected: config() }); + before = resolutions; caseSensitive.faux.setResponses([() => fauxAssistantMessage([fauxText("case")])]); await caseSensitive.wrapper.promptDelegated("case"); assert.equal(resolutions, before); +}); + +test("same selected ID accepts current config; new IDs never connect, and children hold independent transports with idle/disposal", async (t) => { + const resources = snapshot({ loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] }); + const a = await fixture(t, resources), b = await fixture(t, resources); + await execute(a, [["mcp__selected__inspect", {}]]); const first = connections.at(-1); + await execute(b, [["mcp__selected__inspect", {}]]); const second = connections.at(-1); + assert.notEqual(first, second); + assert.equal(first.closed, false); assert.equal(second.closed, false); + const before = connections.length; + await writeFile(join(a.agentDir, "mcp.json"), JSON.stringify({ mcpServers: { selected: { ...config(), url: "https://fixture.invalid/updated", headers: { Authorization: "fixture-updated" } }, newId: config() } })); + assert.equal((await execute(a, [["mcp__selected__inspect", {}]]))[0].isError, false); + assert.equal(connections.length, before + 1); assert.equal(connections.at(-1).entry.config.url, "https://fixture.invalid/updated"); + assert.equal(first.closed, true); assert.equal(second.closed, false); + await a.services.mcpHost.release(); await until(() => connections.at(-1).closed); + const released = connections.length; await execute(a, [["mcp__selected__inspect", {}]]); assert.equal(connections.length, released + 1); + await a.wrapper.shutdown(); assert.equal(connections.at(-1).closed, true); assert.equal(second.closed, false); +}); + +test("child factory refuses unadmitted registration/late owner/old released closure before DefaultFactory resolution", async (t) => { + const cwd = join(root, "unit-cwd"), agentDir = join(root, "unit-agent"); + let registration, refused = false, defaultCalls = 0; + const hooks = new Map(); + const pi = { + on: (event, fn) => hooks.set(event, fn), + getCommands: () => [{ name: "mcp", sourceInfo: { path: "builtin:mcp", source: "builtin" } }], + getAllTools: () => [], + getActiveTools: () => [], + setActiveTools: () => {}, + getMcpServers: () => registration ? [structuredClone(registration)] : [], + registerMcpServer: (name, value) => { if (refused) throw Error('is already registered by extension "foreign"'); registration = { name, config: structuredClone(value), extensionPath: MCP_HOST_EXTENSION_PATH }; }, + unregisterMcpServer: () => { registration = undefined; }, + }; + const host = new McpHost({ agentDir, internals: { loadMcpConfig: () => ({ servers: [{ name: "selected", config: config(), scope: "global", source: join(agentDir, "mcp.json") }], errors: [] }) }, selection: { cwd, servers: [{ scope: "global", name: "selected" }] }, codemodeAvailable: () => false, idleMs: 0, replaceWaitMs: 1 }); + const factory = host.wrapTransportFactory(() => { defaultCalls++; return new FakeTransport({ name: "unit" }); }, true); + host.extension().factory(pi); hooks.get("session_start")({}, { cwd, sessionManager: { getSessionId: () => "unit" }, isIdle: () => true }); + const entry = { name: "selected", config: config(), scope: "extension", source: MCP_HOST_EXTENSION_PATH }; + assert.throws(() => factory(entry, cwd), /not admitted/); + registration = { name: "selected", config: config(), extensionPath: "foreign" }; refused = true; + await host.prepareForPrompt(new AbortController().signal, { wait: false }); + assert.throws(() => factory(entry, cwd), /not admitted/); assert.equal(defaultCalls, 0, "same-config registration collision cannot run value resolution"); + refused = false; registration = undefined; await host.prepareForPrompt(new AbortController().signal, { wait: false }); + registration.extensionPath = "late-foreign"; + assert.throws(() => factory(entry, cwd), /not admitted/); assert.equal(defaultCalls, 0); + registration.extensionPath = MCP_HOST_EXTENSION_PATH; + assert.throws(() => factory({ ...entry, scope: "global" }, cwd), /not admitted/); + assert.throws(() => factory({ ...entry, source: "" }, cwd), /not admitted/); + factory(entry, cwd); assert.equal(defaultCalls, 1, "copy objects are admitted by public owner/config, not WeakSet"); + await host.release(); assert.throws(() => factory(entry, cwd), /not admitted/); assert.equal(defaultCalls, 1); + host.dispose(); assert.throws(() => factory(entry, cwd), /not admitted/); assert.equal(defaultCalls, 1); + const nextFactory = host.wrapTransportFactory(() => { defaultCalls++; return new FakeTransport({ name: "unit-next" }); }, true); + host.extension().factory(pi); hooks.get("session_start")({}, { cwd, sessionManager: { getSessionId: () => "unit" }, isIdle: () => true }); + await host.prepareForPrompt(new AbortController().signal, { wait: false }); + assert.throws(() => factory(entry, cwd), /not admitted/, "late old closure cannot claim the same-config new runtime attempt"); assert.equal(defaultCalls, 1); + nextFactory(entry, cwd); assert.equal(defaultCalls, 2); host.dispose(); + t.diagnostic(JSON.stringify({ foreignAndReleasedDefaultFactoryResolution: 0, network: 0 })); +}); + +test("first actual controller request and resume declare selected direct MCP; preparation Stop/parent abort make zero provider requests and bind once", async (t) => { + const cwd = await mkdtemp(join(root, "controller-")), agentDir = process.env.PI_CODING_AGENT_DIR; + await mkdir(join(cwd, ".pi", "agents"), { recursive: true }); + await writeFile(join(cwd, ".pi", "agents", "mcp-child.md"), "---\ntools: read\nload_mcp: true\nmcp_servers:\n - scope: global\n name: controller\n---\nFixture"); + await writeFile(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: { controller: config(), unselected: config() } })); + const faux = fauxProvider({ models: [{ id: "controller-faux" }] }); + const runtime = await sdk.ModelRuntime.create({ authPath: join(cwd, "auth.json"), modelsPath: null, refreshOnCreate: false }); runtime.registerNativeProvider(faux.provider); + const manager = sdk.SessionManager.inMemory(cwd), wrappers = new Map(); + const parent = { cwd, sessionFile: join(cwd, "parent.jsonl"), isAlive: () => true, isRunning: () => false, waitUntilReady: async () => {}, inner: { sessionManager: manager, modelRuntime: runtime, model: faux.getModel("controller-faux"), agent: { state: {} } } }; + wrappers.set(manager.getSessionId(), parent); + let requests = 0, binds = 0; + const controller = createSubagentController({ getSession: (id) => wrappers.get(id), registerSession(inner, options) { + const bind = inner.bindExtensions.bind(inner); inner.bindExtensions = (...args) => { binds++; return bind(...args); }; + const wrapper = new AgentSessionWrapper(inner, { ...options, subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries()) }); wrappers.set(inner.sessionId, wrapper); wrapper.beginExtensionBinding(); return wrapper.waitUntilReady(); + }, reopenSession: async (id) => wrappers.get(id), resolveSessionPath: async () => null, invalidateSessionList: () => {}, isBuiltInSubagentsEnabled: () => true }); + t.after(async () => { paused.clear(); for (const wrapper of wrappers.values()) if (wrapper !== parent) await wrapper.destroy(); runtime.dispose?.(); }); + const response = (context) => { + requests++; + const declarations = context.messages.filter((m) => m.role === "system").flatMap((m) => m.toolsAdded ?? []); + assert.ok(declarations.some((tool) => tool.name === "mcp__controller__inspect"), "selected direct MCP must be declared in the first actual SDK request"); + assert.ok(!declarations.some((tool) => tool.name.startsWith("mcp__unselected"))); + return fauxAssistantMessage([fauxToolCall("mcp__controller__inspect", {})], { stopReason: "toolUse" }); + }; + faux.setResponses([response, () => fauxAssistantMessage([fauxText("finished")])]); + const request = { parentContext: parent.inner, profile: "mcp-child", parentToolCallId: "parent-call", description: "Fixture", task: "Fixture" }; + const executionsBefore = calls.filter(([server, tool]) => server === "controller" && tool === "inspect").length; + const first = await controller.extensionRuntime.start(request); assert.equal((await first.completion).status, "completed"); assert.equal(requests, 1); assert.equal(binds, 1); + assert.equal(calls.filter(([server, tool]) => server === "controller" && tool === "inspect").length, executionsBefore + 1); + assert.ok(connections.some((transport) => transport.entry.name === "controller")); + assert.ok(!connections.some((transport) => transport.entry.name === "unselected")); + faux.setResponses([response, () => fauxAssistantMessage([fauxText("resumed")])]); + const resumed = await controller.extensionRuntime.resume({ ...request, sessionId: first.run.sessionId }); assert.equal((await resumed.completion).status, "completed"); assert.equal(requests, 2); assert.equal(binds, 1); + assert.equal(calls.filter(([server, tool]) => server === "controller" && tool === "inspect").length, executionsBefore + 2); + for (const cancel of ["Stop", "parent"]) { + paused.add("controller"); const before = requests, beforeConnections = connections.length, signal = new AbortController(); + const run = await controller.extensionRuntime.start({ ...request, signal: signal.signal }); + await until(() => connections.length > beforeConnections); + if (cancel === "Stop") await controller.abort(run.run.sessionId); else signal.abort(); + assert.equal((await run.completion).status, "aborted"); assert.equal(requests, before); paused.delete("controller"); + for (const transport of connections.slice(beforeConnections)) await transport.close(); + } + assert.equal(binds, 3); + t.diagnostic(JSON.stringify({ firstAndResumeSDKRequests: requests, actualSelectedToolExecutions: 2, cancelledRequests: 0, singleBindPerChild: true, excludedServerFactory: 0 })); +}); + +test("cold v1/v2 restoration and reload ignore new profile/settings capability flags; v3 stays frozen and malformed fails closed", async (t) => { + const f = await fixture(t), oldAgent = process.env.PI_CODING_AGENT_DIR, createRuntime = sdk.ModelRuntime.create; + process.env.PI_CODING_AGENT_DIR = f.agentDir; sdk.ModelRuntime.create = async () => f.runtime; + t.after(() => { process.env.PI_CODING_AGENT_DIR = oldAgent; sdk.ModelRuntime.create = createRuntime; }); + await mkdir(join(f.cwd, ".pi", "agents"), { recursive: true }); + await writeFile(join(f.cwd, ".pi", "agents", "widened.md"), "---\ntools: all\ncode_mode: true\nload_mcp: true\nmcp_servers: [{scope: global, name: selected}]\n---\nWidened"); + await writeFile(join(f.agentDir, "settings.json"), JSON.stringify({ defaultTools: ["+codemode", "+tool_search", "+powershell"], cacheWarming: "off" })); + const persist = (value) => { + const manager = sdk.SessionManager.create(f.cwd, join(f.dir, `sessions-${Math.random()}`)); + manager.appendCustomEntry(SUBAGENT_META_TYPE, { ...metadata(value), profile: "widened" }); + manager.appendMessage(fauxAssistantMessage([fauxText("persist fixture")])); return manager; + }; + for (const version of [1, 2]) { + const manager = persist(version === 1 ? { ...snapshot({ codeMode: true, loadMcp: true }), version: 1, tools: ["read"] } : { ...snapshot({ codeMode: true, loadMcp: true }), version: 2 }); + const before = resolutions; + const { session: cold } = await startRpcSession(manager.getSessionId(), manager.getSessionFile(), f.cwd); t.after(() => cold.destroy()); + await cold.waitUntilReady(); assert.equal(cold.inner.getAllTools().some((tool) => ["codemode", "tool_search"].includes(tool.name)), false); + f.faux.setResponses([() => fauxAssistantMessage([fauxText("old")])]); await cold.promptDelegated("old"); + await cold.send({ type: "reload" }); assert.equal(resolutions, before); + } + const resources = snapshot({ codeMode: true, loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }] }); + const manager = persist(resources); + await writeFile(join(f.cwd, ".pi", "agents", "widened.md"), "---\ntools: all\ncode_mode: false\nload_mcp: false\n---\nChanged"); + const { session: cold } = await startRpcSession(manager.getSessionId(), manager.getSessionFile(), f.cwd); t.after(() => cold.destroy()); await cold.waitUntilReady(); + f.faux.setResponses([() => fauxAssistantMessage([fauxToolCall("mcp__selected__inspect", {})], { stopReason: "toolUse" }), () => fauxAssistantMessage([fauxText("done")])]); await cold.promptDelegated("frozen"); + assert.equal(cold.inner.messages.filter((m) => m.role === "toolResult").at(-1).isError, false); + assert.equal(cold.inner.getActiveToolNames().includes("powershell"), false); assert.equal(cold.inner.getActiveToolNames().includes("codemode"), true); + await cold.send({ type: "reload" }); assert.equal(cold.inner.getActiveToolNames().includes("codemode"), true); + const invalid = persist({ ...resources, loadMcp: undefined }); const before = resolutions; + await assert.rejects(startRpcSession(invalid.getSessionId(), invalid.getSessionFile(), f.cwd), /invalid resource snapshot/); assert.equal(resolutions, before); +}); + +test("host capabilities with extensions None never import excluded modules; external replacement needs explicit resource/tool policy", async (t) => { + const f = await fixture(t), path = join(f.agentDir, "extensions", "replacement.ts"); + await mkdir(join(f.agentDir, "extensions")); + globalThis.__childExcludedModule = 0; globalThis.__childReplacementCalls = 0; + t.after(() => { delete globalThis.__childExcludedModule; delete globalThis.__childReplacementCalls; }); + await writeFile(path, `globalThis.__childExcludedModule++; export default function(pi) { pi.registerTool({ name:'codemode',label:'External',description:'External replacement',parameters:{type:'object',properties:{}},execute:async()=>{globalThis.__childReplacementCalls++;return {content:[{type:'text',text:'external'}],details:undefined};} }); }`); + const create = async (resources) => { + const services = await createSubagentSessionServices({ cwd: f.cwd, agentDir: f.agentDir, modelRuntime: f.runtime, settingsManager: sdk.SettingsManager.create(f.cwd, f.agentDir) }, resources); + const { session } = await sdk.createAgentSessionFromServices({ services, sessionManager: sdk.SessionManager.inMemory(f.cwd), model: f.faux.getModel("child-faux"), noTools: "builtin", excludeTools: subagentToolExclusions(resources.builtinTools) }); + const wrapper = new AgentSessionWrapper(session, { subagentResources: { ...resources, tools: resources.builtinTools }, mcpHost: services.mcpHost }); wrapper.beginExtensionBinding(); await wrapper.waitUntilReady(); t.after(() => wrapper.destroy()); return { ...f, session, wrapper, services }; + }; + const isolated = await create(snapshot({ codeMode: true, loadMcp: true })); await execute(isolated, [["codemode", { code: "return 42" }]]); + assert.equal(globalThis.__childExcludedModule, 0); assert.equal(globalThis.__childReplacementCalls, 0); + const granted = await create(snapshot({ codeMode: true, loadExtensions: true, extensions: [path], toolPolicy: { mode: "selectors", selectors: ["ext:replacement"], deny: [] } })); + assert.equal(granted.session.getAllTools().find((tool) => tool.name === "codemode").sourceInfo.path, path); + assert.equal((await execute(granted, [["codemode", {}]]))[0].isError, false); assert.equal(globalThis.__childReplacementCalls, 1); + const denied = await create(snapshot({ codeMode: true, loadExtensions: true, extensions: [path], toolPolicy: { mode: "none", selectors: [], deny: [] } })); + assert.equal((await execute(denied, [["codemode", {}]]))[0].isError, true); assert.equal(globalThis.__childReplacementCalls, 1, "role Code mode grants no foreign replacement execution"); +}); + +test("builtin only-mode description and discovery list permitted tools only; models.classify/generateImages are unavailable with zero catalog/provider calls", async (t) => { + const f = await fixture(t, snapshot({ codeMode: true, loadMcp: true, mcpServers: [{ scope: "global", name: "selected" }], toolPolicy: { mode: "none", selectors: [], deny: ["ext:builtin:mcp/mcp__selected__mutate"] } }), { codemode: { mode: "only" } }); + let catalogCalls = 0; + for (const name of ["getModelsOfType", "getAvailableOfType", "getModelOfType"]) if (typeof f.runtime[name] === "function") t.mock.method(f.runtime, name, () => { catalogCalls++; throw Error("models API forbidden"); }); + const [result] = await execute(f, [["codemode", { code: "return { tools: ALL_TOOLS.map(t => t.name), classifier: typeof models === 'undefined' ? 'unavailable' : await models.classify({}, {}), image: typeof models === 'undefined' ? 'unavailable' : await models.generateImages({}, {}) }" }]]); + assert.equal(result.isError, false); const text = JSON.stringify(result.content); assert.match(text, /unavailable/); assert.ok(text.includes("mcp__selected__inspect")); assert.ok(!text.includes("mcp__selected__mutate")); assert.equal(catalogCalls, 0); + const description = f.session.agent.state.tools.find((tool) => tool.name === "codemode").description; + assert.ok(description.includes("mcp__selected")); assert.ok(!description.includes("mutate")); assert.ok(!description.includes("models.classify")); + const before = calls.length; const [denied] = await execute(f, [["codemode", { code: "return await tools.mcp__selected__mutate({})" }]]); assert.equal(denied.isError, true); assert.equal(calls.length, before); +}); + +test("v3 capabilities reject missing delegated/readiness/host boundaries with zero real provider/tool requests", async (t) => { + const f = await fixture(t), parentManager = sdk.SessionManager.inMemory(f.cwd); + const parent = { cwd: f.cwd, sessionFile: join(f.cwd, "parent.jsonl"), isAlive: () => true, isRunning: () => false, waitUntilReady: async () => {}, inner: { sessionManager: parentManager, modelRuntime: f.runtime, model: f.faux.getModel("child-faux"), agent: { state: {} } } }; + await mkdir(join(f.cwd, ".pi", "agents"), { recursive: true }); + let providers = 0, tools = 0, bypasses = 0; + for (const mode of ["code-no-wrapper", "mcp-no-wrapper", "code-no-ready", "mcp-wrapper-no-host"]) { + const mcp = mode.startsWith("mcp"); + await writeFile(join(f.cwd, ".pi", "agents", "missing.md"), `---\ntools: read\ncode_mode: ${!mcp}\nload_mcp: ${mcp}\nmcp_servers: []\n---\nFixture`); + const wrappers = new Map([[parentManager.getSessionId(), parent]]); + f.faux.setResponses([() => { providers++; return fauxAssistantMessage([fauxText("must not run")]); }]); + const controller = createSubagentController({ getSession: (id) => wrappers.get(id), registerSession(inner, options) { + t.after(() => inner.dispose()); inner.subscribe((event) => { if (event.type === "tool_execution_start") tools++; }); + if (mode.endsWith("no-wrapper")) return; + if (mode === "code-no-ready") { wrappers.set(inner.sessionId, { inner, isAlive: () => true, isRunning: () => false, promptDelegated: async () => { bypasses++; } }); return; } + assert.ok(options.mcpHost, "actual services supplied a host; the faulty registration omits it"); + const wrapper = new AgentSessionWrapper(inner, { subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries()) }); + wrappers.set(inner.sessionId, wrapper); wrapper.beginExtensionBinding(); t.after(() => wrapper.destroy()); return wrapper.waitUntilReady(); + }, reopenSession: async (id) => wrappers.get(id), resolveSessionPath: async () => null, invalidateSessionList: () => {}, isBuiltInSubagentsEnabled: () => true }); + const run = await controller.extensionRuntime.start({ parentContext: parent.inner, parentToolCallId: "missing", profile: "missing", description: "Missing", task: "must not run" }); + const result = await run.completion; assert.equal(result.status, "failed", mode); assert.match(result.error, /require.*(preparation|host)/i); + assert.equal(providers, 0); assert.equal(tools, 0); assert.equal(bypasses, 0); + } + t.diagnostic(JSON.stringify({ missingBoundaryCases: 4, actualProviderRequests: providers, actualToolExecutions: tools, fakeDelegatedBypasses: bypasses })); +}); + +test("aggregate resources fail closed while registry revocation precedes SDK catalog reconciliation", async (t) => { + const f = await fixture(t), path = join(f.agentDir, "barrier.ts"); + let entered, release; + const enteredPromise = new Promise((resolve) => { entered = resolve; }); + const barrier = new Promise((resolve) => { release = resolve; }); + globalThis.__childResourceBarrier = { enabled: false, entered, barrier }; + await writeFile(path, `export default function(pi) { pi.on('mcp_servers_change', async () => { const gate=globalThis.__childResourceBarrier; if(gate?.enabled) { gate.entered(); await gate.barrier; } }); }`); + const resources = snapshot({ loadMcp: true, loadExtensions: true, extensions: [path], mcpServers: [{ scope: "global", name: "kept" }, { scope: "global", name: "revoked" }] }); + await writeFile(join(f.agentDir, "mcp.json"), JSON.stringify({ mcpServers: { kept: config(), revoked: config() } })); + const services = await createSubagentSessionServices({ cwd: f.cwd, agentDir: f.agentDir, modelRuntime: f.runtime, settingsManager: sdk.SettingsManager.create(f.cwd, f.agentDir) }, resources); + const { session } = await sdk.createAgentSessionFromServices({ services, sessionManager: sdk.SessionManager.inMemory(f.cwd), model: f.faux.getModel("child-faux"), noTools: "builtin", excludeTools: subagentToolExclusions(resources.builtinTools) }); + const wrapper = new AgentSessionWrapper(session, { subagentResources: { ...resources, tools: resources.builtinTools }, mcpHost: services.mcpHost }); wrapper.beginExtensionBinding(); await wrapper.waitUntilReady(); + t.after(async () => { release(); delete globalThis.__childResourceBarrier; await wrapper.destroy(); }); + const target = { ...f, services, session, wrapper }; + assert.equal((await execute(target, [["list_mcp_resources", {}]]))[0].isError, false); + globalThis.__childResourceBarrier.enabled = true; + await writeFile(join(f.agentDir, "mcp.json"), JSON.stringify({ mcpServers: { kept: config() } })); + const preparing = services.mcpHost.prepareForPrompt(new AbortController().signal, { wait: false }); + try { + await enteredPromise; await preparing; + // The ordinary registry now contains only kept, but the earlier handler holds + // the SDK's resource catalog at kept+revoked. Do not replace that catalog. + const requestsBefore = connections.reduce((sum, transport) => sum + transport.requests.length, 0); + f.faux.setResponses([() => fauxAssistantMessage([fauxToolCall("list_mcp_resources", {})], { stopReason: "toolUse" }), () => fauxAssistantMessage([fauxText("done")])]); + await session.prompt("aggregate revocation window"); + assert.equal(connections.reduce((sum, transport) => sum + transport.requests.length, 0), requestsBefore, "blocked aggregate executes zero remote requests"); + const result = session.messages.filter((message) => message.role === "toolResult").at(-1); + assert.equal(result.isError, true, JSON.stringify(result.content)); + } finally { globalThis.__childResourceBarrier.enabled = false; release(); } + await until(() => connections.filter((transport) => transport.entry.name === "revoked").every((transport) => transport.closed)); + // Declaration never consults the consumed roster, so the catalog lag must not prune + // the aggregate into a dead state; recovery comes from the SDK's own catalog reload. + assert.ok(session.getActiveToolNames().includes("list_mcp_resources"), "catalog lag does not prune the aggregate"); + await delay(30); + const [recovered] = await execute(target, [["list_mcp_resources", {}]]); + assert.equal(recovered.isError, false); assert.ok(!JSON.stringify(recovered.content).includes("revoked")); + t.diagnostic(JSON.stringify({ aggregateRevocationWindowRequests: 0, recoveredViaSdkCatalog: true, catalogMutation: false })); +}); + +test("explicitly disabled aggregate during a held catalog reconciliation stays off with zero extra requests", async (t) => { + const f = await fixture(t), path = join(f.agentDir, "off-barrier.ts"); + let entered, release; + const enteredPromise = new Promise((resolve) => { entered = () => { + assert.equal(session.getActiveToolNames().includes("list_mcp_resources"), true, "active before explicit off"); + session.setActiveToolsByName(session.getActiveToolNames().filter((name) => name !== "list_mcp_resources")); + assert.equal(session.getActiveToolNames().includes("list_mcp_resources"), false, "explicit off took effect"); + resolve(); + }; }); + const barrier = new Promise((resolve) => { release = resolve; }); + globalThis.__childResourceBarrier = { enabled: false, entered, barrier }; + await writeFile(path, `export default function(pi) { pi.on('mcp_servers_change', async () => { const gate=globalThis.__childResourceBarrier; if(gate?.enabled) { gate.entered(); await gate.barrier; } }); }`); + const resources = snapshot({ loadMcp: true, loadExtensions: true, extensions: [path], mcpServers: [{ scope: "global", name: "kept" }, { scope: "global", name: "revoked" }] }); + await writeFile(join(f.agentDir, "mcp.json"), JSON.stringify({ mcpServers: { kept: config(), revoked: config() } })); + const services = await createSubagentSessionServices({ cwd: f.cwd, agentDir: f.agentDir, modelRuntime: f.runtime, settingsManager: sdk.SettingsManager.create(f.cwd, f.agentDir) }, resources); + const { session } = await sdk.createAgentSessionFromServices({ services, sessionManager: sdk.SessionManager.inMemory(f.cwd), model: f.faux.getModel("child-faux"), noTools: "builtin", excludeTools: subagentToolExclusions(resources.builtinTools) }); + const wrapper = new AgentSessionWrapper(session, { subagentResources: { ...resources, tools: resources.builtinTools }, mcpHost: services.mcpHost }); wrapper.beginExtensionBinding(); await wrapper.waitUntilReady(); + t.after(async () => { release(); delete globalThis.__childResourceBarrier; await wrapper.destroy(); }); + const target = { ...f, services, session, wrapper }; + assert.equal((await execute(target, [["list_mcp_resources", {}]]))[0].isError, false); + globalThis.__childResourceBarrier.enabled = true; + await writeFile(join(f.agentDir, "mcp.json"), JSON.stringify({ mcpServers: { kept: config() } })); + const preparing = services.mcpHost.prepareForPrompt(new AbortController().signal, { wait: false }); + try { await enteredPromise; await preparing; } + finally { globalThis.__childResourceBarrier.enabled = false; release(); } + await until(() => connections.filter((transport) => transport.entry.name === "revoked").every((transport) => transport.closed)); + await delay(30); + assert.equal(session.getActiveToolNames().includes("list_mcp_resources"), false, "an explicit off survives catalog consumption without replay"); + const remoteBefore = connections.reduce((sum, transport) => sum + transport.requests.length, 0); + const [rejected] = await execute(target, [["list_mcp_resources", {}]]); + assert.equal(rejected.isError, true, "a disabled aggregate is not directly callable"); + assert.equal(connections.reduce((sum, transport) => sum + transport.requests.length, 0), remoteBefore, "a disabled aggregate executes zero remote requests"); + t.diagnostic(JSON.stringify({ explicitOffPreserved: true, directCallIsError: true, subsequentRemoteRequests: 0 })); +}); diff --git a/lib/subagent-prompt.ts b/lib/subagent-prompt.ts index 15eb6e7928..5f81f7ff6a 100644 --- a/lib/subagent-prompt.ts +++ b/lib/subagent-prompt.ts @@ -10,11 +10,13 @@ export function buildSubagentPromptPlan(options: { tools: readonly string[]; loadSkills?: boolean; loadExtensions?: boolean; + codeMode?: boolean; + loadMcp?: boolean; promptMode?: "replace" | "append"; task: string; inheritedParentContext?: string; }): SubagentPromptPlan { - const chatOnly = options.tools.length === 0 && !options.loadSkills && !options.loadExtensions; + const chatOnly = options.tools.length === 0 && !options.loadSkills && !options.loadExtensions && !options.codeMode && !options.loadMcp; const replacePrompt = options.promptMode === "replace"; const appendSystemPrompt = [options.profileSystemPrompt]; if (options.inheritedParentContext && !chatOnly) { diff --git a/lib/subagent-resources.integration.test.mjs b/lib/subagent-resources.integration.test.mjs index 8c24f11f85..ef693d8673 100644 --- a/lib/subagent-resources.integration.test.mjs +++ b/lib/subagent-resources.integration.test.mjs @@ -461,7 +461,10 @@ test("real controller creation snapshots resource selections; cold rpc restorati created.sessionManager.appendMessage({ role: "assistant", content: [{ type: "text", text: "Fixture" }], api: "faux", provider: "faux", model: "resource-faux", 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() }); const entries = created.sessionManager.getEntries(); const snapshot = entries.find((e) => e.type === "custom" && e.customType === SUBAGENT_META_TYPE).data.resourceSnapshot; - assert.equal(snapshot.version, 2); + assert.equal(snapshot.version, 3); + assert.equal(snapshot.codeMode, false); + assert.equal(snapshot.loadMcp, false); + assert.deepEqual(snapshot.mcpServers, []); assert.deepEqual(snapshot.builtinTools, ["read"]); assert.deepEqual(snapshot.toolPolicy, { mode: "implicitAll", selectors: [], deny: [] }); assert.ok(created.getActiveToolNames().includes("browser_mock")); diff --git a/lib/subagent-resources.ts b/lib/subagent-resources.ts index 30ebca5d73..78afe27d1e 100644 --- a/lib/subagent-resources.ts +++ b/lib/subagent-resources.ts @@ -3,7 +3,27 @@ import { isPathWithinRoots } from "./path-security"; import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust"; import { addExplicitResources, assertResourceSettingsReadable, explicitResourcePath, readSubagentResourceCatalog, resourceIdentity, selectCatalogResources, uniqueResourceDiagnostics, type SubagentResourceItem } from "./subagent-resource-catalog"; import type { SubagentResourceSelection } from "./subagent-resource-selection"; -import { createSubagentToolPolicyExtension, type SubagentToolPolicy } from "./subagent-tool-policy"; +import { allowedSubagentTools, createSubagentToolPolicyExtension, type SubagentHostToolAdmission, type SubagentToolPolicy } from "./subagent-tool-policy"; +import { createPiWebBuiltinExtensions, scopedBuiltinExtensionSwitches } from "./builtin-extensions"; +import type { McpHost } from "./mcp-host"; +import { createReadOnlyMcpPolicyExtension } from "./mcp-read-only-policy"; +import type { SubagentMcpServerRef } from "./subagents"; +import { CODEMODE_EXTENSION_PATH, MCP_EXTENSION_PATH, TOOL_SEARCH_EXTENSION_PATH } from "./mcp-command"; + +/** + * The shared host admission predicate. `declare` gates builtin discovery/active + * loadout, `execute` additionally validates the live roster and explicit server. + * The phase is an explicit parameter, never inferred from an absent input: a + * no-argument aggregate call is a real execution too. + */ +export function subagentHostToolAdmission(policy: { codeMode?: boolean; loadMcp?: boolean }, host?: Pick): SubagentHostToolAdmission { + return (tool, phase, input) => { + if (tool.sourceInfo?.source !== "builtin") return false; + if (tool.sourceInfo.path === CODEMODE_EXTENSION_PATH) return policy.codeMode === true && tool.name === "codemode"; + if (tool.sourceInfo.path === TOOL_SEARCH_EXTENSION_PATH) return policy.loadMcp === true && tool.name === "tool_search"; + return policy.loadMcp === true && (host?.admitsTool(tool, phase, input) ?? false); + }; +} const RESOURCE_KEYS = ["packages", "extensions", "skills", "prompts", "themes"] as const; function clearResources(settings: ReturnType): string { @@ -62,6 +82,9 @@ export interface SubagentResourcePolicy { extensions?: SubagentResourceSelection; builtinTools?: string[]; toolPolicy?: SubagentToolPolicy; + codeMode?: boolean; + loadMcp?: boolean; + mcpServers?: SubagentMcpServerRef[]; } /** Any discovered project source or selected project alias makes the canonical file project-owned. */ @@ -79,10 +102,27 @@ function projectExtensionIdentities(entries: SubagentResourceItem[], selection: /** Reuse original services/model runtime/cwd. Only restricted resources get a transient SDK loader. */ export async function createSubagentSessionServices(options: CreateAgentSessionServicesOptions & { agentDir: string; settingsManager: SettingsManager }, policy: SubagentResourcePolicy) { + policy = structuredClone(policy); const { cwd, agentDir, settingsManager: source } = options; + assertResourceSettingsReadable(source); + let policyLoader: Awaited>["resourceLoader"] | undefined; + const builtins: Awaited> | undefined = policy.codeMode || policy.loadMcp ? await createPiWebBuiltinExtensions({ + agentDir, + capability: { + cwd, codeMode: policy.codeMode === true, loadMcp: policy.loadMcp === true, mcpServers: structuredClone(policy.mcpServers ?? []), + builtinEnabled: async (name) => (await scopedBuiltinExtensionSwitches(source, cwd, agentDir))[name].enabled, + allowedTools: (tools) => policyLoader && policy.toolPolicy + ? allowedSubagentTools(policy.builtinTools ?? [], policy.toolPolicy, tools, policyLoader.getExtensions().extensions, subagentHostToolAdmission(policy, builtins?.mcpHost)) + : new Set(), + }, + }) : undefined; + const admitHost = subagentHostToolAdmission(policy, builtins?.mcpHost); + options = { ...options, resourceLoaderOptions: { ...options.resourceLoaderOptions, + extensionFactories: [...builtins?.extensions ?? [], ...options.resourceLoaderOptions?.extensionFactories ?? [], + ...(policy.loadMcp ? [createReadOnlyMcpPolicyExtension(policy.builtinTools ?? [])] : [])], + } }; // Compose host factories in one place on both create and cold restore. Reload reuses // this factory but obtains the newly approved, trust-checked roster from the loader. - let policyLoader: Awaited>["resourceLoader"] | undefined; if (policy.toolPolicy) options = { ...options, resourceLoaderOptions: { @@ -92,7 +132,7 @@ export async function createSubagentSessionServices(options: CreateAgentSessionS createSubagentToolPolicyExtension(policy.builtinTools ?? [], policy.toolPolicy, () => { if (!policyLoader) throw new Error("Subagent resource roster not ready"); return policyLoader.getExtensions().extensions; - }), + }, admitHost), ], }, }; @@ -108,7 +148,7 @@ export async function createSubagentSessionServices(options: CreateAgentSessionS await reload(projectTrustReloadOptions(cwd, agentDir)); assertResourceSettingsReadable(source); }; - return services; + return { ...services, mcpHost: builtins?.mcpHost }; } const settingsManager = createSubagentMemorySettings(source); @@ -139,7 +179,7 @@ export async function createSubagentSessionServices(options: CreateAgentSessionS diagnostics = [...catalog.diagnostics, ...skills.diagnostics, ...extensions.diagnostics]; for (const entry of extensions.items.filter((item) => !approvedExtensions.includes(item))) diagnostics.push({ type: "warning", path: entry.path, message: "Project extension not loaded: project trust required" }); // Load the identity we approved, not an alias that can be retargeted before SDK import. - extensionPaths.splice(0, extensionPaths.length, ...approvedExtensions.map((entry) => entry.identity)); + extensionPaths.splice(0, extensionPaths.length, ...(builtins ? [CODEMODE_EXTENSION_PATH, TOOL_SEARCH_EXTENSION_PATH, MCP_EXTENSION_PATH] : []), ...approvedExtensions.map((entry) => entry.identity)); skillPaths.splice(0, skillPaths.length, ...approvedSkills.map((entry) => entry.path)); }; const skillsOverride = (base: { skills: Skill[]; diagnostics: typeof diagnostics }) => ({ @@ -154,6 +194,8 @@ export async function createSubagentSessionServices(options: CreateAgentSessionS resourceLoaderOptions: { ...options.resourceLoaderOptions, noExtensions: true, noSkills: true, + // Explicit builtin paths bypass noExtensions only; their factories still consult + // the un-cleared source settings above. Excluded user modules never run. additionalExtensionPaths: extensionPaths, additionalSkillPaths: skillPaths, skillsOverride, }, @@ -187,5 +229,5 @@ export async function createSubagentSessionServices(options: CreateAgentSessionS extend({ skillPaths: paths.skillPaths }); restoreSources(); }; - return services; + return { ...services, mcpHost: builtins?.mcpHost }; } diff --git a/lib/subagent-runtime.ts b/lib/subagent-runtime.ts index 9a5df91280..cb41b68bbd 100644 --- a/lib/subagent-runtime.ts +++ b/lib/subagent-runtime.ts @@ -18,6 +18,7 @@ import { } from "./subagent-extension"; import { readSubagentRun, + readSubagentSessionResources, resolveSubagentProfile, SUBAGENT_META_TYPE, SUBAGENT_STATUS_TYPE, @@ -54,7 +55,7 @@ export interface SubagentRuntimeDependencies { getSession(sessionId: string): HostSession | undefined; registerSession( inner: AgentSessionLike, - options?: { exactSystemPrompt?: string; chatOnly?: boolean }, + options?: { exactSystemPrompt?: string; chatOnly?: boolean; mcpHost?: import("./mcp-host").McpHost }, ): void | Promise; reopenSession(sessionId: string, sessionFile: string): Promise; resolveSessionPath(sessionId: string): Promise; @@ -79,6 +80,11 @@ type StoredSubagentExecution = { async function promptDelegated(wrapper: HostSession | undefined, inner: AgentSessionLike, message: string, stored: StoredSubagentExecution): Promise { throwIfSubagentCancelled(stored); + const resources = readSubagentSessionResources(inner.sessionManager.getEntries() as unknown as SessionEntry[]); + if (resources?.version === 3 && (resources.codeMode || resources.loadMcp) + && (typeof wrapper?.promptDelegated !== "function" || typeof wrapper.waitUntilReady !== "function")) { + throw new Error("Subagent host capabilities require delegated wrapper preparation"); + } if (wrapper?.promptDelegated) return wrapper.promptDelegated(message, (stored.promptController ??= new AbortController()).signal); await inner.prompt(message, { source: "rpc" }); } @@ -251,6 +257,8 @@ export function createSubagentController( tools: profile.tools, loadSkills: profile.loadSkills, loadExtensions: profile.loadExtensions, + codeMode: profile.codeMode, + loadMcp: profile.loadMcp, promptMode: profile.promptMode, task: appendSubagentInputFiles(request.task, inputFiles), inheritedParentContext, @@ -302,7 +310,10 @@ export function createSubagentController( runInBackground, createdAt, resourceSnapshot: { - version: 2, + version: 3, + codeMode: profile.codeMode === true, + loadMcp: profile.loadMcp === true, + mcpServers: structuredClone(profile.mcpServers ?? []), appendSystemPrompt: [...appendSystemPrompt], builtinTools: [...builtinTools], toolPolicy, @@ -337,6 +348,7 @@ export function createSubagentController( ? { exactSystemPrompt: promptPlan.exactSystemPrompt } : {}), chatOnly, + mcpHost: services.mcpHost, })).then( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }), diff --git a/lib/subagent-tool-policy.test.mjs b/lib/subagent-tool-policy.test.mjs index 9c57030f4f..0db3a52638 100644 --- a/lib/subagent-tool-policy.test.mjs +++ b/lib/subagent-tool-policy.test.mjs @@ -77,6 +77,6 @@ test("v2 snapshots strictly validate policy and frozen builtins; v1 is not promo const snapshotV1 = { version: 1, tools: ["read"], appendSystemPrompt: [], loadExtensions: true, loadSkills: false }; const persistedV1 = entries(snapshotV1), originalV1 = structuredClone(persistedV1); const legacy = readSubagentSessionResources(persistedV1); - assert.deepEqual(legacy, { tools: ["read"], appendSystemPrompt: [], loadExtensions: true, loadSkills: false }); + assert.deepEqual(legacy, { version: 1, tools: ["read"], appendSystemPrompt: [], loadExtensions: true, loadSkills: false }); assert.deepEqual(persistedV1, originalV1, "reading never migrates or widens persisted legacy authority"); }); diff --git a/lib/subagent-tool-policy.ts b/lib/subagent-tool-policy.ts index f0b493b7af..75d1e39365 100644 --- a/lib/subagent-tool-policy.ts +++ b/lib/subagent-tool-policy.ts @@ -1,5 +1,6 @@ import type { ExtensionAPI, ExtensionFactory, ResourceLoader } from "@earendil-works/pi-coding-agent"; import { isSubagentToolPolicy, selectSubagentExtensionTools, SUBAGENT_BUILTIN_TOOL_NAMES, SUBAGENT_BUILTIN_TOOLS, SUBAGENT_CONTROL_TOOL_NAMES, SUBAGENT_CONTROL_TOOLS, type SubagentToolPolicy } from "./subagents"; +import { MCP_EXTENSION_PATH } from "./mcp-command"; // Compatibility exports; the persisted configuration contract owns these definitions. export { isSubagentToolPolicy, SUBAGENT_BUILTIN_TOOL_NAMES, type SubagentToolPolicy } from "./subagents"; @@ -16,14 +17,16 @@ export function subagentToolExclusions(builtinTools: readonly string[]): string[ return [...SUBAGENT_BUILTIN_TOOL_NAMES.filter((name) => !builtinTools.includes(name)), ...SUBAGENT_CONTROL_TOOL_NAMES]; } -type Tool = Pick[number], "name" | "sourceInfo">; +type Tool = Pick[number], "name" | "sourceInfo" | "namespace" | "annotations">; +export type SubagentHostToolAdmission = (tool: Tool, phase: "declare" | "execute", input?: Record) => boolean; type Extension = ReturnType["extensions"][number]; /** Resolve selectors against the approved roster, but grant only the actual registry winner. */ -export function allowedSubagentTools(builtinTools: readonly string[], policy: SubagentToolPolicy, tools: readonly Tool[], extensions: readonly Extension[]): Set { +export function allowedSubagentTools(builtinTools: readonly string[], policy: SubagentToolPolicy, tools: readonly Tool[], extensions: readonly Extension[], admitHost?: SubagentHostToolAdmission): Set { if (!isSubagentToolPolicy(policy)) return new Set(); const roster = extensions.map((extension) => ({ ...extension, tools: new Map() })); const allowed = new Set(); + const hostNames = new Set(); for (const tool of tools) { if (SUBAGENT_CONTROL_TOOLS.has(tool.name)) continue; const builtin = SUBAGENT_BUILTIN_TOOLS.has(tool.name); @@ -38,10 +41,15 @@ export function allowedSubagentTools(builtinTools: readonly string[], policy: Su && extension.sourceInfo?.source === source.source && extension.sourceInfo?.origin === source.origin && extension.sourceInfo?.scope === source.scope); - if (owners.length === 1) owners[0].tools.set(tool.name, undefined); + if (owners.length === 1) { + owners[0].tools.set(tool.name, undefined); + if (source.source === "builtin" && source.path.startsWith("builtin:") && admitHost?.(tool, "declare")) hostNames.add(tool.name); + } } const selectors = policy.mode === "implicitAll" ? ["ext:*"] : policy.mode === "selectors" ? policy.selectors : []; - for (const name of selectSubagentExtensionTools(roster, selectors, policy.deny)) allowed.add(name); + const externalRoster = roster.filter((extension) => extension.sourceInfo?.source !== "builtin"); + for (const name of selectSubagentExtensionTools(externalRoster, selectors, policy.deny)) allowed.add(name); + for (const name of selectSubagentExtensionTools(roster, ["ext:*"], policy.deny)) if (hostNames.has(name)) allowed.add(name); return allowed; } @@ -51,9 +59,10 @@ export function reconcileSubagentActiveTools( builtinTools: readonly string[], policy: SubagentToolPolicy, getExtensions: () => readonly Extension[], + admitHost?: SubagentHostToolAdmission, ): void { let allowed: Set; - try { allowed = allowedSubagentTools(builtinTools, policy, api.getAllTools(), getExtensions()); } + try { allowed = allowedSubagentTools(builtinTools, policy, api.getAllTools(), getExtensions(), admitHost); } catch { allowed = new Set(); } const active = api.getActiveTools(); const next = active.filter((name) => allowed.has(name)); @@ -61,20 +70,21 @@ export function reconcileSubagentActiveTools( } /** No registration-policy hook exists in SDK 1.0: active pruning is not registry filtering. */ -export function createSubagentToolPolicyExtension(builtinTools: readonly string[], policy: SubagentToolPolicy, getExtensions: () => readonly Extension[]): ExtensionFactory { +export function createSubagentToolPolicyExtension(builtinTools: readonly string[], policy: SubagentToolPolicy, getExtensions: () => readonly Extension[], admitHost?: SubagentHostToolAdmission): ExtensionFactory { // Freeze creation authority, including against later mutations of the caller's profile. const base = [...builtinTools]; const frozen = structuredClone(policy); return (pi) => { - const permitted = () => allowedSubagentTools(base, frozen, pi.getAllTools(), getExtensions()); - const prune = () => reconcileSubagentActiveTools(pi, base, frozen, getExtensions); + const permitted = () => allowedSubagentTools(base, frozen, pi.getAllTools(), getExtensions(), admitHost); + const prune = () => reconcileSubagentActiveTools(pi, base, frozen, getExtensions, admitHost); pi.on("session_start", prune); pi.on("before_agent_start", prune); pi.on("turn_start", prune); pi.on("turn_end", prune); pi.on("tool_call", (event) => { try { - if (permitted().has(event.toolName)) return; + const tool = pi.getAllTools().find((tool) => tool.name === event.toolName); + if (permitted().has(event.toolName) && (tool?.sourceInfo.path !== MCP_EXTENSION_PATH || admitHost?.(tool, "execute", event.input))) return; } catch { /* Policy/source errors block execution, including nested calls. */ } return { block: true, reason: `Subagent tool policy denied ${event.toolName}` }; }); diff --git a/lib/subagents.test.mjs b/lib/subagents.test.mjs index 481c529066..076d2fba25 100644 --- a/lib/subagents.test.mjs +++ b/lib/subagents.test.mjs @@ -485,6 +485,7 @@ test("persisted subagent resources restore the exact isolated prompt and tools", }]; assert.deepEqual(readSubagentSessionResources(entries), { + version: 1, appendSystemPrompt: ["Review carefully.", "Inherited parent context."], tools: ["read", "grep", "web_search"], loadSkills: true, @@ -509,6 +510,7 @@ test("legacy subagent resource snapshots keep skills and extensions disabled", ( }]; assert.deepEqual(readSubagentSessionResources(entries), { + version: 1, appendSystemPrompt: ["Stay focused."], tools: ["read"], loadSkills: false, diff --git a/lib/subagents.ts b/lib/subagents.ts index 1ddd62888b..1f5daebe6a 100644 --- a/lib/subagents.ts +++ b/lib/subagents.ts @@ -41,6 +41,14 @@ export type SubagentStatus = SubagentSessionStatus; export type SubagentScope = "builtin" | "global" | "workspace" | "project"; export type SubagentWritableScope = Extract; +export type SubagentMcpServerRef = { scope: "global" | "project"; name: string }; + +export function isSubagentMcpServerRefs(value: unknown): value is SubagentMcpServerRef[] { + return Array.isArray(value) && value.every((ref) => ref && typeof ref === "object" && !Array.isArray(ref) + && Object.keys(ref).every((key) => key === "scope" || key === "name") + && (ref.scope === "global" || ref.scope === "project") && typeof ref.name === "string" && /^[A-Za-z0-9_-]+$/.test(ref.name)); +} + export interface SubagentProfile { name: string; displayName: string; @@ -52,6 +60,9 @@ export interface SubagentProfile { disallowedExtensionTools?: string[]; loadSkills: boolean; loadExtensions: boolean; + codeMode?: boolean; + loadMcp?: boolean; + mcpServers?: SubagentMcpServerRef[]; skills?: SubagentResourceSelection; extensions?: SubagentResourceSelection; model?: string; @@ -86,6 +97,7 @@ export interface SubagentMetadata { export type SubagentResourceSnapshot = SubagentResourceSnapshotBase & ( | { version: 1; tools: string[] } | { version: 2; builtinTools: string[]; toolPolicy: SubagentToolPolicy } + | { version: 3; builtinTools: string[]; toolPolicy: SubagentToolPolicy; codeMode: boolean; loadMcp: boolean; mcpServers: SubagentMcpServerRef[] } ); interface SubagentResourceSnapshotBase { @@ -97,10 +109,11 @@ interface SubagentResourceSnapshotBase { exactSystemPrompt?: string; } -/** Legacy reads retain their unversioned shape; only v2 carries predicate authority. */ +/** Each version is explicit; legacy authority never acquires v3 host capabilities. */ export type SubagentSessionResources = SubagentResourceSnapshotBase & { tools: string[] } & ( - | { version?: undefined; builtinTools?: never; toolPolicy?: never } + | { version: 1; builtinTools?: never; toolPolicy?: never } | { version: 2; builtinTools: string[]; toolPolicy: SubagentToolPolicy } + | { version: 3; builtinTools: string[]; toolPolicy: SubagentToolPolicy; codeMode: boolean; loadMcp: boolean; mcpServers: SubagentMcpServerRef[] } ); export interface SubagentResultMetadata { @@ -156,6 +169,9 @@ const MANAGED_FRONTMATTER_KEYS = new Set([ "tools", "load_skills", "load_extensions", + "code_mode", + "load_mcp", + "mcp_servers", "enabled", "inherit_context", "run_in_background", @@ -320,6 +336,9 @@ function parseProfileFile(filePath: string, scope: SubagentScope): SubagentProfi ...(stringValue(data?.model) ? { model: stringValue(data?.model) } : {}), ...(thinkingValue && THINKING_LEVELS.has(thinkingValue) ? { thinking: thinkingValue } : {}), ...(maxTurnsValue && maxTurnsValue > 0 ? { maxTurns: maxTurnsValue } : {}), + codeMode: booleanValue(data?.code_mode, false), + loadMcp: booleanValue(data?.load_mcp, false), + mcpServers: isSubagentMcpServerRefs(data?.mcp_servers) ? structuredClone(data.mcp_servers) : [], inheritContext: booleanValue(data?.inherit_context, false), runInBackground: booleanValue(data?.run_in_background, false), promptMode: data?.prompt_mode === "replace" ? "replace" : "append", @@ -448,6 +467,10 @@ export function saveSubagentProfile( for (const selection of [profile.skills, profile.extensions]) { if (selection !== undefined && typeof selection !== "boolean" && !(Array.isArray(selection) && selection.every((entry) => typeof entry === "string"))) throw new Error("skills/extensions must be boolean or string[]"); } + for (const key of ["codeMode", "loadMcp"] as const) { + if (profile[key] !== undefined && typeof profile[key] !== "boolean") throw new Error(`${key} must be boolean`); + } + if (profile.mcpServers !== undefined && !isSubagentMcpServerRefs(profile.mcpServers)) throw new Error("mcpServers must be scoped server references"); const loadSkills = profile.skills !== undefined ? resourceSelectionEnabled(profile.skills) : profile.loadSkills === true; const loadExtensions = profile.extensions !== undefined ? resourceSelectionEnabled(profile.extensions) : profile.loadExtensions === true; const promptMode = profile.promptMode === "replace" ? "replace" : "append"; @@ -466,6 +489,9 @@ export function saveSubagentProfile( tools: composeToolsField([...tools, ...extensionTools], stored.tools), load_skills: loadSkills, load_extensions: loadExtensions, + code_mode: profile.codeMode ?? booleanValue(stored.code_mode, false), + load_mcp: profile.loadMcp ?? booleanValue(stored.load_mcp, false), + mcp_servers: profile.mcpServers ?? (isSubagentMcpServerRefs(stored.mcp_servers) ? stored.mcp_servers : []), enabled: profile.enabled, inherit_context: profile.inheritContext, run_in_background: profile.runInBackground, @@ -488,6 +514,9 @@ export function saveSubagentProfile( writePrivateFileAtomicSync(filePath, `---\n${yaml}\n---\n\n${systemPrompt}\n`); return { ...profile, + codeMode: managed.code_mode === true, + loadMcp: managed.load_mcp === true, + mcpServers: structuredClone(managed.mcp_servers as SubagentMcpServerRef[]), skills: profileResourceSelection(managed.skills, loadSkills), extensions: profileResourceSelection(managed.extensions, loadExtensions), name, @@ -550,7 +579,8 @@ export function readSubagentSessionResources( const snapshot = data.resourceSnapshot; const loadSkills = isRecord(snapshot) && snapshot.loadSkills === true; const loadExtensions = isRecord(snapshot) && snapshot.loadExtensions === true; - if (isRecord(snapshot) && snapshot.version === 2) { + if (isRecord(snapshot) && (snapshot.version === 2 || snapshot.version === 3)) { + if (snapshot.version === 3 && (typeof snapshot.codeMode !== "boolean" || typeof snapshot.loadMcp !== "boolean" || !isSubagentMcpServerRefs(snapshot.mcpServers))) return null; if (typeof snapshot.loadSkills !== "boolean" || typeof snapshot.loadExtensions !== "boolean" || ![snapshot.skills, snapshot.extensions].every((value) => value === undefined || typeof value === "boolean" || (Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim().length > 0))) || !Array.isArray(snapshot.appendSystemPrompt) || !snapshot.appendSystemPrompt.every((item) => typeof item === "string") @@ -559,7 +589,9 @@ export function readSubagentSessionResources( || (!loadExtensions && snapshot.toolPolicy.mode !== "none") || (snapshot.exactSystemPrompt !== undefined && typeof snapshot.exactSystemPrompt !== "string")) return null; return { - version: 2, + ...(snapshot.version === 3 + ? { version: 3 as const, codeMode: snapshot.codeMode as boolean, loadMcp: snapshot.loadMcp as boolean, mcpServers: structuredClone(snapshot.mcpServers as SubagentMcpServerRef[]) } + : { version: 2 as const }), builtinTools: [...new Set(snapshot.builtinTools as string[])], tools: [...new Set(snapshot.builtinTools as string[])], toolPolicy: structuredClone(snapshot.toolPolicy), @@ -585,6 +617,7 @@ export function readSubagentSessionResources( ) ) { return { + version: 1, appendSystemPrompt: [...snapshot.appendSystemPrompt], tools: [...new Set(snapshot.tools)], loadSkills, From af16763bb6c5457deabd7f6fe68ec8f08ea05906 Mon Sep 17 00:00:00 2001 From: lyx2145181 <41118458+lyx2145181@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:47:54 +0800 Subject: [PATCH 5/6] feat(subagents): add compact capability controls Why: Expose independent Code mode and MCP role capabilities without expanding the profile editor into a separate configuration panel. Safety: Reuse the Resources field, responsive cards and shared picker DOM policy. Read the masked files-only MCP overview; never change server configuration, enable servers or invoke Test or Sign-in. Keep built-in profiles read-only and preserve dormant scoped selections. Search, retry and stale responses do not change the draft. Compatibility: Use the existing profile draft and Save flow. Default legacy drafts off and preserve exact scoped names when copying. Add matching English, Simplified Chinese and Traditional Chinese strings. Leave backend authorization and snapshot behavior unchanged. Validation: Exported and checked this staged tree independently. Typecheck, ESLint and whitespace checks passed with no warnings. Isolated offline suite: 2395 passed; separately mocked plugin suite: 5 passed. Independent UI and shared resource-picker tests: 57 passed. Static, SSR and mocked checks are not browser or real keyboard acceptance. No browser validation, real profile save, build, deployment or real provider/MCP request. --- app/settings.css | 3 + components/AgentMcpControls.test.mjs | 177 +++++++++++++++++++ components/AgentMcpControls.tsx | 253 +++++++++++++++++++++++++++ components/AgentsConfig.test.mjs | 37 ++++ components/AgentsConfig.tsx | 17 +- docs/agents/subagent-resources.md | 2 +- lib/i18n/messages/en.ts | 12 ++ lib/i18n/messages/zh-CN.ts | 12 ++ lib/i18n/messages/zh-TW.ts | 12 ++ 9 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 components/AgentMcpControls.test.mjs create mode 100644 components/AgentMcpControls.tsx diff --git a/app/settings.css b/app/settings.css index 81f299ced7..dacc67f277 100644 --- a/app/settings.css +++ b/app/settings.css @@ -1947,6 +1947,9 @@ overflow-wrap: anywhere; white-space: normal; } +.agents-mcp-name { min-width: 0; overflow-wrap: anywhere; } +.agents-mcp-row-note { flex: 1 1 100%; color: var(--text-muted); font-size: 11px; overflow-wrap: anywhere; } +.agents-mcp-notice { flex-shrink: 0; padding: 6px 10px; font-size: 11px; overflow-wrap: anywhere; } .agents-resource-unknown-note { flex: 1 1 100%; color: #d97706; font-size: 10px; } .agents-resource-row.is-unknown { color: #d97706; } .agents-resource-row.is-wildcard .agents-resource-name { color: var(--text-muted); } diff --git a/components/AgentMcpControls.test.mjs b/components/AgentMcpControls.test.mjs new file mode 100644 index 0000000000..652975616e --- /dev/null +++ b/components/AgentMcpControls.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true }); +const React = await jiti.import("react"); +const { renderToStaticMarkup } = await jiti.import("react-dom/server"); +const { I18nProvider } = await jiti.import("@/hooks/useI18n.tsx"); +const { + AgentMcpControls, AgentMcpPickerList, agentMcpBlock, agentMcpBulkState, + filterAgentMcpServers, hasAgentMcpRef, readAgentMcpOverview, selectAgentMcpServers, toggleAgentMcpRef, +} = await jiti.import("./AgentMcpControls.tsx"); +const source = await readFile(new URL("./AgentMcpControls.tsx", import.meta.url), "utf8"); +const agents = await readFile(new URL("./AgentsConfig.tsx", import.meta.url), "utf8"); +const noop = () => {}; +const render = (component, props) => renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(component, props))); +const ref = (scope, name) => ({ scope, name }); +const server = (scope, name, extra = {}) => ({ scope, name, enabled: true, ...extra }); +const overview = (servers = [], extra = {}) => ({ + servers, files: [], mcp: { available: true }, + codemode: { sandbox: { state: "not-checked" }, builtinDisabled: false }, + project: { trust: { decision: true } }, ...extra, +}); + +// Pure profile-reference logic: no files, SDK runtime, server connections or browser. +test("MCP references are exact scope + original name, not namespace or bare name", () => { + const refs = [ref("global", "a-b"), ref("project", "a-b"), ref("global", "a_b"), ref("global", "__proto__")]; + assert.equal(hasAgentMcpRef(refs, ref("project", "a_b")), false); + assert.equal(hasAgentMcpRef(refs, ref("global", "A-b")), false); + assert.equal(hasAgentMcpRef(refs, ref("global", "a-b ")), false); + assert.equal(hasAgentMcpRef(refs, ref("global", "__proto__")), true); + assert.deepEqual(toggleAgentMcpRef(refs, ref("project", "a-b"), false), [refs[0], refs[2], refs[3]]); + const next = toggleAgentMcpRef(refs, { ...ref("project", "same"), namespace: "wrong", sourcePath: "/not-a-resource" }, true); + assert.deepEqual(next.at(-1), ref("project", "same")); + assert.equal(refs.length, 4); +}); + +test("file/trust blocks are visible and not inferred from a connection status", () => { + const data = overview([]); + assert.equal(agentMcpBlock(server("global", "disabled", { enabled: false }), data), "mcp.server.disabled"); + assert.equal(agentMcpBlock(server("global", "invalid", { invalidError: "invalid" }), data), "mcp.state.invalid"); + assert.equal(agentMcpBlock(server("global", "secret", { webPasswordField: {} }), data), "mcp.state.web-password"); + assert.equal(agentMcpBlock(server("project", "untrusted"), overview([], { project: { trust: { trusted: true, decision: null } } })), "mcp.stateDetail.not-trusted"); + assert.equal(agentMcpBlock(server("project", "unreadable"), overview([], { project: { trustError: "error" } })), "mcp.stateDetail.not-trusted"); + assert.equal(agentMcpBlock(server("global", "same", { shadowedByProject: true }), data), "mcp.server.shadowedByProject"); + assert.equal(agentMcpBlock(server("global", "same", { shadowedByProject: true }), overview([], { project: undefined })), undefined); + assert.equal(agentMcpBlock(server("global", "off"), overview([], { mcp: { available: false, reason: "operator-disabled" } })), "mcp.stateDetail.mcp-off"); + assert.equal(agentMcpBlock(server("global", "last-failed", { status: { origin: "test", state: "failed" } }), data), undefined); +}); + +test("bulk interaction is tri-state, explicit, catalog-wide, and preserves unknown/blocked references", () => { + const data = overview([server("global", "one"), server("project", "one"), server("global", "disabled", { enabled: false })]); + const dormant = [ref("global", "missing"), ref("global", "disabled")]; + assert.equal(agentMcpBulkState(dormant, data), "none"); + const partial = toggleAgentMcpRef(dormant, data.servers[0], true); + assert.equal(agentMcpBulkState(partial, data), "partial"); + const all = selectAgentMcpServers(partial, data); + assert.equal(agentMcpBulkState(all, data), "all"); + assert.deepEqual(all, [...dormant, ref("global", "one"), ref("project", "one")]); + assert.deepEqual(selectAgentMcpServers(all, data), all); + assert.equal(agentMcpBulkState(all, overview([...data.servers, server("project", "later")])), "partial"); + assert.equal(agentMcpBulkState([], overview([])), "none"); + assert.equal(agentMcpBulkState([], overview(data.servers, { mcp: { available: false } })), "none"); + assert.equal(all.some((entry) => typeof entry === "string" || "path" in entry || "identity" in entry), false); + assert.match(source, /bulk === "all" \? \[\] : selectAgentMcpServers\(refs, overview\)/); +}); + +test("search uses scope and original names without normalizing or changing the selection", () => { + const list = [server("global", "a-b"), server("project", "a_b"), server("project", "__proto__")]; + assert.deepEqual(filterAgentMcpServers(list, "GLOBAL"), [list[0]]); + assert.deepEqual(filterAgentMcpServers(list, "a_b"), [list[1]]); + assert.deepEqual(filterAgentMcpServers(list, "__proto__"), [list[2]]); + assert.deepEqual(filterAgentMcpServers(list, " "), list); +}); + +test("read-only rendering lists both scopes and shows blocked why text, not a tooltip", () => { + const data = overview([server("global", "same", { enabled: false }), server("project", "same")], { project: { trust: { decision: false } } }); + const html = render(AgentMcpPickerList, { refs: [], overview: data, disabled: true, search: "", onChange: noop }); + assert.equal((html.match(/type="checkbox"/g) ?? []).length, 2); + assert.equal((html.match(/disabled=""/g) ?? []).length, 2); + assert.match(html, /config-scope-tag[^\"]*">global/); + assert.match(html, /config-scope-tag[^\"]*">project/); + assert.match(html, /Turned off in the file, so it does not connect/); + assert.match(html, /This project is not trusted, so this server does not connect/); + assert.doesNotMatch(html, /title=/); +}); + +test("chosen blocked/unknown servers can only be explicitly unchecked and survive search/load failure", () => { + const selected = [ref("global", "off"), ref("project", "gone")]; + const data = overview([server("global", "off", { enabled: false })]); + const html = render(AgentMcpPickerList, { refs: selected, overview: data, disabled: false, search: "", onChange: noop }); + assert.equal((html.match(/checked=""/g) ?? []).length, 2); + assert.doesNotMatch(html, /disabled=""/); + assert.match(html, /Not listed; retained, not loaded/); + const failed = render(AgentMcpPickerList, { refs: selected, overview: null, disabled: false, search: "no-match", onChange: noop }); + assert.match(failed, /off/); + assert.match(failed, /gone/); + assert.match(failed, /Selection retained; availability not checked/); + assert.doesNotMatch(failed, /Not listed/); + assert.deepEqual(selected, [ref("global", "off"), ref("project", "gone")]); +}); + +test("hidden characters are revealed only for display and do not change stored names", () => { + const raw = ref("global", "x\u200by"); + const html = render(AgentMcpPickerList, { refs: [raw], overview: overview([server(raw.scope, raw.name)]), disabled: false, search: "", onChange: noop }); + assert.match(html, /x\\u\{200B\}y/); + assert.deepEqual(toggleAgentMcpRef([], raw, true), [raw]); +}); + +test("independent role switches render no list or third tool-search/global-mode control", () => { + const props = { cwd: "/fixture", trustKey: "trusted", profileKey: "builtin:explore", codeMode: false, loadMcp: false, mcpServers: [], disabled: true, + onCodeModeChange: noop, onLoadMcpChange: noop, onServersChange: noop }; + const html = render(AgentMcpControls, props); + assert.equal((html.match(/role="switch"/g) ?? []).length, 2); + assert.equal((html.match(/class="agents-resources-grid"/g) ?? []).length, 1); + assert.equal((html.match(/class="agents-resource-summary"/g) ?? []).length, 2); + assert.match(html, /Code mode/); + assert.match(html, /MCP/); + assert.match(html, /None/); + assert.match(html, /Choose/); + assert.doesNotMatch(html, /role="dialog"|\s*/); + assert.match(agents, /onLoadMcpChange=\{\(value\) => update\("loadMcp", value\)\}/); + assert.doesNotMatch(agents, /onLoadMcpChange[^\n]*mcpServers/); +}); + +test("overview GET is masked files-only, demand-loaded, bounded and race guarded", async () => { + const previous = globalThis.fetch; + const calls = []; + const data = overview([server("global", "kept")]); + try { + globalThis.fetch = async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => data }; }; + const controller = new AbortController(); + assert.deepEqual(await readAgentMcpOverview("/fixture with spaces", controller.signal), data); + assert.equal(calls[0].url, "/api/mcp?cwd=%2Ffixture%20with%20spaces"); + assert.equal(calls[0].options.cache, "no-store"); + assert.equal(calls[0].options.signal, controller.signal); + assert.equal(calls[0].options.method, undefined); + let finish; + globalThis.fetch = async () => ({ ok: true, json: () => new Promise((resolve) => { finish = resolve; }) }); + const pending = readAgentMcpOverview("/old-cwd", controller.signal); + await Promise.resolve(); + controller.abort(); + finish(data); + assert.equal(await pending, null, "late response ignoring abort cannot publish"); + globalThis.fetch = async () => ({ ok: false, status: 500, json: async () => ({ error: "fixture failure" }) }); + await assert.rejects(readAgentMcpOverview("/fixture", new AbortController().signal), /fixture failure/); + } finally { globalThis.fetch = previous; } + assert.match(source, /if \(!needed\) return/); + assert.match(source, /const needed = open \|\| codeMode \|\| loadMcp/); + assert.match(source, /listing\?\.context === context && listing\.refresh === refresh/); + assert.match(source, /if \(data && !controller\.signal\.aborted\) setListing/); + assert.match(source, /clearTimeout\(timer\); controller\.abort\(\)/); + assert.match(source, /15000/); + assert.doesNotMatch(source, /method: "(?:POST|PUT|PATCH|DELETE)"|\/api\/mcp\/test|sign-in|sign-out|\/api\/tools\/settings|createDefaultTransport|SubagentResourceItem/); +}); + +test("picker reuses the resource DOM policy and gates old panels before effects, without resize refocus", () => { + assert.match(source, /resourcePickerLayout\(trigger\.getBoundingClientRect\(\),/); + assert.match(source, /offsetTop: viewport\?\.offsetTop, offsetLeft: viewport\?\.offsetLeft/); + assert.match(source, /bindResourcePickerDismissal\(document, panelRef\.current, trigger,/); + assert.match(source, /openResourcePickerDialog\(document, \(\) => closeResourcePicker\(trigger,/); + assert.match(source, /observeResourcePickerVisibility\(window, trigger,/); + assert.match(source, /const open = pickerFor === pickerContext && !disabled;/); + assert.match(source, /JSON\.stringify\(\[context, profileKey, disabled, loadMcp\]\)/); + assert.match(source, /setPickerFor\(null\); \}, \[pickerContext\]/); + assert.equal((source.match(/openResourcePickerDialog\(/g) ?? []).length, 1); + assert.doesNotMatch(source, /preventDefault\(|\.focus\(|onKeyDown=/); + // Native keyboard/coarse-pointer/visibility details are unit-tested in AgentResourceControls.test.mjs. + // These source wiring checks and SSR are not real DOM or browser acceptance. +}); diff --git a/components/AgentMcpControls.tsx b/components/AgentMcpControls.tsx new file mode 100644 index 0000000000..ea11ffe2be --- /dev/null +++ b/components/AgentMcpControls.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "@/hooks/useI18n"; +import type { McpResponse, McpServerInfo } from "@/lib/api-types"; +import type { SubagentMcpServerRef } from "@/lib/subagents"; +import { revealHiddenCharacters } from "@/lib/mcp-server-display"; +import { mcpRowContext, mcpServerKey, mcpServerRowState, MCP_ROW_STATE_DETAIL_KEYS, MCP_ROW_STATE_LABEL_KEYS } from "./mcp-config-helpers"; +import { bindResourcePickerDismissal, closeResourcePicker, observeResourcePickerVisibility, openResourcePickerDialog, resourcePickerLayout } from "./AgentResourceControls"; +import { ConfigButton, ConfigEmptyState, ConfigScopeTag, ConfigSwitch } from "./SettingsUi"; + +/** Exact file identity, never a normalized namespace or a filesystem resource. */ +export function hasAgentMcpRef(refs: readonly SubagentMcpServerRef[], server: SubagentMcpServerRef): boolean { + return refs.some((ref) => ref.scope === server.scope && ref.name === server.name); +} + +export function toggleAgentMcpRef(refs: readonly SubagentMcpServerRef[], server: SubagentMcpServerRef, checked: boolean): SubagentMcpServerRef[] { + const remaining = refs.filter((ref) => ref.scope !== server.scope || ref.name !== server.name); + return checked ? [...remaining, { scope: server.scope, name: server.name }] : remaining; +} + +/** File/trust availability only; a past failed connection never grants or denies selection. */ +export function agentMcpBlock(server: McpServerInfo, overview: McpResponse): string | undefined { + const state = mcpServerRowState({ ...server, status: undefined }, mcpRowContext(overview)); + return state === "on" ? undefined : MCP_ROW_STATE_DETAIL_KEYS[state] ?? MCP_ROW_STATE_LABEL_KEYS[state]; +} + +export function filterAgentMcpServers(servers: McpServerInfo[], search: string): McpServerInfo[] { + const query = search.trim().toLowerCase(); + return servers.filter((server) => `${server.scope} ${server.name}`.toLowerCase().includes(query)); +} + +export function agentMcpBulkState(refs: readonly SubagentMcpServerRef[], overview: McpResponse): "all" | "none" | "partial" { + const selectable = overview.servers.filter((server) => !agentMcpBlock(server, overview)); + const count = selectable.filter((server) => hasAgentMcpRef(refs, server)).length; + return count === 0 ? "none" : count === selectable.length ? "all" : "partial"; +} + +/** Select the complete current eligible list, not the search results; retain dormant/unknown refs. */ +export function selectAgentMcpServers(refs: readonly SubagentMcpServerRef[], overview: McpResponse): SubagentMcpServerRef[] { + const next = refs.map((ref) => ({ ...ref })); + for (const server of overview.servers) { + if (!agentMcpBlock(server, overview) && !hasAgentMcpRef(next, server)) next.push({ scope: server.scope, name: server.name }); + } + return next; +} + +/** The existing masked, files-only GET. Even a fetch mock ignoring abort cannot publish stale data. */ +export async function readAgentMcpOverview(cwd: string, signal: AbortSignal): Promise { + const response = await fetch(`/api/mcp?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store", signal }); + const data = await response.json() as McpResponse & { error?: string }; + if (signal.aborted) return null; + if (!response.ok || data.error || !Array.isArray(data.servers) || !Array.isArray(data.files) || !data.mcp || !data.codemode) { + throw new Error(data.error ?? `HTTP ${response.status}`); + } + return data; +} + +export function AgentMcpPickerList({ refs, overview, disabled, search, onChange }: { + refs: SubagentMcpServerRef[]; overview: McpResponse | null; disabled: boolean; search: string; + onChange: (refs: SubagentMcpServerRef[]) => void; +}) { + const { t } = useI18n(); + const servers = overview?.servers ?? []; + const visible = filterAgentMcpServers(servers, search); + // Until GET succeeds, show chosen references as retained, not falsely unknown. + const retained = refs.filter((ref) => !hasAgentMcpRef(servers, ref)); + return ( +
+ {visible.map((server) => { + const checked = hasAgentMcpRef(refs, server); + const block = overview ? agentMcpBlock(server, overview) : undefined; + return ( +
+ + {block && {t(block)}{server.invalidError && <> {revealHiddenCharacters(server.invalidError)}}} +
+ ); + })} + {retained.map((ref) => ( +
+ + {t(overview ? "agents.mcp.unknown" : "agents.mcp.retained")} +
+ ))} + {visible.length === 0 && retained.length === 0 && {t(overview ? "agents.mcp.empty" : "agents.loading")}} +
+ ); +} + +function AgentMcpPicker({ trigger, refs, overview, loading, error, onRetry, onChange, onClose }: { + trigger: HTMLButtonElement; refs: SubagentMcpServerRef[]; overview: McpResponse | null; + loading: boolean; error: string | null; onRetry: () => void; + onChange: (refs: SubagentMcpServerRef[]) => void; onClose: () => void; +}) { + const { t } = useI18n(); + const panelRef = useRef(null); + const searchRef = useRef(null); + const closeRef = useRef(onClose); + closeRef.current = onClose; + const [search, setSearch] = useState(""); + const computeLayout = useCallback(() => { + const viewport = window.visualViewport; + return resourcePickerLayout(trigger.getBoundingClientRect(), { + width: viewport?.width ?? window.innerWidth, height: viewport?.height ?? window.innerHeight, + offsetTop: viewport?.offsetTop, offsetLeft: viewport?.offsetLeft, + }); + }, [trigger]); + const [layout, setLayout] = useState(computeLayout); + const updateLayout = useCallback(() => { + const next = computeLayout(); + if (next.offscreen || next.maxHeight <= 0) closeRef.current(); + else setLayout(next); + }, [computeLayout]); + useEffect(() => { + updateLayout(); + window.addEventListener("resize", updateLayout); + window.addEventListener("scroll", updateLayout, true); + window.visualViewport?.addEventListener("resize", updateLayout); + window.visualViewport?.addEventListener("scroll", updateLayout); + return () => { + window.removeEventListener("resize", updateLayout); + window.removeEventListener("scroll", updateLayout, true); + window.visualViewport?.removeEventListener("resize", updateLayout); + window.visualViewport?.removeEventListener("scroll", updateLayout); + }; + }, [updateLayout]); + useLayoutEffect(() => { + const panel = panelRef.current; + if (!panel || !layout.above) return; + const position = () => { panel.style.top = `${layout.top + Math.max(0, layout.maxHeight - panel.getBoundingClientRect().height)}px`; }; + position(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(position); + observer.observe(panel); + return () => observer.disconnect(); + }, [layout]); + // Initial focus runs only on opening, never on filtering/loading/viewport resize. + useEffect(() => openResourcePickerDialog(document, () => closeResourcePicker(trigger, () => closeRef.current()), panelRef.current, searchRef.current), [trigger]); + useEffect(() => bindResourcePickerDismissal(document, panelRef.current, trigger, () => closeRef.current()), [trigger]); + useEffect(() => observeResourcePickerVisibility(window, trigger, () => closeRef.current()), [trigger]); + const bulk = overview ? agentMcpBulkState(refs, overview) : "none"; + const hasSelectable = overview?.servers.some((server) => !agentMcpBlock(server, overview)); + return createPortal( +
+
+ {t("agents.mcp.label")} + + {t(refs.length ? "agents.mcp.selectedCount" : "agents.mcp.none", { count: refs.length })} + +
+
+ setSearch(event.target.value)} /> +
+ {loading && {t("agents.loading")}} + {error &&
{error} {t("agents.resource.retry")}
} + +
, document.body, + ); +} + +/** Two independent role intents; all edits stay in the profile's native draft/Save flow. */ +export function AgentMcpControls({ cwd, trustKey, profileKey, codeMode, loadMcp, mcpServers, disabled, onCodeModeChange, onLoadMcpChange, onServersChange }: { + cwd: string; trustKey: string; profileKey: string; codeMode: boolean; loadMcp: boolean; + mcpServers: SubagentMcpServerRef[]; disabled: boolean; + onCodeModeChange: (value: boolean) => void; onLoadMcpChange: (value: boolean) => void; + onServersChange: (refs: SubagentMcpServerRef[]) => void; +}) { + const { t } = useI18n(); + const triggerRef = useRef(null); + const context = JSON.stringify([cwd, trustKey]); + const pickerContext = JSON.stringify([context, profileKey, disabled, loadMcp]); + const [pickerFor, setPickerFor] = useState(null); + const open = pickerFor === pickerContext && !disabled; + const [refresh, setRefresh] = useState(0); + const [listing, setListing] = useState<{ context: string; refresh: number; data: McpResponse } | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const needed = open || codeMode || loadMcp; + const overview = needed && listing?.context === context && listing.refresh === refresh ? listing.data : null; + useEffect(() => { setPickerFor(null); }, [pickerContext]); + useEffect(() => { + if (!needed) return; + const controller = new AbortController(); + let timedOut = false; + setListing(null); + setError(null); + setLoading(true); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + setError(t("agents.mcp.timeout")); + setLoading(false); + }, 15000); + void readAgentMcpOverview(cwd, controller.signal).then((data) => { + if (data && !controller.signal.aborted) setListing({ context, refresh, data }); + }).catch((cause) => { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)); + }).finally(() => { + clearTimeout(timer); + if (!controller.signal.aborted && !timedOut) setLoading(false); + }); + return () => { clearTimeout(timer); controller.abort(); }; + }, [cwd, context, needed, refresh, t]); + const unavailable = overview && !overview.mcp.available ? overview.mcp : null; + const problems = overview?.files.flatMap((file) => file.problems.map((problem) => ({ ...problem, scope: file.scope }))) ?? []; + const retry = () => setRefresh((value) => value + 1); + return ( +
+
+
+ {t("agents.codeMode")} + +
+
+ {t("agents.mcp.label")} + + {t(mcpServers.length ? "agents.mcp.selectedCount" : "agents.mcp.none", { count: mcpServers.length })} + { + if (open) closeResourcePicker(triggerRef.current, () => setPickerFor(null)); + else { setPickerFor(pickerContext); retry(); } + }}>{t("agents.resource.choose")} +
+
+ {loadMcp === false && mcpServers.length > 0 && {t("agents.mcp.dormant")}} + {loadMcp && unavailable && {t(unavailable.reason === "builtin-disabled" && !unavailable.settingsPath ? "mcp.unavailable.builtin-disabled-unknown" : `mcp.unavailable.${unavailable.reason}`, { path: revealHiddenCharacters(unavailable.settingsPath ?? "") })}} + {codeMode && overview?.codemode.sandbox.state === "unavailable" && {t("agents.codeModeUnavailable")}} + {codeMode && overview?.codemode.builtinDisabled && {t(overview.codemode.builtinSettingsPath ? "mcp.codemode.builtinDisabled" : "mcp.codemode.builtinDisabledUnknown", { path: revealHiddenCharacters(overview.codemode.builtinSettingsPath ?? "") })}} + {!open && needed && loading && {t("agents.loading")}} + {!open && needed && error &&
{error} {t("agents.resource.retry")}
} + {loadMcp && problems.length > 0 &&
{t("agents.mcp.diagnostics", { count: problems.length })}{problems.map((problem, index) =>
{t(`agents.scope.${problem.scope}`)}: {t(`mcp.fileProblem.${problem.reason}`)}
)}
} + {open && triggerRef.current && setPickerFor(null)} />} +
+ ); +} diff --git a/components/AgentsConfig.test.mjs b/components/AgentsConfig.test.mjs index f3221a1a3f..bde404a388 100644 --- a/components/AgentsConfig.test.mjs +++ b/components/AgentsConfig.test.mjs @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true }); +const { editableProfile } = await jiti.import("./AgentsConfig.tsx"); const source = await readFile(new URL("./AgentsConfig.tsx", import.meta.url), "utf8"); const cssSource = await readFile(new URL("../app/settings.css", import.meta.url), "utf8"); @@ -149,6 +153,39 @@ test("places duplicate and delete immediately before the enabled switch", () => assert.match(source, /onClick=\{beginDuplicate\}[\s\S]*?onClick=\{\(\) => void remove\(\)\}[\s\S]*? { + const profile = { + name: "fixture", displayName: "Fixture", description: "", systemPrompt: "", + tools: ["read"], loadSkills: false, loadExtensions: false, skills: false, extensions: false, + promptMode: "replace", inheritContext: false, runInBackground: false, enabled: true, + scope: "builtin", codeMode: true, loadMcp: false, + mcpServers: [{ scope: "global", name: "a-b" }, { scope: "project", name: "a-b" }, { scope: "project", name: "missing" }], + }; + const selectedDraft = editableProfile(profile); + const clonedDraft = { ...editableProfile(profile), name: "fixture-copy" }; + for (const draft of [selectedDraft, clonedDraft]) { + const payload = JSON.parse(JSON.stringify({ cwd: "/fixture", scope: "global", profile: draft })); + assert.equal(payload.profile.codeMode, true); + assert.equal(payload.profile.loadMcp, false); + assert.deepEqual(payload.profile.mcpServers, profile.mcpServers); + assert.notEqual(draft.mcpServers, profile.mcpServers); + assert.notEqual(draft.mcpServers[0], profile.mcpServers[0]); + assert.equal("scope" in payload.profile, false); + assert.equal("filePath" in payload.profile, false); + } + const legacy = { ...profile }; + for (const field of ["codeMode", "loadMcp", "mcpServers"]) delete legacy[field]; + const legacyDraft = editableProfile(legacy); + assert.equal(legacyDraft.codeMode, false); + assert.equal(legacyDraft.loadMcp, false); + assert.deepEqual(legacyDraft.mcpServers, []); + assert.match(source, /const EMPTY_PROFILE[\s\S]*?codeMode: false,[\s\S]*?loadMcp: false,[\s\S]*?mcpServers: \[\]/); + assert.match(source, /setDraft\(editableProfile\(chosen\)\)/); + assert.match(source, /setDraft\(editableProfile\(profile\)\)/); + assert.match(source, / update\("codeMode", value\)\}/); + assert.match(source, /onServersChange=\{\(refs\) => update\("mcpServers", refs\)\}/); +}); + test("confirms deletion and limits it to writable profiles", () => { assert.match(source, /window\.confirm\(t\("agents\.deleteConfirm", \{ name: selected\.displayName \}\)\)/); assert.match(source, /selected && isWritableScope\(selected\.scope\) && mode === "edit"/); diff --git a/components/AgentsConfig.tsx b/components/AgentsConfig.tsx index 48f3edd9eb..424e6226d3 100644 --- a/components/AgentsConfig.tsx +++ b/components/AgentsConfig.tsx @@ -38,6 +38,7 @@ import { } from "./SettingsUi"; import { ModelSelector } from "./ModelSelector"; import { AgentResourceControls } from "./AgentResourceControls"; +import { AgentMcpControls } from "./AgentMcpControls"; import { projectTrustReloadKey } from "./settings-ui-helpers"; const TOOL_OPTIONS = ["read", "bash", "edit", "write", "grep", "find", "ls"]; @@ -54,6 +55,9 @@ const EMPTY_PROFILE: EditableProfile = { tools: ["read", "bash", "edit", "write", "grep", "find", "ls"], loadSkills: false, loadExtensions: false, + codeMode: false, + loadMcp: false, + mcpServers: [], promptMode: "append", inheritContext: false, runInBackground: false, @@ -79,7 +83,7 @@ const disabledInputStyle: CSSProperties = { cursor: "default", }; -function editableProfile(profile: SubagentProfile): EditableProfile { +export function editableProfile(profile: SubagentProfile): EditableProfile { return { name: profile.name, displayName: profile.displayName, @@ -88,6 +92,9 @@ function editableProfile(profile: SubagentProfile): EditableProfile { tools: [...profile.tools], loadSkills: profile.loadSkills, loadExtensions: profile.loadExtensions, + codeMode: profile.codeMode ?? false, + loadMcp: profile.loadMcp ?? false, + mcpServers: (profile.mcpServers ?? []).map((ref) => ({ ...ref })), skills: profile.skills ?? profile.loadSkills, extensions: profile.extensions ?? profile.loadExtensions, extensionTools: profile.extensionTools ? [...profile.extensionTools] : undefined, @@ -627,6 +634,14 @@ export function AgentsConfig({ disabled={disabled} onChange={(kind, value) => setDraft((current) => ({ ...current, [kind]: value, [kind === "skills" ? "loadSkills" : "loadExtensions"]: value !== false }))} /> + update("codeMode", value)} + onLoadMcpChange={(value) => update("loadMcp", value)} + onServersChange={(refs) => update("mcpServers", refs)} + />
diff --git a/docs/agents/subagent-resources.md b/docs/agents/subagent-resources.md index d92a44cc96..e5ed43bd44 100644 --- a/docs/agents/subagent-resources.md +++ b/docs/agents/subagent-resources.md @@ -45,7 +45,7 @@ Profiles add `code_mode` / `load_mcp` booleans and `mcp_servers: [{scope: "globa ## Resource picker UI -Settings › Sub-agents uses shared `SettingsUi` blocks and the existing profile draft/Save flow. Built-in profiles remain read-only. Each resource row shows All enabled, Disabled or a selected count, with a Choose button and visible unknown/ambiguous warnings; there is no mode dropdown or always-expanded list. +Settings › Sub-agents uses shared `SettingsUi` blocks and the existing profile draft/Save flow. Built-in profiles remain read-only. Skills/extensions summaries and the independent Code mode/MCP switches share the Resources field's compact two-column cards, stacking on narrow screens. Each resource row shows All enabled, Disabled or a selected count, with a Choose button and visible unknown/ambiguous warnings; there is no mode dropdown or always-expanded list. MCP chooses scoped identities from files-only `GET /api/mcp`, never writes server configuration, enables a server or runs Test/Sign-in. - One checkbox picker opens beside the current button. Search filters the view, not the selection. Source/path details expand on demand, and packaged skills keep their effective SDK names. - The header has one tri-state bulk toggle. Selecting all writes explicit paths for the complete enabled catalog, preserving unknown/ambiguous entries; clearing writes `false`. It never converts a complete list into future-enabled `true`/`*`. Loading, unavailable and read-only catalogs disable bulk editing. diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 12f6742493..a12849a334 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -97,6 +97,18 @@ export const enLocale: LocalePlugin = { "agents.prompt": "System instructions", "agents.tools": "Tools", "agents.resources": "Resources", + "agents.codeMode": "Code mode", + "agents.codeModeUnavailable": "Code mode is unavailable on this server.", + "agents.mcp.label": "MCP", + "agents.mcp.none": "None", + "agents.mcp.selectedCount": "{count} selected", + "agents.mcp.search": "Search server name or scope", + "agents.mcp.empty": "No matching servers", + "agents.mcp.unknown": "Not listed; retained, not loaded", + "agents.mcp.retained": "Selection retained; availability not checked", + "agents.mcp.dormant": "Selections retained while MCP is off.", + "agents.mcp.timeout": "MCP listing timed out. Retry to check availability.", + "agents.mcp.diagnostics": "MCP diagnostics ({count})", "agents.resource.all": "All enabled", "agents.resource.none": "Disabled", "agents.resource.selectedCount": "{count} enabled", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index b427f30bd2..bcb9721c75 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -97,6 +97,18 @@ export const zhCNLocale: LocalePlugin = { "agents.prompt": "系统指令", "agents.tools": "工具", "agents.resources": "资源", + "agents.codeMode": "Code mode", + "agents.codeModeUnavailable": "此服务器无法使用 Code mode。", + "agents.mcp.label": "MCP", + "agents.mcp.none": "未选择", + "agents.mcp.selectedCount": "已选择 {count} 项", + "agents.mcp.search": "搜索服务器原名或范围", + "agents.mcp.empty": "没有匹配的服务器", + "agents.mcp.unknown": "列表中不存在;保留但不加载", + "agents.mcp.retained": "选择已保留;尚未检查可用性", + "agents.mcp.dormant": "MCP 关闭时保留选择。", + "agents.mcp.timeout": "读取 MCP 列表超时。重试以检查可用性。", + "agents.mcp.diagnostics": "MCP 诊断({count})", "agents.resource.all": "全部启用", "agents.resource.none": "禁用", "agents.resource.selectedCount": "已启用 {count} 项", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index e109493e10..6ef738d4cb 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -97,6 +97,18 @@ export const zhTWLocale: LocalePlugin = { "agents.prompt": "系統指令", "agents.tools": "工具", "agents.resources": "資源", + "agents.codeMode": "Code mode", + "agents.codeModeUnavailable": "此伺服器無法使用 Code mode。", + "agents.mcp.label": "MCP", + "agents.mcp.none": "未選擇", + "agents.mcp.selectedCount": "已選擇 {count} 項", + "agents.mcp.search": "搜尋伺服器原名或範圍", + "agents.mcp.empty": "沒有符合的伺服器", + "agents.mcp.unknown": "清單中不存在;保留但不載入", + "agents.mcp.retained": "選擇已保留;尚未檢查可用性", + "agents.mcp.dormant": "MCP 關閉時保留選擇。", + "agents.mcp.timeout": "讀取 MCP 清單逾時。重試以檢查可用性。", + "agents.mcp.diagnostics": "MCP 診斷({count})", "agents.resource.all": "全部啟用", "agents.resource.none": "停用", "agents.resource.selectedCount": "已啟用 {count} 項", From 6bd46d2e2c765552a53c212ad1a6ecca3149b8b4 Mon Sep 17 00:00:00 2001 From: lyx2145181 <41118458+lyx2145181@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:06:39 +0800 Subject: [PATCH 6/6] feat(subagents): preserve configured model choices Why: Parent-supplied model parameters should not silently replace a role's specified model, and reopening a setup-only child should not reset its recorded choice to the default. Behavior: Add a default-off profile switch for parent model overrides beside the model selector. With a specified role model and the switch off, ignore parent model parameters and continue using the role model without retry. Keep explicit choice and inheritance unchanged for roles without a model. Resume ignores attached model parameters and retains the child's current model, including manual changes. Cold restoration uses the active branch's recorded choice and fails if that model or configured auth is unavailable. Compatibility: Reuse profile frontmatter, the draft/Save flow and existing model lookup. Preserve omitted fields from old clients, copies and foreign frontmatter. Keep built-ins read-only and normal unsent-session defaults unchanged. Introduce no permission store, dependency or resource snapshot version. Roles relying on parent overrides must explicitly enable the new switch. Validation: Exported and checked this staged tree independently. Typecheck, ESLint and whitespace checks passed with no warnings. Isolated offline suite: 2426 passed; separately mocked plugin suite: 5 passed. Independent targeted review: 123 passed, with additional cold-restore checks. Real in-process provider fixtures verify one-shot role selection and a real Agent-to-controller resume retaining the manually selected child model. Manual model behavior was confirmed by the tester after preview restart. No real provider request or comprehensive browser acceptance was run by this validation; no production deployment was performed. --- app/api/subagents/profiles/route.test.mjs | 29 +++- components/AgentsConfig.test.mjs | 35 +++++ components/AgentsConfig.tsx | 23 ++- docs/agents/models.md | 2 + docs/agents/subagents.md | 7 + lib/i18n/messages/en.ts | 2 + lib/i18n/messages/zh-CN.ts | 2 + lib/i18n/messages/zh-TW.ts | 2 + ...anager-subagent-model.integration.test.mjs | 118 +++++++++++++++ lib/rpc-manager.ts | 9 +- lib/subagent-extension.integration.test.mjs | 35 +++++ lib/subagent-extension.ts | 2 +- ...bagent-model-override.integration.test.mjs | 136 ++++++++++++++++++ lib/subagent-runtime.ts | 19 ++- lib/subagents.ts | 9 +- 15 files changed, 418 insertions(+), 12 deletions(-) create mode 100644 lib/rpc-manager-subagent-model.integration.test.mjs create mode 100644 lib/subagent-model-override.integration.test.mjs diff --git a/app/api/subagents/profiles/route.test.mjs b/app/api/subagents/profiles/route.test.mjs index 7868f02397..ef18779aa9 100644 --- a/app/api/subagents/profiles/route.test.mjs +++ b/app/api/subagents/profiles/route.test.mjs @@ -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"; @@ -65,6 +65,33 @@ test("profile PUT validates scoped MCP fields and old clients preserve stored ro 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); diff --git a/components/AgentsConfig.test.mjs b/components/AgentsConfig.test.mjs index bde404a388..8fb094e808 100644 --- a/components/AgentsConfig.test.mjs +++ b/components/AgentsConfig.test.mjs @@ -186,6 +186,41 @@ test("round-trips Code mode and dormant scoped MCP refs through selected and clo assert.match(source, /onServersChange=\{\(refs\) => update\("mcpServers", refs\)\}/); }); +test("round-trips model override intent through selection, copies and temporary model clearing", () => { + const profile = { + name: "fixture", displayName: "Fixture", description: "", systemPrompt: "", + tools: [], loadSkills: false, loadExtensions: false, promptMode: "append", + inheritContext: false, runInBackground: false, enabled: true, scope: "builtin", + model: "fixture/pinned", allowParentModelOverride: true, + }; + const selected = editableProfile(profile); + const copied = { ...editableProfile(profile), name: "fixture-copy" }; + for (const draft of [selected, copied]) { + assert.equal(JSON.parse(JSON.stringify(draft)).allowParentModelOverride, true); + assert.equal(draft.model, profile.model); + const cleared = editableProfile({ ...draft, scope: "global", model: undefined }); + assert.equal(cleared.allowParentModelOverride, true, "clearing a model must not erase dormant intent"); + assert.equal(cleared.model, undefined); + } + const legacy = { ...profile }; + delete legacy.allowParentModelOverride; + assert.equal(editableProfile(legacy).allowParentModelOverride, false); + assert.equal(editableProfile({ ...profile, allowParentModelOverride: false }).allowParentModelOverride, false); + assert.match(source, /const EMPTY_PROFILE[\s\S]*?allowParentModelOverride: false/); +}); + +test("places a draft-only override checkbox beside the model label with a visible inherited-model reason", () => { + const modelField = source.slice(source.indexOf(' update\("allowParentModelOverride", checked\)\}/); + assert.match(modelField, /describedBy=\{!draft\.model \? modelOverrideHintId : undefined\}/); + assert.match(modelField, /!draft\.model && { assert.match(source, /window\.confirm\(t\("agents\.deleteConfirm", \{ name: selected\.displayName \}\)\)/); assert.match(source, /selected && isWritableScope\(selected\.scope\) && mode === "edit"/); diff --git a/components/AgentsConfig.tsx b/components/AgentsConfig.tsx index 424e6226d3..0a9d8b2900 100644 --- a/components/AgentsConfig.tsx +++ b/components/AgentsConfig.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useId, useMemo, useState, type CSSProperties } from "react"; import { useI18n } from "@/hooks/useI18n"; import { useIsMobile } from "@/hooks/useIsMobile"; import type { ProjectTrustStatus, SubagentProfilesResponse, SubagentSettingsResponse } from "@/lib/api-types"; @@ -58,6 +58,7 @@ const EMPTY_PROFILE: EditableProfile = { codeMode: false, loadMcp: false, mcpServers: [], + allowParentModelOverride: false, promptMode: "append", inheritContext: false, runInBackground: false, @@ -103,6 +104,7 @@ export function editableProfile(profile: SubagentProfile): EditableProfile { isolation: profile.isolation, persistSession: profile.persistSession, promptMode: profile.promptMode, + allowParentModelOverride: profile.allowParentModelOverride ?? false, ...(profile.model ? { model: profile.model } : {}), ...(profile.thinking ? { thinking: profile.thinking } : {}), ...(profile.maxTurns ? { maxTurns: profile.maxTurns } : {}), @@ -145,14 +147,14 @@ function displayProfilePath(profile: SubagentProfile, cwd: string): string | nul : shortenPath(profile.filePath); } -function Field({ label, children }: { label: string; children: React.ReactNode }) { +function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { return {children}; } -function Toggle({ checked, disabled, label, onChange }: { checked: boolean; disabled: boolean; label: string; onChange: (checked: boolean) => void }) { +function Toggle({ checked, disabled, label, onChange, describedBy }: { checked: boolean; disabled: boolean; label: string; onChange: (checked: boolean) => void; describedBy?: string }) { return ( ); @@ -176,6 +178,7 @@ export function AgentsConfig({ }) { const isMobile = useIsMobile(); const { t } = useI18n(); + const modelOverrideHintId = useId(); const [profiles, setProfiles] = useState([]); const [modelOptions, setModelOptions] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); @@ -645,7 +648,16 @@ export function AgentsConfig({
- + + {t("agents.model")} + update("allowParentModelOverride", checked)} + /> + }>
+ {!draft.model && {t("agents.allowParentModelOverrideInherited")}} {modelsError && {modelsError}}
diff --git a/docs/agents/models.md b/docs/agents/models.md index c1f97d4f43..cb308984ca 100644 --- a/docs/agents/models.md +++ b/docs/agents/models.md @@ -7,6 +7,8 @@ Saving a default is the star on each row of the model selector and the reasoning The reasoning control stays usable while a run streams: pi-agent-core snapshots `reasoning` when a run starts, but `AgentSession`'s `prepareRequest` / `prepareNextTurnWithContext` re-read `agent.state.thinkingLevel` before every model request, so `set_thinking_level` applies from the next request (the response already streaming keeps its level). `lib/thinking-level-mid-run.integration.test.mjs` pins this; if an SDK upgrade breaks it, disable the control while streaming rather than let it change nothing. +Subagent cold restoration uses the recorded model on the active branch, including setup-only sessions without conversation messages. An unavailable recorded model or missing configured authentication fails restoration instead of silently selecting a default. It does not reread the role's current model or change normal unsent-session defaults; manual `set_model` remains session-scoped and its recorded choice is restored. + ## Remote provider catalogs Built-in model lists are frozen at the pinned SDK version; newer models come from the SDK's pi.dev catalog overlay, which `ModelRuntime.refresh()` persists to `~/.pi/agent/models-store.json` (the pi CLI fills it too) and which restores offline. pi-web's own runtimes only restore it (`allowNetwork: false` / `refreshOnCreate: false`, listed in the header of `lib/model-catalog-refresh.ts`). That module's network pass runs **only when the user asks** (the "Refresh catalog" button in `EnabledModelsSection` → `/api/models/refresh`), never on a timer or on another request's path, which must not wait on a slow catalog. It calls `refresh()` with `force: true` and never `allowNetwork`, so pi's `PI_OFFLINE` rule holds (`reason: "offline"`), and `shareModelCatalogRefresh()` joins concurrent presses for the same providers. The route returns no model list: a change runs `invalidateModelsCache()` and reloads the panel, whose ordinary `/api/models` and `/api/models/enabled` loads build a fresh runtime that restores the store. diff --git a/docs/agents/subagents.md b/docs/agents/subagents.md index a2317e0785..bf2fdb7426 100644 --- a/docs/agents/subagents.md +++ b/docs/agents/subagents.md @@ -1,5 +1,12 @@ # Built-in subagents +## Role model selection + +- Profiles use `allow_parent_model_override` (API `allowParentModelOverride`), default false. Saves, copies and enabled PATCH preserve it; an old PUT omitting it retains the stored value. +- For a role with a specified model, create uses that model and ignores parent `Agent.model` unless this switch is on, including unknown or ambiguous ignored parameters. The selected role model must still resolve; it is not silently replaced when unavailable. With no specified model, parent selection or inheritance remains unchanged. An allowed explicit override is per-call, never a profile write. +- The checkbox sits beside the model field and uses the existing draft/Save flow. No-model drafts keep the intent but disable the checkbox with a visible explanation; built-in forms stay read-only. +- Resume retains the child's current model and ignores any attached `Agent.model`, without rejecting or retrying the task. Parent or profile edits do not reassign an existing child, and manual child `set_model` remains available. Cold restoration follows the saved-model rules in [models.md](models.md); no new resource snapshot version or override-permission store is introduced. + ## Built-in subagents - The global `builtInEnabled` in `~/.pi/agent/agents/settings.json` is off when the file or field is absent and when the file is malformed (fail closed); writes are atomic and keep unknown fields. The inline extension factory is always present, so a reload can apply the switch, but registers no tools while off; after changing it the user must reload the session explicitly, and `Agent` dispatch re-checks the setting so a stale call cannot start a subagent. When on, only a recognized legacy `pi-subagents` extension registering a reserved tool (`Agent`, `get_subagent_result`, `steer_subagent`) is removed, along with the conflict errors it caused; unrelated extensions stay. ADR 0003. - The three tools register with `exposure: "model-only"`, so `ctx.executeTool()` (a codemode script) cannot start, collect or steer a subagent: a script-started run would record the nested call id (`/`) as its `parentToolCallId`, which no transcript entry carries, and the chat would lose its link to the child session. `lib/subagent-extension.integration.test.mjs` pins this against a real `AgentSession`. diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index a12849a334..d6941ac1e8 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -129,6 +129,8 @@ export const enLocale: LocalePlugin = { "agents.loadSkills": "Load skills", "agents.loadExtensions": "Load extensions", "agents.model": "Model override", + "agents.allowParentModelOverride": "Allow parent model override", + "agents.allowParentModelOverrideInherited": "No model specified: the parent can choose or inherit its model.", "agents.modelsLoading": "Loading models...", "agents.modelUnavailable": "{model} (unavailable)", "agents.thinking": "Thinking", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index bcb9721c75..ef7274fad4 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -130,6 +130,8 @@ export const zhCNLocale: LocalePlugin = { "agents.loadSkills": "加载技能", "agents.loadExtensions": "加载扩展", "agents.model": "指定模型", + "agents.allowParentModelOverride": "允许父会话指定其他模型", + "agents.allowParentModelOverrideInherited": "未指定模型时,仍允许父会话选择或继承模型。", "agents.modelsLoading": "正在加载模型...", "agents.modelUnavailable": "{model}(不可用)", "agents.thinking": "思考级别", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 6ef738d4cb..1ee547bfd2 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -130,6 +130,8 @@ export const zhTWLocale: LocalePlugin = { "agents.loadSkills": "載入技能", "agents.loadExtensions": "載入擴充功能", "agents.model": "指定模型", + "agents.allowParentModelOverride": "允許父會話指定其他模型", + "agents.allowParentModelOverrideInherited": "未指定模型時,仍允許父會話選擇或繼承模型。", "agents.modelsLoading": "正在載入模型...", "agents.modelUnavailable": "{model}(無法使用)", "agents.thinking": "思考層級", diff --git a/lib/rpc-manager-subagent-model.integration.test.mjs b/lib/rpc-manager-subagent-model.integration.test.mjs new file mode 100644 index 0000000000..6aaee40a47 --- /dev/null +++ b/lib/rpc-manager-subagent-model.integration.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import test, { after } from "node:test"; +import { createJiti } from "jiti"; + +const root = await mkdtemp(join(tmpdir(), "pi-subagent-model-restore-")); +const previous = { HOME: process.env.HOME, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }; +process.env.HOME = join(root, "home"); +process.env.PI_CODING_AGENT_DIR = join(root, "agent"); +await mkdir(process.env.HOME); +await mkdir(process.env.PI_CODING_AGENT_DIR); +const provider = "restore-fixture"; +await writeFile(join(process.env.PI_CODING_AGENT_DIR, "models.json"), JSON.stringify({ providers: { + [provider]: { api: "openai-completions", baseUrl: "http://127.0.0.1:9/v1", apiKey: "fixture-only", models: [{ id: "saved" }, { id: "default" }] }, + "restore-no-auth": { api: "openai-completions", baseUrl: "http://127.0.0.1:9/v1", models: [{ id: "saved" }] }, +} })); +await writeFile(join(process.env.PI_CODING_AGENT_DIR, "settings.json"), JSON.stringify({ defaultProvider: provider, defaultModel: "default", cacheWarming: "off" })); +const jiti = createJiti(import.meta.url); +const { startRpcSession, getRpcSession } = await jiti.import("./rpc-manager.ts"); +const { SUBAGENT_META_TYPE } = await jiti.import("./subagents.ts"); +const keepAlive = setInterval(() => {}, 1000); +after(async () => { + clearInterval(keepAlive); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await rm(root, { recursive: true, force: true }); +}); + +async function sessionFixture({ child = true, snapshotVersion = 3, history = "none", modelId = "saved", modelProvider = provider } = {}) { + const cwd = await mkdtemp(join(root, "cwd-")), id = randomUUID(), file = join(cwd, "fixture.jsonl"); + const timestamp = new Date().toISOString(); + const entries = [{ type: "session", version: 3, id, timestamp, cwd }]; + let parentId = null; + const append = (entry) => { + const next = { ...entry, id: randomUUID().slice(0, 8), parentId, timestamp }; + entries.push(next); parentId = next.id; + }; + if (child) append({ type: "custom", customType: SUBAGENT_META_TYPE, data: { + version: 1, parentSessionId: "parent-fixture", parentSessionPath: join(cwd, "parent.jsonl"), + resourceSnapshot: { + version: snapshotVersion, appendSystemPrompt: [], loadSkills: false, loadExtensions: false, + ...(snapshotVersion === 1 ? { tools: [] } : { builtinTools: [], toolPolicy: { mode: "none", selectors: [], deny: [] } }), + ...(snapshotVersion === 3 ? { codeMode: false, loadMcp: false, mcpServers: [] } : {}), + }, + } }); + append({ type: "model_change", provider: modelProvider, modelId }); + if (history === "system") append({ type: "message", message: { role: "system", content: "", sections: {}, timestamp: Date.now() } }); + if (history === "user") append({ type: "message", message: { role: "user", content: "Fixture history; never sent", timestamp: Date.now() } }); + await writeFile(file, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + return { cwd, id, file }; +} + +async function open(t, fixture, options = {}) { + const { session } = await startRpcSession(fixture.id, fixture.file, fixture.cwd, options); + t.after(() => session.shutdown()); + await session.waitUntilReady(); + return session; +} + +for (const snapshotVersion of [1, 2, 3]) { + test(`restores a v${snapshotVersion} child's recorded model before its first conversation instead of the global default`, async (t) => { + const f = await sessionFixture({ snapshotVersion }); + const session = await open(t, f, { initialModel: { provider, modelId: "default" }, allowInitialModelFallback: true }); + assert.equal(session.inner.model?.id, "saved"); + assert.equal(session.inner.model?.provider, provider); + }); +} + +test("restores the recorded child model with only a system message", async (t) => { + const f = await sessionFixture({ history: "system" }); + assert.equal((await open(t, f)).inner.model?.id, "saved"); +}); + +test("retains a child's persisted manual model change across shutdown and cold reopen", async (t) => { + const f = await sessionFixture({ history: "user" }); + const first = await open(t, f); + assert.equal(first.inner.model?.id, "saved"); + const response = await first.send({ type: "set_model", provider, modelId: "default" }); + assert.deepEqual(response, { id: "default", provider }); + assert.equal(first.inner.model?.id, "default"); + await first.shutdown(); + const second = await open(t, f); + assert.equal(second.inner.model?.id, "default"); + assert.ok((await readFile(f.file, "utf8")).includes('"modelId":"default"')); +}); + +for (const history of ["none", "user"]) { + test(`refuses an unavailable recorded child model (${history}) without substituting a configured default or rewriting its file`, async (t) => { + const f = await sessionFixture({ history, modelId: "removed-model" }); + t.after(() => getRpcSession(f.id)?.destroy()); + const before = await readFile(f.file, "utf8"); + await assert.rejects(startRpcSession(f.id, f.file, f.cwd), /Cannot restore subagent model.*removed-model/); + assert.equal(getRpcSession(f.id), undefined); + assert.equal(await readFile(f.file, "utf8"), before); + }); +} + +test("refuses a saved child model lacking configured authentication rather than falling back", async (t) => { + const f = await sessionFixture({ history: "user", modelProvider: "restore-no-auth" }); + t.after(() => getRpcSession(f.id)?.destroy()); + await assert.rejects(startRpcSession(f.id, f.file, f.cwd), /Cannot restore subagent model.*restore-no-auth/); + assert.equal(getRpcSession(f.id), undefined); +}); + +test("normal unsent sessions retain existing default selection behavior", async (t) => { + const f = await sessionFixture({ child: false }); + assert.equal((await open(t, f, { toolNames: [] })).inner.model?.id, "default"); +}); + +test("normal sessions with history still restore the saved model", async (t) => { + const f = await sessionFixture({ child: false, history: "user" }); + assert.equal((await open(t, f, { toolNames: [] })).inner.model?.id, "saved"); +}); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 7120b7c4c6..485310ebe6 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -2490,13 +2490,18 @@ export async function startRpcSession( const branch = sessionManager.getBranch(); // System messages carry the prompt and tool loadout, not a conversation. const hasExistingMessages = branch.some((entry) => entry.type === "message" && entry.message.role !== "system"); - const savedModel = hasExistingMessages + // Child setup already records its selected model, even before its first conversation. + const savedModel = hasExistingMessages || subagentResources ? getLatestModelChange(branch as unknown as SessionEntry[]) : null; const restoredModel = savedModel ? services.modelRuntime.getModel(savedModel.provider, savedModel.modelId) : undefined; - const initial = hasExistingMessages ? null : selectInitialModelScope(scope, { + if (subagentResources && savedModel + && (!restoredModel || !services.modelRuntime.hasConfiguredAuth(restoredModel.provider))) { + throw new Error(`Cannot restore subagent model ${savedModel.provider}/${savedModel.modelId}: model or authentication is unavailable`); + } + const initial = hasExistingMessages || (subagentResources && savedModel) ? null : selectInitialModelScope(scope, { ...(effectiveInitialModel ? { requestedModel: effectiveInitialModel } : {}), ...(defaultProvider && defaultModelId ? { defaultModel: { provider: defaultProvider, modelId: defaultModelId } } diff --git a/lib/subagent-extension.integration.test.mjs b/lib/subagent-extension.integration.test.mjs index ada80601b8..dd40737a24 100644 --- a/lib/subagent-extension.integration.test.mjs +++ b/lib/subagent-extension.integration.test.mjs @@ -188,6 +188,41 @@ test("subagent control tools are declared to the model but cannot be called from } }); +test("Agent ignores an explicit resume model and continues without a retry", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-subagent-resume-model-")); + let session; + try { + const faux = fauxProvider({ models: [{ id: "faux-model" }] }); + const { calls, runtime } = recordingRuntime(); + const resumedRequests = []; + runtime.resume = async (request) => { + calls.push("resume"); resumedRequests.push(request); + const run = { + sessionId: request.sessionId, sessionPath: "/tmp/child.jsonl", parentSessionId: "parent-session", + parentToolCallId: "model-call", profile: "explore", description: "Continue", task: request.task, + runInBackground: false, status: "completed", result: "Continued with the existing model.", + createdAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:01.000Z", + }; + return { run, completion: Promise.resolve(run) }; + }; + session = await createSession(dir, faux, [createSubagentExtension(runtime, () => [])]); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("Agent", { resume: "child-session", model: "unknown/ignored-model", prompt: "Continue", description: "Continue" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("done")]), + ]); + await session.prompt("Continue the child without changing its model"); + const result = session.agent.state.messages.find((message) => message.role === "toolResult" && message.toolName === "Agent"); + assert.equal(result?.isError, false); + assert.match(textOf(result), /Continued with the existing model/); + assert.deepEqual(calls, ["resume"], "continue exactly once instead of returning to the parent to retry"); + assert.equal(resumedRequests[0].sessionId, "child-session"); + assert.equal("model" in resumedRequests[0], false, "the parent's ignored model never reaches the child runtime"); + } finally { + session?.dispose(); + await rm(dir, { recursive: true, force: true }); + } +}); + test("a session with the subagent feature switched off registers no control tools", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-web-subagent-disabled-")); let session; diff --git a/lib/subagent-extension.ts b/lib/subagent-extension.ts index fa84d67401..f5b6818a8b 100644 --- a/lib/subagent-extension.ts +++ b/lib/subagent-extension.ts @@ -181,7 +181,7 @@ export function createSubagentExtension( })), description: Type.String({ description: "Short activity label shown in the UI." }), run_in_background: Type.Optional(Type.Boolean({ description: "Return immediately and notify this session when complete." })), - model: Type.Optional(Type.String({ description: "Optional provider/modelId override." })), + model: Type.Optional(Type.String({ description: "Optional provider/modelId for new sessions. Ignored when the role specifies a model and disallows parent overrides; that role model is used instead. Ignored with resume, which retains the child's current model." })), thinking: Type.Optional(Type.String({ description: "Optional thinking level override." })), max_turns: Type.Optional(Type.Number({ description: "Optional positive agent turn limit." })), inherit_context: Type.Optional(Type.Boolean({ description: "Include the parent session's active conversation context." })), diff --git a/lib/subagent-model-override.integration.test.mjs b/lib/subagent-model-override.integration.test.mjs new file mode 100644 index 0000000000..2721f6aee0 --- /dev/null +++ b/lib/subagent-model-override.integration.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { after } from "node:test"; +import { createJiti } from "jiti"; + +const root = await mkdtemp(join(tmpdir(), "pi-role-model-")); +const previous = { HOME: process.env.HOME, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }; +process.env.HOME = join(root, "home"); process.env.PI_CODING_AGENT_DIR = join(root, "agent"); +await mkdir(process.env.HOME); await mkdir(process.env.PI_CODING_AGENT_DIR); +const keepAlive = setInterval(() => {}, 1000); +after(async () => { + clearInterval(keepAlive); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + await rm(root, { recursive: true, force: true }); +}); +const { ModelRuntime, SessionManager, SettingsManager, createAgentSessionServices, createAgentSessionFromServices } = await import("@earendil-works/pi-coding-agent"); +const { fauxProvider, fauxAssistantMessage, fauxText, fauxToolCall } = await import("@earendil-works/pi-ai"); +const jiti = createJiti(import.meta.url); +const { createSubagentController } = await jiti.import("./subagent-runtime.ts"); +const { createSubagentExtension } = await jiti.import("./subagent-extension.ts"); +const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts"); +const { readSubagentSessionResources } = await jiti.import("./subagents.ts"); + +async function fixture(t, { pinned = "role-a/pin", allow } = {}) { + const cwd = await mkdtemp(join(root, "cwd-")), profileDir = join(cwd, ".pi", "agents"); + await mkdir(profileDir, { recursive: true }); + const profilePath = join(profileDir, "model-child.md"); + await writeFile(profilePath, `---\ntools: none\n${pinned ? `model: ${pinned}\n` : ""}${allow === undefined ? "" : `allow_parent_model_override: ${allow}\n`}---\nFixture`); + const a = fauxProvider({ provider: "role-a", models: [{ id: "pin" }, { id: "next" }, { id: "parent" }, { id: "shared" }] }); + const b = fauxProvider({ provider: "role-b", models: [{ id: "shared" }] }); + const runtime = await ModelRuntime.create({ authPath: join(cwd, "auth.json"), modelsPath: null, refreshOnCreate: false }); + runtime.registerNativeProvider(a.provider); runtime.registerNativeProvider(b.provider); + const requests = []; + const response = (_context, _options, _state, model) => { requests.push(`${model.provider}/${model.id}`); return fauxAssistantMessage([fauxText("Fixture done")]); }; + a.setResponses(Array.from({ length: 10 }, () => response)); b.setResponses(Array.from({ length: 10 }, () => response)); + const manager = SessionManager.inMemory(cwd); + const parent = { cwd, sessionFile: join(cwd, "parent.jsonl"), isAlive: () => true, isRunning: () => false, waitUntilReady: async () => {}, + inner: { sessionManager: manager, modelRuntime: runtime, model: a.getModel("parent"), agent: { state: {} } } }; + const wrappers = new Map([[manager.getSessionId(), parent]]); + const controller = createSubagentController({ + getSession: (id) => wrappers.get(id), + registerSession(inner, options) { + const wrapper = new AgentSessionWrapper(inner, { ...options, subagentResources: readSubagentSessionResources(inner.sessionManager.getEntries()) }); + wrappers.set(inner.sessionId, wrapper); wrapper.beginExtensionBinding(); return wrapper.waitUntilReady(); + }, + reopenSession: async (id) => wrappers.get(id), resolveSessionPath: async () => null, + invalidateSessionList: () => {}, isBuiltInSubagentsEnabled: () => true, + }); + t.after(async () => { + for (const [id, wrapper] of wrappers) if (id !== manager.getSessionId()) await wrapper.destroy(); + runtime.dispose?.(); + }); + const request = { parentContext: parent.inner, parentToolCallId: "model-fixture", profile: "model-child", description: "Model", task: "Fixture" }; + return { controller, request, requests, wrappers, parent, a, b, profilePath, cwd, runtime }; +} + +for (const [name, options, requested, expected] of [ + ["missing flag protects the role", {}, undefined, "role-a/pin"], + ["closed accepts the same bare ID", { allow: false }, "pin", "role-a/pin"], + ["closed accepts the same qualified ID", {}, "role-a/pin", "role-a/pin"], + ["blank override retains the pinned model", {}, " ", "role-a/pin"], + ["closed ignores a different parent model without retry", {}, "role-a/next", "role-a/pin"], + ["closed ignores the same ID on another provider", { pinned: "role-a/shared", allow: false }, "role-b/shared", "role-a/shared"], + ["closed ignores an unknown parent model", {}, "role-a/removed", "role-a/pin"], + ["closed ignores an ambiguous bare parent ID", {}, "shared", "role-a/pin"], + ["open permits another provider", { allow: true }, "role-b/shared", "role-b/shared"], + ["open without an override still uses the role", { allow: true }, undefined, "role-a/pin"], + ["no specified model accepts parent choice", { pinned: null }, "role-a/next", "role-a/next"], + ["no specified model inherits the parent", { pinned: null }, undefined, "role-a/parent"], +]) { + test(name, async (t) => { + const f = await fixture(t, options); + const originalProfile = await readFile(f.profilePath, "utf8"); + const execution = await f.controller.extensionRuntime.start({ ...f.request, ...(requested === undefined ? {} : { model: requested }) }); + assert.equal((await execution.completion).status, "completed"); + assert.deepEqual(f.requests, [expected], "exactly one actual request using the selected model, without retry"); + assert.equal(await readFile(f.profilePath, "utf8"), originalProfile, "never rewrite the role's configured model"); + }); +} + +for (const [name, options, requested, error] of [ + ["missing pinned model is not substituted", { pinned: "role-a/removed" }, "role-a/next", /Subagent model not found/], + ["open invalid override is not substituted", { allow: true }, "role-a/removed", /Subagent model not found/], + ["unconfigured role still validates an explicit model", { pinned: null }, "role-a/removed", /Subagent model not found/], +]) { + test(name, async (t) => { + const f = await fixture(t, options); + const before = await readdir(join(process.env.PI_CODING_AGENT_DIR, "sessions")).catch((error) => { if (error.code === "ENOENT") return []; throw error; }); + await assert.rejects(f.controller.extensionRuntime.start({ ...f.request, model: requested }), error); + assert.equal(f.wrappers.size, 1, "reject before registering a child"); assert.deepEqual(f.requests, []); + assert.deepEqual(await readdir(join(process.env.PI_CODING_AGENT_DIR, "sessions")).catch((error) => { if (error.code === "ENOENT") return []; throw error; }), before); + }); +} + +test("a real Agent resume with an unknown model keeps the manually selected child model", async (t) => { + const f = await fixture(t); + const first = await f.controller.extensionRuntime.start(f.request); + assert.equal((await first.completion).status, "completed"); + await f.wrappers.get(first.run.sessionId).send({ type: "set_model", provider: "role-a", modelId: "next" }); + await writeFile(f.profilePath, "---\ntools: none\nmodel: role-a/shared\nallow_parent_model_override: true\n---\nChanged role"); + const services = await createAgentSessionServices({ + cwd: f.cwd, agentDir: process.env.PI_CODING_AGENT_DIR, modelRuntime: f.runtime, + settingsManager: SettingsManager.inMemory(), + resourceLoaderOptions: { + noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [createSubagentExtension(f.controller.extensionRuntime, () => [])], + }, + }); + const { session } = await createAgentSessionFromServices({ services, sessionManager: f.parent.inner.sessionManager, model: f.b.getModel("shared") }); + t.after(() => session.dispose()); + f.b.setResponses([ + fauxAssistantMessage([fauxToolCall("Agent", { resume: first.run.sessionId, model: "unknown/ignored", prompt: "Continue", description: "Continue" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("done")]), + ]); + await session.prompt("Continue without changing the child's model"); + const results = session.agent.state.messages.filter((message) => message.role === "toolResult" && message.toolName === "Agent"); + assert.equal(results.length, 1, "no retry required"); + assert.equal(results[0].isError, false); + assert.deepEqual(f.requests, ["role-a/pin", "role-a/next"], "ignore parent parameter and edited role; retain the child's actual model"); +}); + +test("parent and role edits do not change a resumed child's model; a manual child change remains allowed", async (t) => { + const f = await fixture(t); + const first = await f.controller.extensionRuntime.start(f.request); assert.equal((await first.completion).status, "completed"); + f.parent.inner.model = f.b.getModel("shared"); + await writeFile(f.profilePath, "---\ntools: none\nmodel: role-a/next\nallow_parent_model_override: true\n---\nChanged role"); + const resumed = await f.controller.extensionRuntime.resume({ ...f.request, sessionId: first.run.sessionId }); + assert.equal((await resumed.completion).status, "completed"); assert.deepEqual(f.requests, ["role-a/pin", "role-a/pin"]); + await f.wrappers.get(first.run.sessionId).send({ type: "set_model", provider: "role-a", modelId: "next" }); + const manual = await f.controller.extensionRuntime.resume({ ...f.request, sessionId: first.run.sessionId }); + assert.equal((await manual.completion).status, "completed"); assert.deepEqual(f.requests, ["role-a/pin", "role-a/pin", "role-a/next"]); +}); diff --git a/lib/subagent-runtime.ts b/lib/subagent-runtime.ts index cb41b68bbd..093b27ce15 100644 --- a/lib/subagent-runtime.ts +++ b/lib/subagent-runtime.ts @@ -24,6 +24,7 @@ import { SUBAGENT_STATUS_TYPE, SUBAGENT_RESULT_TYPE, type SubagentMetadata, + type SubagentProfile, type SubagentResultMetadata, type SubagentRunInfo, } from "./subagents"; @@ -192,6 +193,18 @@ function parseSubagentModel(runtime: ModelRuntime, value: string | undefined) { throw new Error(`Subagent model is ambiguous; use provider/modelId: ${requested}`); } +/** A protected role ignores the parent's model parameter, even if that parameter is invalid. */ +function resolveSubagentModel( + runtime: ModelRuntime, + profile: Pick, + requestModel: string | undefined, +): ReturnType { + if (profile.model?.trim() && profile.allowParentModelOverride !== true) { + return parseSubagentModel(runtime, profile.model); + } + return parseSubagentModel(runtime, requestModel) ?? parseSubagentModel(runtime, profile.model); +} + function parentContextText(parent: HostSession): string { const messages = parent.inner.sessionManager.buildSessionContext().messages; const serialized = JSON.stringify(messages); @@ -228,6 +241,10 @@ export function createSubagentController( const profile = resolveSubagentProfile(parent.cwd, request.profile); if (!profile) throw new Error(`Unknown or disabled subagent profile: ${request.profile}`); + // Resolve before creating child resources; an unavailable selected model must not leave an orphan. + const parentModelRuntime = (parent.inner as unknown as { modelRuntime: ModelRuntime }).modelRuntime; + const requestedModel = resolveSubagentModel(parentModelRuntime, profile, request.model); + const runInBackground = request.runInBackground ?? profile.runInBackground; const isolation = profile.isolation === "off" ? undefined : request.isolation ?? profile.isolation; if (isolation === "worktree") { @@ -246,7 +263,6 @@ export function createSubagentController( } const agentDir = getAgentDir(); - const parentModelRuntime = (parent.inner as unknown as { modelRuntime: ModelRuntime }).modelRuntime; const settingsManager = SettingsManager.create(childCwd, agentDir, { projectTrusted: getProjectTrustStatus(childCwd, agentDir).trusted }); const inheritedParentContext = inheritContext ? `The following is the active conversation context from the parent session. Use it only as background for the delegated task:\n${parentContextText(parent)}` @@ -328,7 +344,6 @@ export function createSubagentController( sessionManager.appendCustomEntry(SUBAGENT_META_TYPE, metadata); sessionManager.appendSessionInfo(metadata.description); - const requestedModel = parseSubagentModel(parentModelRuntime, request.model ?? profile.model); const parentModel = parent.inner.model as ReturnType; const { session: inner } = await createAgentSessionFromServices({ services, diff --git a/lib/subagents.ts b/lib/subagents.ts index 1f5daebe6a..7b11dde28c 100644 --- a/lib/subagents.ts +++ b/lib/subagents.ts @@ -62,6 +62,7 @@ export interface SubagentProfile { loadExtensions: boolean; codeMode?: boolean; loadMcp?: boolean; + allowParentModelOverride?: boolean; mcpServers?: SubagentMcpServerRef[]; skills?: SubagentResourceSelection; extensions?: SubagentResourceSelection; @@ -171,6 +172,7 @@ const MANAGED_FRONTMATTER_KEYS = new Set([ "load_extensions", "code_mode", "load_mcp", + "allow_parent_model_override", "mcp_servers", "enabled", "inherit_context", @@ -338,6 +340,7 @@ function parseProfileFile(filePath: string, scope: SubagentScope): SubagentProfi ...(maxTurnsValue && maxTurnsValue > 0 ? { maxTurns: maxTurnsValue } : {}), codeMode: booleanValue(data?.code_mode, false), loadMcp: booleanValue(data?.load_mcp, false), + allowParentModelOverride: booleanValue(data?.allow_parent_model_override, false), mcpServers: isSubagentMcpServerRefs(data?.mcp_servers) ? structuredClone(data.mcp_servers) : [], inheritContext: booleanValue(data?.inherit_context, false), runInBackground: booleanValue(data?.run_in_background, false), @@ -386,6 +389,8 @@ function builtInProfiles(): SubagentProfile[] { return BUILTIN_PROFILES.map((profile) => ({ ...profile, tools: [...profile.tools], + // Built-ins ship no frontmatter: a missing override flag is the default, false. + allowParentModelOverride: profile.allowParentModelOverride ?? false, enabled: !disabled.has(profile.name.toLowerCase()), })); } @@ -467,7 +472,7 @@ export function saveSubagentProfile( for (const selection of [profile.skills, profile.extensions]) { if (selection !== undefined && typeof selection !== "boolean" && !(Array.isArray(selection) && selection.every((entry) => typeof entry === "string"))) throw new Error("skills/extensions must be boolean or string[]"); } - for (const key of ["codeMode", "loadMcp"] as const) { + for (const key of ["codeMode", "loadMcp", "allowParentModelOverride"] as const) { if (profile[key] !== undefined && typeof profile[key] !== "boolean") throw new Error(`${key} must be boolean`); } if (profile.mcpServers !== undefined && !isSubagentMcpServerRefs(profile.mcpServers)) throw new Error("mcpServers must be scoped server references"); @@ -491,6 +496,7 @@ export function saveSubagentProfile( load_extensions: loadExtensions, code_mode: profile.codeMode ?? booleanValue(stored.code_mode, false), load_mcp: profile.loadMcp ?? booleanValue(stored.load_mcp, false), + allow_parent_model_override: profile.allowParentModelOverride ?? booleanValue(stored.allow_parent_model_override, false), mcp_servers: profile.mcpServers ?? (isSubagentMcpServerRefs(stored.mcp_servers) ? stored.mcp_servers : []), enabled: profile.enabled, inherit_context: profile.inheritContext, @@ -516,6 +522,7 @@ export function saveSubagentProfile( ...profile, codeMode: managed.code_mode === true, loadMcp: managed.load_mcp === true, + allowParentModelOverride: managed.allow_parent_model_override === true, mcpServers: structuredClone(managed.mcp_servers as SubagentMcpServerRef[]), skills: profileResourceSelection(managed.skills, loadSkills), extensions: profileResourceSelection(managed.extensions, loadExtensions),