Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
},
"scripts": {
"postinstall": "cd phoenix-builder-mcp && npm install",
"enableBuilderMcpInProd": "node phoenix-builder-mcp/enable-in-prod/index.cjs",
"lint": "eslint --quiet src test",
"lint:fix": "eslint --quiet --fix src test",
"prepare": "husky install",
Expand Down Expand Up @@ -128,4 +129,4 @@
"@xterm/addon-web-links": "0.13.0-beta.301",
"@xterm/addon-webgl": "0.20.0-beta.300"
}
}
}
30 changes: 30 additions & 0 deletions phoenix-builder-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,36 @@ Each Builder process owns its localhost WebSocket listener and stdio session. A

Builder binds to `localhost` and intentionally trusts every renderer Origin, including custom `phtaur://…` and `phtauri://…` URLs. The listener is for trusted development apps. The optional remote framework has separate authentication for workers and its dashboard.

### Enable Builder MCP in a production desktop build

From the repository root or this `phoenix-builder-mcp` directory, run:

```sh
npm run enableBuilderMcpInProd
```

The single [Node.js script](enable-in-prod/index.cjs) works on Windows, macOS and Linux and needs
no npm dependencies. It asks whether to **Enable for today**, **Disable**, or **Cancel** (the default).
After you choose an action, it requests `sudo` access on Linux/macOS or Windows administrator
approval through UAC. You do not need to run npm itself as administrator.

It sets today's **local** date in `prodMCPOverrideDate` in the existing system override file:

| Platform | File |
| --- | --- |
| Windows | `C:\Program Files\Phoenix Code Control\phoenix_override_config.json` |
| macOS | `/Library/Application Support/Phoenix Code Control/phoenix_override_config.json` |
| Linux | `/etc/phoenix-code-control/phoenix_override_config.json` |

Other override settings are preserved. Disable removes only the Builder permission, deleting the
file if no settings remain. Invalid JSON is left untouched and reported instead of overwritten.

**Restart the production app twice after enabling or disabling.** Boot uses a cached permission:
the first start refreshes it from the file and the second applies it. The permission is valid only
for that local calendar day; run this command again on another day to renew it. This does not
disconnect an already running session. Start the Builder MCP server separately using the setup
above; the script only manages the desktop app's permission file.

### Optional remote machines

Use two independent MCP servers: **Phoenix Builder** for app interaction, screenshots and Jasmine tests, and **remote-control** for machine discovery, remote commands, file transfers, Git sync and agent coordination. Builder has no framework package dependency and opens no orchestrator agent session. Local Builder use needs no remote framework.
Expand Down
240 changes: 240 additions & 0 deletions phoenix-builder-mcp/enable-in-prod/index.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/*
* Copyright (c) 2021 - present core.ai
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-env node */

const fs = require("fs/promises");
const path = require("path");
const readline = require("readline");
const {spawn} = require("child_process");
const {randomBytes} = require("crypto");

// Keep these paths in sync with src/utils/SystemConfigOverride.js.
const OVERRIDE_PATHS = {
win32: "C:\\Program Files\\Phoenix Code Control\\phoenix_override_config.json",
darwin: "/Library/Application Support/Phoenix Code Control/phoenix_override_config.json",
linux: "/etc/phoenix-code-control/phoenix_override_config.json"
};
const DATE_KEY = "prodMCPOverrideDate";

/**
* Match the local calendar date used by Phoenix's production boot gate.
* @param {Date} [now] Date to format.
* @return {string} Local date in YYYY-MM-DD form.
*/
function localDate(now = new Date()) {
return now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0") + "-" +
String(now.getDate()).padStart(2, "0");
}

/**
* Inspect an existing path without following symlinks; absence is allowed.
* @param {string} target File or directory to inspect.
* @return {Promise<Object|null>} File stats, or null when missing.
*/
async function inspectPath(target) {
try {
const stat = await fs.lstat(target);
if (stat.isSymbolicLink()) {
throw new Error("Refusing to modify a symbolic link: " + target);
}
return stat;
} catch (error) {
if (error.code === "ENOENT") { return null; }
throw error;
}
}

/**
* Change only the Builder permission, preserving other machine-wide overrides.
* The caller must obtain admin rights first. A path parameter allows temporary-file verification.
* @param {string} filePath Override file to update.
* @param {string} action Either enable or disable.
* @return {Promise<void>} Resolves after the change is on disk.
*/
async function updateOverride(filePath, action) {
if (action !== "enable" && action !== "disable") {
throw new Error("Choose enable or disable.");
}
const directory = path.dirname(filePath);
const directoryStat = await inspectPath(directory);
if (directoryStat && !directoryStat.isDirectory()) {
throw new Error("Not a directory: " + directory);
}
const fileStat = await inspectPath(filePath);
let config = {};
if (fileStat) {
if (!fileStat.isFile()) { throw new Error("Not a regular file: " + filePath); }
const contents = await fs.readFile(filePath, "utf8");
try {
config = JSON.parse(contents.replace(/^\uFEFF/, ""));
} catch (error) {
throw new Error("The override file contains invalid JSON; it was left unchanged: " + filePath);
}
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new Error("The override file must contain a JSON object; it was left unchanged: " + filePath);
}
}
if (action === "disable") {
if (!Object.prototype.hasOwnProperty.call(config, DATE_KEY)) { return; }
delete config[DATE_KEY];
if (Object.keys(config).length === 0) {
await fs.unlink(filePath);
return;
}
} else {
const today = localDate();
if (config[DATE_KEY] === today) { return; }
config[DATE_KEY] = today;
if (!directoryStat) {
await fs.mkdir(directory, {recursive: true, mode: 0o755});
if (process.platform !== "win32") { await fs.chmod(directory, 0o755); }
}
}

// Write beside the destination and rename, so an interrupted write cannot truncate the policy.
const temporaryFile = filePath + "." + randomBytes(12).toString("hex") + ".tmp";
try {
await fs.writeFile(temporaryFile, JSON.stringify(config, null, 4) + "\n", {flag: "wx", mode: 0o644});
if (process.platform !== "win32") {
// Preserve existing file permissions; make a new root-owned file readable by Phoenix.
await fs.chmod(temporaryFile, fileStat ? fileStat.mode % 0o1000 : 0o644);
}
await fs.rename(temporaryFile, filePath);
} finally {
await fs.rm(temporaryFile, {force: true});
}
}

/**
* Encode a literal value for PowerShell without interpreting quotes or substitutions.
* @param {string} value Literal argument.
* @return {string} Single-quoted PowerShell literal.
*/
function powershellLiteral(value) {
return "'" + value.replace(/'/g, "''") + "'";
}

/**
* Build an admin launcher that also works when Node or the checkout path contains spaces.
* @param {string} action Either enable or disable.
* @param {string} [scriptPath] Absolute entry point.
* @param {string} [nodePath] Absolute Node executable.
* @return {string} PowerShell source, passed with -EncodedCommand rather than shell interpolation.
*/
function windowsElevationScript(action, scriptPath = __filename, nodePath = process.execPath) {
if (action !== "enable" && action !== "disable") { throw new Error("Invalid action"); }
const worker = "$ErrorActionPreference = 'Stop'\n" +
"try {\n" +
" & " + powershellLiteral(nodePath) + " " + powershellLiteral(scriptPath) +
" '--apply' " + powershellLiteral(action) + "\n" +
" $result = $LASTEXITCODE\n" +
"} catch { Write-Host $_; $result = 1 }\n" +
"if ($result -ne 0) { Read-Host 'Press Enter to close' | Out-Null }\n" +
"exit $result\n";
const encodedWorker = Buffer.from(worker, "utf16le").toString("base64");
return "$ErrorActionPreference = 'Stop'\n" +
"$identity = [Security.Principal.WindowsIdentity]::GetCurrent()\n" +
"$principal = New-Object Security.Principal.WindowsPrincipal($identity)\n" +
"if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {\n" +
" & " + powershellLiteral(nodePath) + " " + powershellLiteral(scriptPath) +
" '--apply' " + powershellLiteral(action) + "\n" +
" exit $LASTEXITCODE\n" +
"}\n" +
"$child = Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') " +
"-ArgumentList @('-NoProfile', '-EncodedCommand', '" + encodedWorker + "') " +
"-Verb RunAs -Wait -PassThru\n" +
"exit $child.ExitCode\n";
}

/**
* Run the writer with sudo or UAC, inheriting the terminal for authentication and errors.
* @param {string} action Either enable or disable.
* @return {Promise<void>} Resolves only after a successful elevated write.
*/
async function applyAsAdmin(action) {
let command, args;
if (process.platform === "win32") {
command = path.join(process.env.SystemRoot || "C:\\Windows",
"System32", "WindowsPowerShell", "v1.0", "powershell.exe");
args = ["-NoProfile", "-EncodedCommand",
Buffer.from(windowsElevationScript(action), "utf16le").toString("base64")];
console.log("Windows will request administrator approval if needed.");
} else if (process.getuid() === 0) {
await updateOverride(OVERRIDE_PATHS[process.platform], action);
return;
} else {
command = "/usr/bin/sudo";
args = ["--", process.execPath, __filename, "--apply", action];
console.log("Administrator access is required. sudo may ask for your password.");
}
await new Promise(function (resolve, reject) {
const child = spawn(command, args, {stdio: "inherit", shell: false});
child.once("error", reject);
child.once("exit", function (code, signal) {
if (code === 0) {
resolve();
} else {
reject(new Error("Administrator update failed or was cancelled (" + (signal || code) + ")."));
}
});
});
}

/** @return {Promise<string|null>} User's action, or null on Cancel, EOF or Ctrl+C. */
async function chooseAction() {
const input = readline.createInterface({input: process.stdin, output: process.stdout});
input.on("SIGINT", function () { input.close(); });
try {
process.stdout.write("[e] Enable for today / [d] Disable / [c] Cancel (default): ");
for await (const line of input) {
const answer = line.trim().toLowerCase();
if (["e", "enable"].includes(answer)) { return "enable"; }
if (["d", "disable"].includes(answer)) { return "disable"; }
if (["", "c", "cancel"].includes(answer)) { return null; }
process.stdout.write("Please enter e, d or c: ");
}
return null;
} finally {
input.close();
}
}

/** @return {Promise<void>} Run the interactive command or its internal elevated writer. */
async function main() {
const filePath = OVERRIDE_PATHS[process.platform];
if (!filePath) { throw new Error("Unsupported platform: " + process.platform); }
const args = process.argv.slice(2);
if (args.length === 2 && args[0] === "--apply") {
await updateOverride(filePath, args[1]);
return;
}
if (args.length) {
if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
console.log("Run npm run enableBuilderMcpInProd, then choose Enable, Disable or Cancel.");
console.log("Permission lasts for today's local date. Restart Phoenix twice after changing it.");
return;
}
throw new Error("Run without arguments to choose Enable, Disable or Cancel.");
}
console.log("Phoenix Builder MCP — production desktop builds");
console.log("Override file: " + filePath);
console.log("Enable permits Builder to control the app for today (" + localDate() + ").");
const action = await chooseAction();
if (!action) { console.log("Cancelled. No settings changed."); return; }
await applyAsAdmin(action);
console.log(action === "enable" ? "Builder MCP permission enabled for " + localDate() + "." :
"Builder MCP permission disabled.");
console.log("Restart the production app twice: the first start updates its cache, the second applies the change.");
}

// Export the file operation for verification against temporary fixtures, never the machine policy.
module.exports = {localDate, updateOverride, windowsElevationScript, OVERRIDE_PATHS};

if (require.main === module) {
main().catch(function (error) {
console.error("Could not update Builder MCP permission: " + error.message);
process.exitCode = 1;
});
}
3 changes: 3 additions & 0 deletions phoenix-builder-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"private": true,
"type": "module",
"main": "index.js",
"scripts": {
"enableBuilderMcpInProd": "node enable-in-prod/index.cjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "latest",
"ws": "^8.0.0",
Expand Down
38 changes: 37 additions & 1 deletion src-mdviewer/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { setLocale } from "./core/i18n.js";
import { marked } from "marked";
import * as docCache from "./core/doc-cache.js";
import { broadcastSelectionStateSync, flushPendingContentChange } from "./components/editor.js";
import { captureSelection, restoreSelection, getRenderedMdLineText } from "./core/selection-context.js";

let _syncId = 0;
let _lastReceivedSyncId = -1;
Expand Down Expand Up @@ -81,6 +82,7 @@ function _annotateTokenLines(tokens) {
for (const token of tokens) {
if (token.type !== "space") {
token._sourceLine = line;
token._sourceEndLine = line + (token.raw.replace(/\n$/, "").match(/\n/g) || []).length;
}
// Recursively annotate children with their source lines
_annotateTokenChildren(token, line);
Expand Down Expand Up @@ -132,6 +134,7 @@ function _annotateNestedTokens(tokens, startLine) {
for (const token of tokens) {
if (token.type !== "space") {
token._sourceLine = line;
token._sourceEndLine = line + (token.raw.replace(/\n$/, "").match(/\n/g) || []).length;
}
// Recurse into nested lists
if (token.type === "list" && token.items) {
Expand Down Expand Up @@ -182,7 +185,8 @@ function _withSourceLine(protoFn, tagRegex) {
return function (token) {
const html = protoFn.call(this, token);
if (token._sourceLine != null) {
return html.replace(tagRegex, `$& data-source-line="${token._sourceLine}"`);
return html.replace(tagRegex, `$& data-source-line="${token._sourceLine}"` +
` data-source-end-line="${token._sourceEndLine || token._sourceLine}"`);
}
return html;
};
Expand Down Expand Up @@ -278,6 +282,24 @@ export function initBridge() {
if (!data || !data.type) return;

switch (data.type) {
case "MDVIEWR_ASK_AI_ENABLED":
if (event.source === window.parent) { emit("ai:enabled", !!data.enabled); }
break;
case "MDVIEWR_ASK_AI_SELECTION":
if (event.source === window.parent) { emit("ai:attach-selection", {titlebar: true}); }
break;
case "MDVIEWR_SELECT_SOURCE_RANGE":
if (event.source === window.parent && data.filePath === docCache.getActiveFilePath()) {
restoreSelection(document.getElementById("viewer-content"), getState().currentContent,
data.selectionId);
}
break;
case "MDVIEWR_RENDERED_LINES":
if (event.source === window.parent) {
sendToParent("mdviewrRenderedLines", {requestId: data.requestId,
result: getRenderedMdLineText(data.params)});
}
break;
case "MDVIEWR_SET_CONTENT":
handleSetContent(data);
break;
Expand Down Expand Up @@ -550,6 +572,20 @@ export function initBridge() {
}, true);

// Listen for content changes from editor (debounced by editor.js)
on("ai:attach-selection", ({rect, titlebar}) => {
if (flushPendingContentChange()) {
emit("editor:source-lines", getState().currentContent);
}
const content = document.getElementById("viewer-content");
const selection = captureSelection(content, getState().currentContent, docCache.getActiveFilePath());
if (selection) {
sendToParent("mdviewrAskAI", {selection, rect, filePath: docCache.getActiveFilePath()});
} else {
if (titlebar) { sendToParent("mdviewrAskAIEmpty", {}); }
else { emit("ai:selection-unavailable"); }
}
});

on("bridge:contentChanged", ({ markdown }) => {
if (_suppressContentChange) return;
_syncId++;
Expand Down
Loading
Loading