fix(coding-agent): skipped xd:// mounting when write tool is not granted

- Prevent xdev state allocation and tool mounting in sessions lacking a write tool.
- Expose discoverable tools top-level instead of auto-granting write transports.
This commit is contained in:
can1357
2026-08-01 20:34:22 +02:00
parent 13a36f7c83
commit 386385f18b
8 changed files with 102 additions and 58 deletions
+1
View File
@@ -16,6 +16,7 @@
### Fixed
- Fixed sessions without a granted `write` tool hiding discoverable and MCP tools behind the unusable `xd://` transport; those sessions now disable device mounting and expose the tools directly without gaining write access.
- Fixed collab guest prompts being sent to models as unframed developer context, so guest messages now retain their transcript attribution while reaching the model as prioritized user interjections ([#7288](https://github.com/can1357/oh-my-pi/issues/7288)).
- Fixed `/memory stats` and `/memory diagnose` showing "Memory stats is not available for the off backend" when memory is off, in both the TUI and ACP/RPC slash-command handlers; the off backend now says memory is off directly instead of naming itself as an unsupported backend ([#7251](https://github.com/can1357/oh-my-pi/pull/7251) by [@KennethHoff](https://github.com/KennethHoff)).
- Fixed `/reload-plugins` retaining stale context-file contents and activation state in the current system prompt ([#7258](https://github.com/can1357/oh-my-pi/issues/7258)).
@@ -4254,7 +4254,7 @@ export const SETTINGS_SCHEMA = {
group: "Discovery & MCP",
label: "xd:// Tools",
description:
"Mount rarely-used (discoverable) tools under xd:// device URLs driven via read/write instead of shipping their schemas on every request. Disable to expose every enabled tool top-level.",
"Mount rarely-used (discoverable) tools under xd:// device URLs driven via read/write instead of shipping their schemas on every request. Sessions without a granted write tool skip mounting and expose every tool top-level. Disable to expose every enabled tool top-level.",
},
},
+13 -9
View File
@@ -2724,10 +2724,11 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
// Existing staged/device paths need write registered before active-set assembly.
// Deferred MCP also registers it now, but refresh activates it only after a server connects.
// xd:// mounts never register write: xdev state only exists when the session
// already granted a write tool (see createTools), so mounting rides that grant.
const hasDeferrableTools = Array.from(toolRegistry.values()).some(tool => tool.deferrable === true);
const hasXdevTools = (toolSession.xdev?.mountedNames.size ?? 0) > 0;
const planModeAvailable = settings.get("plan.enabled");
if (!restrictToolNames && (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI)) {
if (!restrictToolNames && (hasDeferrableTools || planModeAvailable || deferMCPDiscoveryForUI)) {
await ensureWriteRegistered();
}
@@ -2984,6 +2985,9 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
const xdevReadAvailable =
builtInRegistryToolNames.has("read") &&
(explicitlyRequestedToolNameSet === undefined || explicitlyRequestedToolNameSet.has("read"));
const xdevWriteAvailable =
builtInRegistryToolNames.has("write") &&
(explicitlyRequestedToolNameSet === undefined || explicitlyRequestedToolNameSet.has("write"));
const initialRequestedActiveToolNames = options.toolNames
? requestedActiveToolNames
: requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
@@ -3029,23 +3033,23 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
// Partition the initial enabled set for the xd:// transport. Tool instances
// remain in the canonical map; only presentation names move between layers.
// Mounting requires both transport halves in the granted set (`read xd://`
// discovers, `write xd://<tool>` executes); a session without either keeps
// every tool top-level instead of auto-granting the missing transport.
if (toolSession.xdev) {
const topLevelToolNames: string[] = [];
const mountedNames: string[] = [];
for (const name of initialToolNames) {
const tool = toolRegistry.get(name);
const explicitlyRequested = explicitlyRequestedToolNameSet?.has(name) === true;
if (tool && xdevReadAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
if (tool && xdevReadAvailable && xdevWriteAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
mountedNames.push(name);
else topLevelToolNames.push(name);
}
const writeTransportAvailable = mountedNames.length === 0 || (await ensureWriteRegistered());
toolSession.xdev.mountedNames.clear();
if (writeTransportAvailable) {
for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
initialToolNames = topLevelToolNames;
if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
}
for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
initialToolNames = topLevelToolNames;
if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
}
setActiveToolNames(initialToolNames);
@@ -574,24 +574,28 @@ export class SessionTools {
/** Applies an enabled tool set and reconciles its `xd://` partition. */
async applyActiveToolsByName(toolNames: string[]): Promise<void> {
toolNames = normalizeToolNames(toolNames);
let builtInWriteAvailable = this.#builtInToolNames.has("write");
if (toolNames.includes("write") && !builtInWriteAvailable) {
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
}
const selectedTools = toolNames.flatMap(name => {
const tool = this.#toolRegistry.get(name);
return tool ? [{ name, tool }] : [];
});
const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
const xdevWriteAvailable = builtInWriteAvailable && selectedTools.some(({ name }) => name === "write");
const isPresentationPinned = (name: string): boolean =>
this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
const mountCandidates = selectedTools.filter(
({ name, tool }) =>
this.#xdev !== undefined && xdevReadAvailable && !isPresentationPinned(name) && isMountableUnderXdev(tool),
this.#xdev !== undefined &&
xdevReadAvailable &&
xdevWriteAvailable &&
!isPresentationPinned(name) &&
isMountableUnderXdev(tool),
);
let builtInWriteAvailable = this.#builtInToolNames.has("write");
if (mountCandidates.length > 0 && !builtInWriteAvailable) {
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
}
const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
const mountNames = new Set(mountCandidates.map(({ name }) => name));
const tools: AgentTool[] = [];
const validToolNames: string[] = [];
for (const { name, tool } of selectedTools) {
+10 -6
View File
@@ -667,8 +667,12 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
// Ordinary sessions use xd:// for discoverable built-ins, custom tools, and
// MCP tools. Structured children must expose only their host-provided names,
// so never allocate a registry that later SDK assembly could populate.
// The transport rides read/write, so a session granted no write tool never
// allocates xd:// state — its tools are exposed top-level directly instead
// of auto-granting a write transport the session was denied.
// Explicitly requested built-ins retain their top-level presentation.
const xdevEnabled = !restrictToolNames && session.settings.get("tools.xdev");
const xdevEnabled =
!restrictToolNames && session.settings.get("tools.xdev") && tools.some(tool => tool.name === "write");
const mountBuiltinTools = requestedTools === undefined;
if (xdevEnabled) {
const mountedNames = new Set<string>();
@@ -686,14 +690,14 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
};
tools = kept;
}
// The xd:// transport rides read/write: `read xd://` lists+documents devices,
// `write xd://<tool>` executes them. Staged previews from deferrable tools
// (e.g. ast_edit) also resolve through a `write` to xd://resolve/reject. Retain
// both whenever any device is mounted or a deferrable tool can stage one.
// Staged previews from deferrable tools (e.g. ast_edit) resolve through a
// `write` to xd://resolve/reject, so retain write whenever one can stage.
// xd:// mounting itself never registers write: sessions without a granted
// write tool skip mounting entirely (see xdevEnabled above).
const xdevMounted = (session.xdev?.mountedNames.size ?? 0) > 0;
if (
!restrictToolNames &&
(tools.some(tool => tool.deferrable === true) || xdevMounted) &&
tools.some(tool => tool.deferrable === true) &&
!tools.some(tool => tool.name === "write")
) {
const writeTool = await logger.time("createTools:write", BUILTIN_TOOLS.write, session);
@@ -457,11 +457,14 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
// Ordinary xd:// inventory changes travel through mount notices and do
// not affect the global MCP-route guidance or its rebuild signature.
await session.setActiveToolPresentation(["read", search.name, catalog.name], [search.name, catalog.name]);
await session.setActiveToolPresentation(
["read", "write", search.name, catalog.name],
[search.name, catalog.name],
);
expect(session.getMountedXdevToolNames()).toContain(catalog.name);
expect(rebuildCount).toBe(1);
await session.setActiveToolPresentation(["read", search.name], [search.name]);
await session.setActiveToolPresentation(["read", "write", search.name], [search.name]);
expect(session.getMountedXdevToolNames()).not.toContain(catalog.name);
expect(rebuildCount).toBe(1);
});
@@ -1176,7 +1179,7 @@ These tools became available:
expect(delivered[0]).toContain("xd://mcp__nucleus_search");
});
it("keeps lazy write registration while rolling back applied state on rebuild failure", async () => {
it("does not register write while rolling back a direct-tool rebuild failure", async () => {
let failRebuild = true;
const xdevState = createTestXdevState();
const { session } = newSession(
@@ -1194,13 +1197,14 @@ These tools became available:
expect(session.getActiveToolNames()).toEqual(activeBefore);
expect(session.getMountedXdevToolNames()).toEqual(mountedBefore);
expect(session.getToolByName("write")).toBeDefined();
expect(session.hasBuiltInTool("write")).toBe(true);
expect(session.getToolByName("write")).toBeUndefined();
expect(session.hasBuiltInTool("write")).toBe(false);
failRebuild = false;
await session.refreshMCPTools([search]);
expect(session.getActiveToolNames()).toContain("write");
expect(session.getMountedXdevToolNames()).toContain(search.name);
expect(session.getActiveToolNames()).toContain(search.name);
expect(session.getActiveToolNames()).not.toContain("write");
expect(session.getMountedXdevToolNames()).not.toContain(search.name);
});
it("rolls back MCP catalog replacement when prompt rebuild fails", async () => {
@@ -129,18 +129,20 @@ describe("generate_image tool gating", () => {
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("generate_image");
});
it("keeps carried mounted devices under xd after runtime tool selection", async () => {
it("keeps ambient tools top-level across runtime selection without write", async () => {
const ambientTool = customTool("ambient_search");
const session = await sessionWithCustomTools(["read"], [ambientTool]);
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
expect(session.getActiveToolNames()).toContain(ambientTool.name);
expect(session.getXdevToolEntries()).toEqual([]);
await session.setActiveToolsByName(session.getEnabledToolNames());
expect(session.getActiveToolNames()).not.toContain(ambientTool.name);
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
expect(session.getActiveToolNames()).toContain(ambientTool.name);
expect(session.getActiveToolNames()).not.toContain("write");
expect(session.getXdevToolEntries()).toEqual([]);
});
it("keeps explicit discoverable tools top-level while mounting ambient MCP-shaped custom tools", async () => {
it("exposes ambient MCP-shaped tools directly when write was not granted", async () => {
let mcpCalls = 0;
const mcpTool = {
name: "mcp__test__search",
@@ -168,37 +170,37 @@ describe("generate_image tool gating", () => {
sessions.push(session);
expect(session.getActiveToolNames()).toContain("generate_image");
expect(session.getActiveToolNames()).not.toContain(mcpTool.name);
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(mcpTool.name);
expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("generate_image");
expect(session.getActiveToolNames()).toContain(mcpTool.name);
expect(session.getActiveToolNames()).not.toContain("write");
expect(session.getXdevToolEntries()).toEqual([]);
expect(session.getAllToolNames()).toContain(mcpTool.name);
expect(session.getActiveToolNames()).toContain("write");
const writeTool = session.getToolByName("write");
expect(writeTool).toBeDefined();
const result = await writeTool!.execute("mcp-xdev-dispatch", {
path: `xd://${mcpTool.name}`,
content: "{}",
});
const directTool = session.getToolByName(mcpTool.name);
expect(directTool).toBeDefined();
const result = await directTool!.execute("mcp-direct-dispatch", {});
expect(result.content.find(part => part.type === "text")?.text).toBe("ok");
expect(mcpCalls).toBe(1);
});
it("drops transport-only write after the last MCP device disconnects", async () => {
it("does not add write for an MCP device when write was omitted", async () => {
const session = await sessionWithCustomTools(["read"], [customTool("mcp__test__search", true)]);
expect(session.getActiveToolNames()).toContain("write");
expect(session.getActiveToolNames()).toContain("mcp__test__search");
expect(session.getActiveToolNames()).not.toContain("write");
expect(session.getXdevToolEntries()).toEqual([]);
await session.refreshMCPTools([]);
expect(session.getActiveToolNames()).not.toContain("write");
});
it("does not pin transport-only write during enabled-set round trips", async () => {
it("does not add write during enabled-set round trips", async () => {
const session = await sessionWithCustomTools(["read"], [customTool("mcp__test__search", true)]);
expect(session.getActiveToolNames()).toContain("write");
expect(session.getActiveToolNames()).not.toContain("write");
await session.setActiveToolsByName(session.getEnabledToolNames());
await session.refreshMCPTools([]);
expect(session.getActiveToolNames()).toContain("mcp__test__search");
expect(session.getActiveToolNames()).not.toContain("write");
await session.refreshMCPTools([]);
expect(session.getActiveToolNames()).not.toContain("write");
});
@@ -210,14 +212,27 @@ describe("generate_image tool gating", () => {
expect(session.getActiveToolNames()).toContain("write");
});
it("preserves write while a non-MCP device remains mounted", async () => {
it("unmounts devices when write is removed at runtime", async () => {
const ambientTool = customTool("ambient_search");
const session = await sessionWithCustomTools(["read", "write"], [ambientTool]);
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
await session.setActiveToolsByName(["read", ambientTool.name]);
expect(session.getActiveToolNames()).toContain(ambientTool.name);
expect(session.getActiveToolNames()).not.toContain("write");
expect(session.getXdevToolEntries()).toEqual([]);
});
it("keeps all remaining tools top-level after MCP disconnect without write", async () => {
const ambientTool = customTool("ambient_search");
const session = await sessionWithCustomTools(["read"], [ambientTool, customTool("mcp__test__search", true)]);
await session.refreshMCPTools([]);
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
expect(session.getActiveToolNames()).toContain("write");
expect(session.getActiveToolNames()).toContain(ambientTool.name);
expect(session.getActiveToolNames()).not.toContain("write");
expect(session.getXdevToolEntries()).toEqual([]);
});
it("keeps ambient custom tools top-level when an explicit session omitted read", async () => {
@@ -298,7 +313,7 @@ describe("generate_image tool gating", () => {
expect(session.getActiveToolNames()).toContain(rpcTool.name);
expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain(rpcTool.name);
});
it("activates write when an RPC host tool mounts under xd://", async () => {
it("exposes newly discovered RPC tools directly when write was omitted", async () => {
const { session } = await createAgentSession({
cwd: registryDir,
agentDir: registryDir,
@@ -326,7 +341,8 @@ describe("generate_image tool gating", () => {
};
await session.refreshRpcHostTools([rpcTool]);
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("rpc_search");
expect(session.getActiveToolNames()).toContain("write");
expect(session.getActiveToolNames()).toContain("rpc_search");
expect(session.getActiveToolNames()).not.toContain("write");
expect(session.getXdevToolEntries()).toEqual([]);
});
});
+12 -1
View File
@@ -150,10 +150,21 @@ describe("createTools", () => {
it("creates xd:// presentation state without remounting explicitly requested built-ins", async () => {
const session = createTestSession();
const tools = await createTools(session, ["read", "lsp"]);
const tools = await createTools(session, ["read", "lsp", "write"]);
expect(session.xdev).toBeDefined();
expect(session.xdev?.mountedNames.size).toBe(0);
expect(tools.map(tool => tool.name)).toEqual(["read", "lsp", "write"]);
});
it("skips xd:// state entirely when the session grants no write tool", async () => {
// The xd:// transport rides `write xd://<tool>`; without a granted write
// tool nothing can dispatch a device, so no state is allocated and later
// SDK assembly exposes custom/MCP tools top-level instead.
const session = createTestSession();
const tools = await createTools(session, ["read", "lsp"]);
expect(session.xdev).toBeUndefined();
expect(tools.map(tool => tool.name)).toEqual(["read", "lsp"]);
});