6254b6e81b
Every native `pi_edit` failed after a session switched onto Cursor. The replace-mode `edit` instance the frame needs was built only for sessions CREATED on Cursor, and the tool roster is built once, at creation - a session that started elsewhere kept its configured-mode `edit` in the registry, which `executeTool` resolves before its fallback, so the frame's `old_text`/`new_text` pairs failed validation against a `hashline` schema. The instance is now built from the `edit` grant regardless of the initial provider, lazily so a session that never reaches Cursor never constructs one, and `pi_edit` asks for it through a dedicated `getEditReplaceTool` accessor rather than relying on Cursor sessions having deleted `edit` from the registry. A session that was never granted `edit` is still refused. That accessor also closes an escalation the previous wiring opened up. The session's device resolver is handed to the bridge as `getTool` and installed as the agent loop's `resolveFallbackTool`, which runs for ANY call outside the advertised set - so serving `edit` from it let a hallucinated call, or one naming a tool the session deselected after startup, execute a replace-mode edit the model was never offered. It is device-only again. Regressions cover both directions at the SDK level, driving a real unadvertised `edit` through the loop and asserting the surfaced `Tool edit not found`: an unchanged file alone would also pass if the fallback had resolved the tool and the edit then failed validation. (cherry picked from commit 11a28dcf7b995a9e94913269733b3199d6f4790d)
82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
/**
|
|
* Per-call tools the Cursor exec bridge needs but the model-facing registry
|
|
* cannot supply.
|
|
*
|
|
* Both bridge callsites — the primary session and the advisor roster — build
|
|
* the same instances, and both must apply the session's approval wrapper. A
|
|
* raw tool here silently escapes the gate every registry call goes through, so
|
|
* the construction lives in one place rather than being repeated per callsite.
|
|
*/
|
|
|
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
import { EditTool } from "./edit";
|
|
import type { ExtensionRunner } from "./extensibility/extensions";
|
|
import { ExtensionToolWrapper } from "./extensibility/extensions";
|
|
import type { GrepToolOptions, Tool, ToolSession } from "./tools";
|
|
import { GrepTool } from "./tools";
|
|
|
|
/**
|
|
* Build the bridge's `createGrepTool` factory for one tool session.
|
|
*
|
|
* A `pi_grep` frame carries its own context width and total match cap. Neither
|
|
* is expressible in the model-facing `grep` schema — context comes from
|
|
* `grep.contextBefore`/`grep.contextAfter`, fixed when the shared instance is
|
|
* constructed — so honoring them needs a fresh tool per call.
|
|
*
|
|
* The result is wrapped exactly like a registry tool: the approval gate runs on
|
|
* every call site, and a per-call instance is no exception.
|
|
*/
|
|
export function createBridgeGrepFactory(
|
|
session: ToolSession,
|
|
extensionRunner: ExtensionRunner,
|
|
): (options: GrepToolOptions) => AgentTool {
|
|
return options => {
|
|
const grepTool: Tool = new GrepTool(session, options);
|
|
return new ExtensionToolWrapper(grepTool, extensionRunner);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the `replace`-mode `edit` the bridge answers `pi_edit` with.
|
|
*
|
|
* `PiEditExecArgs` carries `old_text`/`new_text` pairs, which is exactly
|
|
* `replace`'s schema and nothing else's. The session's own instance follows the
|
|
* configured `edit.mode` — `hashline` by default, whose schema is a single
|
|
* `input` string — so a frame handed that instance fails validation instead of
|
|
* editing the file.
|
|
*
|
|
* Callers MUST gate this on the session having actually granted `edit`: the
|
|
* tool is constructed rather than looked up, so building one unconditionally
|
|
* hands a restricted agent a mutating tool it was denied (issue #5680).
|
|
*/
|
|
export function createBridgeEditTool(session: ToolSession, extensionRunner: ExtensionRunner): AgentTool {
|
|
const editTool: Tool = new EditTool(session, "replace");
|
|
return new ExtensionToolWrapper(editTool, extensionRunner);
|
|
}
|
|
|
|
/**
|
|
* The tool map the exec bridge should run, given the map a caller granted.
|
|
*
|
|
* `pi_edit` needs a `replace`-mode instance, but only when `edit` was granted:
|
|
* the tool is constructed rather than looked up, so substituting
|
|
* unconditionally would hand a restricted roster a mutating tool it was denied
|
|
* (issue #5680). The granted map is never mutated: an unsubstituted result is a
|
|
* copy, so a caller without an `edit` grant cannot accidentally gain one.
|
|
*
|
|
* The advisor roster passes its granted map here; the primary session keeps its
|
|
* instance out of the registry entirely (Cursor does not advertise `edit`) and
|
|
* serves it through the bridge's `getEditReplaceTool` accessor instead — not
|
|
* the `getTool` fallback, which doubles as the agent loop's resolver for
|
|
* unadvertised calls and must stay device-only.
|
|
*/
|
|
export function bridgeToolMap(
|
|
granted: ReadonlyMap<string, AgentTool>,
|
|
createEditTool: (() => AgentTool | undefined) | undefined,
|
|
): Map<string, AgentTool> {
|
|
const bridged = new Map(granted);
|
|
if (!granted.has("edit") || !createEditTool) return bridged;
|
|
const bridgeEdit = createEditTool();
|
|
if (bridgeEdit) bridged.set("edit", bridgeEdit);
|
|
return bridged;
|
|
}
|