mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
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:
1 parent
72ee1a85b2
commit
6a17790914
17 files changed
+1586
-17
No files matched your search
@@ -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 }}"}'
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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__;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Executable
+338
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user