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)
This commit is contained in:
BillyOutlast committed 2026-10-12 19:35:04 -04:00
1 parent 72ee1a85b2
commit 6a17790914
17 files changed
+1586 -17

No files matched your search

+57 -3
View File
@@ -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-<os>.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-<os>.txt and SHA512SUMS-<os>.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-<os>.txt` file for your platform.
- Verify downloads against the `SHA256SUMS-<os>.txt` and `SHA512SUMS-<os>.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 }}"}'
+105
View File
@@ -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
+6 -5
View File
@@ -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.
+25 -2
View File
@@ -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__;
}
+57
View File
@@ -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}"`);
}
}
+65
View File
@@ -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),
};
}
+338
View File
@@ -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();
}
+4 -2
View File
@@ -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"
}
}
+26 -5
View File
@@ -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));
}
+112
View File
@@ -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 <release-tag> \
// --sha256-x64 <hash> \
// [--sha256-arm64 <hash>] \
// [--formula <path>]
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 <tag> --sha256-x64 <sha> [--sha256-arm64 <sha>]');
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}`);
}
+131
View File
@@ -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] <electron-binary-path>
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] <electron-binary-path>');
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}`);
}
}
+223
View File
@@ -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 <target-path>
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 <target-directory-or-archive>');
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 });
}
}
}
+187
View File
@@ -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":{}'));
});
});
@@ -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/);
});
});
+52
View File
@@ -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');
});
});
+53
View File
@@ -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'));
});
});
@@ -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 });
}
});
});