feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
@@ -0,0 +1,9 @@
---
description: "Loads alpha+beta, selects only alpha via ext:. Flip mutes beta."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs"
expect_tools_present: "read, bash, alpha_read, alpha_write"
expect_tools_absent: "beta_tool"
---
e2e template: a single ext: selector flips extension tools to an allowlist;
alpha is selected (all its tools surface), beta is loaded but muted.
@@ -0,0 +1,9 @@
---
description: "Selects beta via ext:; the loaded alpha extension is muted."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-beta.mjs"
expect_tools_present: "read, beta_tool"
expect_tools_absent: "alpha_read, alpha_write"
---
e2e template: mirror of all-and-alpha-selected — selecting beta proves the flip
mutes the *other* loaded extension (alpha) regardless of which one is named.
@@ -0,0 +1,10 @@
---
description: "Selects alpha but denylists alpha_write via disallowed_tools."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs"
disallowed_tools: "alpha_write"
expect_tools_present: "read, alpha_read"
expect_tools_absent: "alpha_write, beta_tool"
---
e2e template: disallowed_tools removes an extension tool even when the ext:
selector would otherwise surface it.
@@ -0,0 +1,12 @@
---
description: "An ext: selector cannot resurrect an excluded extension."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
exclude_extensions: ext-beta.mjs
tools: "*, ext:ext-beta.mjs"
expect_tools_present: "read"
expect_tools_absent: "beta_tool, alpha_read, alpha_write"
---
e2e template: exclude_extensions beats a tools: ext: selector — beta never
loads, so ext:ext-beta.mjs is an orphan (warns, does not pull beta back in).
Alpha tools are also absent because any ext: entry flips extension tools to an
explicit allowlist and alpha is not selected.
+12
View File
@@ -0,0 +1,12 @@
---
description: "Loads alpha+beta, excludes beta via exclude_extensions."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
exclude_extensions: ext-beta.mjs
tools: "*"
expect_tools_present: "read, alpha_read, alpha_write"
expect_tools_absent: "beta_tool"
---
e2e template: exclude_extensions removes an extension after the include set is
computed — alpha surfaces normally, beta's tools never register. (Excluding a
name that the extensions: list also loads warns "in both — exclude wins"; the
exclusion still applies.)
@@ -0,0 +1,8 @@
---
description: "extensions:false — no extension tools at all."
extensions: false
tools: "*"
expect_tools_present: "read, bash, edit, write, grep, find, ls"
expect_tools_absent: "alpha_read, alpha_write, beta_tool"
---
e2e template: extensions are disabled, so only the built-in tools are active.
+8
View File
@@ -0,0 +1,8 @@
---
description: "tools value format: YAML flow array."
tools: [read, grep, find]
expect_tools_present: "read, grep, find"
expect_tools_absent: "bash, edit, write, ls"
---
e2e template (format check 3/3): YAML array. Per the README, `[a, b]` == `"a, b"`,
so this must yield the same active tool set as the CSV forms.
@@ -0,0 +1,8 @@
---
description: "tools value format: quoted CSV string."
tools: "read, grep, find"
expect_tools_present: "read, grep, find"
expect_tools_absent: "bash, edit, write, ls"
---
e2e template (format check 2/3): quoted CSV string. Same result as the unquoted
and array forms.
@@ -0,0 +1,8 @@
---
description: "tools value format: unquoted CSV."
tools: read, grep, find
expect_tools_present: "read, grep, find"
expect_tools_absent: "bash, edit, write, ls"
---
e2e template (format check 1/3): unquoted CSV. Must be equivalent to the quoted
and array forms — see fmt-quoted-csv.md and fmt-array.md.
@@ -0,0 +1,11 @@
---
description: "isolated:true forces built-ins only, overriding extensions and ext:."
isolated: true
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs"
expect_tools_present: "read, bash, edit, write, grep, find, ls"
expect_tools_absent: "alpha_read, alpha_write, beta_tool"
---
e2e template: per the README, isolated:true is hermetic — it forces
extensions:false + skills:false and drops ext: selectors, leaving only built-ins,
even though this template also sets extensions and an ext: selector.
@@ -0,0 +1,10 @@
---
description: "ext: selecting the lazy extension surfaces its session_start tool."
extensions: "./ext-lazy.mjs, ./ext-alpha.mjs"
tools: "*, ext:ext-lazy.mjs"
expect_tools_present: "read, bash, lazy_tool"
expect_tools_absent: "alpha_read, alpha_write"
---
e2e template: the case a static allowlist can never express. `lazy_tool` does not
exist when the session is constructed, so it cannot be listed up front — scope
has to be re-derived once the extension registers it.
@@ -0,0 +1,10 @@
---
description: "A lazy extension left out of the ext: flip stays muted."
extensions: "./ext-lazy.mjs, ./ext-alpha.mjs"
tools: "*, ext:ext-alpha.mjs"
expect_tools_present: "read, bash, alpha_read, alpha_write"
expect_tools_absent: "lazy_tool"
---
e2e template: the mirror of lazy-ext-selected — admitting late tools must not
mean admitting ALL late tools. ext-lazy loads and its session_start handler runs,
but the `ext:` flip did not select it, so `lazy_tool` never becomes active.
@@ -0,0 +1,9 @@
---
description: "A tool registered at session_start reaches the subagent (#125)."
extensions: "./ext-lazy.mjs"
tools: "*"
expect_tools_present: "read, bash, lazy_tool"
---
e2e template: ext-lazy registers `lazy_tool` from `session_start`, i.e. AFTER
loader.reload() has already run. Any scoping that snapshots the tool set at
construction drops it permanently — this asserts it survives.
@@ -0,0 +1,25 @@
---
description: "memory + disallowed_tools — a denied write tool must not count as write capability."
memory: project
tools: read, write
disallowed_tools: write
expect_tools_present: "read"
expect_tools_absent: "write, edit, bash, grep"
expect_prompt_contains: "Agent Memory (read-only), Memory scope: project"
expect_prompt_absent: "persistent memory directory"
---
README: "The `disallowed_tools` field is respected when determining write
capability — an agent with `tools: write` + `disallowed_tools: write` correctly
gets read-only memory."
This is the combination neither `memory-readonly.md` (no write tool at all) nor
`memory-readwrite.md` (write tool, nothing denied) exercises: the write tool IS
in the `tools:` set, so a naive capability check sees it and hands the agent the
read-write memory prompt — which also appends `write`/`edit` to the tool names,
widening the set the agent asked for. The denylist still filters the registry, so
the visible symptom is an agent instructed to write memory files with no tool to
do it; the invisible one is the tool-name widening.
Scope `project` is safe here precisely because the read-only branch creates no
memory directory — if this fixture ever flips to the read-write branch, it would
also start writing into the repo, which is the loudest possible signal.
@@ -0,0 +1,13 @@
---
description: "memory read-only — an agent without write tools gets a read-only memory block."
memory: project
tools: read, grep
expect_tools_present: "read, grep"
expect_tools_absent: "write, edit"
expect_prompt_contains: "Agent Memory (read-only), Memory scope: project"
expect_prompt_absent: "persistent memory directory"
---
A read-only memory agent. Per the README, agents without write/edit tools
auto-get a read-only memory fallback: existing memory is injected, no write
access is granted, and no memory directory is created (so `project` scope is
safe here — nothing is written into the repo).
@@ -0,0 +1,12 @@
---
description: "memory read-write — an agent with write tools gets a writable memory block."
memory: user
tools: read, write
expect_tools_present: "read, write, edit"
expect_prompt_contains: "persistent memory directory, Memory scope: user"
expect_prompt_absent: "(read-only)"
---
A write-capable memory agent. Per the README, agents with write/edit tools get
full read-write memory; the memory tool set is completed (edit is auto-added) and
a writable memory block is injected into the system prompt. Scope `user` so the
memory dir is created under the hermetic HOME, never in the repo.
+9
View File
@@ -0,0 +1,9 @@
---
description: "Minimal agent — only description and body; every other field omitted."
expect_tools_present: "read, bash, edit, write, grep, find, ls"
expect_tools_absent: "alpha_read, alpha_write, beta_tool"
---
A minimal agent. Per the README defaults: omitted `tools` => all 7 built-ins;
omitted `extensions` => true (all *discovered* extensions load — none exist in
this hermetic fixture, so no extension tools surface). expect_* are test-harness
annotations and are ignored by the agent loader.
@@ -0,0 +1,9 @@
---
description: "Narrows alpha to a single tool via ext:ext-alpha.mjs/alpha_read."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*, ext:ext-alpha.mjs/alpha_read"
expect_tools_present: "read, alpha_read"
expect_tools_absent: "alpha_write, beta_tool"
---
e2e template: ext:<ext>/<tool> narrows alpha to just alpha_read; alpha_write
and the unselected beta extension are both muted.
@@ -0,0 +1,8 @@
---
description: "No ext: selector, so all loaded extensions' tools surface."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: "*"
expect_tools_present: "read, alpha_read, alpha_write, beta_tool"
---
e2e template: with no ext: entry there is no flip, so every loaded extension's
tools surface alongside the built-ins.
@@ -0,0 +1,8 @@
---
description: "prompt_mode append — body appended to the parent's prompt."
prompt_mode: append
expect_tools_present: "read"
expect_prompt_contains: "PARENT_PROMPT_MARKER, APPEND_BODY_MARKER"
---
APPEND_BODY_MARKER — in append mode the parent prompt flows in verbatim, so the
real session's system prompt contains BOTH the parent marker and this body.
@@ -0,0 +1,9 @@
---
description: "prompt_mode replace (default) — body is the full system prompt."
prompt_mode: replace
expect_tools_present: "read"
expect_prompt_contains: "REPLACE_BODY_MARKER"
expect_prompt_absent: "PARENT_PROMPT_MARKER"
---
REPLACE_BODY_MARKER — in replace mode the parent prompt is NOT inherited, so the
real session's system prompt contains this body but not the parent's marker.
@@ -0,0 +1,8 @@
---
description: "skills: preloads a named skill into the system prompt."
skills: probe-skill
expect_tools_present: "read"
expect_prompt_contains: "Preloaded Skill: probe-skill, SKILL_BODY_MARKER"
---
A skill-preloading agent. The `skills: probe-skill` entry must inject
test/fixtures/.pi/skills/probe-skill.md into the real session's system prompt.
+9
View File
@@ -0,0 +1,9 @@
---
description: "tools narrowed to two built-ins; extensions omitted (=> true)."
tools: read, grep
expect_tools_present: "read, grep"
expect_tools_absent: "bash, edit, write, find, ls, alpha_read, alpha_write, beta_tool"
---
e2e template: a plain built-in allowlist narrows to exactly the listed tools.
extensions is omitted (defaults to true); none are discovered here, so no
extension tools surface.
+9
View File
@@ -0,0 +1,9 @@
---
description: "tools:none => zero built-ins; loaded extension tools still surface."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
tools: none
expect_tools_present: "alpha_read, alpha_write, beta_tool"
expect_tools_absent: "read, bash, edit, write, grep, find, ls"
---
e2e template: `tools: none` yields zero built-ins. With extensions loaded and no
ext: selector, all extension tools still surface — only the built-ins are dropped.
@@ -0,0 +1,7 @@
---
description: "tools omitted (=> all built-ins) with extensions explicitly loaded."
extensions: "./ext-alpha.mjs, ./ext-beta.mjs"
expect_tools_present: "read, bash, edit, write, grep, find, ls, alpha_read, alpha_write, beta_tool"
---
e2e template: omitting `tools` yields all 7 built-ins; with extensions loaded and
no ext: selector there is no flip, so every loaded extension tool also surfaces.
+5
View File
@@ -0,0 +1,5 @@
# Probe Skill
SKILL_BODY_MARKER — distinctive content used by the template-driven e2e to
prove that a `skills:` frontmatter entry is preloaded into the real subagent
system prompt.
+25
View File
@@ -0,0 +1,25 @@
/**
* Real extension fixture for the end-to-end test. Loaded by pi-mono's actual
* DefaultResourceLoader via `additionalExtensionPaths`. Registers one tool,
* `e2e_probe`, that writes a marker file when executed so the test can prove
* the model was actually able to call it (not just that it appeared in a list).
*
* Plain ESM (.mjs) so node imports it without any TS transform step.
*/
import { writeFileSync } from "node:fs";
import { Type } from "@sinclair/typebox";
export default function (pi) {
pi.registerTool({
name: "e2e_probe",
label: "E2E Probe",
description: "Writes a marker file. Used only by the end-to-end test.",
parameters: Type.Object({
marker: Type.String({ description: "Absolute path of the marker file to write." }),
}),
async execute(_id, params) {
writeFileSync(params.marker, "probed");
return { content: [{ type: "text", text: `wrote ${params.marker}` }] };
},
});
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Real extension fixture "alpha" for the template-driven e2e runner.
* Registers two tools so narrowing (ext:ext-alpha.mjs/alpha_read) can be
* distinguished from exposing the whole extension. Plain ESM so node imports
* it without a TS transform; lives inside the repo tree so `@sinclair/typebox`
* resolves. Tools are never invoked by the runner — it only inspects the
* session's active tool set — so execute() is a trivial stub.
*/
import { Type } from "@sinclair/typebox";
export default function (pi) {
for (const name of ["alpha_read", "alpha_write"]) {
pi.registerTool({
name,
label: name,
description: `Alpha extension tool ${name} (e2e fixture).`,
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: name }] };
},
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Real extension fixture "beta" for the template-driven e2e runner.
* Registers a single tool, used to prove that the `ext:` allowlist flip mutes
* a loaded-but-unselected extension. See ext-alpha.mjs for the conventions.
*/
import { Type } from "@sinclair/typebox";
export default function (pi) {
pi.registerTool({
name: "beta_tool",
label: "beta_tool",
description: "Beta extension tool (e2e fixture).",
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: "beta_tool" }] };
},
});
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Real extension fixture "lazy" for the template-driven e2e runner.
*
* Registers its tool from `session_start` rather than at load — the shape that
* broke subagents in issue #125. pi-mcp does exactly this (it can only enumerate
* tools once its MCP servers connect), and eagerly connecting at load time would
* orphan child processes on pi's non-agent code paths.
*
* The point of the fixture is the TIMING: at `loader.reload()` this extension
* contributes no tools at all, so any scoping that snapshots the tool set then
* will drop `lazy_tool` permanently. See ext-alpha.mjs for the conventions.
*/
import { Type } from "@sinclair/typebox";
export default function (pi) {
pi.on("session_start", () => {
pi.registerTool({
name: "lazy_tool",
label: "lazy_tool",
description: "Lazily-registered extension tool (e2e fixture).",
parameters: Type.Object({}),
async execute() {
return { content: [{ type: "text", text: "lazy_tool" }] };
},
});
});
}