FN-9098: bridge scoped Fusion tools into Cursor
Publish engine-owned Fusion tools to Cursor through a crash-safe, worktree-scoped MCP bridge. - preserve operator MCP configuration with locking, journaling, quarantine, and lease reconciliation - enforce identity-scoped fn_* provenance so injected custom and MCP tools are never exposed - secure loopback dispatch with per-session tokens, heartbeats, cleanup, and normalized tool events - document the Cursor contract and cover bridge lifecycle, config hygiene, and failure handling Files changed: .changeset/fn-9098-cursor-mcp-bridge.md | 7 + docs/cursor-cli-contract.md | 140 ++------------- docs/mcp.md | 4 + .../src/__tests__/agent-session-helpers.test.ts | 24 +++ .../src/__tests__/step-session-executor.test.ts | 16 ++ .../src/__tests__/web-fetch-universal.test.ts | 4 +- packages/engine/src/agent-heartbeat.ts | 3 +- packages/engine/src/agents/agent-runtime.ts | 9 + .../engine/src/agents/agent-session-helpers.ts | 26 +-- packages/engine/src/execution/reviewer.ts | 1 + .../engine/src/execution/step-session-executor.ts | 31 ++-- .../engine/src/executor/execute-workflow-step.ts | 4 +- packages/engine/src/merger.ts | 4 +- plugins/fusion-plugin-cursor-runtime/README.md | 18 +- plugins/fusion-plugin-cursor-runtime/package.json | 2 +- .../src/__tests__/cursor-mcp-config.test.ts | 100 +++++++++++ .../cursor-mcp-server-failure.stream.jsonl | 3 + .../fixtures/cursor-mcp-tool-call.stream.jsonl | 4 + .../src/__tests__/runtime-adapter.test.ts | 57 +++++- .../src/__tests__/worktree-hygiene.test.ts | 52 ++++++ .../src/cursor-mcp-config.ts | 196 +++++++++++++++++++++ .../src/mcp-schema-server.cjs | 155 ++++++++++++++++ .../src/prompt-transport.ts | 4 +- .../src/runtime-adapter.ts | 67 +++++-- .../src/tool-bridge.ts | 48 +++++ .../src/tool-mapping.ts | 11 ++ plugins/fusion-plugin-cursor-runtime/src/types.ts | 6 +- .../src/worktree-hygiene.ts | 117 ++++++++++++ 28 files changed, 934 insertions(+), 179 deletions(-) Fusion-Task-Id: FN-9098 Fusion-Task-Lineage: 11b6cb10-ce0e-4f33-9007-c83f2bbf82ea Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9098-cursor-mcp-bridge.md
Normal file
7
.changeset/fn-9098-cursor-mcp-bridge.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Bridge Fusion task tools into Cursor CLI sessions safely.
|
||||||
|
category: feature
|
||||||
|
dev: Adds tokenized bridge env vars, baseline-first journaled `.cursor/mcp.json` leases, exclusion-before-creation, operator-edit quarantine/recovery, tracked-config refusal, and awaited disposal.
|
||||||
@@ -1,139 +1,25 @@
|
|||||||
# Cursor CLI Contract (FN-3396 Step 0)
|
|
||||||
|
|
||||||
Date: 2026-05-07
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
FNXC:CursorCli 2026-07-08-00:00:
|
FNXC:CursorCli 2026-08-15-21:17:
|
||||||
The original FN-3396 preflight assumed model discovery via JSON-flagged subcommand variants with a plain-text fallback, and stated no auth-status command was confirmed. FN-7697 captured and shipped the real `cursor-agent` CLI contract: model discovery is `cursor-agent models` (plain text `id - Label` lines, no JSON flag) and authentication is derived from `cursor-agent status --format json` (`isAuthenticated`). This doc was corrected on 2026-07-08 to match the verified contract; see FN-7697 for the implementation.
|
Cursor MCP support is enabled only after the CLI contract is observed: project config resolution,
|
||||||
|
tool-name prefix, MCP approval semantics, and negotiated protocol. Fusion stages its config only
|
||||||
|
for that verified session contract; it never assumes a fallback CLI flag or config location.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
**Update history:** 2026-07-08 — corrected the model-discovery and auth-status contract from FN-3396's assumed `--json` commands to the verified `cursor-agent models` / `cursor-agent status --format json` contract captured and implemented in FN-7697.
|
## MCP staging and cleanup
|
||||||
|
|
||||||
## Research method
|
Fusion creates a unique `fusion-custom-tools-<uuid>` server key per Cursor session. The `.cursor/.fusion-mcp-state.json` manifest retains the complete `{ command, args, env }` entry for every lease, allowing one process to recompose a peer process's live entry. Operator content is taken from current bytes; Fusion content is taken from that manifest.
|
||||||
|
|
||||||
- Local runtime inspection in the task environment (`which`, direct command execution).
|
Before staging in a git worktree, Fusion writes its marker block to `info/exclude`, then creates `.cursor/` and its lock directory. The marker covers `mcp.json`, the state record, and the lock so step-boundary `git add -A` cannot capture session files. The first stage persists the byte-exact baseline before it writes config bytes. Later changes journal the intended output before atomic config replacement, then promote the record, so a crash can resolve either the intended or previous byte sequence without mistaking Fusion output for an operator edit.
|
||||||
- Local binary wrapper inspection (`cursor`, `cursor-agent` launch scripts and install layout).
|
|
||||||
- Bounded `fn_research_run` was attempted but failed in this environment with: `table research_runs has no column named projectId`.
|
|
||||||
|
|
||||||
## Confirmed invocation and binary detection
|
A tracked `.cursor/mcp.json` is refused. Byte-different operator edits are preserved rather than restored over. If an operator edit makes the file unparsable, Fusion quarantines the worktree: no process writes that config, the exclusion remains, and further staging is refused. Reconciliation clears the quarantine only after the config is deleted or has valid JSON with no `fusion-custom-tools-*` keys.
|
||||||
|
|
||||||
- **Primary executable aliases found on PATH:**
|
### Worktree safety protocol
|
||||||
- `cursor`
|
|
||||||
- `cursor-agent`
|
|
||||||
- **Not found on PATH:**
|
|
||||||
- `cursor-cli`
|
|
||||||
- `cursor` is a wrapper that can delegate to agent mode and emits a targeted message when IDE install is missing.
|
|
||||||
- `cursor-agent` is the direct CLI runtime entrypoint and is symlinked to a versioned install under:
|
|
||||||
- `~/.local/share/cursor-agent/versions/<version>/cursor-agent`
|
|
||||||
|
|
||||||
### Detection strategy
|
The lease manifest records each `serverEntry` as `{ command, args, env? }`; entries are always recomposed from the durable manifest while non-Fusion content comes from the current on-disk JSON. A peer can therefore dispose without dropping another process's bridge. The first stage commits the raw baseline before its first config write. Every subsequent mutation writes `pending { kind, raw, seq }`, atomically replaces the config, then promotes the pending record. Recovery compares bytes against the pending result and last confirmed result: match pending promotes, match prior discards, and any other bytes latch an operator edit.
|
||||||
|
|
||||||
1. If the global `cursorCliBinaryPath` setting is a non-empty string, probe that configured binary first.
|
Bootstrap is deliberately outside the main lock because that lock lives in `.cursor/`: resolve git shape, serialize the `info/exclude` marker under the git-dir bootstrap lock, observe/create `.cursor/` with an `EEXIST`-safe ownership observation, then acquire the main lock. On final cleanup the inverse is used: the exclusion marker is the last in-lock removal, the main lock is released immediately, and only then may Fusion make one non-recursive `rmdir` attempt. A failed `rmdir` is a benign peer/operator race and is retried only by a later reconciliation.
|
||||||
2. Probe `cursor-agent` from PATH.
|
|
||||||
3. Probe `cursor` from PATH.
|
|
||||||
4. Deduplicate candidates when the configured value is exactly `cursor-agent` or `cursor`.
|
|
||||||
5. Persist the resolved path and executable name in probe results.
|
|
||||||
6. Report explicit failure reason when neither exists.
|
|
||||||
|
|
||||||
### Manual binary path override
|
Lock owners persist PID, hostname, and acquisition time. Contenders retry briefly and may reclaim only a dead same-host owner or an expired critical-section TTL. Leases heartbeat independently for long turns. The synchronous process-exit backstop makes one free-lock attempt only; it never bootstraps, takes over a stale lock, or writes when a peer owns the lock. Reconciliation is the crash-recovery owner.
|
||||||
|
|
||||||
<!--
|
When quarantine is held, `.fusion-mcp-state.json` remains as the durable record and the Fusion `info/exclude` block is intentionally retained so leftover bridge entries cannot be swept by `git add -A`. To recover, repair the JSON and remove every `fusion-custom-tools-*` entry, or delete the config; a later Cursor session clears the record and exclusion automatically. A merely parseable file that still has a Fusion key remains quarantined.
|
||||||
FNXC:CursorCli 2026-07-02-00:00:
|
|
||||||
Operators can set a global Cursor CLI binary path when PATH discovery resolves the wrong shim. The override is optional and must never remove the cursor-agent/cursor fallback probes.
|
|
||||||
-->
|
|
||||||
|
|
||||||
Settings → Authentication → Cursor CLI exposes an optional binary path field. Leave it blank to use PATH auto-detection. When populated, Fusion validates the configured path by running the same `--version` probe used for status/enable, saves it only if that configured candidate itself succeeds, and then uses it for status, enable validation, and Cursor model discovery before falling back to PATH candidates.
|
|
||||||
|
|
||||||
If the configured path fails during ordinary status/model-discovery probes but a PATH candidate succeeds, Fusion remains usable and reports the PATH candidate as the effective `binaryPath`; bounded diagnostics include the configured-path failure. If saving a new non-empty override fails or only succeeds via PATH fallback, the Settings save returns a 400 diagnostic and does not persist the path.
|
|
||||||
|
|
||||||
Windows paths with spaces, for example `C:\Users\A User\AppData\Roaming\npm\cursor-agent.cmd`, are treated as one operator-provided string. Users should not quote or split the path in the UI.
|
|
||||||
|
|
||||||
### Windows PATH shim invocation
|
|
||||||
|
|
||||||
<!--
|
|
||||||
FNXC:CursorCli 2026-07-02-00:00:
|
|
||||||
Windows Cursor installs may publish `cursor-agent.cmd`, `cursor.cmd`, or equivalent `.bat` shims on PATH; Fusion must invoke Cursor probe and discovery commands through the Windows shell so Node can execute those wrappers.
|
|
||||||
Unix and macOS stay direct-spawned to avoid broadening shell semantics beyond the platform that requires it.
|
|
||||||
-->
|
|
||||||
|
|
||||||
On Windows, `cursor-agent`, `cursor`, and manual override paths can resolve to `.cmd` / `.bat` wrappers rather than native executables. Node.js direct `spawn(binary, args)` does not execute those wrappers reliably; Fusion's Cursor command runner therefore sets shell execution only when `process.platform === "win32"`.
|
|
||||||
|
|
||||||
The Windows shell-backed path applies to every Cursor CLI command Fusion currently runs through the shared runner:
|
|
||||||
|
|
||||||
- Configured binary / `cursor-agent --version` / `cursor --version` probe attempts.
|
|
||||||
- Auth-status probe against the effective probe-selected binary: `cursor-agent status --format json`.
|
|
||||||
- Model discovery against the effective probe-selected binary: `cursor-agent models` (plain text, no `--json` flag).
|
|
||||||
|
|
||||||
Non-Windows probes and discovery continue to use direct spawn. Spawn errors such as `ENOENT` or `EACCES` are included in the unavailable probe reason in bounded diagnostic form so a working terminal command is distinguishable from known Cursor runtime/auth states; Fusion does not dump PATH, environment variables, or unbounded stdout/stderr.
|
|
||||||
|
|
||||||
## Confirmed error/auth/runtime signals
|
|
||||||
|
|
||||||
Observed command behavior in this environment:
|
|
||||||
|
|
||||||
- `cursor --help` (without IDE install):
|
|
||||||
- `Error: No Cursor IDE installation found. Use 'cursor agent' or 'agent' to run the agent.`
|
|
||||||
- `cursor-agent --help` and `cursor agent --help` (with locked keychain):
|
|
||||||
- `Error: Your macOS login keychain is locked.`
|
|
||||||
- `Run security unlock-keychain and try again.`
|
|
||||||
|
|
||||||
### Auth/readiness implications
|
|
||||||
|
|
||||||
- Keychain-locked is a distinct, expected failure mode and must be surfaced as an auth/runtime-blocked state (not as unknown crash).
|
|
||||||
- Missing IDE install is a distinct expected failure mode from missing binary.
|
|
||||||
|
|
||||||
## Structured output and model discovery
|
|
||||||
|
|
||||||
- **Confirmed:** `cursor-agent models` is the model-list command. Output is plain text — passing an unsupported JSON output flag (e.g. appending `--json` to the `models` subcommand) fails with `error: unknown option '--json'`.
|
|
||||||
- Output shape: an `Available models` header line, a blank line, then one model per line formatted as `<id> - <Label>` (e.g. `auto - Auto (default)`, `claude-4.5-sonnet - Sonnet 4.5`), followed by a trailing tip line: `Tip: use --model <id> (or /model <id> in interactive mode) to switch.`.
|
|
||||||
- Empty-account state: `No models available for this account.` (no model lines follow).
|
|
||||||
- `cursor-agent --list-models` exists but is unreliable — it can report "No models available for this account." even while the CLI is authenticated with models available. Prefer `cursor-agent models`.
|
|
||||||
|
|
||||||
### Model discovery parsing strategy (implemented)
|
|
||||||
|
|
||||||
1. Run `cursor-agent models` (or the effective probe-selected binary) with a short timeout.
|
|
||||||
2. Split stdout into lines; extract the bare model id as the segment before the first ` - ` on each line.
|
|
||||||
3. Filter out the `Available models` header, the trailing `Tip:` line, the `No models available for this account.` empty-state line, and blank lines.
|
|
||||||
4. Normalize and dedupe the remaining ids into the discovered model set.
|
|
||||||
5. If the command is unavailable or fails, return an empty discovered set with a machine-readable reason; host surfaces Cursor models only when provider readiness + discovery usability conditions are met.
|
|
||||||
|
|
||||||
### Authentication / status
|
|
||||||
|
|
||||||
- **Confirmed:** authentication state is derived from `cursor-agent status --format json` (alias `whoami`), which returns a JSON object with `isAuthenticated` (boolean), plus `status`, `hasAccessToken`, and `userInfo`.
|
|
||||||
- Use `isAuthenticated` as the auth signal instead of treating a successful `--version` probe as a proxy for readiness. `--version` remains the availability/version probe (bare version string), separate from auth.
|
|
||||||
- Keychain-locked and missing-IDE-install remain distinct expected failure modes on top of this (see "Confirmed error/auth/runtime signals" above) — a locked keychain or missing IDE surfaces as its own runtime-blocked state rather than folding into `isAuthenticated: false`.
|
|
||||||
|
|
||||||
## Provider ID decision
|
|
||||||
|
|
||||||
- Use **`cursor-cli`** as the provider ID.
|
|
||||||
- Rationale: aligns with task requirement; no conflicting provider ID observed in current codebase scan.
|
|
||||||
|
|
||||||
## Contract freeze for FN-3396 (superseded by the verified contract below)
|
|
||||||
|
|
||||||
The original FN-3396 preflight treated the following as canonical pending stronger evidence:
|
|
||||||
|
|
||||||
- Binary candidates: `cursor-agent`, `cursor`.
|
|
||||||
- Expected failure states include: missing binary, missing IDE installation, keychain locked, unauthenticated/not-ready CLI.
|
|
||||||
- Model discovery must be dynamic-first with resilient fallback and no hardcoded static catalog by default.
|
|
||||||
|
|
||||||
Binary candidates and expected failure states above remain accurate. The dynamic-first/no-static-catalog principle also still holds, but the specific commands are now confirmed rather than assumed — see "Structured output and model discovery" and "Windows PATH shim invocation" above for the verified `cursor-agent models` / `cursor-agent status --format json` contract that replaces the earlier `--json`-flag guesswork.
|
|
||||||
|
|
||||||
<!--
|
|
||||||
FNXC:CursorCli 2026-08-15-15:16:
|
|
||||||
FN-9097 verified the non-interactive Cursor transport against cursor-agent 2026.08.11-e8db854. Prompt content travels on stdin and cwd alone binds the workspace, so the streaming transport omits --workspace and avoids an avoidable command-boundary token.
|
|
||||||
-->
|
|
||||||
|
|
||||||
**Update history:** 2026-08-15 — FN-9097 verified the execution transport and added the supervised Windows launch contract.
|
|
||||||
|
|
||||||
## Execution transport contract
|
|
||||||
|
|
||||||
External integration evidence: Cursor CLI is closed-source with no canonical upstream source repository; public tracker: https://github.com/cursor/cursor. Docs: https://cursor.com/docs/cli/overview. Installation: `curl https://cursor.com/install -fsS | bash` or `irm 'https://cursor.com/install?win32=true' | iex`. Binary: `cursor-agent`. Checksum: `upstream-pending-verification` because Cursor publishes no per-release manifest; local observed version: `2026.08.11-e8db854`.
|
|
||||||
|
|
||||||
On 2026-08-15, in an external scratch directory, `printf 'Reply with exactly: ok\\n' | cursor-agent --print --output-format stream-json --force --trust --model auto` exited 0 and emitted NDJSON `system/init`, `user`, `thinking`, `assistant`, and `result` events. The init event reported the process cwd, proving no `--workspace` argument is needed. A `--mode plan --trust` run read a scratch file, emitted a `readToolCall` started/completed pair, and terminated without `--force`. `-p` help states it has write/shell tool access; `--force` controls approvals, while `--trust` clears workspace trust. `--sandbox` was left config-driven. `--resume <session_id>` and `--model <id>` are supported; no system-prompt flag exists, so the first prompt contains the fused system context. `mcp --help` confirms MCP configuration is `.cursor/mcp.json`-based and has no `--mcp-config` flag.
|
|
||||||
|
|
||||||
Contract supports non-interactive execution: **yes**. The transport maps coding to `--force --trust`; readonly and unset tools to `--mode plan --trust`, never `--force`. `--stream-partial-output` is omitted: assistant messages are deduplicated by emitted content because Cursor can emit the same response with and without `model_call_id`.
|
|
||||||
|
|
||||||
### Streaming Windows launch decision
|
|
||||||
|
|
||||||
Prompt turns always use `superviseSpawn` with `shell:false`, a first-line deadline, and an inactivity deadline reset by every stream line; active output has no total-duration cap. When no binary override is configured, an absent `cursor-agent` launch retries the documented `cursor` PATH fallback. They prefer direct executable targets, send the prompt on stdin, and omit `--workspace`. A cmd shim is the sole cmd boundary: each token is validated then rejected for `" % ! ^ & | < > ( )` and controls before quote-only escaping, ComSpec must be an absolute `cmd.exe`, and command lines over 8000 characters fail. `windowsVerbatimArguments` is used only there (it had no prior repository call site). A `.ps1` target uses PowerShell `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File`; unknown extensions fail.
|
|
||||||
|
|
||||||
This avoids cmd's re-parse (Node argv escaping is not cmd-safe), Node's refusal to direct-spawn batch files, and launcher-only kills that orphan agents. Resolution honors the first `where.exe` directory and PATHEXT only within it. POSIX teardown uses the supervisor process group; Windows additionally invokes `taskkill /T /F`. Windows PATH shim shape remains unverified on this macOS host, so launch is shape-agnostic. Probe/discovery retains its existing shell-backed behavior described above; only streaming turns use this hardened branch.
|
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ The scanner reads only these well-known paths; missing files are normal and malf
|
|||||||
|
|
||||||
Claude Desktop, Claude Code, Cursor, and Windsurf use the Claude-style `{ "mcpServers": { ... } }` shape. VS Code project config can use `{ "servers": { ... } }`; Fusion normalizes it to the same import parser before rendering candidates.
|
Claude Desktop, Claude Code, Cursor, and Windsurf use the Claude-style `{ "mcpServers": { ... } }` shape. VS Code project config can use `{ "servers": { ... } }`; Fusion normalizes it to the same import parser before rendering candidates.
|
||||||
|
|
||||||
|
## Cursor runtime task tools
|
||||||
|
|
||||||
|
Cursor runtime sessions bridge Fusion `fn_*` tools through a session-scoped project `.cursor/mcp.json` stdio entry. Fusion writes an `info/exclude` block before staging the entry, retains a durable lease manifest so concurrent sessions preserve each other's server definitions, and restores a pre-existing operator config after the last lease. The initial baseline is recorded before Fusion writes config bytes; later writes use an intent journal so crash recovery can distinguish its own atomic write from an operator edit. A tracked `.cursor/mcp.json` disables the bridge. An operator edit is preserved; an unparsable operator edit quarantines the worktree, retains the exclusion, and disables staging until the JSON is repaired and `fusion-custom-tools-*` entries are removed (or the file is deleted).
|
||||||
|
|
||||||
Sensitive discovery follows the same no-plaintext rule as manual import. If a third-party file contains inline environment values, header values, or token-like values, the API response includes only secret descriptor metadata (`field`, `key`, `suggestedKey`, `scope`) and the candidate definition uses Fusion `McpSecretRef` placeholders. The dashboard **Add** flow opens the server editor so operators choose existing Fusion secrets or create new Fusion-managed secrets; the settings blob stores only `{ secretRef, scope }` references.
|
Sensitive discovery follows the same no-plaintext rule as manual import. If a third-party file contains inline environment values, header values, or token-like values, the API response includes only secret descriptor metadata (`field`, `key`, `suggestedKey`, `scope`) and the candidate definition uses Fusion `McpSecretRef` placeholders. The dashboard **Add** flow opens the server editor so operators choose existing Fusion secrets or create new Fusion-managed secrets; the settings blob stores only `{ secretRef, scope }` references.
|
||||||
|
|
||||||
The dashboard uses this route:
|
The dashboard uses this route:
|
||||||
|
|||||||
@@ -972,6 +972,30 @@ describe("createResolvedAgentSession", () => {
|
|||||||
expect(execute).not.toHaveBeenCalled();
|
expect(execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves an identity-scoped Fusion subset without trusting injected fn_* names", async () => {
|
||||||
|
const createSessionMock = vi.fn().mockResolvedValue({ session: { prompt: vi.fn() } });
|
||||||
|
resolveRuntimeMock.mockResolvedValue({
|
||||||
|
runtime: { id: "cursor", name: "Cursor", createSession: createSessionMock, promptWithFallback: vi.fn(), describeModel: vi.fn(() => "cursor/auto") },
|
||||||
|
runtimeId: "cursor",
|
||||||
|
wasConfigured: true,
|
||||||
|
});
|
||||||
|
const trusted = { name: "fn_task_list", description: "", parameters: {}, execute: vi.fn() } as any;
|
||||||
|
const injected = { name: "fn_evil", description: "", parameters: {}, execute: vi.fn() } as any;
|
||||||
|
|
||||||
|
await createResolvedAgentSession({
|
||||||
|
sessionPurpose: "executor",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
systemPrompt: "system",
|
||||||
|
customTools: [injected, trusted],
|
||||||
|
fusionTools: [trusted],
|
||||||
|
});
|
||||||
|
|
||||||
|
const passed = createSessionMock.mock.calls[0][0];
|
||||||
|
expect(passed.customTools.map((tool: { name: string }) => tool.name)).toEqual(["fn_evil", "fn_task_list"]);
|
||||||
|
expect(passed.fusionTools.map((tool: { name: string }) => tool.name)).toEqual(["fn_task_list"]);
|
||||||
|
expect(passed.fusionTools[0]).toBe(passed.customTools[1]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not pre-wrap customTools for the pi runtime (createFnAgent owns the chain)", async () => {
|
it("does not pre-wrap customTools for the pi runtime (createFnAgent owns the chain)", async () => {
|
||||||
const mockSession = { prompt: vi.fn() } as any;
|
const mockSession = { prompt: vi.fn() } as any;
|
||||||
const createSessionMock = vi.fn().mockResolvedValue({ session: mockSession });
|
const createSessionMock = vi.fn().mockResolvedValue({ session: mockSession });
|
||||||
|
|||||||
@@ -2618,6 +2618,22 @@ describe("StepSessionExecutor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("cleanup failure diagnostics", () => {
|
describe("cleanup failure diagnostics", () => {
|
||||||
|
it("awaits asynchronous session disposal before the step boundary completes", async () => {
|
||||||
|
const task = makeTaskDetail({ prompt: makeStepPrompt("FN-ASYNC-DISPOSE", 1), steps: [{ name: "Step 0", status: "pending" }] });
|
||||||
|
let resolveDispose!: () => void;
|
||||||
|
const session = makeMockSession();
|
||||||
|
session.dispose = vi.fn(() => new Promise<void>((resolve) => { resolveDispose = resolve; }));
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({ session } as any);
|
||||||
|
const executor = new StepSessionExecutor({ taskDetail: task, worktreePath: "/project/.worktrees/main", rootDir: "/project", settings: makeSettings() });
|
||||||
|
let completed = false;
|
||||||
|
const execution = executor.executeAll().then((result) => { completed = true; return result; });
|
||||||
|
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalledOnce());
|
||||||
|
expect(completed).toBe(false);
|
||||||
|
resolveDispose();
|
||||||
|
await execution;
|
||||||
|
expect(completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("logs warning when session dispose fails during error cleanup", async () => {
|
it("logs warning when session dispose fails during error cleanup", async () => {
|
||||||
const task = makeTaskDetail({
|
const task = makeTaskDetail({
|
||||||
prompt: makeStepPrompt("FN-001", 1),
|
prompt: makeStepPrompt("FN-001", 1),
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ describe("fn_web_fetch universal registration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("merger registers fn_web_fetch", () => {
|
it("merger registers fn_web_fetch", () => {
|
||||||
expect(readSource("merger.ts")).toContain("customTools: [reportBuildFailureTool, createWebFetchTool()]");
|
const mergerSrc = readSource("merger.ts");
|
||||||
|
expect(mergerSrc).toContain("const mergerFusionTools = [reportBuildFailureTool, createWebFetchTool()]");
|
||||||
|
expect(mergerSrc).toContain("customTools: mergerFusionTools");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("triage registers fn_web_fetch", () => {
|
it("triage registers fn_web_fetch", () => {
|
||||||
|
|||||||
@@ -3172,6 +3172,7 @@ export class HeartbeatMonitor {
|
|||||||
systemPromptLayers: heartbeatLayers,
|
systemPromptLayers: heartbeatLayers,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
customTools: heartbeatTools,
|
customTools: heartbeatTools,
|
||||||
|
fusionTools: heartbeatTools,
|
||||||
defaultProvider: heartbeatSessionModels.defaultProvider,
|
defaultProvider: heartbeatSessionModels.defaultProvider,
|
||||||
defaultModelId: heartbeatSessionModels.defaultModelId,
|
defaultModelId: heartbeatSessionModels.defaultModelId,
|
||||||
...(heartbeatSessionModels.credentialInstanceId ? { credentialInstanceId: heartbeatSessionModels.credentialInstanceId } : {}),
|
...(heartbeatSessionModels.credentialInstanceId ? { credentialInstanceId: heartbeatSessionModels.credentialInstanceId } : {}),
|
||||||
@@ -3727,7 +3728,7 @@ export class HeartbeatMonitor {
|
|||||||
session.dispose();
|
session.dispose();
|
||||||
const created = await createResolvedAgentSession({
|
const created = await createResolvedAgentSession({
|
||||||
sessionPurpose: "heartbeat", runtimeHint: extractRuntimeHint(agent.runtimeConfig), pluginRunner: this.pluginRunner,
|
sessionPurpose: "heartbeat", runtimeHint: extractRuntimeHint(agent.runtimeConfig), pluginRunner: this.pluginRunner,
|
||||||
cwd: sessionCwd, systemPrompt: systemPromptFinal, systemPromptLayers: heartbeatLayers, tools: "coding", customTools: heartbeatTools,
|
cwd: sessionCwd, systemPrompt: systemPromptFinal, systemPromptLayers: heartbeatLayers, tools: "coding", customTools: heartbeatTools, fusionTools: heartbeatTools,
|
||||||
defaultProvider: heartbeatSessionModels.defaultProvider, defaultModelId: heartbeatSessionModels.defaultModelId,
|
defaultProvider: heartbeatSessionModels.defaultProvider, defaultModelId: heartbeatSessionModels.defaultModelId,
|
||||||
credentialInstanceId: activeInstanceId, fallbackProvider: heartbeatSessionModels.fallbackProvider,
|
credentialInstanceId: activeInstanceId, fallbackProvider: heartbeatSessionModels.fallbackProvider,
|
||||||
fallbackModelId: heartbeatSessionModels.fallbackModelId,
|
fallbackModelId: heartbeatSessionModels.fallbackModelId,
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ export interface AgentRuntimeOptions {
|
|||||||
tools?: "coding" | "readonly";
|
tools?: "coding" | "readonly";
|
||||||
/** Additional custom tools to merge with the base toolset */
|
/** Additional custom tools to merge with the base toolset */
|
||||||
customTools?: ToolDefinition[];
|
customTools?: ToolDefinition[];
|
||||||
|
/**
|
||||||
|
* Engine-owned Fusion tools that a runtime may publish through an external bridge.
|
||||||
|
* This must be an identity-based subset of customTools; plugin/MCP tools remain custom-only.
|
||||||
|
*
|
||||||
|
* FNXC:CursorMcpBridge 2026-08-15-23:46:
|
||||||
|
* Cursor must never infer Fusion ownership from an `fn_` name or a forgeable marker.
|
||||||
|
* The engine records provenance structurally before plugin-runtime wrappers run.
|
||||||
|
*/
|
||||||
|
fusionTools?: ToolDefinition[];
|
||||||
/** Per-result shared tool-output cap. `null` disables the wrapper; undefined uses the built-in default. */
|
/** Per-result shared tool-output cap. `null` disables the wrapper; undefined uses the built-in default. */
|
||||||
toolOutputMaxChars?: number | null;
|
toolOutputMaxChars?: number | null;
|
||||||
/** Callback for text output from the agent */
|
/** Callback for text output from the agent */
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ export function wrapCustomToolsForPluginRuntime(
|
|||||||
return wrapToolsWithOutputBudget(withActionGate, { maxChars: options.toolOutputMaxChars });
|
return wrapToolsWithOutputBudget(withActionGate, { maxChars: options.toolOutputMaxChars });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapPluginRuntimeToolOptions(
|
||||||
|
options: AgentRuntimeOptions,
|
||||||
|
logContext: { runtimeId: string; sessionPurpose: string },
|
||||||
|
): Pick<AgentRuntimeOptions, "customTools" | "fusionTools"> {
|
||||||
|
const original = options.customTools;
|
||||||
|
const wrapped = wrapCustomToolsForPluginRuntime(original, options, logContext);
|
||||||
|
if (!original || !wrapped) return { customTools: wrapped, fusionTools: undefined };
|
||||||
|
|
||||||
|
const scoped = new Set(options.fusionTools ?? []);
|
||||||
|
const fusionTools = original.flatMap((tool, index) =>
|
||||||
|
scoped.has(tool) && tool.name.startsWith("fn_") && wrapped[index] ? [wrapped[index]] : []);
|
||||||
|
return { customTools: wrapped, fusionTools };
|
||||||
|
}
|
||||||
|
|
||||||
function shouldWrapCustomToolsForRuntime(runtimeId: string): boolean {
|
function shouldWrapCustomToolsForRuntime(runtimeId: string): boolean {
|
||||||
return !RUNTIMES_WITH_INTERNAL_TOOL_GATING.has(runtimeId);
|
return !RUNTIMES_WITH_INTERNAL_TOOL_GATING.has(runtimeId);
|
||||||
}
|
}
|
||||||
@@ -976,11 +990,7 @@ export async function createResolvedAgentSession(
|
|||||||
? {
|
? {
|
||||||
...effectiveRuntimeOptionsWithModel,
|
...effectiveRuntimeOptionsWithModel,
|
||||||
sessionPurpose,
|
sessionPurpose,
|
||||||
customTools: wrapCustomToolsForPluginRuntime(
|
...wrapPluginRuntimeToolOptions(effectiveRuntimeOptionsWithModel, { runtimeId: resolved.runtimeId, sessionPurpose }),
|
||||||
effectiveRuntimeOptionsWithModel.customTools,
|
|
||||||
effectiveRuntimeOptionsWithModel,
|
|
||||||
{ runtimeId: resolved.runtimeId, sessionPurpose },
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
...effectiveRuntimeOptionsWithModel,
|
...effectiveRuntimeOptionsWithModel,
|
||||||
@@ -1107,11 +1117,7 @@ export async function createResolvedAgentSession(
|
|||||||
grokCreateOptions: shouldWrapCustomToolsForRuntime("grok")
|
grokCreateOptions: shouldWrapCustomToolsForRuntime("grok")
|
||||||
? {
|
? {
|
||||||
...grokBaseOptions,
|
...grokBaseOptions,
|
||||||
customTools: wrapCustomToolsForPluginRuntime(
|
...wrapPluginRuntimeToolOptions(grokBaseOptions, { runtimeId: "grok", sessionPurpose }),
|
||||||
effectiveRuntimeOptionsWithModel.customTools,
|
|
||||||
effectiveRuntimeOptionsWithModel,
|
|
||||||
{ runtimeId: "grok", sessionPurpose },
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: grokBaseOptions,
|
: grokBaseOptions,
|
||||||
primaryProvider: runtimeOptions.defaultProvider,
|
primaryProvider: runtimeOptions.defaultProvider,
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ export async function reviewStep(
|
|||||||
systemPromptLayers: layers,
|
systemPromptLayers: layers,
|
||||||
tools: options.allowInlineFixes === true && reviewType === "code" ? "coding" : "readonly",
|
tools: options.allowInlineFixes === true && reviewType === "code" ? "coding" : "readonly",
|
||||||
customTools: reviewCustomTools,
|
customTools: reviewCustomTools,
|
||||||
|
fusionTools: reviewCustomTools,
|
||||||
onText: handleReviewerText,
|
onText: handleReviewerText,
|
||||||
onThinking: agentLogger?.onThinking,
|
onThinking: agentLogger?.onThinking,
|
||||||
onToolStart: agentLogger?.onToolStart,
|
onToolStart: agentLogger?.onToolStart,
|
||||||
|
|||||||
@@ -690,7 +690,7 @@ const RETRY_DELAYS_MS = [1_000, 5_000, 15_000];
|
|||||||
|
|
||||||
/** A minimal session handle stored for termination support. */
|
/** A minimal session handle stored for termination support. */
|
||||||
interface SessionHandle {
|
interface SessionHandle {
|
||||||
dispose: () => void;
|
dispose: () => void | Promise<void>;
|
||||||
/** Abort the session's currently-running bash command (if any) so its
|
/** Abort the session's currently-running bash command (if any) so its
|
||||||
* detached subprocess tree — including grandchildren like vitest workers —
|
* detached subprocess tree — including grandchildren like vitest workers —
|
||||||
* is killed via pi-coding-agent's killProcessTree. dispose() alone only
|
* is killed via pi-coding-agent's killProcessTree. dispose() alone only
|
||||||
@@ -824,7 +824,7 @@ export class StepSessionExecutor {
|
|||||||
stepExecLog.warn(`Failed to abort reusable primary step session: ${err}`);
|
stepExecLog.warn(`Failed to abort reusable primary step session: ${err}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.reusablePrimarySession.dispose();
|
await this.reusablePrimarySession.dispose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stepExecLog.warn(`Failed to dispose reusable primary step session: ${err}`);
|
stepExecLog.warn(`Failed to dispose reusable primary step session: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1039,7 +1039,8 @@ export class StepSessionExecutor {
|
|||||||
stepExecLog.warn(`Failed to abort bash for step ${stepIdx}: ${err}`);
|
stepExecLog.warn(`Failed to abort bash for step ${stepIdx}: ${err}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
handle.dispose();
|
// FNXC:CursorMcpBridge 2026-08-15-21:20: Abort teardown stays fire-and-forget; normal step cleanup awaits disposal before git worktree cleanup, while journal reconciliation repairs a hard-abort lease.
|
||||||
|
void handle.dispose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stepExecLog.warn(`Failed to dispose session for step ${stepIdx}: ${err}`);
|
stepExecLog.warn(`Failed to dispose session for step ${stepIdx}: ${err}`);
|
||||||
}
|
}
|
||||||
@@ -1428,6 +1429,16 @@ export class StepSessionExecutor {
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const fusionTools = [
|
||||||
|
...documentTools,
|
||||||
|
webFetchTool,
|
||||||
|
...memoryTools,
|
||||||
|
...taskLogTool,
|
||||||
|
...taskCreateTool,
|
||||||
|
...delegationTools,
|
||||||
|
...messagingTools,
|
||||||
|
];
|
||||||
|
|
||||||
// Create or reuse the agent session for this attempt
|
// Create or reuse the agent session for this attempt
|
||||||
// Resolve executor model using canonical lane hierarchy:
|
// Resolve executor model using canonical lane hierarchy:
|
||||||
// 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId)
|
// 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId)
|
||||||
@@ -1483,16 +1494,8 @@ Follow instructions precisely and avoid unrelated changes.`,
|
|||||||
settings,
|
settings,
|
||||||
// FNXC:McpConfig 2026-06-25-23:02: Workflow model-node step sessions receive the same resolved, secret-materialized MCP server set as the parent executor; runtime support is still enforced inside the pi session seam without logging server contents.
|
// FNXC:McpConfig 2026-06-25-23:02: Workflow model-node step sessions receive the same resolved, secret-materialized MCP server set as the parent executor; runtime support is still enforced inside the pi session seam without logging server contents.
|
||||||
mcpServers: this.options.mcpServers,
|
mcpServers: this.options.mcpServers,
|
||||||
customTools: [
|
customTools: [...pluginTools, ...fusionTools],
|
||||||
...pluginTools,
|
fusionTools,
|
||||||
...documentTools,
|
|
||||||
webFetchTool,
|
|
||||||
...memoryTools,
|
|
||||||
...taskLogTool,
|
|
||||||
...taskCreateTool,
|
|
||||||
...delegationTools,
|
|
||||||
...messagingTools,
|
|
||||||
],
|
|
||||||
onText: (delta) => {
|
onText: (delta) => {
|
||||||
const telemetry = reusePrimarySession ? this.selectReusableTelemetry(localTelemetry) : localTelemetry;
|
const telemetry = reusePrimarySession ? this.selectReusableTelemetry(localTelemetry) : localTelemetry;
|
||||||
telemetry.agentLogger.onText(delta);
|
telemetry.agentLogger.onText(delta);
|
||||||
@@ -1708,7 +1711,7 @@ Follow instructions precisely and avoid unrelated changes.`,
|
|||||||
this.reusableStepTelemetry = null;
|
this.reusableStepTelemetry = null;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
session?.dispose();
|
await session?.dispose();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
stepExecLog.warn(`Failed to dispose session for step ${stepIndex}: ${msg}`);
|
stepExecLog.warn(`Failed to dispose session for step ${stepIndex}: ${msg}`);
|
||||||
|
|||||||
@@ -686,7 +686,9 @@ CRITICAL SCOPING RULES — read before doing anything else:
|
|||||||
// Skill selection: assigned-agent / role-fallback skills, plus the step's own named skill (U1) made discoverable via additionalSkillPaths.
|
// Skill selection: assigned-agent / role-fallback skills, plus the step's own named skill (U1) made discoverable via additionalSkillPaths.
|
||||||
...(effectiveSkillSelection ? { skillSelection: effectiveSkillSelection } : {}),
|
...(effectiveSkillSelection ? { skillSelection: effectiveSkillSelection } : {}),
|
||||||
...(additionalSkillPaths ? { additionalSkillPaths } : {}),
|
...(additionalSkillPaths ? { additionalSkillPaths } : {}),
|
||||||
...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}),
|
...(readonlyCustomTools.allowed.length > 0
|
||||||
|
? { customTools: readonlyCustomTools.allowed, fusionTools: readonlyCustomTools.allowed }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
// FNXC:CommandCenterActivity 2026-08-15-22:15: session boundary for the workflow-step runtime session (restored post-wave-18).
|
// FNXC:CommandCenterActivity 2026-08-15-22:15: session boundary for the workflow-step runtime session (restored post-wave-18).
|
||||||
emitAgentSessionStart({ store: deps.store, agentId: task.assignedAgentId ?? null, taskId: task.id, nodeId: task.effectiveNodeId ?? task.nodeId ?? null, model: primaryModelId ?? null, provider: primaryProvider ?? null, lane: "executor" });
|
emitAgentSessionStart({ store: deps.store, agentId: task.assignedAgentId ?? null, taskId: task.id, nodeId: task.effectiveNodeId ?? task.nodeId ?? null, model: primaryModelId ?? null, provider: primaryProvider ?? null, lane: "executor" });
|
||||||
|
|||||||
@@ -11029,6 +11029,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
|||||||
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
|
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
|
||||||
|
|
||||||
const mergerFallbackModel = resolveMergerFallbackModel(settings);
|
const mergerFallbackModel = resolveMergerFallbackModel(settings);
|
||||||
|
const mergerFusionTools = [reportBuildFailureTool, createWebFetchTool()];
|
||||||
|
|
||||||
// FN-5279: Layer 3 / merge-authoring AI runs in the resolved integration
|
// FN-5279: Layer 3 / merge-authoring AI runs in the resolved integration
|
||||||
// root so arbiter edits land in the reused task worktree when handoff mode
|
// root so arbiter edits land in the reused task worktree when handoff mode
|
||||||
@@ -11040,7 +11041,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
|||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: mergerSystemPrompt,
|
systemPrompt: mergerSystemPrompt,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
customTools: [reportBuildFailureTool, createWebFetchTool()],
|
customTools: mergerFusionTools,
|
||||||
|
fusionTools: mergerFusionTools,
|
||||||
onText: agentLogger.onText,
|
onText: agentLogger.onText,
|
||||||
onThinking: agentLogger.onThinking,
|
onThinking: agentLogger.onThinking,
|
||||||
onToolStart: agentLogger.onToolStart,
|
onToolStart: agentLogger.onToolStart,
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ Fusion runs one supervised `cursor-agent --print --output-format stream-json` tu
|
|||||||
| `coding` | `--force --trust` |
|
| `coding` | `--force --trust` |
|
||||||
| `readonly` or unset | `--mode plan --trust` |
|
| `readonly` or unset | `--mode plan --trust` |
|
||||||
|
|
||||||
`--print` already grants built-in write and shell tools. `--force` controls approval, so it is limited to coding sessions whose cwd is Fusion's isolated task worktree. Fusion does not use `--auto-review`, worktree, add-dir, MCP approval, plugin-dir, or sandbox override flags. Fusion `fn_*` tools are not bridged into Cursor; Cursor uses only its own built-in tools.
|
`--print` already grants built-in write and shell tools. `--force` controls approval, so it is limited to coding sessions whose cwd is Fusion's isolated task worktree. Fusion does not use `--auto-review`, worktree, add-dir, plugin-dir, or sandbox override flags.
|
||||||
|
|
||||||
|
## Fusion MCP bridge
|
||||||
|
|
||||||
|
When a session has Fusion `fn_*` custom tools, Fusion starts a token-protected loopback bridge and stages a per-session stdio entry in the task worktree's `.cursor/mcp.json`. Cursor receives `--approve-mcps` only after that lease is staged. The stdio child receives `FUSION_CURSOR_TOOL_BRIDGE_URL` and `FUSION_CURSOR_TOOL_BRIDGE_TOKEN`; `mcp-schema-server.cjs` must be copied into the built package.
|
||||||
|
|
||||||
|
Fusion records the original config before its first write, persists live server entries in `.cursor/.fusion-mcp-state.json`, and removes its entry on disposal. The config is excluded from git while a lease is live. A git-tracked `.cursor/mcp.json` disables the bridge rather than risking a step-boundary commit. If an operator makes the config unparsable during a session, Fusion quarantines that worktree and leaves the config and exclusion untouched; repair the JSON and remove `fusion-custom-tools-*` entries (or delete the config), then the next Cursor session reconciles it.
|
||||||
|
|
||||||
|
<!-- FNXC:CursorMcpBridge 2026-08-15-21:17: The exclusion is installed before `.cursor/` exists because the nested lock has an owner file that `git add -A` could otherwise commit. Cleanup removes that exclusion only after the final lease's lock-protected compose. -->
|
||||||
|
|
||||||
|
### Worktree hygiene and recovery
|
||||||
|
|
||||||
|
Fusion may briefly create a git-dir `fusion-cursor-exclude.lock` during bootstrap, then `.cursor/.fusion-mcp.lock` while a session composes the config. Both are excluded before they exist. The state record can contain a `pending` journal after a crash; the next Cursor session resolves it from exact config bytes. A hard-killed lease is reaped by its heartbeat TTL, not by assuming an exit hook ran. A final empty `.cursor/` directory is removed with a single non-recursive attempt after its nested lock is released; a peer race leaves it for the next reconciliation.
|
||||||
|
|
||||||
|
### Quarantined configuration
|
||||||
|
|
||||||
|
A lane reports `bridge-start-failed` when `.cursor/.fusion-mcp-state.json` contains a `quarantine` record. Fusion deliberately refuses to rewrite that config and keeps its `info/exclude` marker, because the malformed file can still contain `fusion-custom-tools-*` entries that must not enter git. Do not delete the state file by hand. Instead repair `mcp.json` and remove every Fusion entry, or delete `mcp.json`; the next Cursor session clears quarantine and removes the marker automatically.
|
||||||
|
|
||||||
All turns use `superviseSpawn` with a finite lifetime. The Windows prompt transport prefers a direct executable; `.cmd`/`.bat` shims validate and reject cmd metacharacters before a quoted cmd launch, `.ps1` uses PowerShell `-File`, and unknown extensions fail loudly. `PI_CURSOR_CLI_FIRST_LINE_TIMEOUT_MS` and `PI_CURSOR_CLI_TIMEOUT_MS` optionally tune cold-start and inactivity guards.
|
All turns use `superviseSpawn` with a finite lifetime. The Windows prompt transport prefers a direct executable; `.cmd`/`.bat` shims validate and reject cmd metacharacters before a quoted cmd launch, `.ps1` uses PowerShell `-File`, and unknown extensions fail loudly. `PI_CURSOR_CLI_FIRST_LINE_TIMEOUT_MS` and `PI_CURSOR_CLI_TIMEOUT_MS` optionally tune cold-start and inactivity guards.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc && node -e \"require('node:fs').copyFileSync('src/mcp-schema-server.cjs','dist/mcp-schema-server.cjs')\"",
|
||||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { composeMcpConfig, evaluateQuarantine, reconcileCursorWorktree, stageCursorMcpLease } from "../cursor-mcp-config.js";
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
const dir = () => { const value = mkdtempSync(join(tmpdir(), "fusion-cursor-mcp-")); dirs.push(value); return value; };
|
||||||
|
afterEach(() => dirs.splice(0).forEach((value) => rmSync(value, { recursive: true, force: true })));
|
||||||
|
|
||||||
|
describe("Cursor MCP lease config", () => {
|
||||||
|
it("quarantines an initially malformed operator config without changing its bytes", async () => {
|
||||||
|
const root = dir(); const cursor = join(root, ".cursor"); mkdirSync(cursor);
|
||||||
|
const config = join(cursor, "mcp.json"); const malformed = '{"mcpServers":{"operator":'; writeFileSync(config, malformed);
|
||||||
|
await expect(stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } })).rejects.toMatchObject({ code: "bridge-start-failed" });
|
||||||
|
expect(readFileSync(config, "utf8")).toBe(malformed);
|
||||||
|
const state = JSON.parse(readFileSync(join(cursor, ".fusion-mcp-state.json"), "utf8"));
|
||||||
|
expect(state.baseline.parsable).toBe(false);
|
||||||
|
expect(state.quarantine.raw).toBe(malformed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a pre-existing operator config after the final lease", async () => {
|
||||||
|
const root = dir(); const cursor = join(root, ".cursor");
|
||||||
|
await import("node:fs/promises").then(({ mkdir }) => mkdir(cursor));
|
||||||
|
const config = join(cursor, "mcp.json"); const baseline = '{\n "mcpServers": { "operator": { "command": "echo" } }\n}\n'; writeFileSync(config, baseline);
|
||||||
|
const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: process.execPath, args: ["bridge"] } });
|
||||||
|
expect(readFileSync(config, "utf8")).toContain("fusion-custom-tools-one");
|
||||||
|
await lease.dispose(); expect(readFileSync(config, "utf8")).toBe(baseline);
|
||||||
|
});
|
||||||
|
it("preserves a parsable operator edit made before disposal", async () => {
|
||||||
|
const root = dir(); const cursor = join(root, ".cursor"); mkdirSync(cursor);
|
||||||
|
const config = join(cursor, "mcp.json"); writeFileSync(config, '{"mcpServers":{"operator":{"command":"before"}}}\n');
|
||||||
|
const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } });
|
||||||
|
const edited = '{\n "mcpServers": { "operator-edit": { "command": "after" }, "fusion-custom-tools-one": { "command": "stale" } }\n}\n';
|
||||||
|
writeFileSync(config, edited);
|
||||||
|
await lease.dispose();
|
||||||
|
const after = readFileSync(config, "utf8"); expect(after).toContain("operator-edit"); expect(after).not.toContain("fusion-custom-tools-one");
|
||||||
|
});
|
||||||
|
it("removes a Fusion-created cursor directory after releasing its nested lock", async () => {
|
||||||
|
const root = dir(); const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } });
|
||||||
|
await lease.dispose();
|
||||||
|
expect(() => readFileSync(join(root, ".cursor"))).toThrow();
|
||||||
|
});
|
||||||
|
it("reclaims a stale owner-recorded lock before staging", async () => {
|
||||||
|
const root = dir(); const cursor = join(root, ".cursor"); mkdirSync(join(cursor, ".fusion-mcp.lock"), { recursive: true });
|
||||||
|
writeFileSync(join(cursor, ".fusion-mcp.lock", "owner.json"), JSON.stringify({ pid: process.pid, hostname: hostname(), acquiredAt: Date.now() - 31_000 }));
|
||||||
|
const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } });
|
||||||
|
await lease.dispose();
|
||||||
|
});
|
||||||
|
it("does not steal a freshly-created lock before its owner is published", async () => {
|
||||||
|
const root = dir(); const cursor = join(root, ".cursor"); const lock = join(cursor, ".fusion-mcp.lock");
|
||||||
|
mkdirSync(lock, { recursive: true });
|
||||||
|
// Model a second process between atomic mkdir and its subsequent owner.json publication.
|
||||||
|
const contender = stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-contender", serverEntry: { command: "bridge", args: [] } });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
const owner = { pid: process.pid, hostname: hostname(), acquiredAt: Date.now() };
|
||||||
|
writeFileSync(join(lock, "owner.json"), JSON.stringify(owner));
|
||||||
|
await expect(contender).rejects.toMatchObject({ code: "bridge-start-failed" });
|
||||||
|
expect(JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"))).toEqual(owner);
|
||||||
|
expect(() => readFileSync(join(cursor, "mcp.json"), "utf8")).toThrow();
|
||||||
|
});
|
||||||
|
it("keeps peer entries from the durable manifest", async () => {
|
||||||
|
const root = dir(); const first = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "one", args: ["a"] } });
|
||||||
|
const second = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-two", serverEntry: { command: "two", args: ["b"], env: { TOKEN: "x" } } });
|
||||||
|
await first.dispose(); const raw = readFileSync(join(root, ".cursor", "mcp.json"), "utf8"); expect(raw).toContain('"two"'); expect(raw).not.toContain('"one"'); await second.dispose();
|
||||||
|
});
|
||||||
|
it("reaps a dead same-host lease before its heartbeat TTL", async () => {
|
||||||
|
const root = dir(); const first = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-dead", serverEntry: { command: "dead", args: [] } });
|
||||||
|
const statePath = join(root, ".cursor", ".fusion-mcp-state.json");
|
||||||
|
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
||||||
|
state.leases["fusion-custom-tools-dead"].pid = 999_999_999;
|
||||||
|
state.leases["fusion-custom-tools-dead"].hostname = hostname();
|
||||||
|
state.leases["fusion-custom-tools-dead"].heartbeatAt = Date.now();
|
||||||
|
writeFileSync(statePath, JSON.stringify(state));
|
||||||
|
const second = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-live", serverEntry: { command: "live", args: [] } });
|
||||||
|
const config = readFileSync(join(root, ".cursor", "mcp.json"), "utf8");
|
||||||
|
expect(config).not.toContain("fusion-custom-tools-dead");
|
||||||
|
expect(config).toContain("fusion-custom-tools-live");
|
||||||
|
await first.dispose(); await second.dispose();
|
||||||
|
});
|
||||||
|
it("recomposes after reconciliation reaps a dead peer while another lease remains", async () => {
|
||||||
|
const root = dir();
|
||||||
|
const first = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-dead", serverEntry: { command: "dead", args: [] } });
|
||||||
|
const second = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-live", serverEntry: { command: "live", args: [] } });
|
||||||
|
const statePath = join(root, ".cursor", ".fusion-mcp-state.json");
|
||||||
|
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
||||||
|
state.leases["fusion-custom-tools-dead"].pid = 999_999_999;
|
||||||
|
state.leases["fusion-custom-tools-dead"].hostname = hostname();
|
||||||
|
writeFileSync(statePath, JSON.stringify(state));
|
||||||
|
await reconcileCursorWorktree(root);
|
||||||
|
const config = readFileSync(join(root, ".cursor", "mcp.json"), "utf8");
|
||||||
|
expect(config).not.toContain("fusion-custom-tools-dead");
|
||||||
|
expect(config).toContain("fusion-custom-tools-live");
|
||||||
|
await first.dispose(); await second.dispose();
|
||||||
|
});
|
||||||
|
it("does not create a cursor directory when reconciliation has no residue", async () => { const root = dir(); await reconcileCursorWorktree(root); expect(() => readFileSync(join(root, ".cursor"))).toThrow(); });
|
||||||
|
it("composes current operator content without Fusion keys", () => { const raw = composeMcpConfig({ currentRaw: '{"mcpServers":{"operator":{"command":"x"},"fusion-custom-tools-old":{"command":"old"}}}', leases: { "fusion-custom-tools-new": { serverEntry: { command: "new", args: [] } } } }); expect(raw).toContain("operator"); expect(raw).toContain("fusion-custom-tools-new"); expect(raw).not.toContain("fusion-custom-tools-old"); });
|
||||||
|
it("re-pins a changed unparsable quarantine instead of clearing it", () => { expect(evaluateQuarantine({ currentRaw: "{broken", quarantine: { raw: "{older", fusionKeys: [], observedAt: "now" } })).toBe("repin"); });
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"type":"system","subtype":"init","cwd":"<scratch>","session_id":"<session>","model":"Auto"}
|
||||||
|
{"type":"tool_call","subtype":"completed","tool_call":{"getMcpToolsToolCall":{"result":{"success":{"content":"{\"mode\":\"catalog\",\"servers\":[]}"}}}}}
|
||||||
|
{"type":"result","subtype":"success","result":"No MCP servers were available."}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{"type":"system","subtype":"init","cwd":"<scratch>","session_id":"<session>","model":"Auto"}
|
||||||
|
{"type":"tool_call","subtype":"started","tool_call":{"mcpToolCall":{"args":{"name":"fn9098-echo-echo","args":{"message":"hello"},"providerIdentifier":"fn9098-echo","toolName":"echo","serverIdentifier":"fn9098-echo"}}}}
|
||||||
|
{"type":"tool_call","subtype":"completed","tool_call":{"mcpToolCall":{"result":{"success":{"content":[{"text":{"text":"ECHO:hello"}}],"isError":false}}}}}
|
||||||
|
{"type":"result","subtype":"success","result":"ECHO:hello"}
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { CursorRuntimeAdapter } from "../runtime-adapter.js";
|
import { CursorRuntimeAdapter } from "../runtime-adapter.js";
|
||||||
|
import { toolsToMcpToolDefs } from "../tool-bridge.js";
|
||||||
import * as transport from "../prompt-transport.js";
|
import * as transport from "../prompt-transport.js";
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
afterEach(() => { vi.useRealTimers(); dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })); });
|
||||||
|
const tempWorktree = () => { const dir = mkdtempSync(join(tmpdir(), "fusion-cursor-runtime-")); dirs.push(dir); return dir; };
|
||||||
|
const schemaFiles = () => readdirSync(tmpdir()).filter((name) => name.startsWith(`fusion-cursor-mcp-schemas-${process.pid}-`)).sort();
|
||||||
|
|
||||||
describe("CursorRuntimeAdapter", () => {
|
describe("CursorRuntimeAdapter", () => {
|
||||||
|
it("maps only executable fn_* tools from the engine-scoped set", () => {
|
||||||
|
expect(toolsToMcpToolDefs([
|
||||||
|
{ name: "fn_task_list", execute: vi.fn() },
|
||||||
|
{ name: "plugin_tool", execute: vi.fn() },
|
||||||
|
{ name: "fn_missing_execute" },
|
||||||
|
]).map((tool) => tool.name)).toEqual(["fn_task_list"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes model and creates a cwd-bound session", async () => {
|
it("normalizes model and creates a cwd-bound session", async () => {
|
||||||
const result = await new CursorRuntimeAdapter().createSession({ cwd: "/tmp", systemPrompt: "sys", defaultModelId: "cursor-cli/pro", tools: "readonly" });
|
const result = await new CursorRuntimeAdapter().createSession({ cwd: "/tmp", systemPrompt: "sys", defaultModelId: "cursor-cli/pro", tools: "readonly" });
|
||||||
expect(result.session.model).toBe("pro");
|
expect(result.session.model).toBe("pro");
|
||||||
@@ -19,6 +36,44 @@ describe("CursorRuntimeAdapter", () => {
|
|||||||
expect(spy.mock.calls[0][0].prompt).toContain("system");
|
expect(spy.mock.calls[0][0].prompt).toContain("system");
|
||||||
expect(spy.mock.calls[1][0]).toMatchObject({ prompt: "two", resumeId: "chat-1", tools: "readonly" });
|
expect(spy.mock.calls[1][0]).toMatchObject({ prompt: "two", resumeId: "chat-1", tools: "readonly" });
|
||||||
});
|
});
|
||||||
|
it("does not publish an arbitrary custom tool merely because its name starts with fn_", async () => {
|
||||||
|
const cwd = tempWorktree();
|
||||||
|
const { session } = await new CursorRuntimeAdapter().createSession({
|
||||||
|
cwd,
|
||||||
|
systemPrompt: "system",
|
||||||
|
customTools: [{ name: "fn_evil", execute: vi.fn() }],
|
||||||
|
});
|
||||||
|
expect(session.toolBridge).toBeUndefined();
|
||||||
|
expect(() => readFileSync(join(cwd, ".cursor", "mcp.json"), "utf8")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renews an active Cursor MCP lease before the stale threshold", async () => {
|
||||||
|
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-15T23:00:00Z"));
|
||||||
|
const cwd = tempWorktree();
|
||||||
|
const { session } = await new CursorRuntimeAdapter().createSession({ cwd, systemPrompt: "system", fusionTools: [{ name: "fn_task_list", execute: vi.fn() }] });
|
||||||
|
const statePath = join(cwd, ".cursor", ".fusion-mcp-state.json");
|
||||||
|
const initial = JSON.parse(readFileSync(statePath, "utf8")).leases[session.mcpServerKey!].heartbeatAt;
|
||||||
|
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
||||||
|
const renewed = JSON.parse(readFileSync(statePath, "utf8")).leases[session.mcpServerKey!].heartbeatAt;
|
||||||
|
expect(renewed).toBeGreaterThan(initial);
|
||||||
|
await session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disposes a started bridge when malformed operator config prevents lease staging", async () => {
|
||||||
|
const cwd = tempWorktree(); const cursor = join(cwd, ".cursor"); mkdirSync(cursor);
|
||||||
|
const config = join(cursor, "mcp.json"); const malformed = "{broken"; writeFileSync(config, malformed);
|
||||||
|
const before = schemaFiles();
|
||||||
|
const { session } = await new CursorRuntimeAdapter().createSession({
|
||||||
|
cwd,
|
||||||
|
systemPrompt: "system",
|
||||||
|
fusionTools: [{ name: "fn_task_list", execute: vi.fn() }],
|
||||||
|
});
|
||||||
|
expect(session.fusionToolBridgeError).toEqual({ reasonCode: "bridge-start-failed" });
|
||||||
|
expect(session.toolBridge).toBeUndefined();
|
||||||
|
expect(schemaFiles()).toEqual(before);
|
||||||
|
expect(readFileSync(config, "utf8")).toBe(malformed);
|
||||||
|
});
|
||||||
|
|
||||||
it("restores the session id on transport failure and disposal aborts an active turn exactly once", async () => {
|
it("restores the session id on transport failure and disposal aborts an active turn exactly once", async () => {
|
||||||
vi.spyOn(transport, "launchCursorPrompt").mockRejectedValueOnce(new Error("failed"));
|
vi.spyOn(transport, "launchCursorPrompt").mockRejectedValueOnce(new Error("failed"));
|
||||||
const adapter = new CursorRuntimeAdapter();
|
const adapter = new CursorRuntimeAdapter();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { ensureCursorConfigExcluded, resolveGitShape } from "../worktree-hygiene.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||||
|
const root = () => {
|
||||||
|
const value = mkdtempSync(join(tmpdir(), "fusion-cursor-hygiene-"));
|
||||||
|
roots.push(value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
afterEach(() => roots.splice(0).forEach((value) => rmSync(value, { recursive: true, force: true })));
|
||||||
|
|
||||||
|
describe("Cursor worktree hygiene", () => {
|
||||||
|
it("does not reclaim a fresh bootstrap lock before its owner is published", () => {
|
||||||
|
const repository = root();
|
||||||
|
git(repository, "init");
|
||||||
|
const shape = resolveGitShape(repository)!;
|
||||||
|
const lock = join(shape.gitDir, "fusion-cursor-exclude.lock");
|
||||||
|
mkdirSync(lock);
|
||||||
|
expect(() => ensureCursorConfigExcluded(repository)).toThrow(/exclude lock unavailable/);
|
||||||
|
expect(existsSync(lock)).toBe(true);
|
||||||
|
expect(existsSync(join(repository, ".cursor"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Git's absolute linked-worktree exclude path instead of nesting it below the worktree", () => {
|
||||||
|
const repository = root();
|
||||||
|
git(repository, "init");
|
||||||
|
git(repository, "config", "user.email", "test@example.invalid");
|
||||||
|
git(repository, "config", "user.name", "Cursor test");
|
||||||
|
writeFileSync(join(repository, "README.md"), "seed\n");
|
||||||
|
git(repository, "add", "README.md");
|
||||||
|
git(repository, "commit", "-m", "seed");
|
||||||
|
const linked = join(repository, "linked");
|
||||||
|
git(repository, "worktree", "add", "-b", "cursor-hygiene-test", linked);
|
||||||
|
|
||||||
|
const shape = resolveGitShape(linked);
|
||||||
|
expect(shape).toBeDefined();
|
||||||
|
expect(shape!.excludePath).toBe(git(linked, "rev-parse", "--git-path", "info/exclude"));
|
||||||
|
ensureCursorConfigExcluded(linked);
|
||||||
|
|
||||||
|
expect(readFileSync(shape!.excludePath, "utf8")).toContain("# >>> fusion cursor-runtime >>>");
|
||||||
|
expect(readFileSync(shape!.excludePath, "utf8")).toContain("/.cursor/mcp.json");
|
||||||
|
mkdirSync(join(linked, ".cursor"));
|
||||||
|
writeFileSync(join(linked, ".cursor", "mcp.json"), "{}\n");
|
||||||
|
// A regression joined the absolute Git path under `linked`, creating this bogus tree.
|
||||||
|
expect(existsSync(join(linked, shape!.excludePath.replace(/^[/\\]+/, "")))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
196
plugins/fusion-plugin-cursor-runtime/src/cursor-mcp-config.ts
Normal file
196
plugins/fusion-plugin-cursor-runtime/src/cursor-mcp-config.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:20:
|
||||||
|
The manifest is the authority for Fusion server entries, while current on-disk bytes remain authority for operator entries.
|
||||||
|
A baseline is committed before the first config write and every later write is journaled, so a crash cannot recapture Fusion output as operator content.
|
||||||
|
An unparsable operator edit is quarantined: it is never rewritten and the exclusion remains until reconciliation observes a repaired, Fusion-key-free file.
|
||||||
|
*/
|
||||||
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { classifyCursorConfigTracking, ensureCursorConfigExcluded, removeCursorConfigExclusion } from "./worktree-hygiene.js";
|
||||||
|
|
||||||
|
export interface ServerEntry { command: string; args: string[]; env?: Record<string, string>; }
|
||||||
|
type Pending = { kind: "write" | "restore" | "delete" | "quarantine"; raw: string | null; seq: number };
|
||||||
|
type Lease = { pid: number; hostname: string; heartbeatAt: number; serverEntry: ServerEntry };
|
||||||
|
type State = { version: 1; baseline: { existed: boolean; raw: string | null; mode: number | null; parsable: boolean; createdDir: boolean }; exclusionOwnedByFusion: boolean; lastWrittenRaw: string | null; pending: Pending | null; operatorEditObserved: boolean; configUnwritable: boolean; quarantine: { raw: string | null; fusionKeys: string[]; observedAt: string } | null; leases: Record<string, Lease> };
|
||||||
|
const TTL = 15 * 60_000;
|
||||||
|
const LOCK_TTL_MS = 30_000;
|
||||||
|
const LOCK_ATTEMPTS = 5;
|
||||||
|
const held = new Map<string, Set<string>>();
|
||||||
|
const paths = (worktreePath: string) => { const dir = join(worktreePath, ".cursor"); return { dir, config: join(dir, "mcp.json"), state: join(dir, ".fusion-mcp-state.json"), lock: join(dir, ".fusion-mcp.lock") }; };
|
||||||
|
const rawFile = (file: string) => existsSync(file) ? readFileSync(file, "utf8") : null;
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:26:
|
||||||
|
The baseline, pending journal, and quarantine record are recovery authorities after
|
||||||
|
power loss. Fsync their temp file before rename and the containing directory after
|
||||||
|
rename so Fusion never writes a config whose only baseline or write intent vanished.
|
||||||
|
*/
|
||||||
|
const atomic = (file: string, raw: string, durable = false) => {
|
||||||
|
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
||||||
|
writeFileSync(temp, raw, { mode: 0o600 });
|
||||||
|
if (durable) {
|
||||||
|
const fileDescriptor = openSync(temp, "r");
|
||||||
|
try { fsyncSync(fileDescriptor); } finally { closeSync(fileDescriptor); }
|
||||||
|
}
|
||||||
|
renameSync(temp, file);
|
||||||
|
if (durable) {
|
||||||
|
// Some Windows filesystems reject directory fsync; the renamed file fsync remains durable there.
|
||||||
|
try { const directoryDescriptor = openSync(dirname(file), "r"); try { fsyncSync(directoryDescriptor); } finally { closeSync(directoryDescriptor); } } catch { /* platform does not support directory fsync */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const writeState = (file: string, state: State, durable = false) => atomic(file, JSON.stringify(state), durable);
|
||||||
|
const same = (raw: string | null, expected: string | null) => raw === expected;
|
||||||
|
const own = (key: string) => /^fusion-custom-tools-/.test(key);
|
||||||
|
|
||||||
|
export const parseCursorMcpConfig = (raw: string) => JSON.parse(raw) as { mcpServers?: Record<string, unknown> };
|
||||||
|
export const detectFusionKeysInRaw = (raw: string | null) => { try { return Object.keys(parseCursorMcpConfig(raw ?? "{}").mcpServers ?? {}).filter(own); } catch { return []; } };
|
||||||
|
export function stripFusionKeys(parsed: { mcpServers?: Record<string, unknown> }) { const copy = { ...parsed, mcpServers: { ...(parsed.mcpServers ?? {}) } }; for (const key of Object.keys(copy.mcpServers)) if (own(key)) delete copy.mcpServers[key]; return copy; }
|
||||||
|
export function composeMcpConfig({ currentRaw, leases }: { currentRaw: string | null; leases: Record<string, { serverEntry: ServerEntry }> }) { let parsed: { mcpServers?: Record<string, unknown> }; try { parsed = currentRaw ? parseCursorMcpConfig(currentRaw) : {}; } catch { parsed = {}; } const next = stripFusionKeys(parsed); next.mcpServers ??= {}; for (const [key, lease] of Object.entries(leases)) next.mcpServers[key] = lease.serverEntry; return `${JSON.stringify(next, null, 2)}\n`; }
|
||||||
|
export function detectOperatorEdit({ currentRaw, lastWrittenRaw, pending, baseline, stateExists }: { currentRaw: string | null; lastWrittenRaw: string | null; pending: Pending | null; baseline: { raw: string | null }; stateExists: boolean }) { if (!stateExists) return false; return !same(currentRaw, lastWrittenRaw ?? baseline.raw) && !same(currentRaw, pending?.raw ?? "__no_pending__"); }
|
||||||
|
export function resolvePendingJournal({ currentRaw, lastWrittenRaw, pending, baseline }: { currentRaw: string | null; lastWrittenRaw: string | null; pending: Pending | null; baseline: { raw: string | null } }): "promote" | "discard" | "discard-edited" { if (!pending) return "discard"; if (pending.kind === "quarantine" || same(currentRaw, pending.raw)) return "promote"; return same(currentRaw, lastWrittenRaw ?? baseline.raw) ? "discard" : "discard-edited"; }
|
||||||
|
export function evaluateQuarantine({ currentRaw, quarantine }: { currentRaw: string | null; quarantine: State["quarantine"] }): "clear" | "hold" | "repin" | "unknown" { if (!quarantine) return "unknown"; if (currentRaw === null) return "clear"; if (currentRaw === quarantine.raw) return "hold"; try { return Object.keys(parseCursorMcpConfig(currentRaw).mcpServers ?? {}).some(own) ? "hold" : "clear"; } catch { return "repin"; } }
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:17:
|
||||||
|
The lock owns an on-disk identity, not merely a directory. A crashed holder may
|
||||||
|
be reclaimed only after a dead same-host PID or the short critical-section TTL;
|
||||||
|
bounded retries let a peer finish its compose without writing unlocked.
|
||||||
|
*/
|
||||||
|
type LockOwner = { pid: number; hostname: string; acquiredAt: number };
|
||||||
|
const lockOwnerPath = (lock: string) => join(lock, "owner.json");
|
||||||
|
const currentHost = () => hostname();
|
||||||
|
const pidAlive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
||||||
|
const readLockOwner = (lock: string): LockOwner | undefined => { try { return JSON.parse(readFileSync(lockOwnerPath(lock), "utf8")) as LockOwner; } catch { return undefined; } };
|
||||||
|
const lockAgeMs = (lock: string) => { try { return Date.now() - statSync(lock).mtimeMs; } catch { return Number.POSITIVE_INFINITY; } };
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:46:
|
||||||
|
A contender can observe the atomic lock directory after mkdir but before owner.json
|
||||||
|
is published. Treat missing or malformed metadata as a live acquisition until the
|
||||||
|
lock directory itself exceeds the TTL; reclaiming it immediately would admit two
|
||||||
|
writers to the MCP config critical section.
|
||||||
|
*/
|
||||||
|
const lockIsStale = (lock: string, owner: LockOwner | undefined) =>
|
||||||
|
lockAgeMs(lock) > LOCK_TTL_MS ||
|
||||||
|
(owner !== undefined && (Date.now() - owner.acquiredAt > LOCK_TTL_MS ||
|
||||||
|
(owner.hostname === currentHost() && !pidAlive(owner.pid))));
|
||||||
|
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function withLock<T>(worktreePath: string, create: boolean, fn: () => T | Promise<T>): Promise<T | undefined> {
|
||||||
|
const p = paths(worktreePath); if (!existsSync(p.dir)) { if (!create) return undefined; mkdirSync(p.dir, { recursive: true }); }
|
||||||
|
const mine: LockOwner = { pid: process.pid, hostname: currentHost(), acquiredAt: Date.now() };
|
||||||
|
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
||||||
|
try { mkdirSync(p.lock); writeFileSync(lockOwnerPath(p.lock), JSON.stringify(mine), { mode: 0o600 }); break; }
|
||||||
|
catch {
|
||||||
|
if (existsSync(p.lock) && lockIsStale(p.lock, readLockOwner(p.lock))) { try { rmSync(p.lock, { recursive: true, force: true }); } catch { /* contender won; retry */ } }
|
||||||
|
if (attempt === LOCK_ATTEMPTS - 1) throw Object.assign(new Error("Cursor MCP config lock unavailable"), { code: "bridge-start-failed" });
|
||||||
|
await pause(8 + Math.floor(Math.random() * 12));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { return await fn(); } finally {
|
||||||
|
const owner = readLockOwner(p.lock);
|
||||||
|
if (owner?.pid === mine.pid && owner.hostname === mine.hostname) { try { rmSync(p.lock, { recursive: true, force: true }); } catch { /* stale cleanup will repair */ } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exit handlers cannot await retry or stale-owner checks; they only take a free lock once. */
|
||||||
|
function withLockSync<T>(worktreePath: string, fn: () => T): T | undefined {
|
||||||
|
const p = paths(worktreePath); if (!existsSync(p.dir)) return undefined;
|
||||||
|
const mine: LockOwner = { pid: process.pid, hostname: currentHost(), acquiredAt: Date.now() };
|
||||||
|
try { mkdirSync(p.lock); writeFileSync(lockOwnerPath(p.lock), JSON.stringify(mine), { mode: 0o600 }); } catch { return undefined; }
|
||||||
|
try { return fn(); } catch { return undefined; } finally {
|
||||||
|
const owner = readLockOwner(p.lock);
|
||||||
|
if (owner?.pid === mine.pid && owner.hostname === mine.hostname) { try { rmSync(p.lock, { recursive: true, force: true }); } catch { /* exit backstop never throws */ } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function readState(p: ReturnType<typeof paths>): State | undefined { try { const state = JSON.parse(readFileSync(p.state, "utf8")) as State; return state.version === 1 && state.baseline ? state : undefined; } catch { return undefined; } }
|
||||||
|
function recoverJournal(p: ReturnType<typeof paths>, state: State, current: string | null) { if (!state.pending) return; const resolution = resolvePendingJournal({ currentRaw: current, lastWrittenRaw: state.lastWrittenRaw, pending: state.pending, baseline: state.baseline }); if (resolution === "promote") state.lastWrittenRaw = state.pending.raw; if (resolution === "discard-edited") { state.operatorEditObserved = true; try { if (current !== null) parseCursorMcpConfig(current); } catch { state.configUnwritable = true; } } state.pending = null; writeState(p.state, state); }
|
||||||
|
function journalWrite(p: ReturnType<typeof paths>, state: State, kind: Pending["kind"], raw: string | null) { state.pending = { kind, raw, seq: (state.pending?.seq ?? 0) + 1 }; writeState(p.state, state, true); if (kind === "delete") { if (existsSync(p.config)) unlinkSync(p.config); } else if (kind !== "quarantine") atomic(p.config, raw ?? ""); state.lastWrittenRaw = raw; state.pending = null; writeState(p.state, state); }
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:26:
|
||||||
|
A same-host process ID can be proven dead immediately, so retain neither its bridge
|
||||||
|
entry nor its stale MCP server until the lease TTL. Foreign-host records remain
|
||||||
|
TTL-only because process IDs are not portable across shared worktrees.
|
||||||
|
*/
|
||||||
|
function stale(state: State) {
|
||||||
|
for (const [key, lease] of Object.entries(state.leases)) {
|
||||||
|
if (Date.now() - lease.heartbeatAt > TTL || (lease.hostname === currentHost() && !pidAlive(lease.pid))) delete state.leases[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:17:
|
||||||
|
Release must latch a byte-different operator edit before composing its own write.
|
||||||
|
When the last lease exits, this function returns directory cleanup to its caller
|
||||||
|
so the nested lock is removed first; otherwise rmdir can never succeed.
|
||||||
|
*/
|
||||||
|
function terminal(worktreePath: string, p: ReturnType<typeof paths>, state: State): boolean {
|
||||||
|
const current = rawFile(p.config);
|
||||||
|
if (state.configUnwritable) { state.leases = {}; state.quarantine = { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }; state.pending = { kind: "quarantine", raw: current, seq: 1 }; writeState(p.state, state, true); state.pending = null; state.lastWrittenRaw = null; writeState(p.state, state, true); return false; }
|
||||||
|
if (!state.operatorEditObserved) journalWrite(p, state, state.baseline.existed ? "restore" : "delete", state.baseline.raw);
|
||||||
|
else if (current !== null) journalWrite(p, state, "write", composeMcpConfig({ currentRaw: current, leases: {} }));
|
||||||
|
unlinkSync(p.state); if (state.exclusionOwnedByFusion) removeCursorConfigExclusion(worktreePath);
|
||||||
|
// The nested main lock still exists here. Its owner performs this one rmdir only after releasing it.
|
||||||
|
return state.baseline.createdDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCursorDirectoryAfterLock(dir: string, shouldRemove: boolean) {
|
||||||
|
if (!shouldRemove) return;
|
||||||
|
try { rmdirSync(dir); } catch { /* a peer or operator directory is retained without retry */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAndLatch(p: ReturnType<typeof paths>, state: State, current: string | null) {
|
||||||
|
recoverJournal(p, state, current);
|
||||||
|
const observed = rawFile(p.config);
|
||||||
|
if (detectOperatorEdit({ currentRaw: observed, lastWrittenRaw: state.lastWrittenRaw, pending: state.pending, baseline: state.baseline, stateExists: true })) {
|
||||||
|
state.operatorEditObserved = true;
|
||||||
|
try { if (observed) parseCursorMcpConfig(observed); } catch { state.configUnwritable = true; }
|
||||||
|
writeState(p.state, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bootstrapCursorWorktree(worktreePath: string) { if (classifyCursorConfigTracking(worktreePath) === "tracked") throw Object.assign(new Error("Cursor MCP config is git-tracked"), { code: "bridge-start-failed" }); const exclusion = ensureCursorConfigExcluded(worktreePath); const p = paths(worktreePath); const existed = existsSync(p.dir); mkdirSync(p.dir, { recursive: true }); return { exclusion, createdDirObserved: !existed }; }
|
||||||
|
export async function stageCursorMcpLease({ worktreePath, serverKey, serverEntry }: { worktreePath: string; serverKey: string; serverEntry: ServerEntry }): Promise<{ heartbeat: () => Promise<unknown>; dispose: () => Promise<void> }> {
|
||||||
|
const boot = bootstrapCursorWorktree(worktreePath); const p = paths(worktreePath); let clearedQuarantine = false;
|
||||||
|
await withLock(worktreePath, true, () => {
|
||||||
|
const current = rawFile(p.config); let state = readState(p);
|
||||||
|
if (state?.quarantine) {
|
||||||
|
const verdict = evaluateQuarantine({ currentRaw: current, quarantine: state.quarantine });
|
||||||
|
if (verdict !== "clear") {
|
||||||
|
if (verdict === "repin") { state.quarantine = { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }; writeState(p.state, state, true); }
|
||||||
|
throw Object.assign(new Error("Cursor MCP worktree is quarantined"), { code: "bridge-start-failed" });
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:36:
|
||||||
|
A quarantine clear is terminal: remove its exclusion only as the last
|
||||||
|
in-lock act, then release this nested lock before a fresh stage bootstraps.
|
||||||
|
Writing a new lease after removal would expose config and lock files to git add -A.
|
||||||
|
*/
|
||||||
|
unlinkSync(p.state);
|
||||||
|
if (state.exclusionOwnedByFusion) removeCursorConfigExclusion(worktreePath);
|
||||||
|
clearedQuarantine = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
let parsable = true;
|
||||||
|
try { if (current) parseCursorMcpConfig(current); } catch { parsable = false; }
|
||||||
|
state = { version: 1, baseline: { existed: current !== null, raw: current, mode: current === null ? null : statSync(p.config).mode, parsable, createdDir: boot.createdDirObserved }, exclusionOwnedByFusion: boot.exclusion.fusionAuthored, lastWrittenRaw: null, pending: null, operatorEditObserved: false, configUnwritable: !parsable, quarantine: parsable ? null : { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }, leases: {} };
|
||||||
|
writeState(p.state, state, true);
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-23:46:
|
||||||
|
An initially malformed operator config is quarantined before composition; byte equality with the captured baseline must never authorize replacing it with Fusion-only JSON.
|
||||||
|
*/
|
||||||
|
if (!parsable) throw Object.assign(new Error("Cursor MCP config is not safely writable"), { code: "bridge-start-failed" });
|
||||||
|
}
|
||||||
|
detectAndLatch(p, state, current);
|
||||||
|
if (state.configUnwritable) throw Object.assign(new Error("Cursor MCP config is not safely writable"), { code: "bridge-start-failed" });
|
||||||
|
const exclusion = ensureCursorConfigExcluded(worktreePath, { lockHeld: true }); state.exclusionOwnedByFusion ||= exclusion.fusionAuthored; stale(state); state.leases[serverKey] = { pid: process.pid, hostname: currentHost(), heartbeatAt: Date.now(), serverEntry }; const composed = composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases }); journalWrite(p, state, "write", composed); const worktreeLeases = held.get(worktreePath) ?? new Set<string>(); worktreeLeases.add(serverKey); held.set(worktreePath, worktreeLeases);
|
||||||
|
});
|
||||||
|
if (clearedQuarantine) return stageCursorMcpLease({ worktreePath, serverKey, serverEntry });
|
||||||
|
let released = false;
|
||||||
|
return { heartbeat: async () => await withLock(worktreePath, false, () => { const state = readState(p); if (state?.leases[serverKey]) { detectAndLatch(p, state, rawFile(p.config)); if (!state.configUnwritable) { state.leases[serverKey].heartbeatAt = Date.now(); writeState(p.state, state); } } }), dispose: async () => { if (released) return; released = true; let removeDir = false; await withLock(worktreePath, false, () => { const state = readState(p); if (!state) return; detectAndLatch(p, state, rawFile(p.config)); delete state.leases[serverKey]; stale(state); if (Object.keys(state.leases).length) { if (!state.configUnwritable) journalWrite(p, state, "write", composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases })); else writeState(p.state, state); } else removeDir = terminal(worktreePath, p, state); held.get(worktreePath)?.delete(serverKey); }); removeCursorDirectoryAfterLock(p.dir, removeDir); } };
|
||||||
|
}
|
||||||
|
export async function reconcileCursorWorktree(worktreePath: string) { const p = paths(worktreePath); if (!existsSync(p.dir)) return; let removeDir = false; try { await withLock(worktreePath, false, () => { const state = readState(p); if (!state) return; const current = rawFile(p.config); if (state.quarantine) { const verdict = evaluateQuarantine({ currentRaw: current, quarantine: state.quarantine }); if (verdict === "clear") { unlinkSync(p.state); if (state.exclusionOwnedByFusion) removeCursorConfigExclusion(worktreePath); removeDir = state.baseline.createdDir; } else if (verdict === "repin") { state.quarantine = { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }; writeState(p.state, state, true); } return; } detectAndLatch(p, state, current); const leaseCount = Object.keys(state.leases).length; stale(state); if (!Object.keys(state.leases).length) removeDir = terminal(worktreePath, p, state); else if (!state.configUnwritable && Object.keys(state.leases).length !== leaseCount) {
|
||||||
|
// Reaping a dead peer must also remove its MCP entry; the manifest is authoritative.
|
||||||
|
journalWrite(p, state, "write", composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases }));
|
||||||
|
} else if (Object.keys(state.leases).length !== leaseCount) writeState(p.state, state); }); } catch { /* reconciliation is deliberately best effort */ } removeCursorDirectoryAfterLock(p.dir, removeDir); }
|
||||||
|
export function releaseHeldLeasesSync(worktreePath?: string) { for (const [path, keys] of held) { if (worktreePath && path !== worktreePath) continue; for (const key of [...keys]) { const p = paths(path); let removeDir = false; withLockSync(path, () => { const state = readState(p); if (!state || state.quarantine) return; detectAndLatch(p, state, rawFile(p.config)); delete state.leases[key]; if (!Object.keys(state.leases).length) removeDir = terminal(path, p, state); else if (!state.configUnwritable) journalWrite(p, state, "write", composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases })); else writeState(p.state, state); }); removeCursorDirectoryAfterLock(p.dir, removeDir); } } }
|
||||||
155
plugins/fusion-plugin-cursor-runtime/src/mcp-schema-server.cjs
Normal file
155
plugins/fusion-plugin-cursor-runtime/src/mcp-schema-server.cjs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
FNXC:GrokAcp 2026-07-11-14:00:
|
||||||
|
Executable MCP bridge for Fusion custom tools (fn_*) on the Grok ACP path.
|
||||||
|
tools/list is served from a schema file; tools/call POSTs to a localhost bridge
|
||||||
|
owned by GrokRuntimeAdapter so ToolDefinition.execute runs in-process with the
|
||||||
|
engine's closures. Unlike the Claude/Droid schema-only break-early servers,
|
||||||
|
Grok actually invokes MCP tools/call itself.
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const readline = require("readline");
|
||||||
|
// FNXC:GrokAcp 2026-07-11-18:30: CJS has no global URL under eslint no-undef; use node:url.
|
||||||
|
const { URL } = require("node:url");
|
||||||
|
|
||||||
|
const schemaPath = process.argv[2];
|
||||||
|
const bridgeUrl = process.env.FUSION_CURSOR_TOOL_BRIDGE_URL;
|
||||||
|
const bridgeToken = process.env.FUSION_CURSOR_TOOL_BRIDGE_TOKEN;
|
||||||
|
if (!schemaPath || !bridgeUrl || !bridgeToken) {
|
||||||
|
process.stderr.write("fusion-cursor-mcp-server: missing schema path or FUSION_CURSOR_TOOL_BRIDGE_URL\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tools = [];
|
||||||
|
try {
|
||||||
|
tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
|
||||||
|
if (!Array.isArray(tools)) tools = [];
|
||||||
|
} catch {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(msg) {
|
||||||
|
process.stdout.write(JSON.stringify(msg) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function callBridge(toolName, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = JSON.stringify({ name: toolName, arguments: args ?? {} });
|
||||||
|
const url = new URL("/tool-call", bridgeUrl);
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"content-length": Buffer.byteLength(body),
|
||||||
|
authorization: `Bearer ${bridgeToken}`,
|
||||||
|
},
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data || "{}"));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy(new Error("tool bridge timeout"));
|
||||||
|
});
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin });
|
||||||
|
rl.on("line", (line) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === "initialize") {
|
||||||
|
write({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: "2024-11-05",
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: "fusion-custom-tools", version: "1.0.0" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === "notifications/initialized" || msg.method === "initialized") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === "tools/list") {
|
||||||
|
write({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
result: {
|
||||||
|
tools: tools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description ?? "",
|
||||||
|
inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.method === "tools/call") {
|
||||||
|
const toolName = msg.params?.name;
|
||||||
|
const args = msg.params?.arguments ?? {};
|
||||||
|
callBridge(toolName, args)
|
||||||
|
.then((result) => {
|
||||||
|
write({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
result: {
|
||||||
|
content: Array.isArray(result.content)
|
||||||
|
? result.content
|
||||||
|
: [{ type: "text", text: typeof result.text === "string" ? result.text : JSON.stringify(result) }],
|
||||||
|
isError: result.isError === true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
write({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
result: {
|
||||||
|
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id !== undefined) {
|
||||||
|
write({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
error: { code: -32601, message: `Method not found: ${msg.method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ const STDERR_MAX = 16_384;
|
|||||||
function timeout(name: string, fallback: number) { const value = Number(process.env[name]); return Number.isFinite(value) && value > 0 ? value : fallback; }
|
function timeout(name: string, fallback: number) { const value = Number(process.env[name]); return Number.isFinite(value) && value > 0 ? value : fallback; }
|
||||||
|
|
||||||
export interface CursorPromptCallbacks { onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record<string, unknown>) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; }
|
export interface CursorPromptCallbacks { onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record<string, unknown>) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; }
|
||||||
export interface CursorPromptInput extends CursorPromptCallbacks { binary?: string; model?: string; cwd: string; tools?: "coding" | "readonly"; prompt: string; resumeId?: string; signal?: AbortSignal; workspaceFlagRequired?: boolean; }
|
export interface CursorPromptInput extends CursorPromptCallbacks { binary?: string; model?: string; cwd: string; tools?: "coding" | "readonly"; prompt: string; resumeId?: string; signal?: AbortSignal; workspaceFlagRequired?: boolean; approveMcps?: boolean; }
|
||||||
export interface CursorPromptResult { sessionId?: string; text: string; usage?: unknown; }
|
export interface CursorPromptResult { sessionId?: string; text: string; usage?: unknown; }
|
||||||
export interface CursorPromptDependencies { supervise?: typeof superviseSpawn; taskkill?: typeof spawn; platform?: NodeJS.Platform; resolvePowerShell?: () => string; }
|
export interface CursorPromptDependencies { supervise?: typeof superviseSpawn; taskkill?: typeof spawn; platform?: NodeJS.Platform; resolvePowerShell?: () => string; }
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ export async function launchCursorPrompt(input: CursorPromptInput, deps: CursorP
|
|||||||
const args = ["--print", "--output-format", "stream-json", "--model", model, "--trust"];
|
const args = ["--print", "--output-format", "stream-json", "--model", model, "--trust"];
|
||||||
if (input.workspaceFlagRequired) args.push("--workspace", input.cwd);
|
if (input.workspaceFlagRequired) args.push("--workspace", input.cwd);
|
||||||
if (input.tools === "coding") args.push("--force"); else args.push("--mode", "plan");
|
if (input.tools === "coding") args.push("--force"); else args.push("--mode", "plan");
|
||||||
|
// FNXC:CursorMcpBridge 2026-08-15-21:20: Cursor approves MCP calls only when a lease was safely staged for this turn; failed staging must remain a tool-less turn.
|
||||||
|
if (input.approveMcps) args.push("--approve-mcps");
|
||||||
if (input.resumeId) args.push("--resume", input.resumeId);
|
if (input.resumeId) args.push("--resume", input.resumeId);
|
||||||
/*
|
/*
|
||||||
FNXC:CursorCli 2026-08-15-15:47:
|
FNXC:CursorCli 2026-08-15-15:47:
|
||||||
|
|||||||
@@ -1,44 +1,75 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { launchCursorPrompt } from "./prompt-transport.js";
|
import { launchCursorPrompt } from "./prompt-transport.js";
|
||||||
|
import { classifyCursorConfigTracking } from "./worktree-hygiene.js";
|
||||||
|
import { reconcileCursorWorktree, releaseHeldLeasesSync, stageCursorMcpLease } from "./cursor-mcp-config.js";
|
||||||
|
import { fromCursorToolName } from "./tool-mapping.js";
|
||||||
|
import { startCursorToolBridge } from "./tool-bridge.js";
|
||||||
|
import type { CursorToolBridge } from "./tool-bridge.js";
|
||||||
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult, CursorStreamSession } from "./types.js";
|
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult, CursorStreamSession } from "./types.js";
|
||||||
|
|
||||||
function context(options: AgentRuntimeOptions): string {
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-23:46:
|
||||||
|
A live Cursor session renews its lease below the 15-minute stale threshold so peer reconciliation cannot remove an active bridge during a long turn.
|
||||||
|
*/
|
||||||
|
const MCP_HEARTBEAT_INTERVAL_MS = 5 * 60_000;
|
||||||
|
let exitHookInstalled = false;
|
||||||
|
function installExitHook() { if (!exitHookInstalled) { exitHookInstalled = true; process.once("exit", () => { try { releaseHeldLeasesSync(); } catch { /* exit hooks must not throw */ } }); } }
|
||||||
|
function context(options: AgentRuntimeOptions, names: string[] = []): string {
|
||||||
const skills = Array.isArray(options.skills) ? options.skills.filter((value) => typeof value === "string" && value.trim()) : [];
|
const skills = Array.isArray(options.skills) ? options.skills.filter((value) => typeof value === "string" && value.trim()) : [];
|
||||||
return ["Fusion runtime context:", `- Tool mode: ${options.tools ?? "readonly"}`, skills.length ? `- Requested skills: ${skills.join(", ")}` : ""].filter(Boolean).join("\n");
|
return ["Fusion runtime context:", `- Tool mode: ${options.tools ?? "readonly"}`, skills.length ? `- Requested skills: ${skills.join(", ")}` : "", names.length ? `- Fusion MCP tools: ${names.join(", ")}` : ""].filter(Boolean).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:CursorCli 2026-08-15-15:16:
|
FNXC:CursorMcpBridge 2026-08-15-21:20:
|
||||||
Cursor has no system-prompt flag, so Fusion fuses system context only into the first stdin prompt.
|
Runtime setup reconciles previous crash residue before staging a tokenized bridge. Staging failure degrades only custom tools, never the Cursor turn.
|
||||||
The stored cwd and tools are immutable session authority: later turns cannot turn a review session into --force.
|
The exit hook is a latency backstop; journaled reconciliation remains the correctness owner after hard process death.
|
||||||
*/
|
*/
|
||||||
export class CursorRuntimeAdapter implements AgentRuntime {
|
export class CursorRuntimeAdapter implements AgentRuntime {
|
||||||
readonly id = "cursor";
|
readonly id = "cursor";
|
||||||
readonly name = "Cursor Runtime";
|
readonly name = "Cursor Runtime";
|
||||||
constructor(private readonly settings?: Record<string, unknown>) {}
|
constructor(private readonly settings?: Record<string, unknown>) {}
|
||||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||||
|
await reconcileCursorWorktree(options.cwd);
|
||||||
|
installExitHook();
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
const session: CursorStreamSession = { model: options.defaultModelId?.replace(/^cursor-cli\//, "") ?? "auto", systemPrompt: options.systemPrompt, messages, state: { messages }, sessionId: "", cwd: options.cwd, tools: options.tools, callbacks: { onText: options.onText, onThinking: options.onThinking, onToolStart: options.onToolStart, onToolEnd: options.onToolEnd }, fusedSystemPrompt: [options.systemPrompt?.trim(), context(options)].filter(Boolean).join("\n\n"), disposed: false, dispose: () => {
|
const session: CursorStreamSession = { model: options.defaultModelId?.replace(/^cursor-cli\//, "") ?? "auto", systemPrompt: options.systemPrompt, messages, state: { messages }, sessionId: "", cwd: options.cwd, tools: options.tools, callbacks: { onText: options.onText, onThinking: options.onThinking, onToolStart: options.onToolStart, onToolEnd: options.onToolEnd }, fusedSystemPrompt: [options.systemPrompt?.trim(), context(options)].filter(Boolean).join("\n\n"), disposed: false, dispose: async () => {
|
||||||
/* FNXC:CursorCli 2026-08-15-15:32: Disposing a Fusion session must abort its live autonomous Cursor turn so prompt-transport performs supervised process-tree teardown. */
|
if (session.disposed) return;
|
||||||
session.disposed = true;
|
session.disposed = true;
|
||||||
session.activeAbortController?.abort();
|
session.activeAbortController?.abort();
|
||||||
|
if (session.mcpHeartbeatTimer) clearInterval(session.mcpHeartbeatTimer);
|
||||||
|
await session.mcpLease?.dispose().catch(() => undefined);
|
||||||
|
await session.toolBridge?.dispose().catch(() => undefined);
|
||||||
} };
|
} };
|
||||||
|
let startingBridge: CursorToolBridge | null = null;
|
||||||
|
if (options.fusionTools?.length && classifyCursorConfigTracking(options.cwd) !== "tracked") {
|
||||||
|
try {
|
||||||
|
startingBridge = await startCursorToolBridge(options.fusionTools);
|
||||||
|
if (startingBridge) {
|
||||||
|
const serverKey = `fusion-custom-tools-${randomUUID()}`;
|
||||||
|
const lease = await stageCursorMcpLease({ worktreePath: options.cwd, serverKey, serverEntry: startingBridge.serverEntry });
|
||||||
|
session.toolBridge = startingBridge; session.mcpLease = lease; session.mcpServerKey = serverKey;
|
||||||
|
session.mcpHeartbeatTimer = setInterval(() => { void lease.heartbeat().catch(() => undefined); }, MCP_HEARTBEAT_INTERVAL_MS);
|
||||||
|
session.mcpHeartbeatTimer.unref?.();
|
||||||
|
session.fusedSystemPrompt = [options.systemPrompt?.trim(), context(options, options.fusionTools.map((tool) => `${serverKey}-${tool.name}`))].filter(Boolean).join("\n\n");
|
||||||
|
startingBridge = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await startingBridge?.dispose().catch(() => undefined);
|
||||||
|
session.fusionToolBridgeError = { reasonCode: (error as { code?: string }).code === "mcp-schema-server-missing" ? "mcp-schema-server-missing" : "bridge-start-failed" };
|
||||||
|
}
|
||||||
|
} else if (options.fusionTools?.length) session.fusionToolBridgeError = { reasonCode: "bridge-start-failed" };
|
||||||
return { session, sessionFile: undefined };
|
return { session, sessionFile: undefined };
|
||||||
}
|
}
|
||||||
async promptWithFallback(session: CursorStreamSession, prompt: string, _options?: unknown): Promise<void> {
|
async promptWithFallback(session: CursorStreamSession, prompt: string, _options?: unknown): Promise<void> {
|
||||||
if (session.disposed) throw new Error("Cursor session is disposed.");
|
if (session.disposed) throw new Error("Cursor session is disposed.");
|
||||||
const priorId = session.sessionId;
|
const priorId = session.sessionId; const first = !priorId;
|
||||||
const first = !priorId;
|
|
||||||
const sent = first ? `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}` : prompt;
|
const sent = first ? `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}` : prompt;
|
||||||
const emitted = new Set<string>();
|
const emitted = new Set<string>(); const controller = new AbortController(); session.activeAbortController = controller;
|
||||||
const controller = new AbortController();
|
const map = (name: string) => session.mcpServerKey ? fromCursorToolName(name, session.mcpServerKey) : name;
|
||||||
session.activeAbortController = controller;
|
|
||||||
try {
|
try {
|
||||||
const outcome = await launchCursorPrompt({ binary: typeof this.settings?.cursorCliBinaryPath === "string" ? this.settings.cursorCliBinaryPath : undefined, model: session.model, cwd: session.cwd, tools: session.tools, prompt: sent, resumeId: priorId || undefined, signal: controller.signal, onThinking: session.callbacks.onThinking, onToolStart: session.callbacks.onToolStart, onToolEnd: session.callbacks.onToolEnd, onText: (text) => { if (!emitted.has(text)) { emitted.add(text); session.callbacks.onText?.(text); } } });
|
const outcome = await launchCursorPrompt({ binary: typeof this.settings?.cursorCliBinaryPath === "string" ? this.settings.cursorCliBinaryPath : undefined, model: session.model, cwd: session.cwd, tools: session.tools, prompt: sent, resumeId: priorId || undefined, signal: controller.signal, approveMcps: Boolean(session.mcpLease), onThinking: session.callbacks.onThinking, onToolStart: (name, args) => session.callbacks.onToolStart?.(map(name), args), onToolEnd: (name, isError, result) => session.callbacks.onToolEnd?.(map(name), isError, result), onText: (text) => { if (!emitted.has(text)) { emitted.add(text); session.callbacks.onText?.(text); } } });
|
||||||
session.sessionId = outcome.sessionId ?? session.sessionId;
|
session.sessionId = outcome.sessionId ?? session.sessionId; session.messages.push({ role: "user", content: prompt }, { role: "assistant", content: outcome.text });
|
||||||
session.messages.push({ role: "user", content: prompt }, { role: "assistant", content: outcome.text });
|
} catch (error) { session.sessionId = priorId; throw error; } finally { if (session.activeAbortController === controller) session.activeAbortController = undefined; }
|
||||||
} catch (error) { session.sessionId = priorId; throw error; } finally {
|
|
||||||
if (session.activeAbortController === controller) session.activeAbortController = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
describeModel(session: CursorStreamSession): string { return `cursor-cli/${(session.model || "auto").replace(/^cursor-cli\//, "")}`; }
|
describeModel(session: CursorStreamSession): string { return `cursor-cli/${(session.model || "auto").replace(/^cursor-cli\//, "")}`; }
|
||||||
}
|
}
|
||||||
|
|||||||
48
plugins/fusion-plugin-cursor-runtime/src/tool-bridge.ts
Normal file
48
plugins/fusion-plugin-cursor-runtime/src/tool-bridge.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-20:56:
|
||||||
|
Cursor launches MCP servers from its project config, but Fusion tool closures remain in this process.
|
||||||
|
A per-session capability token limits the loopback bridge to its stdio child; never expose this endpoint beyond localhost or log its token.
|
||||||
|
*/
|
||||||
|
import { timingSafeEqual, randomUUID } from "node:crypto";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { existsSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export interface ToolLike { name: string; description?: string; parameters?: Record<string, unknown>; execute?: (id: string, args: unknown, signal?: AbortSignal, update?: unknown, context?: unknown) => unknown | Promise<unknown>; }
|
||||||
|
export interface McpToolDef { name: string; description: string; inputSchema: Record<string, unknown>; }
|
||||||
|
export interface CursorToolBridge { serverEntry: { command: string; args: string[]; env: Record<string, string> }; dispose: () => Promise<void>; toolCount: number; token: string; }
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-23:46:
|
||||||
|
The engine passes a dedicated identity-derived Fusion-tool subset. Keep fn_* and executable checks here as defense in depth; names alone never establish provenance.
|
||||||
|
*/
|
||||||
|
export const toolsToMcpToolDefs = (tools: readonly ToolLike[] | undefined): McpToolDef[] => (tools ?? [])
|
||||||
|
.filter((tool) => tool?.name.startsWith("fn_") && typeof tool.execute === "function")
|
||||||
|
.map((tool) => ({ name: tool.name, description: tool.description ?? "", inputSchema: tool.parameters ?? { type: "object", properties: {} } }));
|
||||||
|
export const cursorMcpSchemaServerPath = (): string => join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs");
|
||||||
|
const text = (value: unknown): string => { if (value == null) return ""; if (typeof value === "string") return value; if (typeof value === "object" && value && "text" in value && typeof (value as { text?: unknown }).text === "string") return (value as { text: string }).text; try { return JSON.stringify(value); } catch { return String(value); } };
|
||||||
|
const loopback = (address: string | undefined) => address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
||||||
|
export async function startCursorToolBridge(tools: readonly ToolLike[] | undefined): Promise<CursorToolBridge | null> {
|
||||||
|
const defs = toolsToMcpToolDefs(tools); if (!defs.length) return null;
|
||||||
|
const asset = cursorMcpSchemaServerPath(); if (!existsSync(asset)) { const error = Object.assign(new Error(`Fusion MCP schema server is missing: ${asset}`), { code: "mcp-schema-server-missing" }); throw error; }
|
||||||
|
const token = randomUUID();
|
||||||
|
const publishedNames = new Set(defs.map((tool) => tool.name));
|
||||||
|
const byName = new Map((tools ?? []).filter((tool) => publishedNames.has(tool.name) && typeof tool.execute === "function").map((tool) => [tool.name, tool]));
|
||||||
|
const schema = join(tmpdir(), `fusion-cursor-mcp-schemas-${process.pid}-${randomUUID()}.json`); writeFileSync(schema, JSON.stringify(defs), { mode: 0o600 }); chmodSync(schema, 0o600);
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const host = req.headers.host; const localHost = `127.0.0.1:${(server.address() as { port: number } | null)?.port ?? ""}`;
|
||||||
|
const auth = req.headers.authorization; const provided = auth?.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||||
|
const matches = provided.length === token.length && timingSafeEqual(Buffer.from(provided), Buffer.from(token));
|
||||||
|
if (!loopback(req.socket.remoteAddress) || host !== localHost || !matches) { res.statusCode = 401; res.end(); return; }
|
||||||
|
if (req.method !== "POST" || req.url !== "/tool-call") { res.statusCode = 404; res.end(); return; }
|
||||||
|
let raw = ""; for await (const chunk of req) raw += chunk;
|
||||||
|
let body: { name?: string; arguments?: unknown }; try { body = JSON.parse(raw || "{}"); } catch { res.statusCode = 400; res.end(JSON.stringify({ isError: true, text: "invalid JSON body" })); return; }
|
||||||
|
const tool = typeof body.name === "string" ? byName.get(body.name) : undefined;
|
||||||
|
res.setHeader("content-type", "application/json");
|
||||||
|
if (!tool?.execute) { res.end(JSON.stringify({ isError: true, content: [{ type: "text", text: "Unknown Fusion tool" }] })); return; }
|
||||||
|
try { const result = await tool.execute(`cursor-mcp-${randomUUID()}`, body.arguments ?? {}); res.end(JSON.stringify({ isError: false, content: [{ type: "text", text: text(result) }] })); } catch (error) { res.end(JSON.stringify({ isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] })); }
|
||||||
|
});
|
||||||
|
const port = await new Promise<number>((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => resolve((server.address() as { port: number }).port)); });
|
||||||
|
return { token, toolCount: defs.length, serverEntry: { command: process.execPath, args: [asset, schema], env: { FUSION_CURSOR_TOOL_BRIDGE_URL: `http://127.0.0.1:${port}`, FUSION_CURSOR_TOOL_BRIDGE_TOKEN: token } }, dispose: async () => { await new Promise<void>((resolve) => server.close(() => resolve())); try { unlinkSync(schema); } catch { /* schema was already removed */ } } };
|
||||||
|
}
|
||||||
11
plugins/fusion-plugin-cursor-runtime/src/tool-mapping.ts
Normal file
11
plugins/fusion-plugin-cursor-runtime/src/tool-mapping.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:20:
|
||||||
|
FN-9098 observed Cursor stream tool names as <serverKey>-<toolName>.
|
||||||
|
Normalize only that session-owned prefix so Fusion callbacks continue to receive bare fn_* tool names.
|
||||||
|
*/
|
||||||
|
export function toCursorToolName(fnName: string, serverKey: string): string { return `${serverKey}-${fnName}`; }
|
||||||
|
export function fromCursorToolName(observedName: string, serverKey: string): string {
|
||||||
|
const exact = `${serverKey}-`;
|
||||||
|
if (observedName.startsWith(exact)) return observedName.slice(exact.length);
|
||||||
|
return observedName.replace(new RegExp(`^mcp[_-]*${serverKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_-]*`), "");
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface CursorBinaryStatus { available: boolean; authenticated?: boolean; binaryPath?: string; binaryName?: string; configuredBinaryPath?: string; usingConfiguredBinaryPath?: boolean; diagnostics?: string[]; version?: string; reason?: string; probeDurationMs: number; }
|
export interface CursorBinaryStatus { available: boolean; authenticated?: boolean; binaryPath?: string; binaryName?: string; configuredBinaryPath?: string; usingConfiguredBinaryPath?: boolean; diagnostics?: string[]; version?: string; reason?: string; probeDurationMs: number; }
|
||||||
|
|
||||||
export interface AgentRuntimeOptions { cwd: string; systemPrompt: string; tools?: "coding" | "readonly"; defaultModelId?: string; skills?: string[]; skillSelection?: unknown; onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record<string, unknown>) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; }
|
import type { CursorToolBridge, ToolLike } from "./tool-bridge.js";
|
||||||
export interface CursorStreamSession { model?: string; systemPrompt?: string; messages: unknown[]; state: { messages: unknown[] }; sessionId: string; cwd: string; tools?: "coding" | "readonly"; callbacks: Pick<AgentRuntimeOptions, "onText" | "onThinking" | "onToolStart" | "onToolEnd">; fusedSystemPrompt: string; disposed: boolean; activeAbortController?: AbortController; dispose: () => void; }
|
|
||||||
|
export interface AgentRuntimeOptions { cwd: string; systemPrompt: string; tools?: "coding" | "readonly"; defaultModelId?: string; skills?: string[]; skillSelection?: unknown; customTools?: ToolLike[]; fusionTools?: ToolLike[]; onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record<string, unknown>) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; }
|
||||||
|
export interface CursorStreamSession { model?: string; systemPrompt?: string; messages: unknown[]; state: { messages: unknown[] }; sessionId: string; cwd: string; tools?: "coding" | "readonly"; callbacks: Pick<AgentRuntimeOptions, "onText" | "onThinking" | "onToolStart" | "onToolEnd">; fusedSystemPrompt: string; disposed: boolean; activeAbortController?: AbortController; toolBridge?: CursorToolBridge; mcpLease?: { dispose: () => Promise<void>; heartbeat: () => Promise<unknown> }; mcpHeartbeatTimer?: ReturnType<typeof setInterval>; mcpServerKey?: string; fusionToolBridgeError?: { reasonCode: "mcp-schema-server-missing" | "bridge-start-failed" }; dispose: () => void | Promise<void>; }
|
||||||
export interface AgentSessionResult { session: CursorStreamSession; sessionFile?: string; }
|
export interface AgentSessionResult { session: CursorStreamSession; sessionFile?: string; }
|
||||||
export interface AgentRuntime { readonly id: string; readonly name: string; createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>; promptWithFallback(session: CursorStreamSession, prompt: string, options?: unknown): Promise<void>; describeModel(session: CursorStreamSession): string; }
|
export interface AgentRuntime { readonly id: string; readonly name: string; createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>; promptWithFallback(session: CursorStreamSession, prompt: string, options?: unknown): Promise<void>; describeModel(session: CursorStreamSession): string; }
|
||||||
|
|||||||
117
plugins/fusion-plugin-cursor-runtime/src/worktree-hygiene.ts
Normal file
117
plugins/fusion-plugin-cursor-runtime/src/worktree-hygiene.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:20:
|
||||||
|
Cursor loads project MCP configuration from the task worktree, while merger paths may use git add -A.
|
||||||
|
Keep every Fusion-owned Cursor file excluded before it exists; tracked operator configuration is refused instead of hidden.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
const START = "# >>> fusion cursor-runtime >>>";
|
||||||
|
const END = "# <<< fusion cursor-runtime <<<";
|
||||||
|
|
||||||
|
export interface GitShape { topLevel: string; excludePath: string; gitDir: string; relativeWorktree: string; }
|
||||||
|
const git = (cwd: string, ...args: string[]) => spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||||
|
const output = (cwd: string, ...args: string[]) => { const result = git(cwd, ...args); return result.status === 0 ? result.stdout.trim() : undefined; };
|
||||||
|
const atomic = (file: string, raw: string) => { mkdirSync(dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.tmp`; writeFileSync(tmp, raw, { mode: 0o600 }); renameSync(tmp, file); };
|
||||||
|
|
||||||
|
export function resolveGitShape(worktreePath: string): GitShape | undefined {
|
||||||
|
const topLevel = output(worktreePath, "rev-parse", "--show-toplevel");
|
||||||
|
const excludePath = output(worktreePath, "rev-parse", "--git-path", "info/exclude");
|
||||||
|
const gitDir = output(worktreePath, "rev-parse", "--git-path", ".");
|
||||||
|
if (!topLevel || !excludePath || !gitDir) return undefined;
|
||||||
|
const rel = relative(topLevel, worktreePath).split(sep).filter(Boolean).join("/");
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:26:
|
||||||
|
Git may return absolute --git-path values, especially for linked worktrees. Preserve
|
||||||
|
those paths; joining an absolute-looking result below the worktree creates a stray
|
||||||
|
`Users/...` tree and leaves the real info/exclude unprotected from git add -A.
|
||||||
|
*/
|
||||||
|
const fromGitPath = (value: string) => isAbsolute(value) ? value : join(worktreePath, value);
|
||||||
|
return { topLevel, excludePath: fromGitPath(excludePath), gitDir: fromGitPath(gitDir), relativeWorktree: rel };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyCursorConfigTracking(worktreePath: string): "tracked" | "untracked" | "non-git" {
|
||||||
|
const shape = resolveGitShape(worktreePath); if (!shape) return "non-git";
|
||||||
|
const path = `${shape.relativeWorktree ? `${shape.relativeWorktree}/` : ""}.cursor/mcp.json`;
|
||||||
|
return git(shape.topLevel, "ls-files", "--error-unmatch", "--", path).status === 0 ? "tracked" : "untracked";
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = (shape: GitShape) => {
|
||||||
|
const prefix = shape.relativeWorktree ? `/${shape.relativeWorktree}/` : "/";
|
||||||
|
return `${START}\n${prefix}.cursor/mcp.json\n${prefix}.cursor/.fusion-mcp-state.json\n${prefix}.cursor/.fusion-mcp.lock/\n${END}\n`;
|
||||||
|
};
|
||||||
|
const withoutBlock = (raw: string) => raw.replace(new RegExp(`${START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "g"), "");
|
||||||
|
|
||||||
|
const BOOTSTRAP_LOCK_TTL_MS = 10_000;
|
||||||
|
const BOOTSTRAP_LOCK_ATTEMPTS = 5;
|
||||||
|
const BOOTSTRAP_LOCK_RETRY_MS = 10;
|
||||||
|
const bootstrapLock = (shape: GitShape) => join(shape.gitDir, "fusion-cursor-exclude.lock");
|
||||||
|
const sleepSync = (milliseconds: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
||||||
|
const lockAgeMs = (lock: string) => {
|
||||||
|
try { return Date.now() - statSync(lock).mtimeMs; } catch { return Number.POSITIVE_INFINITY; }
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:36:
|
||||||
|
The main MCP lock lives under .cursor and cannot protect the exclusion that must
|
||||||
|
exist before .cursor is created. Bootstrap therefore serializes info/exclude with a
|
||||||
|
short git-dir lock; callers already holding the main lock pass lockHeld to avoid a
|
||||||
|
lock-order inversion.
|
||||||
|
*/
|
||||||
|
function withBootstrapLock<T>(shape: GitShape, fn: () => T): T {
|
||||||
|
const lock = bootstrapLock(shape);
|
||||||
|
const mine = { pid: process.pid, hostname: hostname(), acquiredAt: Date.now() };
|
||||||
|
let acquired = false;
|
||||||
|
for (let attempt = 0; attempt < BOOTSTRAP_LOCK_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
mkdirSync(lock);
|
||||||
|
writeFileSync(join(lock, "owner.json"), JSON.stringify(mine), { mode: 0o600 });
|
||||||
|
acquired = true;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
let owner: { pid?: number; hostname?: string; acquiredAt?: number } | undefined;
|
||||||
|
try { owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")); } catch { /* owner publication may still be in progress */ }
|
||||||
|
const deadLocalOwner = owner?.hostname === mine.hostname && typeof owner.pid === "number" && (() => { try { process.kill(owner.pid!, 0); return false; } catch { return true; } })();
|
||||||
|
/*
|
||||||
|
FNXC:CursorMcpBridge 2026-08-15-21:46:
|
||||||
|
mkdir is atomic but owner.json publication is a second operation. A fresh lock
|
||||||
|
without metadata is an in-progress acquisition, not proof of abandonment;
|
||||||
|
only its age may authorize reclamation, preventing two composers from entering.
|
||||||
|
*/
|
||||||
|
const stale = lockAgeMs(lock) > BOOTSTRAP_LOCK_TTL_MS || deadLocalOwner || (owner !== undefined && Date.now() - (owner.acquiredAt ?? 0) > BOOTSTRAP_LOCK_TTL_MS);
|
||||||
|
if (stale) { try { rmSync(lock, { recursive: true, force: true }); } catch { /* contender owns the next retry */ } }
|
||||||
|
if (attempt < BOOTSTRAP_LOCK_ATTEMPTS - 1) sleepSync(BOOTSTRAP_LOCK_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!acquired) throw Object.assign(new Error("Cursor MCP exclude lock unavailable"), { code: "bridge-start-failed" });
|
||||||
|
try { return fn(); } finally {
|
||||||
|
try {
|
||||||
|
const owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")) as typeof mine;
|
||||||
|
if (owner.pid === mine.pid && owner.hostname === mine.hostname) rmSync(lock, { recursive: true, force: true });
|
||||||
|
} catch { /* a stale-lock reconciler owns recovery */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureCursorConfigExcluded(worktreePath: string, options: { lockHeld?: boolean } = {}): { present: boolean; fusionAuthored: boolean; created: boolean } {
|
||||||
|
const shape = resolveGitShape(worktreePath); if (!shape) return { present: false, fusionAuthored: false, created: false };
|
||||||
|
const ensure = () => {
|
||||||
|
const raw = existsSync(shape.excludePath) ? readFileSync(shape.excludePath, "utf8") : "";
|
||||||
|
if (raw.includes(START) && raw.includes(END)) return { present: true, fusionAuthored: true, created: false };
|
||||||
|
atomic(shape.excludePath, `${raw}${raw && !raw.endsWith("\n") ? "\n" : ""}${block(shape)}`);
|
||||||
|
return { present: true, fusionAuthored: true, created: true };
|
||||||
|
};
|
||||||
|
return options.lockHeld ? ensure() : withBootstrapLock(shape, ensure);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCursorConfigExclusion(worktreePath: string): void {
|
||||||
|
const shape = resolveGitShape(worktreePath); if (!shape || !existsSync(shape.excludePath)) return;
|
||||||
|
const raw = readFileSync(shape.excludePath, "utf8"); if (!raw.includes(START) || !raw.includes(END)) return;
|
||||||
|
const next = withoutBlock(raw); if (next) atomic(shape.excludePath, next); else unlinkSync(shape.excludePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectCursorResidue(worktreePath: string, _options: { preservedConfig?: "operator-edit" | "quarantined" } = {}): string[] {
|
||||||
|
const cursor = join(worktreePath, ".cursor");
|
||||||
|
return ["mcp.json", ".fusion-mcp-state.json", ".fusion-mcp.lock"].filter((name) => existsSync(join(cursor, name))).map((name) => join(cursor, name));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user