diff --git a/CLAUDE.md b/CLAUDE.md index 9ffecaba2a..ddffdd6772 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,12 @@ - No trailing whitespace. - Use `const` and `let` instead of `var`. +## Public API docs +- Core modules meant for extension authors must start with `// @INCLUDE_IN_API_DOCS`. Omit it for + internal modules — the marker is what makes a module public, so don't add it by reflex. +- If you add or change that marker, verify the docs still build with `npm run createJSDocs` (note it + also stages `docs/`). + ## Build artifacts — do not hand-edit - **`src/cacheManifest.json`** is a generated build artifact (gitignored, produced by `gulpfile.js/index.js`). It lists files + hashes for the service-worker cache. Never hand-edit or commit it — it is regenerated by the build, so edits are overwritten and won't be tracked anyway. When you add/remove/rename source files, just let the build regenerate it. diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index 2da39623b6..14ca1b12c0 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -457,7 +457,7 @@ const ALLOWED_EXTENSIONS_TO_CACHE = ["js", "html", "htm", "xml", "xhtml", "mjs", "png", "svg", "jpg", "jpeg", "gif", "ico", "webp", "mustache", "md", "markdown"]; const DISALLOWED_EXTENSIONS_TO_CACHE = ["map", "nuspec", "partial", "pre", "post", - "webmanifest", "rb", "ts"]; + "webmanifest", "rb", "ts", "sh"]; // Ceiling for the PWA service worker cache, in KB. Dev builds ship unminified sources and keep the // phoenix-pro sources in dist, so they are legitimately larger than prod - dev gets the looser diff --git a/src/extensionsIntegrated/appUpdater/main.js b/src/extensionsIntegrated/appUpdater/main.js index 2e6ca8c999..55fbb6b8be 100644 --- a/src/extensionsIntegrated/appUpdater/main.js +++ b/src/extensionsIntegrated/appUpdater/main.js @@ -43,6 +43,7 @@ define(function (require, exports, module) { StringUtils = require("utils/StringUtils"), NativeApp = require("utils/NativeApp"), BootGreetings = require("utils/BootGreetings"), + SystemConfigOverride = require("utils/SystemConfigOverride"), PreferencesManager = require("preferences/PreferencesManager"); // Reserve a slot in the boot-greeting coordinator so the tour can wait @@ -164,7 +165,11 @@ define(function (require, exports, module) { if(!updaterWindow){ updaterWindow = window.__TAURI__.window.WebviewWindow.getByLabel(TAURI_UPDATER_WINDOW_LABEL); } - const updateMetadata = await fetchJSON(brackets.config.app_update_url); + // an admin can point us at another update feed with the system wide + // phoenix_override_config.json, see utils/SystemConfigOverride.js + const overrideConfig = await SystemConfigOverride.getOverrides(); + const updateURL = overrideConfig.app_update_url || brackets.config.app_update_url; + const updateMetadata = await fetchJSON(updateURL); const phoenixBinaryVersion = await NodeUtils.getPhoenixBinaryVersion(); const phoenixLoadedAppVersion = Phoenix.metadata.apiVersion; if(semver.gt(updateMetadata.version, phoenixBinaryVersion)){ diff --git a/src/extensionsIntegrated/appUpdater/unit-tests/linux-local-update-override-script.sh b/src/extensionsIntegrated/appUpdater/unit-tests/linux-local-update-override-script.sh new file mode 100755 index 0000000000..85edbbb8fc --- /dev/null +++ b/src/extensionsIntegrated/appUpdater/unit-tests/linux-local-update-override-script.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# SAMPLE / REFERENCE ONLY - a fake linux installer for testing the admin update override. +# +# The real linux update runs: wget -qO- "$UPDATE_URL" | bash -s -- --upgrade +# so this stands in for updates.phcode.io/linux/installer.sh and installs nothing. +# +# Point app_update_linux_installer_url at it in phoenix_override_config.json (see +# localOverride.json in this folder), with the dev server serving the repo on :8000: +# http://localhost:8000/src/extensionsIntegrated/appUpdater/unit-tests/linux-local-update-override-script.sh +# +# Phoenix spawns this in an external terminal at app quit and waits for a keypress, so you +# get to read the output before the window closes. + +echo "================================================" +echo " update done" +echo "================================================" +echo "This is the LOCAL OVERRIDE installer, not the real one." +echo "If you are seeing this, app_update_linux_installer_url was honoured." +echo +echo "args passed by phoenix : $*" +echo "user : $(id -un)" +echo "date : $(date)" +echo +echo "Nothing was installed or changed." + +exit 0 diff --git a/src/extensionsIntegrated/appUpdater/unit-tests/localOverride.json b/src/extensionsIntegrated/appUpdater/unit-tests/localOverride.json new file mode 100644 index 0000000000..33ba394f71 --- /dev/null +++ b/src/extensionsIntegrated/appUpdater/unit-tests/localOverride.json @@ -0,0 +1,36 @@ +{ + "_readme": [ + "SAMPLE / REFERENCE ONLY - this file does nothing where it sits.", + "", + "To actually use it, copy it as admin/root into the machine wide control folder and", + "rename it to phoenix_override_config.json:", + " windows: C:\\Program Files\\Phoenix Code Control\\phoenix_override_config.json", + " mac : /Library/Application Support/Phoenix Code Control/phoenix_override_config.json", + " linux : /etc/phoenix-code-control/phoenix_override_config.json", + "", + "The folder MUST be admin/root owned. That a standard user cannot create the file is the", + "whole security model - see src/utils/SystemConfigOverride.js. Only keys listed in its", + "OVERRIDABLE_KEYS are honoured, anything else here (this _readme included) is ignored.", + "", + "The two urls below point at the sibling sample files in this folder, served by the dev", + "server straight out of the repo, so this works as-is with no extra setup:", + " localUpdateManifest.json - the fake update manifest", + " linux-local-update-override-script.sh - fake installer, just prints 'update done'", + "", + "Why both: on windows/mac the manifest's platform url is what gets downloaded and", + "installed, so app_update_url alone is enough there. Linux installs nothing from the", + "manifest - it pipes app_update_linux_installer_url into bash and that script decides", + "what to install - so a genuine linux test needs the second override too.", + "", + "Gotchas when testing:", + " - the file is read once per app session, so restart Phoenix after editing it.", + " - boot update checks are throttled to 24h (PH_LAST_UPDATE_CHECK_TIME view state), so", + " use Help > Check for Updates to see an override take effect immediately.", + " - the linux install only runs at app quit, in an external terminal." + ], + + "app_update_url": + "http://localhost:8000/src/extensionsIntegrated/appUpdater/unit-tests/localUpdateManifest.json", + "app_update_linux_installer_url": + "http://localhost:8000/src/extensionsIntegrated/appUpdater/unit-tests/linux-local-update-override-script.sh" +} diff --git a/src/extensionsIntegrated/appUpdater/unit-tests/localUpdateManifest.json b/src/extensionsIntegrated/appUpdater/unit-tests/localUpdateManifest.json new file mode 100644 index 0000000000..4d0655902c --- /dev/null +++ b/src/extensionsIntegrated/appUpdater/unit-tests/localUpdateManifest.json @@ -0,0 +1,56 @@ +{ + "_readme": [ + "SAMPLE / REFERENCE ONLY - the fake update manifest that app_update_url can point at.", + "Served by the dev server straight out of the repo:", + " http://localhost:8000/src/extensionsIntegrated/appUpdater/unit-tests/localUpdateManifest.json", + "", + "Only version, notes and platforms are read by the updater (see getUpdateDetails in", + "update-electron.js / main.js). Everything else here, including this _readme, is ignored.", + "", + "version : must be valid semver and GREATER than the installed build, or the app reports", + " 'no updates available'. 99.9.9 is used so it always wins.", + "notes : markdown, rendered as-is in the update dialog. Handy place to prove which", + " manifest was actually fetched.", + "", + "platform keys are `${os}-${arch}` where os is windows|darwin|linux. Careful: arch differs", + "by shell - electron uses node's process.arch (x64, arm64) while tauri uses os.arch()", + "(x86_64, aarch64), so the same machine wants a different key on each. All of them are", + "listed below so this file works either way. To see yours, run in the app:", + " await Phoenix.app.getPlatformArch()", + "", + "WARNING - the platform `url` is real on windows/mac: it is downloaded and run as an", + "installer. Linux ignores it entirely and pipes app_update_linux_installer_url into bash", + "instead. The urls below are deliberately dead ends so nothing can install by accident." + ], + + "version": "99.9.9", + "notes": "# Fake update from the LOCAL OVERRIDE manifest\n\nIf you can read this, the app fetched its update manifest from **localhost** and not from updates.phcode.io.\n\n- nothing here is real\n- nothing will be installed", + "pub_date": "2030-01-01T00:00:00Z", + + "platforms": { + "linux-x64": { + "signature": "", + "url": "http://localhost:8000/not-a-real-installer.tar.gz" + }, + "linux-arm64": { + "signature": "", + "url": "http://localhost:8000/not-a-real-installer.tar.gz" + }, + "darwin-x86_64": { + "signature": "", + "url": "http://localhost:8000/not-a-real-installer.tar.gz" + }, + "darwin-aarch64": { + "signature": "", + "url": "http://localhost:8000/not-a-real-installer.tar.gz" + }, + "windows-x86_64": { + "signature": "", + "url": "http://localhost:8000/not-a-real-installer.exe" + }, + "windows-aarch64": { + "signature": "", + "url": "http://localhost:8000/not-a-real-installer.exe" + } + } +} diff --git a/src/extensionsIntegrated/appUpdater/update-electron.js b/src/extensionsIntegrated/appUpdater/update-electron.js index 61f9cb275f..e9bc569602 100644 --- a/src/extensionsIntegrated/appUpdater/update-electron.js +++ b/src/extensionsIntegrated/appUpdater/update-electron.js @@ -38,6 +38,7 @@ define(function (require, exports, module) { TaskManager = require("features/TaskManager"), NativeApp = require("utils/NativeApp"), BootGreetings = require("utils/BootGreetings"), + SystemConfigOverride = require("utils/SystemConfigOverride"), PreferencesManager = require("preferences/PreferencesManager"); // Reserve a slot in the boot-greeting coordinator so the tour can wait @@ -123,7 +124,11 @@ define(function (require, exports, module) { updatePlatform: updatePlatformKey }; try{ - const updateMetadata = await fetchJSON(brackets.config.app_update_url); + // an admin can point us at another update feed with the system wide + // phoenix_override_config.json, see utils/SystemConfigOverride.js + const overrideConfig = await SystemConfigOverride.getOverrides(); + const updateURL = overrideConfig.app_update_url || brackets.config.app_update_url; + const updateMetadata = await fetchJSON(updateURL); // In Electron, binary version and loaded app version are always the same // since both are loaded at app start and only change after full restart const currentVersion = await window.electronAPI.getAppVersion(); @@ -273,23 +278,58 @@ define(function (require, exports, module) { } } + /** + * The installer script that performs the actual linux upgrade. Unlike windows and mac, linux does + * not download the build named in the update manifest- it pipes this script into bash and the + * script decides what to install. So this url, not the manifest, is what an admin has to point + * elsewhere to genuinely test an update on linux. + * @returns {string} + */ + function _getDefaultLinuxInstallerURL() { + const stageValue = Phoenix.config.environment; + console.log('Stage:', stageValue); + if(stageValue === 'dev' || stageValue === 'stage'){ + return "https://updates.phcode.io/linux/installer-latest-experimental-build.sh"; + } + return 'https://updates.phcode.io/linux/installer.sh'; + } + + /** + * The installer script url for the linux upgrade, preferring an admin override. + * + * The url is taken as given. Whatever it points at is downloaded and piped into bash anyway, + * so sanitising the url itself would buy nothing- anyone who can set it can just as easily + * serve any script they like. What makes it trustworthy is that only an admin/root can create + * the config file naming it, see utils/SystemConfigOverride.js. A url that does not work fails + * loudly through the installer's exit code rather than silently installing something else. + * + * This sits on the update path, so it never throws: a problem reading the override config + * falls back to the shipped default and the update proceeds as normal. + * @returns {Promise} always a usable url + */ + async function _resolveLinuxInstallerURL() { + try { + const overrideConfig = await SystemConfigOverride.getOverrides(); + return overrideConfig.app_update_linux_installer_url || _getDefaultLinuxInstallerURL(); + } catch (e) { + console.error("Error reading system config override, using the default installer", e); + return _getDefaultLinuxInstallerURL(); + } + } + /** * Launches the Linux updater using spawnProcess with streaming output + * @param {string} scriptUrl - installer script to pipe into bash * @param {function} onOutput - Callback for stdout/stderr lines * @returns {Promise} Resolves when update completes, rejects on error */ - function launchLinuxUpdater(onOutput) { + function launchLinuxUpdater(scriptUrl, onOutput) { return new Promise((resolve, reject) => { + console.log('Linux installer script:', scriptUrl); // Spawn the installer in an external terminal emulator so sudo / // interactive prompts work natively. The external terminal IS the // install UI — no internal dialog. Probes common terminals in // order; first hit wins. - const stageValue = Phoenix.config.environment; - console.log('Stage:', stageValue); - let scriptUrl = 'https://updates.phcode.io/linux/installer.sh'; - if(stageValue === 'dev' || stageValue === 'stage'){ - scriptUrl = "https://updates.phcode.io/linux/installer-latest-experimental-build.sh"; - } // Inner command run inside the spawned terminal: fetch installer // from $UPDATE_URL and pipe to bash, print exit code, pause so the @@ -350,7 +390,7 @@ define(function (require, exports, module) { await window.electronAPI.setUpdateScheduled(false); console.log("Launching external terminal for update"); try { - await launchLinuxUpdater(); + await launchLinuxUpdater(await _resolveLinuxInstallerURL()); Metrics.countEvent(Metrics.EVENT_TYPE.UPDATES, 'install', 'launched' + Phoenix.platform); // Success: the terminal is now the user's UI. Let the quit proceed. } catch (err) { @@ -390,6 +430,10 @@ define(function (require, exports, module) { _unblockUpdaterGate(); return; } + // Warm the system override cache at boot. A window that only inherits an already scheduled + // update never runs an update check, so without this its first read would happen at quit + // time- when the node process may already be gone. + SystemConfigOverride.getOverrides(); // Check if another window already scheduled an update (multi-window state persistence) // This ensures the quit handler is registered in this window too try { diff --git a/src/utils/SystemConfigOverride.js b/src/utils/SystemConfigOverride.js new file mode 100644 index 0000000000..e3a8363b96 --- /dev/null +++ b/src/utils/SystemConfigOverride.js @@ -0,0 +1,140 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Reads `phoenix_override_config.json` from a machine wide, admin owned location and exposes the + * values in it as overrides for `brackets.config`. This is only available in the desktop app. + * + * The file lives in a directory that only an administrator/root can write to, so the presence of + * the file is itself the proof that a machine admin put it there. A standard user cannot create it, + * which is what stops a random extension or a downloaded project from pointing the app at an + * attacker controlled server. + * + * This is the shared "Phoenix Code Control" admin folder that machine wide policies should use going + * forward. It is a new folder, so it will not exist on already provisioned machines. The older AI + * disable policy still reads its own `Phoenix AI Control` folder(see phoenix-pro services/ai-control.js) + * and can be moved here later. + * + * Locations (create the directory as admin/root, then drop the file in): + * windows: C:\Program Files\Phoenix Code Control\phoenix_override_config.json + * mac : /Library/Application Support/Phoenix Code Control/phoenix_override_config.json + * linux : /etc/phoenix-code-control/phoenix_override_config.json + * + * File format - a flat json of `brackets.config` keys to override. Eg. to point the auto updater at + * a locally served update manifest: + * { + * "app_update_url": "http://localhost:8000/update-latest-experimental-build.json" + * } + * + * Only the keys listed in `OVERRIDABLE_KEYS` are honoured, everything else in the file is ignored. + */ + +/*global logger*/ + +define(function (require, exports, module) { + const OVERRIDE_FILE_NAME = "phoenix_override_config.json"; + + // Only these `brackets.config` keys can be overridden from the system file. Keep this list + // as small as possible, add a key only when there is a real need to override it on a machine. + // reading this file must never be able to stall the app, see the race in getOverrides() + const READ_TIMEOUT_MS = 5000; + + const OVERRIDABLE_KEYS = [ + "app_update_url", + // linux installs by piping an installer script into bash, so the manifest's downloadURL is + // not enough there- this is what actually decides which build gets installed on linux. + "app_update_linux_installer_url" + ]; + + /** + * Gets the platform specific path of the admin owned override file, or "" in non desktop builds. + * @returns {string} virtual path of the override file + */ + function _getOverrideFilePath() { + if (!Phoenix.isNativeApp) { + return ""; + } + let platformPath; + if (Phoenix.platform === "win") { + platformPath = `C:\\Program Files\\Phoenix Code Control\\${OVERRIDE_FILE_NAME}`; + } else if (Phoenix.platform === "mac") { + platformPath = `/Library/Application Support/Phoenix Code Control/${OVERRIDE_FILE_NAME}`; + } else if (Phoenix.platform === "linux") { + platformPath = `/etc/phoenix-code-control/${OVERRIDE_FILE_NAME}`; + } else { + console.error("System config override: unsupported platform", Phoenix.platform); + return ""; + } + return Phoenix.VFS.getTauriVirtualPath(platformPath); + } + + const OVERRIDE_FILE_PATH = _getOverrideFilePath(); + + let overridesPromise = null; + + /** + * Reads and parses the override file. The file is read once per app session and the result cached, + * so that an admin edit needs an app restart to take effect(same as any other boot config). + * Never rejects- a missing or malformed file just means "no overrides". + * @returns {Promise} the overridable key value pairs present in the file, may be empty + */ + function getOverrides() { + if (overridesPromise) { + return overridesPromise; + } + overridesPromise = (async function () { + if (!OVERRIDE_FILE_PATH) { + return {}; + } + // Hard timeout. This is awaited on the app update path, including at quit time when + // the node process may already be gone, and a read that never settles would hang the + // quit. Timing out just means "no overrides", which is the normal case anyway. + const fileData = await Promise.race([ + Phoenix.VFS.readFileResolves(OVERRIDE_FILE_PATH, "utf8"), + new Promise((resolve) => { + setTimeout(() => resolve({error: new Error("timed out")}), READ_TIMEOUT_MS); + }) + ]); + if (fileData.error || !fileData.data) { + // the common case, no admin has placed an override file on this machine. + return {}; + } + try { + const rawOverrides = JSON.parse(fileData.data); + const overrides = {}; + for (const key of OVERRIDABLE_KEYS) { + if (rawOverrides[key] !== undefined) { + overrides[key] = rawOverrides[key]; + } + } + console.warn(`System config override in effect from ${OVERRIDE_FILE_PATH}:`, overrides); + return overrides; + } catch (e) { + console.error(`Error parsing system config override ${OVERRIDE_FILE_PATH}`, e); + logger.reportError(e, "Error parsing system config override file"); + return {}; + } + }()); + return overridesPromise; + } + + exports.OVERRIDE_FILE_PATH = OVERRIDE_FILE_PATH; + exports.getOverrides = getOverrides; +});