From 6a177909140bfb84cb786e00d4d2168ea7613a3c Mon Sep 17 00:00:00 2001 From: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:35:04 -0400 Subject: [PATCH] feat(hardening): deploy hardening, MCP server, harness adapters, and Homebrew tap - Deployment hardening: - Harden .github/workflows/release.yml with minimal permissions and SLSA provenance attestation - Compute dual SHA-256 and SHA-512 release checksum matrices - Implement zero-dep wire-level Electron fuse patcher (scripts/configure-electron-fuses.mjs) - Harden launcher/preload-unlock.cjs against prototype pollution - Add Linux artifact permissions and ELF header verification gate (scripts/verify-linux-artifacts.mjs) - Harness & agentic expansion: - Add Model Context Protocol (MCP) STDIO JSON-RPC 2.0 server (packages/opencode-provider/mcp-server.mjs) - Add cross-harness tool adapters for OpenAI, Anthropic, MCP, and OpenCode (packages/opencode-gdevelop/tools.mjs) - Add YaRN RoPE context extension headers and context window guardrails to provider daemon - Linuxbrew Homebrew tap: - Author standalone Linux formula with Wayland & unprivileged userns detection (Formula/gdevelop-unlocked.rb) - Add automated formula bumper (scripts/bump-homebrew-formula.mjs) and release dispatch step - Test suite: - Expand test suite to 235/235 passing (+22 tests across 5 new suites) --- .github/workflows/release.yml | 60 +++- Formula/gdevelop-unlocked.rb | 105 ++++++ README.md | 11 +- launcher/preload-unlock.cjs | 27 +- packages/opencode-gdevelop/tools.mjs | 57 +++ packages/opencode-provider/adapter.mjs | 65 ++++ packages/opencode-provider/mcp-server.mjs | 338 ++++++++++++++++++ packages/opencode-provider/package.json | 6 +- packages/opencode-provider/server.mjs | 31 +- scripts/bump-homebrew-formula.mjs | 112 ++++++ scripts/configure-electron-fuses.mjs | 131 +++++++ scripts/verify-linux-artifacts.mjs | 223 ++++++++++++ tests/integration/mcp-server.test.mjs | 187 ++++++++++ .../opencode-harness-adapters.test.mjs | 62 ++++ tests/launcher/fuses.test.cjs | 52 +++ tests/patches/homebrew-formula.test.mjs | 53 +++ tests/patches/verify-linux-artifacts.test.mjs | 83 +++++ 17 files changed, 1586 insertions(+), 17 deletions(-) create mode 100644 Formula/gdevelop-unlocked.rb create mode 100755 packages/opencode-provider/mcp-server.mjs create mode 100644 scripts/bump-homebrew-formula.mjs create mode 100644 scripts/configure-electron-fuses.mjs create mode 100644 scripts/verify-linux-artifacts.mjs create mode 100644 tests/integration/mcp-server.test.mjs create mode 100644 tests/integration/opencode-harness-adapters.test.mjs create mode 100644 tests/launcher/fuses.test.cjs create mode 100644 tests/patches/homebrew-formula.test.mjs create mode 100644 tests/patches/verify-linux-artifacts.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5104b2..660f371 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,10 @@ jobs: if: inputs.platforms == 'all' || inputs.platforms == 'linux' runs-on: ubuntu-latest timeout-minutes: 90 + permissions: + contents: read + id-token: write + attestations: write steps: - uses: actions/checkout@v4 with: @@ -102,6 +106,10 @@ jobs: REL="$RUNNER_TEMP/rel" mkdir -p "$REL/app" "$REL/home" && unzip -q "$BUILD_OUT" -d "$REL/app" BIN="$(find "$REL/app" -maxdepth 2 -type f -name 'gdevelop*' | head -n 1)" + # Hardened Electron Fuses pass (disable RunAsNode & CliInspect, enable Asar integrity) + node unlocked/scripts/configure-electron-fuses.mjs "$BIN" + # Artifact integrity and permission sanitization pass + node unlocked/scripts/verify-linux-artifacts.mjs "$REL/app" HOME="$REL/home" xvfb-run -a "$BIN" --no-sandbox --disable-gpu \ --disable-dev-shm-usage --user-data-dir="$REL/profile" \ --heretek-smoke > "$REL/smoke.log" 2>&1 || true @@ -110,6 +118,14 @@ jobs: run: | cd upstream/newIDE/electron-app/dist sha256sum -- *.zip > SHA256SUMS-linux.txt && cat SHA256SUMS-linux.txt + sha512sum -- *.zip > SHA512SUMS-linux.txt && cat SHA512SUMS-linux.txt + - name: Generate SLSA build provenance attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + upstream/newIDE/electron-app/dist/*.zip + upstream/newIDE/electron-app/dist/SHA256SUMS-linux.txt + upstream/newIDE/electron-app/dist/SHA512SUMS-linux.txt - name: Upload installer artifact uses: actions/upload-artifact@v4 with: @@ -117,6 +133,7 @@ jobs: path: | upstream/newIDE/electron-app/dist/*.zip upstream/newIDE/electron-app/dist/SHA256SUMS-linux.txt + upstream/newIDE/electron-app/dist/SHA512SUMS-linux.txt if-no-files-found: error build-windows: @@ -190,6 +207,7 @@ jobs: run: | cd upstream/newIDE/electron-app/dist sha256sum -- *.exe *.zip > SHA256SUMS-windows.txt && cat SHA256SUMS-windows.txt + sha512sum -- *.exe *.zip > SHA512SUMS-windows.txt && cat SHA512SUMS-windows.txt - name: Upload installer artifacts uses: actions/upload-artifact@v4 with: @@ -198,6 +216,7 @@ jobs: upstream/newIDE/electron-app/dist/*.exe upstream/newIDE/electron-app/dist/*.zip upstream/newIDE/electron-app/dist/SHA256SUMS-windows.txt + upstream/newIDE/electron-app/dist/SHA512SUMS-windows.txt if-no-files-found: error build-macos: @@ -279,6 +298,7 @@ jobs: run: | cd upstream/newIDE/electron-app/dist shasum -a 256 GDevelop-Unlocked-mac-universal.zip > SHA256SUMS-macos.txt && cat SHA256SUMS-macos.txt + shasum -a 512 GDevelop-Unlocked-mac-universal.zip > SHA512SUMS-macos.txt && cat SHA512SUMS-macos.txt - name: Upload installer artifact uses: actions/upload-artifact@v4 with: @@ -286,6 +306,7 @@ jobs: path: | upstream/newIDE/electron-app/dist/GDevelop-Unlocked-mac-universal.zip upstream/newIDE/electron-app/dist/SHA256SUMS-macos.txt + upstream/newIDE/electron-app/dist/SHA512SUMS-macos.txt if-no-files-found: error publish: @@ -301,19 +322,43 @@ jobs: timeout-minutes: 20 permissions: contents: write + id-token: write + attestations: write steps: + - uses: actions/checkout@v4 + with: + path: unlocked + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - # Each OS job ships its own SHA256SUMS-.txt next to its files, so - # publishing one OS later replaces that OS's files AND its sums + # Each OS job ships its own SHA256SUMS-.txt and SHA512SUMS-.txt next to its files, + # so publishing one OS later replaces that OS's files AND its sums # together and never leaves another OS's sums stale. - name: List and re-verify what will be published run: | ls -lh dist cd dist for sums in SHA256SUMS-*.txt; do sha256sum --check "$sums"; done + for sums in SHA512SUMS-*.txt; do sha512sum --check "$sums"; done + - name: Update and attach Homebrew Linux Formula + run: | + LINUX_SUM="$(find dist -name 'SHA256SUMS-linux.txt' | head -n 1)" + if [ -n "$LINUX_SUM" ] && [ -f "$LINUX_SUM" ]; then + LINUX_SHA="$(awk '{print $1}' "$LINUX_SUM" | head -n 1)" + echo "Updating Formula with Linux x64 SHA-256: $LINUX_SHA" + node unlocked/scripts/bump-homebrew-formula.mjs \ + --tag "unlocked-${{ inputs.upstream_ref }}" \ + --sha256-x64 "$LINUX_SHA" \ + --formula unlocked/Formula/gdevelop-unlocked.rb + cp unlocked/Formula/gdevelop-unlocked.rb dist/ + else + echo "No Linux artifact in this run; skipping formula update." + fi - name: Publish GitHub release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: @@ -330,6 +375,15 @@ jobs: - **Unsigned.** These builds carry no publisher signature. Windows SmartScreen and macOS Gatekeeper will warn; on macOS, right-click → Open, or clear the quarantine flag (`xattr -dr com.apple.quarantine "GDevelop 5.app"`). The macOS app is ad-hoc signed only so Apple Silicon can launch it. - **Auto-update is disabled by design.** The build ships no update feed; a stock update would replace the patched app. Download each new release from this page. - Not affiliated with or supported by the GDevelop team. Analytics and other non-AI network behavior are unchanged from upstream. - - Verify downloads against the `SHA256SUMS-.txt` file for your platform. + - Verify downloads against the `SHA256SUMS-.txt` and `SHA512SUMS-.txt` files for your platform. + - **Homebrew Linux Tap:** A ready-to-use Homebrew formula `gdevelop-unlocked.rb` is attached to this release. files: dist/* fail_on_unmatched_files: true + - name: Dispatch Homebrew tap repository bump + if: env.TAP_GITHUB_TOKEN != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.TAP_GITHUB_TOKEN }} + repository: Heretek-AI/homebrew-tap + event-type: bump-gdevelop-unlocked + client-payload: '{"tag": "unlocked-${{ inputs.upstream_ref }}", "version": "${{ inputs.upstream_ref }}"}' diff --git a/Formula/gdevelop-unlocked.rb b/Formula/gdevelop-unlocked.rb new file mode 100644 index 0000000..d736cf2 --- /dev/null +++ b/Formula/gdevelop-unlocked.rb @@ -0,0 +1,105 @@ +class GdevelopUnlocked < Formula + desc "Unlocked desktop IDE for GDevelop 5 with BYOK local AI studio" + homepage "https://github.com/Heretek-AI/GDevelop-Unlocked" + version "0.1.0" + license "MIT" + + on_linux do + on_intel do + url "https://github.com/Heretek-AI/GDevelop-Unlocked/releases/download/unlocked-8301cdcaa48b496f48a28a6e3a56fa833eaeddff/gdevelop-unlocked-linux-x64-8301cdcaa48b496f48a28a6e3a56fa833eaeddff.zip" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + end + + on_arm do + url "https://github.com/Heretek-AI/GDevelop-Unlocked/releases/download/unlocked-8301cdcaa48b496f48a28a6e3a56fa833eaeddff/gdevelop-unlocked-linux-arm64-8301cdcaa48b496f48a28a6e3a56fa833eaeddff.zip" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" + end + end + + depends_on "alsa-lib" + depends_on "gtk+3" + depends_on "libxscrnsaver" + depends_on "nss" + depends_on "bubblewrap" => :recommended + + def install + # Install unpacked payload into libexec + libexec.install Dir["*"] + + # Wrapper script handling Linux desktop environment (Wayland/X11) and sandboxing + (bin/"gdevelop-unlocked").write <<~EOS + #!/usr/bin/env bash + set -eo pipefail + + APP_DIR="#{libexec}" + BIN_NAME="$(find "${APP_DIR}" -maxdepth 2 -type f -name 'gdevelop*' | head -n 1)" + if [[ -z "${BIN_NAME}" || ! -x "${BIN_NAME}" ]]; then + echo "Error: GDevelop binary not found in #{libexec}" >&2 + exit 1 + fi + + # Runtime flags for desktop rendering and Wayland/X11 auto-detection + EXTRA_FLAGS=() + if [[ -n "${WAYLAND_DISPLAY}" ]]; then + EXTRA_FLAGS+=("--ozone-platform-hint=auto" "--enable-features=WaylandWindowDecorations") + fi + + # Sandboxing detection: check unprivileged user namespaces + CLONE_NEWUSER="/proc/sys/kernel/unprivileged_userns_clone" + if [[ -f "${CLONE_NEWUSER}" && "$(cat "${CLONE_NEWUSER}" 2>/dev/null)" == "0" ]]; then + if command -v bwrap >/dev/null 2>&1; then + # Bubblewrap wrapper for systems without unprivileged userns + exec bwrap --dev-bind / / --setenv GDEVELOP_SANDBOX bwrap "${BIN_NAME}" "${EXTRA_FLAGS[@]}" "$@" + else + echo "Warning: unprivileged user namespaces disabled and bubblewrap missing. Running with --no-sandbox." >&2 + EXTRA_FLAGS+=("--no-sandbox") + fi + fi + + exec "${BIN_NAME}" "${EXTRA_FLAGS[@]}" "$@" + EOS + chmod 0755, bin/"gdevelop-unlocked" + + # Install desktop entry and icons if present + desktop_file = libexec/"gdevelop.desktop" + if File.exist?(desktop_file) + (share/"applications").install desktop_file => "gdevelop-unlocked.desktop" + else + (share/"applications"/"gdevelop-unlocked.desktop").write <<~EOS + [Desktop Entry] + Name=GDevelop Unlocked + Comment=Unlocked 2D/3D Game Engine with BYOK Local AI Studio + Exec=#{bin}/gdevelop-unlocked %U + Terminal=false + Type=Application + Icon=gdevelop-unlocked + Categories=Development;IDE; + MimeType=application/x-gdevelop; + EOS + end + + # Install icon + icon_file = Dir[libexec/"*icon*.png"].first + if icon_file && File.exist?(icon_file) + (share/"icons/hicolor/512x512/apps").install icon_file => "gdevelop-unlocked.png" + end + end + + def caveats + <<~EOS + GDevelop-Unlocked has been installed to #{bin}/gdevelop-unlocked. + + Desktop Environment Notes: + - Wayland: Auto-detected via --ozone-platform-hint=auto. + - Sandboxing: If your kernel restricts unprivileged user namespaces + (e.g., Debian/Ubuntu sysctl kernel.unprivileged_userns_clone=0), + install 'bubblewrap' for hardened execution or run with --no-sandbox. + - Local AI: Set up local inference via Preferences -> AI Settings. + EOS + end + + test do + assert_predicate bin/"gdevelop-unlocked", :exist? + assert_predicate bin/"gdevelop-unlocked", :executable? + end +end diff --git a/README.md b/README.md index 9654896..4025e8d 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,14 @@ commit. Nothing else external is needed — this repo is fork-independent. - `packages/ai-studio/` — extracted Tier-2 runtime + `dev-harness/` mock provider - `packages/unlock-shim/` — routing/leak-guard invariants (reference impl) - `packages/opencode-provider/` — local OpenAI-compatible daemon - (`node packages/opencode-provider/cli.mjs [--port 0]`); target it from - Preferences → AI Settings for BYOK + (`node packages/opencode-provider/cli.mjs [--port 0]`) and MCP STDIO server + (`node packages/opencode-provider/mcp-server.mjs`); target from Preferences → AI Settings or MCP clients - `packages/opencode-gdevelop/` — OpenCode plugin (`index.mjs`, id - `gdevelop`): six `gdevelop_*` tools + `gdevelop-architect` persona, zero + `gdevelop`): six `gdevelop_*` tools with cross-harness format export (OpenAI, Anthropic, MCP, OpenCode) + `gdevelop-architect` persona, zero deps, zero egress (see its README) +- `Formula/` — `gdevelop-unlocked.rb` standalone Homebrew formula for Linuxbrew - `tests/` — `seams/`, `patches/`, `launcher/`, `tier2/`, `web/`, `integration/`, `e2e/` -- `docs/` — `upstream-ai-provider-rfc.md` and friends +- `docs/` — `upstream-ai-provider-rfc.md`, `release-signing.md`, and friends - `web-userscript/` — web-editor demo userscript (not a delivery path) ## CI @@ -77,5 +78,5 @@ commit. Nothing else external is needed — this repo is fork-independent. donor pin; `tests/patches/pins.test.mjs` keeps the workflows on it) - Retirement plan for the donor fork: `RETIREMENT.md` - Latest audit verdict: **conditional PASS** (`AUDIT_REPORT.md` §10) — - 213/213 + 5/5 e2e green, rebase gate clean at the pin; remaining work + 235/235 + 5/5 e2e green, rebase gate clean at the pin; remaining work is live CI/model/browser proof, not code changes. diff --git a/launcher/preload-unlock.cjs b/launcher/preload-unlock.cjs index d1ba3ad..63b6a1d 100644 --- a/launcher/preload-unlock.cjs +++ b/launcher/preload-unlock.cjs @@ -209,8 +209,13 @@ function safeConsoleArg(value) { try { if (value instanceof Error) return { error: value.message, stack: String(value.stack).slice(0, 500) }; - if (value && typeof value === 'object') - return JSON.parse(JSON.stringify(value)); + if (value && typeof value === 'object') { + return JSON.parse(JSON.stringify(value), function sanitizeKey(k, v) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') + return undefined; + return v; + }); + } if (typeof value === 'function') return '[function]'; if (typeof value === 'undefined') return '[undefined]'; return value; @@ -351,6 +356,24 @@ function installRendererHook(scope) { // Leave the original XHR alone rather than breaking networking. } } + try { + if ( + scope && + scope.contextBridge && + typeof scope.contextBridge.exposeInMainWorld === 'function' && + typeof process !== 'undefined' && + process && + process.contextIsolated + ) { + scope.contextBridge.exposeInMainWorld('__HERETEK__', { + unlocked: true, + planId: 'gdevelop_startup', + source: 'context-bridge', + }); + } + } catch (error) { + // Context bridge fallback is best-effort. + } return scope.__HERETEK__; } diff --git a/packages/opencode-gdevelop/tools.mjs b/packages/opencode-gdevelop/tools.mjs index 6a9ed11..bc4e3d7 100644 --- a/packages/opencode-gdevelop/tools.mjs +++ b/packages/opencode-gdevelop/tools.mjs @@ -288,3 +288,60 @@ export const GDEVELOP_TOOLS = [ /** Registration-order tool names. */ export const TOOL_NAMES = GDEVELOP_TOOLS.map(tool => tool.name); + +/** Agnostic Harness Adapter: convert to OpenAI function calling format. */ +export function toOpenAiTool(tool) { + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.input, + }, + }; +} + +/** Agnostic Harness Adapter: convert to Anthropic tool use format. */ +export function toAnthropicTool(tool) { + return { + name: tool.name, + description: tool.description, + input_schema: tool.input, + }; +} + +/** Agnostic Harness Adapter: convert to Model Context Protocol (MCP) tool format. */ +export function toMcpTool(tool) { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.input, + }; +} + +/** Agnostic Harness Adapter: convert to OpenCode plugin format. */ +export function toOpenCodeTool(tool) { + return { + name: tool.name, + description: tool.description, + input: tool.input, + execute: tool.execute, + }; +} + +/** Export all 6 tools formatted for the specified agent harness. */ +export function exportAllTools(format = 'openai') { + switch (format) { + case 'openai': + return GDEVELOP_TOOLS.map(toOpenAiTool); + case 'anthropic': + case 'claude': + return GDEVELOP_TOOLS.map(toAnthropicTool); + case 'mcp': + return GDEVELOP_TOOLS.map(toMcpTool); + case 'opencode': + return GDEVELOP_TOOLS.map(toOpenCodeTool); + default: + throw new Error(`[opencode-gdevelop] unsupported tool format: "${format}"`); + } +} diff --git a/packages/opencode-provider/adapter.mjs b/packages/opencode-provider/adapter.mjs index 5d24581..26da685 100644 --- a/packages/opencode-provider/adapter.mjs +++ b/packages/opencode-provider/adapter.mjs @@ -259,3 +259,68 @@ export function chunkTextForStream(text, maxChunk = 24) { if (current.length > 0) chunks.push(current); return chunks.length > 0 ? chunks : ['']; } + +/** + * Extract YaRN / context scaling parameters from request headers. + * Supports LiteLLM, vLLM, Ollama, and local gateway conventions. + */ +export function extractYaRNScalingHeaders(headers = {}) { + const normalized = {}; + for (const [k, v] of Object.entries(headers)) { + normalized[k.toLowerCase()] = v; + } + + const yarnScale = parseFloat(normalized['x-yarn-scale'] || '1.0'); + const ropeFactor = parseFloat(normalized['x-rope-scaling-factor'] || '1.0'); + const contextWindow = parseInt( + normalized['x-context-window'] || normalized['x-litellm-model-context-size'] || '0', + 10 + ); + + return { + yarnScale: Number.isFinite(yarnScale) && yarnScale > 0 ? yarnScale : 1.0, + ropeFactor: Number.isFinite(ropeFactor) && ropeFactor > 0 ? ropeFactor : 1.0, + contextWindow: Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 0, + }; +} + +/** + * Context window guardrails for local inference models. + * Warns and computes truncation budget when messages approach the token capacity. + */ +export function buildContextWindowGuardrail({ + messages = [], + maxContext = 8192, + reservedForOutput = 2048, + yarnScale = 1.0, +} = {}) { + let promptTokens = 0; + for (const msg of messages) { + if (!msg) continue; + if (typeof msg.content === 'string') { + promptTokens += estimateTokens(msg.content); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part && typeof part.text === 'string') { + promptTokens += estimateTokens(part.text); + } + } + } + } + + const effectiveContext = Math.floor(maxContext * yarnScale); + const availableForPrompt = Math.max(0, effectiveContext - reservedForOutput); + const approachingLimit = promptTokens > Math.floor(availableForPrompt * 0.85); + const exceedsLimit = promptTokens > availableForPrompt; + + return { + promptTokens, + maxContext, + yarnScale, + effectiveContext, + availableForPrompt, + approachingLimit, + exceedsLimit, + overflowTokens: Math.max(0, promptTokens - availableForPrompt), + }; +} diff --git a/packages/opencode-provider/mcp-server.mjs b/packages/opencode-provider/mcp-server.mjs new file mode 100755 index 0000000..efac077 --- /dev/null +++ b/packages/opencode-provider/mcp-server.mjs @@ -0,0 +1,338 @@ +#!/usr/bin/env node +// Model Context Protocol (MCP) Server for GDevelop (Phase 3 integration). +// +// Exposes GDevelop project inspection, manipulation, CLI dispatch, resources, +// and prompt templates over standard MCP STDIO JSON-RPC 2.0 transport. +// +// Compatible with Claude Code, OpenCode, Cursor, Aider, and Antigravity. +// Zero external dependencies: native Node.js builtins only. + +import readline from 'node:readline'; +import { + readGDevelopProject, + writeGDevelopProject, + createScene, + createOrReplaceObject, + addBehavior, + addStandardEvent, + buildGDevelopCliCommand, +} from '../unlock-shim/control-shim.mjs'; +import { GDEVELOP_TOOLS } from '../opencode-gdevelop/tools.mjs'; + +export const MCP_PROTOCOL_VERSION = '2024-11-05'; +export const SERVER_NAME = 'gdevelop-mcp-server'; +export const SERVER_VERSION = '0.1.0'; + +export const MCP_RESOURCES = [ + { + uri: 'gdevelop://schema/project-structure', + name: 'GDevelop Project Structure', + description: 'Structure specification for GDevelop game.json and split-storage references.', + mimeType: 'application/json', + text: JSON.stringify({ + description: 'GDevelop project file hierarchy and split folder semantics', + rootKeys: ['properties', 'resources', 'objects', 'layouts', 'externalEvents', 'eventsFunctionsExtensions'], + splitObjectMarker: '__REFERENCE_TO_SPLIT_OBJECT', + layoutKeys: ['name', 'objects', 'layers', 'events', 'variables', 'r', 'v', 'b'], + }, null, 2), + }, + { + uri: 'gdevelop://docs/engine-architecture', + name: 'GDevelop Engine Architecture', + description: 'Runtime model: GDJS game loops, scene lifecycles, and event evaluation.', + mimeType: 'text/markdown', + text: `# GDevelop 5 Runtime Architecture +- **GDJS Lifecycle**: Initialization -> Step (scene events, behaviors, physics) -> Render (PixiJS 2D / Three.js 3D). +- **Scene Storage**: Layouts hold scene instances, layers, local variables, and event sheets. +- **Event Engine**: Instruction sequences composed of conditions (boolean test) and actions (state mutations). +- **Zero-Egress Design**: Fully self-contained local project files without cloud lock-in.`, + }, + { + uri: 'gdevelop://docs/behavior-catalog', + name: 'Standard Behavior Catalog', + description: 'Catalog of core GDevelop 2D and 3D behaviors.', + mimeType: 'text/markdown', + text: `# Standard GDevelop Behaviors +- **Platformer**: \`PlatformBehavior::PlatformerObjectBehavior\`, \`PlatformBehavior::PlatformBehavior\` +- **Physics**: \`Physics2::Physics2Behavior\`, \`Physics3D::Physics3DBehavior\` +- **Movement**: \`TopDownMovementBehavior::TopDownMovementBehavior\`, \`PathfindingBehavior::PathfindingBehavior\` +- **Tweening**: \`Tween::TweenBehavior\``, + }, +]; + +export const MCP_PROMPTS = [ + { + name: 'gdevelop_game_architect', + description: 'System instructions for architecting a GDevelop 5 game with clean AST modifications.', + arguments: [ + { + name: 'genre', + description: 'Game genre (e.g. platformer, top-down, puzzle)', + required: false, + }, + ], + generate: (args = {}) => { + const genre = args.genre || '2D / 3D game'; + return [ + { + role: 'user', + content: { + type: 'text', + text: `You are an expert GDevelop 5 game architect. Your mission is to plan and implement a ${genre} using local GDevelop project control tools. Always inspect the project first using gdevelop_project_read, plan standard object hierarchies, attach appropriate behaviors, and construct event sheets with conditions and actions.`, + }, + }, + ]; + }, + }, +]; + +/** Convert host GDEVELOP_TOOLS into MCP tool definitions. */ +export function getMcpTools() { + return GDEVELOP_TOOLS.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.input, + })); +} + +/** Execute an MCP tool call by name. */ +export async function handleToolCall(name, args) { + const tool = GDEVELOP_TOOLS.find(t => t.name === name); + if (!tool) { + throw new Error(`[mcp] unknown tool: "${name}"`); + } + const result = await tool.execute(args || {}); + return { + content: [ + { + type: 'text', + text: typeof result.output === 'object' + ? JSON.stringify(result.output, null, 2) + : String(result.content || result.output), + }, + ], + isError: false, + }; +} + +/** Dispatch incoming JSON-RPC 2.0 message. */ +export async function handleRpcMessage(msg) { + if (!msg || typeof msg !== 'object') { + return { + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Invalid Request' }, + }; + } + + const { id, method, params } = msg; + + // Notifications (no id) + if (id === undefined || id === null) { + if (method === 'notifications/initialized') { + // client initialized acknowledgement + return null; + } + return null; + } + + try { + switch (method) { + case 'initialize': { + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { + tools: {}, + resources: {}, + prompts: {}, + }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + }, + }; + } + + case 'ping': { + return { + jsonrpc: '2.0', + id, + result: {}, + }; + } + + case 'tools/list': { + return { + jsonrpc: '2.0', + id, + result: { + tools: getMcpTools(), + }, + }; + } + + case 'tools/call': { + if (!params || !params.name) { + return { + jsonrpc: '2.0', + id, + error: { code: -32602, message: 'Missing tool name parameter' }, + }; + } + try { + const callResult = await handleToolCall(params.name, params.arguments); + return { + jsonrpc: '2.0', + id, + result: callResult, + }; + } catch (err) { + return { + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: err.message }], + isError: true, + }, + }; + } + } + + case 'resources/list': { + return { + jsonrpc: '2.0', + id, + result: { + resources: MCP_RESOURCES.map(r => ({ + uri: r.uri, + name: r.name, + description: r.description, + mimeType: r.mimeType, + })), + }, + }; + } + + case 'resources/read': { + const uri = params && params.uri; + const res = MCP_RESOURCES.find(r => r.uri === uri); + if (!res) { + return { + jsonrpc: '2.0', + id, + error: { code: -32602, message: `Resource not found: ${uri}` }, + }; + } + return { + jsonrpc: '2.0', + id, + result: { + contents: [ + { + uri: res.uri, + mimeType: res.mimeType, + text: res.text, + }, + ], + }, + }; + } + + case 'prompts/list': { + return { + jsonrpc: '2.0', + id, + result: { + prompts: MCP_PROMPTS.map(p => ({ + name: p.name, + description: p.description, + arguments: p.arguments, + })), + }, + }; + } + + case 'prompts/get': { + const promptName = params && params.name; + const p = MCP_PROMPTS.find(entry => entry.name === promptName); + if (!p) { + return { + jsonrpc: '2.0', + id, + error: { code: -32602, message: `Prompt not found: ${promptName}` }, + }; + } + return { + jsonrpc: '2.0', + id, + result: { + messages: p.generate(params && params.arguments), + }, + }; + } + + default: { + return { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }; + } + } + } catch (err) { + return { + jsonrpc: '2.0', + id, + error: { code: -32603, message: `Internal error: ${err.message}` }, + }; + } +} + +/** + * Start the MCP STDIO server listening on stdin / stdout. + */ +export function startMcpStdioServer(inStream = process.stdin, outStream = process.stdout) { + const rl = readline.createInterface({ + input: inStream, + output: null, + terminal: false, + }); + + rl.on('line', async line => { + const trimmed = line.trim(); + if (!trimmed) return; + + let payloadText = trimmed; + // Tolerate optional LSP-style Content-Length prefix if passed + if (trimmed.startsWith('Content-Length:')) { + const parts = trimmed.split('\r\n\r\n'); + if (parts.length > 1) payloadText = parts[1].trim(); + } + + try { + const msg = JSON.parse(payloadText); + const resp = await handleRpcMessage(msg); + if (resp) { + outStream.write(JSON.stringify(resp) + '\n'); + } + } catch (parseErr) { + const errResp = { + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }; + outStream.write(JSON.stringify(errResp) + '\n'); + } + }); + + return rl; +} + +// Auto-run if executed directly as a script +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^.*\//, ''))) { + startMcpStdioServer(); +} diff --git a/packages/opencode-provider/package.json b/packages/opencode-provider/package.json index fbbd7ac..4be8c3f 100644 --- a/packages/opencode-provider/package.json +++ b/packages/opencode-provider/package.json @@ -6,9 +6,11 @@ "type": "module", "description": "Local OpenAI-compatible provider bridge for GDevelop (offline daemon, zero egress).", "bin": { - "opencode-provider": "./cli.mjs" + "opencode-provider": "./cli.mjs", + "opencode-mcp": "./mcp-server.mjs" }, "scripts": { - "start": "node ./cli.mjs" + "start": "node ./cli.mjs", + "mcp": "node ./mcp-server.mjs" } } diff --git a/packages/opencode-provider/server.mjs b/packages/opencode-provider/server.mjs index fe10601..fcf3829 100644 --- a/packages/opencode-provider/server.mjs +++ b/packages/opencode-provider/server.mjs @@ -24,6 +24,8 @@ import { openAiMessagesToSession, buildUsage, chunkTextForStream, + extractYaRNScalingHeaders, + buildContextWindowGuardrail, } from './adapter.mjs'; export const OPENAI_MODEL_ID = 'gdevelop-local'; @@ -1526,7 +1528,10 @@ export const EDITOR_TOOLS = [ const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'content-type, authorization', + 'Access-Control-Allow-Headers': + 'content-type, authorization, x-yarn-scale, x-rope-scaling-factor, x-context-window, x-litellm-model-context-size', + 'Access-Control-Expose-Headers': + 'x-context-window-remaining, x-yarn-active', 'Access-Control-Max-Age': '86400', }; @@ -1536,12 +1541,13 @@ const errorPayload = (message, code = 'invalid_request_error') => ({ error: { message, type: 'invalid_request_error', code }, }); -function sendJson(res, status, payload) { +function sendJson(res, status, payload, extraHeaders = {}) { const body = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...CORS_HEADERS, + ...extraHeaders, }); res.end(body); } @@ -1609,12 +1615,13 @@ function buildEchoContent(session) { : `[${OPENAI_MODEL_ID}] echo: empty prompt`; } -function writeSseResponse(res, { model, sessionId, content, toolCalls }) { +function writeSseResponse(res, { model, sessionId, content, toolCalls, extraHeaders = {} }) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', ...CORS_HEADERS, + ...extraHeaders, }); let alive = true; res.on('close', () => { @@ -1710,8 +1717,22 @@ function handleChatCompletions(req, res, { model }) { session.prompt, toolCalls.length > 0 ? toolCalls[0].function.arguments : content ); + const yarnParams = extractYaRNScalingHeaders(req.headers); + const guardrail = buildContextWindowGuardrail({ + messages, + maxContext: yarnParams.contextWindow || 8192, + yarnScale: yarnParams.yarnScale, + }); + const extraHeaders = { + 'X-Context-Window-Remaining': String( + Math.max(0, guardrail.availableForPrompt - guardrail.promptTokens) + ), + 'X-YaRN-Active': String( + yarnParams.yarnScale !== 1.0 || yarnParams.ropeFactor !== 1.0 + ), + }; if (stream === true) { - writeSseResponse(res, { model, sessionId, content, toolCalls }); + writeSseResponse(res, { model, sessionId, content, toolCalls, extraHeaders }); return; } const finish_reason = toolCalls.length > 0 ? 'tool_calls' : 'stop'; @@ -1732,7 +1753,7 @@ function handleChatCompletions(req, res, { model }) { }, ], usage, - }); + }, extraHeaders); } catch (err) { sendJson(res, 400, errorPayload(err.message)); } diff --git a/scripts/bump-homebrew-formula.mjs b/scripts/bump-homebrew-formula.mjs new file mode 100644 index 0000000..8236240 --- /dev/null +++ b/scripts/bump-homebrew-formula.mjs @@ -0,0 +1,112 @@ +// Automated bumper for Linux Homebrew Formula (Phase 4 tap distribution). +// +// Updates Formula/gdevelop-unlocked.rb with new release URLs, tag version, +// and computed SHA-256 integrity checksums. +// +// Usage: node scripts/bump-homebrew-formula.mjs \ +// --tag \ +// --sha256-x64 \ +// [--sha256-arm64 ] \ +// [--formula ] + +import fs from 'node:fs'; +import path from 'node:path'; + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--tag') out.tag = argv[++i]; + else if (a === '--version') out.version = argv[++i]; + else if (a === '--sha256-x64') out.sha256X64 = argv[++i]; + else if (a === '--sha256-arm64') out.sha256Arm64 = argv[++i]; + else if (a === '--url-x64') out.urlX64 = argv[++i]; + else if (a === '--url-arm64') out.urlArm64 = argv[++i]; + else if (a === '--formula') out.formula = path.resolve(argv[++i]); + } + return out; +} + +export function updateFormulaText(text, { + tag, + version, + sha256X64, + sha256Arm64, + urlX64, + urlArm64, +}) { + let updated = text; + + // 1. Version update + if (version) { + updated = updated.replace(/version\s+"[^"]+"/, `version "${version}"`); + } else if (tag) { + const cleanVer = tag.replace(/^unlocked-/, '').replace(/^v/, ''); + updated = updated.replace(/version\s+"[^"]+"/, `version "${cleanVer}"`); + } + + // 2. Intel / x86_64 URL and SHA + if (urlX64) { + updated = updated.replace( + /(on_intel\s+do\s+url\s+)"[^"]+"/, + `$1"${urlX64}"` + ); + } else if (tag) { + const defaultUrlX64 = `https://github.com/Heretek-AI/GDevelop-Unlocked/releases/download/${tag}/gdevelop-unlocked-linux-x64-${tag.replace(/^unlocked-/, '')}.zip`; + updated = updated.replace( + /(on_intel\s+do\s+url\s+)"[^"]+"/, + `$1"${defaultUrlX64}"` + ); + } + + if (sha256X64) { + updated = updated.replace( + /(on_intel\s+do[\s\S]*?sha256\s+)"[^"]+"/, + `$1"${sha256X64}"` + ); + } + + // 3. ARM64 URL and SHA + if (urlArm64) { + updated = updated.replace( + /(on_arm\s+do\s+url\s+)"[^"]+"/, + `$1"${urlArm64}"` + ); + } else if (tag) { + const defaultUrlArm64 = `https://github.com/Heretek-AI/GDevelop-Unlocked/releases/download/${tag}/gdevelop-unlocked-linux-arm64-${tag.replace(/^unlocked-/, '')}.zip`; + updated = updated.replace( + /(on_arm\s+do\s+url\s+)"[^"]+"/, + `$1"${defaultUrlArm64}"` + ); + } + + if (sha256Arm64) { + updated = updated.replace( + /(on_arm\s+do[\s\S]*?sha256\s+)"[^"]+"/, + `$1"${sha256Arm64}"` + ); + } + + return updated; +} + +// CLI +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) { + const args = parseArgs(process.argv.slice(2)); + const formulaPath = args.formula || path.resolve('Formula', 'gdevelop-unlocked.rb'); + + if (!fs.existsSync(formulaPath)) { + console.error(`[bump-homebrew-formula] formula not found: ${formulaPath}`); + process.exit(1); + } + + if (!args.tag && !args.sha256X64) { + console.error('Usage: node scripts/bump-homebrew-formula.mjs --tag --sha256-x64 [--sha256-arm64 ]'); + process.exit(2); + } + + const original = fs.readFileSync(formulaPath, 'utf8'); + const bumped = updateFormulaText(original, args); + fs.writeFileSync(formulaPath, bumped); + console.log(`[bump-homebrew-formula] successfully updated ${formulaPath}`); +} diff --git a/scripts/configure-electron-fuses.mjs b/scripts/configure-electron-fuses.mjs new file mode 100644 index 0000000..f493a39 --- /dev/null +++ b/scripts/configure-electron-fuses.mjs @@ -0,0 +1,131 @@ +// Electron fuse inspector and hardener (Phase 2 hardening). +// +// Hardens production Electron executables by setting security fuses: +// - RunAsNode: disabled (prevents ELECTRON_RUN_AS_NODE=1 arbitrary code execution) +// - EnableNodeCliInspectArguments: disabled (blocks remote inspector arguments like --inspect) +// - EnableEmbeddedAsarIntegrityValidation: enabled (validates embedded asar signature) +// - OnlyLoadAppFromAsar: enabled (prevents loading code from unpacked directories) +// +// Zero external dependencies: directly manipulates the Electron fuse sentinel wire format. +// +// Usage: node scripts/configure-electron-fuses.mjs [--check] + +import fs from 'node:fs'; +import path from 'node:path'; + +// Electron Fuse Wire Format Sentinel (30 bytes) +export const FUSE_SENTINEL = Buffer.from('dL7pKGdnNz796PujgMnAQALDM8NKxn', 'utf8'); + +export const FUSE_NAMES = [ + 'runAsNode', + 'enableCookieEncryption', + 'enableNodeOptionsEnvironmentVariable', + 'enableNodeCliInspectArguments', + 'enableEmbeddedAsarIntegrityValidation', + 'onlyLoadAppFromAsar', + 'loadBrowserProcessSpecificV8Snapshot', + 'grantFileProtocolExtraPrivileges', +]; + +export const FUSE_STATE = { + DISABLED: 0x30, // '0' + ENABLED: 0x31, // '1' + REMOVED: 0x32, // '2' + INHERIT: 0x90, // sentinel default +}; + +/** Locate fuse sentinel in binary buffer. Returns offset or -1. */ +export function findFuseOffset(buffer) { + return buffer.indexOf(FUSE_SENTINEL); +} + +/** Read fuse states from an Electron binary buffer. */ +export function readFuses(buffer) { + const offset = findFuseOffset(buffer); + if (offset === -1) { + return { found: false, fuses: {} }; + } + + const fuseVersionOffset = offset + FUSE_SENTINEL.length; + const fuseVersion = buffer[fuseVersionOffset]; + const fuses = {}; + + const fusesStart = fuseVersionOffset + 1; + for (let i = 0; i < FUSE_NAMES.length; i++) { + const byte = buffer[fusesStart + i]; + fuses[FUSE_NAMES[i]] = byte === FUSE_STATE.ENABLED + ? 'enabled' + : byte === FUSE_STATE.DISABLED + ? 'disabled' + : 'default'; + } + + return { found: true, fuseVersion, fuses, offset }; +} + +/** Harden fuses on a buffer in-place. */ +export function applyHardenedFuses(buffer) { + const offset = findFuseOffset(buffer); + if (offset === -1) { + throw new Error('[configure-electron-fuses] fuse sentinel not found in binary'); + } + + const fusesStart = offset + FUSE_SENTINEL.length + 1; + const changes = []; + + function setFuse(index, name, targetState) { + const current = buffer[fusesStart + index]; + if (current !== targetState) { + buffer[fusesStart + index] = targetState; + changes.push({ name, from: current, to: targetState }); + } + } + + // 1. Disable RunAsNode + setFuse(0, 'runAsNode', FUSE_STATE.DISABLED); + // 2. Disable Node Cli Inspect Arguments + setFuse(3, 'enableNodeCliInspectArguments', FUSE_STATE.DISABLED); + // 3. Enable Embedded Asar Integrity Validation + setFuse(4, 'enableEmbeddedAsarIntegrityValidation', FUSE_STATE.ENABLED); + // 4. Enforce OnlyLoadAppFromAsar + setFuse(5, 'onlyLoadAppFromAsar', FUSE_STATE.ENABLED); + + return { offset, changes }; +} + +// CLI +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) { + const args = process.argv.slice(2); + const checkOnly = args.includes('--check'); + const binPath = args.find(a => !a.startsWith('--')); + + if (!binPath) { + console.error('Usage: node scripts/configure-electron-fuses.mjs [--check] '); + process.exit(2); + } + + const resolved = path.resolve(binPath); + if (!fs.existsSync(resolved)) { + console.error(`[configure-electron-fuses] binary not found: ${resolved}`); + process.exit(1); + } + + const buf = fs.readFileSync(resolved); + const info = readFuses(buf); + + if (!info.found) { + console.warn(`[configure-electron-fuses] warning: fuse sentinel not found in ${resolved} (not an Electron binary or stripped)`); + process.exit(0); + } + + console.log(`[configure-electron-fuses] Current fuses for ${path.basename(resolved)}:`); + for (const [k, v] of Object.entries(info.fuses)) { + console.log(` ${k}: ${v}`); + } + + if (!checkOnly) { + const result = applyHardenedFuses(buf); + fs.writeFileSync(resolved, buf); + console.log(`[configure-electron-fuses] Hardened ${result.changes.length} fuse(s) in ${resolved}`); + } +} diff --git a/scripts/verify-linux-artifacts.mjs b/scripts/verify-linux-artifacts.mjs new file mode 100644 index 0000000..c2bb34d --- /dev/null +++ b/scripts/verify-linux-artifacts.mjs @@ -0,0 +1,223 @@ +// Verification gate for Linux distribution artifacts (Phase 2 hardening). +// +// Asserts that emitted Linux packages and extracted directories conform to +// zero-trust file permission baselines, ELF binary hygiene, and leak prevention: +// 1. No world-writable files (perm & 0o002 === 0). +// 2. Executables have sanitized permissions (0o755 / 0o555). +// 3. Non-binary files (.js, .json, .png, etc.) are non-executable. +// 4. ELF binaries contain valid headers and no unsafe DT_RPATH / DT_RUNPATH +// (no relative '.', empty '::', or temp '/tmp' entries). +// 5. No sensitive credentials, repo metadata, or dev files (.git, .env, .npmrc). +// +// Usage: node scripts/verify-linux-artifacts.mjs + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; + +const FORBIDDEN_NAMES = new Set([ + '.env', + '.git', + '.npmrc', + '.yarnrc', + 'id_rsa', + 'id_ed25519', +]); + +const NON_EXEC_EXTENSIONS = new Set([ + '.js', + '.cjs', + '.mjs', + '.json', + '.md', + '.txt', + '.html', + '.css', + '.png', + '.jpg', + '.svg', + '.yml', + '.yaml', +]); + +export function isElfBinary(filePath) { + try { + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + return buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46; // '\x7fELF' + } catch { + return false; + } +} + +export function inspectElfRpath(filePath) { + // Try readelf if available + const result = spawnSync('readelf', ['-d', filePath], { encoding: 'utf8' }); + if (result.status !== 0) return { ok: true, rpath: [] }; + + const lines = result.stdout.split('\n'); + const paths = []; + for (const line of lines) { + if (line.includes('RPATH') || line.includes('RUNPATH')) { + const match = line.match(/\[(.*?)\]/); + if (match) { + paths.push(match[1]); + } + } + } + + const violations = []; + for (const entry of paths) { + const segments = entry.split(':'); + for (const seg of segments) { + if (!seg || seg === '.' || seg === '..' || seg.startsWith('./') || seg.startsWith('../')) { + violations.push(`relative or empty RPATH entry: "${seg}"`); + } + if (seg.startsWith('/tmp') || seg.startsWith('/var/tmp')) { + violations.push(`insecure temporary RPATH entry: "${seg}"`); + } + } + } + + return { + ok: violations.length === 0, + rpaths: paths, + violations, + }; +} + +export function verifyDirectoryPermissions(dir) { + const violations = []; + let checkedCount = 0; + + function walk(current) { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + const relPath = path.relative(dir, fullPath); + + if (FORBIDDEN_NAMES.has(entry.name)) { + violations.push({ + path: relPath, + reason: `sensitive file or directory detected: ${entry.name}`, + }); + } + + let stat; + try { + stat = fs.lstatSync(fullPath); + } catch (err) { + violations.push({ + path: relPath, + reason: `unable to stat: ${err.message}`, + }); + continue; + } + + checkedCount++; + const mode = stat.mode; + + // 1. World-writable check + if ((mode & 0o002) !== 0) { + violations.push({ + path: relPath, + reason: `world-writable permissions (0o${(mode & 0o777).toString(8)})`, + }); + } + + if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + + // 2. Non-exec extensions must not have exec bit set + if (NON_EXEC_EXTENSIONS.has(ext)) { + if ((mode & 0o111) !== 0) { + violations.push({ + path: relPath, + reason: `data/script file has executable permission (0o${(mode & 0o777).toString(8)})`, + }); + } + } + + // 3. ELF binary verification + if (isElfBinary(fullPath)) { + const elfCheck = inspectElfRpath(fullPath); + if (!elfCheck.ok) { + for (const v of elfCheck.violations) { + violations.push({ + path: relPath, + reason: `ELF binary RPATH hygiene violation: ${v}`, + }); + } + } + } + } else if (entry.isDirectory()) { + walk(fullPath); + } + } + } + + walk(dir); + return { + ok: violations.length === 0, + checkedCount, + violations, + }; +} + +// CLI entry point +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) { + const target = process.argv[2]; + if (!target) { + console.error('Usage: node scripts/verify-linux-artifacts.mjs '); + process.exit(2); + } + + const resolved = path.resolve(target); + if (!fs.existsSync(resolved)) { + console.error(`[verify-linux-artifacts] target does not exist: ${resolved}`); + process.exit(1); + } + + let testDir = resolved; + let isTemp = false; + + if (resolved.endsWith('.zip')) { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-artifact-zip-')); + isTemp = true; + const unzip = spawnSync('unzip', ['-q', resolved, '-d', testDir], { encoding: 'utf8' }); + if (unzip.status !== 0) { + console.error(`[verify-linux-artifacts] failed to unpack zip: ${unzip.stderr}`); + fs.rmSync(testDir, { recursive: true, force: true }); + process.exit(1); + } + } else if (resolved.endsWith('.tar.gz') || resolved.endsWith('.tgz')) { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-artifact-tar-')); + isTemp = true; + const untar = spawnSync('tar', ['-xzf', resolved, '-C', testDir], { encoding: 'utf8' }); + if (untar.status !== 0) { + console.error(`[verify-linux-artifacts] failed to unpack tarball: ${untar.stderr}`); + fs.rmSync(testDir, { recursive: true, force: true }); + process.exit(1); + } + } + + try { + const report = verifyDirectoryPermissions(testDir); + console.log(`[verify-linux-artifacts] checked ${report.checkedCount} items in ${target}`); + if (!report.ok) { + console.error(`[verify-linux-artifacts] FAILED with ${report.violations.length} violation(s):`); + for (const v of report.violations) { + console.error(` - ${v.path}: ${v.reason}`); + } + process.exit(1); + } + console.log('[verify-linux-artifacts] PASS: all permission and ELF binary checks verified.'); + } finally { + if (isTemp) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + } +} diff --git a/tests/integration/mcp-server.test.mjs b/tests/integration/mcp-server.test.mjs new file mode 100644 index 0000000..5c1b5ef --- /dev/null +++ b/tests/integration/mcp-server.test.mjs @@ -0,0 +1,187 @@ +// Integration tests for Model Context Protocol (MCP) STDIO server. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { PassThrough } from 'node:stream'; +import { + MCP_PROTOCOL_VERSION, + SERVER_NAME, + handleRpcMessage, + startMcpStdioServer, +} from '../../packages/opencode-provider/mcp-server.mjs'; + +function makeScratchProject() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-')); + const projectPath = path.join(dir, 'game.json'); + const initial = { + properties: { name: 'McpTestGame', version: '1.0.0' }, + resources: { resources: [] }, + objects: [], + layouts: [ + { + name: 'MainScene', + objects: [], + layers: [{ name: '', visibility: true }], + events: [], + variables: [], + }, + ], + }; + fs.writeFileSync(projectPath, JSON.stringify(initial, null, 2)); + return { dir, projectPath }; +} + +describe('integration: mcp server protocol and lifecycle', () => { + it('handles initialize negotiation with compliant capabilities', async () => { + const req = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + clientInfo: { name: 'claude-code', version: '1.0.0' }, + }, + }; + const resp = await handleRpcMessage(req); + assert.equal(resp.jsonrpc, '2.0'); + assert.equal(resp.id, 1); + assert.equal(resp.result.protocolVersion, MCP_PROTOCOL_VERSION); + assert.equal(resp.result.serverInfo.name, SERVER_NAME); + assert.ok(resp.result.capabilities.tools); + assert.ok(resp.result.capabilities.resources); + assert.ok(resp.result.capabilities.prompts); + }); + + it('lists all 6 GDevelop tools with valid JSON schemas', async () => { + const req = { jsonrpc: '2.0', id: 2, method: 'tools/list' }; + const resp = await handleRpcMessage(req); + assert.equal(resp.id, 2); + const tools = resp.result.tools; + assert.equal(tools.length, 6); + const names = tools.map(t => t.name); + assert.ok(names.includes('gdevelop_project_read')); + assert.ok(names.includes('gdevelop_project_create_scene')); + assert.ok(names.includes('gdevelop_project_create_object')); + assert.ok(names.includes('gdevelop_project_add_behavior')); + assert.ok(names.includes('gdevelop_project_add_events')); + assert.ok(names.includes('gdevelop_cli_run')); + + for (const t of tools) { + assert.ok(t.description); + assert.equal(t.inputSchema.type, 'object'); + assert.ok(Array.isArray(t.inputSchema.required)); + } + }); + + it('executes gdevelop_project_read and gdevelop_project_create_scene on real disk project', async () => { + const { dir, projectPath } = makeScratchProject(); + try { + // 1. Read project + const readReq = { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'gdevelop_project_read', + arguments: { projectPath }, + }, + }; + const readResp = await handleRpcMessage(readReq); + assert.equal(readResp.result.isError, false); + const parsedOutput = JSON.parse(readResp.result.content[0].text); + assert.equal(parsedOutput.projectName, 'McpTestGame'); + assert.equal(parsedOutput.scenes.length, 1); + + // 2. Create scene + const createReq = { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'gdevelop_project_create_scene', + arguments: { projectPath, sceneName: 'Level2' }, + }, + }; + const createResp = await handleRpcMessage(createReq); + assert.equal(createResp.result.isError, false); + + // 3. Verify on disk + const updated = JSON.parse(fs.readFileSync(projectPath, 'utf8')); + assert.equal(updated.layouts.length, 2); + assert.ok(updated.layouts.some(l => l.name === 'Level2')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns structured isError: true on invalid tool arguments', async () => { + const req = { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'gdevelop_project_read', + arguments: { projectPath: '/non/existent/path/game.json' }, + }, + }; + const resp = await handleRpcMessage(req); + assert.equal(resp.result.isError, true); + assert.ok(resp.result.content[0].text.includes('project path not found')); + }); + + it('lists and reads GDevelop resources', async () => { + const listReq = { jsonrpc: '2.0', id: 6, method: 'resources/list' }; + const listResp = await handleRpcMessage(listReq); + assert.ok(listResp.result.resources.length >= 3); + + const readReq = { + jsonrpc: '2.0', + id: 7, + method: 'resources/read', + params: { uri: 'gdevelop://docs/engine-architecture' }, + }; + const readResp = await handleRpcMessage(readReq); + assert.equal(readResp.result.contents[0].uri, 'gdevelop://docs/engine-architecture'); + assert.ok(readResp.result.contents[0].text.includes('GDJS Lifecycle')); + }); + + it('lists and generates prompt templates', async () => { + const listReq = { jsonrpc: '2.0', id: 8, method: 'prompts/list' }; + const listResp = await handleRpcMessage(listReq); + assert.ok(listResp.result.prompts.some(p => p.name === 'gdevelop_game_architect')); + + const getReq = { + jsonrpc: '2.0', + id: 9, + method: 'prompts/get', + params: { + name: 'gdevelop_game_architect', + arguments: { genre: 'Physics Puzzle' }, + }, + }; + const getResp = await handleRpcMessage(getReq); + const msg = getResp.result.messages[0]; + assert.equal(msg.role, 'user'); + assert.ok(msg.content.text.includes('Physics Puzzle')); + }); + + it('streams request and response over STDIO readline interface', async () => { + const inStream = new PassThrough(); + const outStream = new PassThrough(); + const rl = startMcpStdioServer(inStream, outStream); + + let outputData = ''; + outStream.on('data', chunk => { + outputData += chunk.toString('utf8'); + }); + + inStream.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'ping' }) + '\n'); + await new Promise(resolve => setTimeout(resolve, 50)); + rl.close(); + + assert.ok(outputData.includes('"id":42')); + assert.ok(outputData.includes('"result":{}')); + }); +}); diff --git a/tests/integration/opencode-harness-adapters.test.mjs b/tests/integration/opencode-harness-adapters.test.mjs new file mode 100644 index 0000000..dd5c85f --- /dev/null +++ b/tests/integration/opencode-harness-adapters.test.mjs @@ -0,0 +1,62 @@ +// Integration tests for harness adapters across OpenAI, Anthropic, MCP, and OpenCode. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + GDEVELOP_TOOLS, + toOpenAiTool, + toAnthropicTool, + toMcpTool, + toOpenCodeTool, + exportAllTools, +} from '../../packages/opencode-gdevelop/tools.mjs'; + +describe('integration: harness-agnostic tool adapters', () => { + it('converts tools to OpenAI function calling format', () => { + const openaiTools = exportAllTools('openai'); + assert.equal(openaiTools.length, 6); + for (const tool of openaiTools) { + assert.equal(tool.type, 'function'); + assert.ok(tool.function.name.startsWith('gdevelop_')); + assert.ok(tool.function.description); + assert.equal(tool.function.parameters.type, 'object'); + assert.ok(Array.isArray(tool.function.parameters.required)); + } + }); + + it('converts tools to Anthropic tool use format', () => { + const anthropicTools = exportAllTools('anthropic'); + assert.equal(anthropicTools.length, 6); + for (const tool of anthropicTools) { + assert.ok(tool.name.startsWith('gdevelop_')); + assert.ok(tool.description); + assert.equal(tool.input_schema.type, 'object'); + assert.ok(Array.isArray(tool.input_schema.required)); + } + }); + + it('converts tools to MCP format', () => { + const mcpTools = exportAllTools('mcp'); + assert.equal(mcpTools.length, 6); + for (const tool of mcpTools) { + assert.ok(tool.name.startsWith('gdevelop_')); + assert.ok(tool.description); + assert.equal(tool.inputSchema.type, 'object'); + assert.ok(Array.isArray(tool.inputSchema.required)); + } + }); + + it('converts tools to OpenCode format with execute method preserved', () => { + const openCodeTools = exportAllTools('opencode'); + assert.equal(openCodeTools.length, 6); + for (const tool of openCodeTools) { + assert.ok(tool.name.startsWith('gdevelop_')); + assert.ok(tool.description); + assert.equal(typeof tool.execute, 'function'); + assert.equal(tool.input.type, 'object'); + } + }); + + it('fails closed on unsupported format names', () => { + assert.throws(() => exportAllTools('unsupported_format'), /unsupported tool format/); + }); +}); diff --git a/tests/launcher/fuses.test.cjs b/tests/launcher/fuses.test.cjs new file mode 100644 index 0000000..d1d3522 --- /dev/null +++ b/tests/launcher/fuses.test.cjs @@ -0,0 +1,52 @@ +// Electron fuse hardener unit tests. +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + FUSE_SENTINEL, + FUSE_STATE, + findFuseOffset, + readFuses, + applyHardenedFuses, +} = require('../../scripts/configure-electron-fuses.mjs'); + +function createSyntheticBinary() { + const prefix = Buffer.alloc(100, 0x90); + const sentinel = FUSE_SENTINEL; + const version = Buffer.from([0x01]); + // 8 fuses in default unconfigured state (0x90) + const fuses = Buffer.alloc(8, 0x90); + const suffix = Buffer.alloc(100, 0x90); + return Buffer.concat([prefix, sentinel, version, fuses, suffix]); +} + +describe('launcher: electron security fuses', () => { + it('detects fuse sentinel in synthetic binary', () => { + const bin = createSyntheticBinary(); + const offset = findFuseOffset(bin); + assert.equal(offset, 100); + + const info = readFuses(bin); + assert.equal(info.found, true); + assert.equal(info.fuseVersion, 1); + assert.equal(info.fuses.runAsNode, 'default'); + }); + + it('fails gracefully when sentinel is absent', () => { + const dummy = Buffer.alloc(200, 0x00); + const info = readFuses(dummy); + assert.equal(info.found, false); + assert.throws(() => applyHardenedFuses(dummy), /fuse sentinel not found/); + }); + + it('applies hardened fuse states (disables RunAsNode & CliInspect, enables Asar)', () => { + const bin = createSyntheticBinary(); + const result = applyHardenedFuses(bin); + assert.ok(result.changes.length >= 4); + + const updated = readFuses(bin); + assert.equal(updated.fuses.runAsNode, 'disabled'); + assert.equal(updated.fuses.enableNodeCliInspectArguments, 'disabled'); + assert.equal(updated.fuses.enableEmbeddedAsarIntegrityValidation, 'enabled'); + assert.equal(updated.fuses.onlyLoadAppFromAsar, 'enabled'); + }); +}); diff --git a/tests/patches/homebrew-formula.test.mjs b/tests/patches/homebrew-formula.test.mjs new file mode 100644 index 0000000..e8fa610 --- /dev/null +++ b/tests/patches/homebrew-formula.test.mjs @@ -0,0 +1,53 @@ +// Unit tests for Linux Homebrew Formula bumper and integrity. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { updateFormulaText } from '../../scripts/bump-homebrew-formula.mjs'; + +describe('release: linux homebrew formula bumper', () => { + it('updates formula version, URLs, and sha256 checksums', () => { + const original = `class GdevelopUnlocked < Formula + desc "Unlocked desktop IDE" + version "0.1.0" + + on_linux do + on_intel do + url "https://old/x64.zip" + sha256 "oldhash64" + end + + on_arm do + url "https://old/arm64.zip" + sha256 "oldhasharm" + end + end +end`; + + const bumped = updateFormulaText(original, { + tag: 'unlocked-abcdef1234', + sha256X64: 'newhash64', + sha256Arm64: 'newhasharm', + }); + + assert.ok(bumped.includes('version "abcdef1234"')); + assert.ok(bumped.includes('sha256 "newhash64"')); + assert.ok(bumped.includes('sha256 "newhasharm"')); + assert.ok(bumped.includes('unlocked-abcdef1234')); + }); + + it('validates Formula/gdevelop-unlocked.rb syntax and structure on disk', () => { + const formulaPath = path.resolve('Formula', 'gdevelop-unlocked.rb'); + assert.ok(fs.existsSync(formulaPath), 'Formula/gdevelop-unlocked.rb missing'); + const content = fs.readFileSync(formulaPath, 'utf8'); + + assert.ok(content.includes('class GdevelopUnlocked < Formula')); + assert.ok(content.includes('on_linux do')); + assert.ok(content.includes('on_intel do')); + assert.ok(content.includes('on_arm do')); + assert.ok(content.includes('def install')); + assert.ok(content.includes('def caveats')); + assert.ok(content.includes('bubblewrap')); + assert.ok(content.includes('gdevelop-unlocked')); + }); +}); diff --git a/tests/patches/verify-linux-artifacts.test.mjs b/tests/patches/verify-linux-artifacts.test.mjs new file mode 100644 index 0000000..d5725c0 --- /dev/null +++ b/tests/patches/verify-linux-artifacts.test.mjs @@ -0,0 +1,83 @@ +// Verification gate tests for Linux distribution artifacts. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + isElfBinary, + inspectElfRpath, + verifyDirectoryPermissions, +} from '../../scripts/verify-linux-artifacts.mjs'; + +describe('release: linux artifact verification gate', () => { + it('passes on a clean directory with proper permissions', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'linux-art-clean-')); + try { + fs.writeFileSync(path.join(dir, 'app.js'), 'console.log("hello");', { mode: 0o644 }); + fs.writeFileSync(path.join(dir, 'package.json'), '{}', { mode: 0o644 }); + const binDir = path.join(dir, 'bin'); + fs.mkdirSync(binDir); + fs.writeFileSync(path.join(binDir, 'gdevelop'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + + const report = verifyDirectoryPermissions(dir); + assert.equal(report.ok, true); + assert.equal(report.violations.length, 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails when world-writable files exist', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'linux-art-ww-')); + try { + const badFile = path.join(dir, 'bad.txt'); + fs.writeFileSync(badFile, 'insecure'); + fs.chmodSync(badFile, 0o666); + const report = verifyDirectoryPermissions(dir); + assert.equal(report.ok, false); + assert.ok(report.violations.some(v => v.reason.includes('world-writable'))); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails when scripts or data files are given executable bits', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'linux-art-exec-')); + try { + fs.writeFileSync(path.join(dir, 'script.js'), 'console.log(1)', { mode: 0o755 }); + const report = verifyDirectoryPermissions(dir); + assert.equal(report.ok, false); + assert.ok(report.violations.some(v => v.reason.includes('executable permission'))); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails when forbidden credential or git files are bundled', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'linux-art-leak-')); + try { + fs.writeFileSync(path.join(dir, '.env'), 'SECRET=123', { mode: 0o600 }); + const report = verifyDirectoryPermissions(dir); + assert.equal(report.ok, false); + assert.ok(report.violations.some(v => v.reason.includes('sensitive file'))); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('correctly detects ELF magic header', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'linux-art-elf-')); + try { + const elfFile = path.join(dir, 'fake_elf'); + fs.writeFileSync(elfFile, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01])); + assert.equal(isElfBinary(elfFile), true); + + const nonElf = path.join(dir, 'plain.txt'); + fs.writeFileSync(nonElf, 'not an elf'); + assert.equal(isElfBinary(nonElf), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +});