Surgical conversion: Tier-1 patcher + launcher hook, Tier-2 extraction (Phases 1-5)

Replaces the 205-file fork delivery vehicle with patches/ + launcher/
+ versioned packages, per the implementation plan:

Phase 1: seam tripwires (27 tests) freezing every surgical target in
upstream (Usage predicate, %16 gate, SubscriptionChecker, debugger,
LoadingScreen branding, Electron hook surface, no-plugin-registry
verdict).

Phase 2: 5 generated Tier-1 hunks (round-trip verified; 3 files reach
byte-identical fork parity) + apply driver (dry-run gate, marker
verify, asar extract/repack).

Phase 3: launcher hook (preload fetch/XHR spoof of the two usage GETs,
BrowserWindow wrap, webRequest fallback, updater pin) + 06 loader
patch. Module._load interception dropped: renderers run a webpack
bundle, so the spoof rides the axios/XHR path the provider hydrates
from instead.

Phase 4: Tier-2 verbatim extraction (CustomAIClient + 12 Studio
modules + offline mock harness, manifest-pinned) + 07/08 verbatim
glue patches + pure generation shim with fail-closed leak guard.

Phase 5: web userscript demo (Tier-1 only, documented CORS/LAN/churn
limits). Phase 6 runbook + upstream provider-interface RFC draft
included; fork archiving left to owners (destructive).

Suite: 66 tests green (seams, patcher incl. re-apply refusal,
launcher incl. vm-sandbox fallback eval, tier-2 provenance, web).
This commit is contained in:
BillyOutlast committed 2026-10-10 20:41:35 -04:00
commit 30da3f8b4e
64 files changed
+21875

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
name: seam-tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 6 * * 1' # weekly drift check against upstream baseline
jobs:
seams:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# Baselines are gitignored locally (multi-GB); CI clones the pins:
# upstream verified-at commit + fork donor baseline (provenance tests).
- name: Clone pinned upstream baseline
run: |
git clone --no-checkout https://github.com/4ian/GDevelop review/GDevelop
git -C review/GDevelop checkout 8301cdcaa48b496f48a28a6e3a56fa833eaeddff
- name: Clone fork donor baseline
run: |
git clone --no-checkout https://github.com/Heretek-AI/GDevelop-Heretek review/GDevelop-Heretek
git -C review/GDevelop-Heretek checkout b9109cfbaaaf19c31b5af4d315317fb67d8903c3
- name: Run full suite (seams + patcher + launcher + tier-2 + web)
run: npm test
- name: Patcher gate (seams + patch inventory)
run: node scripts/apply-patches.mjs --check
+8
View File
@@ -0,0 +1,8 @@
# Local-only upstream/fork checkouts (multi-GB baselines for seam +
# provenance tests). CI clones the pinned commits (see UPSTREAM_BASELINE.json
# and packages/ai-studio/MANIFEST.json); never commit these.
review/
node_modules/
coverage/
*.log
.DS_Store
+180
View File
@@ -0,0 +1,180 @@
# CLAUDE CODE RESEARCH PROMPT: GDEVELOP PLUGIN AUDIT & HERETEK SURGICAL CONVERSION
> **How to Use**: Copy and paste the entire prompt below directly into **Claude Code** (or run `claude` inside `/home/john/Projects/GDevelop-Unlocked` and pass this file).
***
# TASK: Comprehensive Technical Audit & Surgical Conversion Feasibility Study (GDevelop vs. GDevelop-Heretek)
## Context & Repository Locations
You are tasked with conducting an exhaustive technical audit of two local repositories:
1. **Upstream GDevelop**: `/home/john/Projects/GDevelop-Unlocked/review/GDevelop`
- Upstream GitHub: https://github.com/4ian/GDevelop
- The official open-source 2D/3D no-code game engine (Core C++, Emscripten/Wasm `GDevelop.js`, Pixi/Three runtime `GDJS`, and React/Electron `newIDE`).
2. **GDevelop-Heretek (BYOK Edition)**: `/home/john/Projects/GDevelop-Unlocked/review/GDevelop-Heretek`
- Fork GitHub: https://github.com/Heretek-AI/GDevelop-Heretek
- A fork maintaining daily syncs with upstream that unlocks premium subscription features and adds a Bring-Your-Own-Key (BYOK) local AI client and multi-agent game studio.
- Reference documentation in fork: `AGENTS.md`, `fork-divergence.json`, `MAINTENANCE.md`, and README feature comparison.
---
## Core Mission
Analyze whether maintaining an upstream-diverging 205-file fork can be replaced by a **surgical conversion** (e.g., an external plugin, Electron runtime injector, AST/codemod patcher, or local proxy), enabling users to run vanilla upstream GDevelop while unlocking all features listed in the Heretek Feature Comparison table without maintaining a massive fork.
---
## Phase 1: Upstream Architecture & Plugin Capability Audit
Investigate `/home/john/Projects/GDevelop-Unlocked/review/GDevelop`:
1. **Disambiguate Engine Extensions vs. IDE Plugins:**
- Examine `review/GDevelop/Extensions/` and `review/GDevelop/GDJS/Runtime/`.
- Confirm whether "Extensions" in GDevelop strictly apply to the **game engine runtime** (`gdjs.RuntimeBehavior`, `gdjs.RuntimeObject`, actions, conditions, and Pixi/Three renderers) or if any extension points exist for the editor itself.
2. **Audit `newIDE/app` (The React/Flow Editor):**
- Search for any dynamic module loading, plugin registries, extension hooks, or lifecycle events in `newIDE/app/src/`.
- Inspect `newIDE/app/src/index.js`, `LocalApp.js`, `BrowserApp.js`, and `MainFrame/index.js`.
- Does `newIDE` have any native plugin or add-on architecture for third-party UI components, custom tabs, or service overrides?
3. **Audit `newIDE/electron-app` (The Desktop Shell):**
- Inspect `newIDE/electron-app/app/main.js` and `Utils/UrlLoader.js`.
- Analyze Electron security and configuration settings:
- Notice `webPreferences.nodeIntegration`, `contextIsolation`, and `webSecurity`.
- How is the renderer page loaded (`file://`, `gdide://`, or dev server)?
- Are preload scripts utilized or can arbitrary preload scripts be attached?
- What IPC channels exist between renderer and main?
---
## Phase 2: Feature-by-Feature Entitlement Dissection
Compare `/home/john/Projects/GDevelop-Unlocked/review/GDevelop` with `/home/john/Projects/GDevelop-Unlocked/review/GDevelop-Heretek`.
Audit the exact implementation of every feature listed in the Heretek Feature Comparison table:
### 1. Watermark, Startup Logo, & Splash Duration Clamps
- Inspect `newIDE/app/src/ProjectManager/LoadingScreenEditor.js`.
- Contrast how upstream gates:
- `loadingScreen.isGDevelopLogoShownDuringLoadingScreen()`
- `watermark.isGDevelopWatermarkShown()`
- `loadingScreen.setMinDuration()` / `forcedLogo.minDuration`
- Identify the exact boolean checks that guard these options.
### 2. Live Preview (Hot Reloading) & Wi-Fi / LAN Preview
- Inspect `newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js`.
- Analyze:
- The paywall popup triggered every 16 hot reloads (`this.state.hotReloadsCount % 16 === 0`).
- Network preview gating (`_checkSubscriptionForNetworkPreview`).
- How does Heretek neutralize these checks?
### 3. In-Game Debugger & Profiler
- Inspect `newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js`.
- Analyze `_checkUserHasSubscription()` and how upstream restricts the debugger tab.
### 4. Entitlements & Subscription Architecture
- Inspect `newIDE/app/src/Utils/GDevelopServices/Usage.js`:
- Analyze `hasValidSubscriptionPlan()`, `Subscription` type, and `Capabilities` object.
- Inspect `newIDE/app/src/Profile/AuthenticatedUserContext.js` and `AuthenticatedUserProvider.js`:
- Analyze `UNLOCKED_HERETEK_SUBSCRIPTION` and `UNLOCKED_HERETEK_CAPABILITIES`.
- Contrast how upstream populates user capabilities from backend APIs vs. how Heretek seeds default unlocked capabilities.
### 5. AI Assistant & Game Creator (BYOK & Local LLMs)
- Inspect `newIDE/app/src/AI/CustomAIClient.js` (~5,870 lines) in Heretek:
- Analyze the OpenAI-compatible client, tool schemas (`GDEVELOP_OPENAI_TOOLS`), SSE streaming, and local storage keys (`gd-custom-ai-config`, `gd-custom-ai-requests`).
- Inspect `newIDE/app/src/Utils/GDevelopServices/Generation.js`:
- How does Heretek hook into `Generation.js` (`isCustomEndpointEnabled()`, `customCreateAiRequest()`)?
- Inspect `newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js`:
- How is the `'ai'` configuration tab injected into the preferences modal?
### 6. Multi-Agent AI Studio (`AiGeneration/Studio/`)
- Inspect `newIDE/app/src/AiGeneration/Studio/` in Heretek (~15 files):
- Review `Roles.js`, `PlanStore.js`, `LoopGuard.js`, `SpawnSubAgents.js`, `FinalizeSubAgents.js`, `UseStudioRuntime.js`.
- Determine how deeply these files couple with the rest of the editor vs. operating as modular self-contained logic.
### 7. Local HTML5 & Desktop Exports
- Audit export limits in `Usage.js` and `ExportAndShare/` to determine if any local build operations are restricted in upstream.
---
## Phase 3: Architectural Feasibility of Surgical Conversion
Evaluate the feasibility, pros, cons, and implementation complexity of converting Heretek's features into an external solution across these 5 technical vectors:
### Vector A: Electron Preload / Runtime Hook Injection (The "BetterDiscord / Spicetify" Model)
- **Mechanics**:
- Because upstream Electron sets `nodeIntegration: true` and `contextIsolation: false`, any code running in the renderer has unrestricted access to the Node runtime, DOM, and React component tree.
- Can an external launcher inject a custom script via:
1. An Electron preload argument / CLI flag (`--require <script.js>`).
2. Modifying `newIDE/electron-app/app/main.js` (or `app.asar`) with a single-line hook loader.
3. A standalone wrapper launcher executing Electron with a custom bootstrap script.
- **Feasibility Questions**:
- Can React Context (`AuthenticatedUserContext`, `PreferencesContext`) be intercepted or patched at window load before components render?
- Can `Usage.hasValidSubscriptionPlan` or Axios interceptors be monkey-patched at runtime?
- How could the BYOK AI settings tab and custom AI client be injected into React DOM/Context from an external script?
### Vector B: Local Reverse Proxy / Network Mocking Layer
- **Mechanics**:
- GDevelop's subscription checks and AI generation requests communicate with `api.gdevelop.io` and Firebase.
- Can a lightweight local HTTP proxy (or Electron `session.webRequest` / protocol interceptor) intercept:
1. Requests to `/usage` or `/user` and return simulated Gold/Enterprise subscription payloads.
2. Requests to `/ai-request` and translate GDevelop AI format into OpenAI / Ollama format directly on the network level?
- **Feasibility Questions**:
- Does spoofing network responses completely satisfy the client-side UI checks (e.g. `LoadingScreenEditor`, `LocalPreviewLauncher`), or do those checks evaluate local React state regardless of network responses?
- Can the entire multi-agent studio loop function via network proxy alone, or does it require deep editor DOM/state integration?
### Vector C: AST / Codemod / Patcher Engine (The "patch-package" Model)
- **Mechanics**:
- Instead of maintaining a full git repository with daily upstream merge commits, maintain a directory of atomic diff patches (or Babel / `jscodeshift` AST transforms).
- A CLI tool (`gdevelop-unlocked patch`) unpacks upstream `app.asar` (or runs post-install in `newIDE/`), applies the surgical transforms, and repacks.
- **Feasibility Questions**:
- How fragile are the AST patches against upstream code updates?
- Contrast the maintenance burden of updating AST transforms vs. resolving git merge conflicts across 205 files in `fork-divergence.json`.
### Vector D: Browser Userscript / Extension (Web Editor Support)
- **Mechanics**:
- For `editor.gdevelop.io`, analyze whether a Userscript (Tampermonkey/Violentmonkey) or Chrome extension can hook into the Webpack chunk registry (`window.webpackChunk...`), patch React state, intercept `fetch`/`XMLHttpRequest`, and inject BYOK AI.
- **Feasibility Questions**:
- What are the limitations in the web version compared to Electron (e.g., local network preview, Ollama CORS)?
### Vector E: Upstream Plugin Architecture PR
- **Mechanics**:
- Assess whether an RFC / PR proposing a clean IDE Plugin API to 4ian/GDevelop would be technically viable and whether upstream's commercial model makes it unlikely to be accepted.
---
## Phase 4: Separation of Concerns (Two-Tier Architecture)
Notice that Heretek's changes divide sharply into two categories:
1. **Tier 1: Entitlement Unlocks (<50 lines across ~5 files):**
- Removing paywalls, logos, watermarks, splash delays, hot-reload restrictions.
2. **Tier 2: BYOK AI & Multi-Agent Studio (~8,000+ lines):**
- Full LLM orchestration, local endpoint support, multi-agent task runner.
Evaluate:
- Should Tier 1 and Tier 2 be decoupled into separate modules (e.g., a lightweight 100-line "Entitlement Unlocker" and a modular "AI Studio Addon")?
- How does decoupling affect maintenance, legal risk, and user flexibility?
---
## Deliverables Required in Your Report
Provide a clear, detailed, and technically rigorous report containing:
1. **Executive Summary**: Core verdict on whether a plugin/injector can replace the fork.
2. **Upstream Architecture Audit**: Official findings on GDevelop's extension points (runtime vs. IDE).
3. **Entitlement Mechanism Map**: Complete table mapping each feature to:
- Upstream file path and line numbers
- Gating logic (how upstream locks it)
- Heretek unlock logic (how Heretek bypassed it)
- Surgical injection vector (how to bypass it without source modification)
4. **Injection Vector Comparison Matrix**:
- Compare Vector A (Electron Preload Hook), Vector B (Network Proxy), Vector C (AST/Patcher), and Vector D (Userscript) on:
- Feasibility (High/Med/Low)
- Upstream update resilience (fragility)
- Implementation complexity
- Support for Desktop vs. Web
- Support for Tier 1 (Unlocks) vs. Tier 2 (BYOK AI)
5. **Recommended Architecture & Design Blueprint**:
- Detailed specification of the recommended approach.
- Proposed file structure for a standalone `gdevelop-unlocked` / `gdevelop-plugin` project.
- Exact code snippets demonstrating:
- How to hook Electron window creation or preload.
- How to patch `hasValidSubscriptionPlan` and `AuthenticatedUserContext`.
- How to mount the BYOK AI settings UI and redirect generation calls.
6. **Actionable Implementation Roadmap**: Step-by-step phases to build, test, and transition away from the fork.
+54
View File
@@ -0,0 +1,54 @@
# Phase 6: Decommission fork — retirement runbook (issue #6)
The 205-file fork (`Heretek-AI/GDevelop-Heretek`) stops being the
delivery vehicle and stays only as the Tier-2 donor archive. Nothing
here is automated: archiving and workflow deletion are destructive and
need a human owner. Do not close issue #6 until every box is ticked.
## Preconditions (M1 + M2 must hold without the fork present)
- [ ] `npm test` green with the fork checkout absent
(`FORK_DIR` pointed at an empty dir — only `tests/tier2/extract.test.mjs`
needs the donor, and only to re-verify byte-identity).
- [ ] `node scripts/apply-patches.mjs --target <pristine-upstream>` green
(all 8 patches + launcher sync + marker verify).
- [ ] Desktop smoke on the patched build: logo uncheck, watermark
uncheck, hot reload x17, wifi preview, debugger open (issue #2).
- [ ] Desktop E2E: `http://localhost:11434/v1` generation + subagent
spawn/finalize loop, zero `api.gdevelop.io/generation` calls in
BYOK mode (issue #4, procedure in `packages/ai-studio/README.md`).
## Retirement steps (human, in order)
1. Delete the daily sync: remove `.github/workflows/upstream-sync.yml`
from the fork (last sync first — record its commit hash below).
2. Mark the fork read-only: repository Settings → Archive, with the
description pointing here:
`Retired as delivery vehicle; Tier-2 donor archive for
Heretek-AI/GDevelop-Unlocked. Donor commit b9109cfbaaaf19c31b5af4d315317fb67d8903c3.`
3. After archiving, Tier-1 ships only as `patches/` + `launcher/`
(Phases 2–3); Tier-2 only as versioned `packages/ai-studio`
(Phase 4). Any future fork-sync need is a `scripts/extract-tier2.mjs`
re-run + `scripts/generate-tier2-patches.mjs` regeneration, reviewed
as a normal PR — never a merge treadmill.
```bash
# 1. (in the fork checkout, final state)
git log -1 --format=%H # record as last-sync hash: ________________
gh workflow disable upstream-sync.yml --repo Heretek-AI/GDevelop-Heretek
# 2. (owner only)
gh repo archive Heretek-AI/GDevelop-Heretek --yes
```
## Post-retirement invariant
The delivery path (patcher, hook, preload, shim, userscript) never
*reads* the fork: `GDevelop-Heretek` appears in delivery code only in
provenance comments (`Unlocked in GDevelop-Heretek`) and in donor-dir
defaults owned by the extractor (`scripts/extract-tier2.mjs`,
`scripts/generate-tier2-patches.mjs`) and the two provenance tests
(`tests/tier2/extract.test.mjs`, `tests/tier2/tier2-patches.test.mjs`),
which fail loudly — by design — when the donor is absent. Everything
else in `npm test` is fork-free (verified: 63 pass with `FORK_DIR`
pointed at an empty dir; the 1 failure + 2 cancellations are the
donor-provenance tests failing loudly by design — not silent drift).
+15
View File
@@ -0,0 +1,15 @@
{
"comment": "Pinned upstream shape the Phase 1 seam tests were written against. CI re-runs the suite against this commit on schedule; any red suite means a patch hunk or hook target moved.",
"donorBaselineCommit": "b9109cfbaaaf19c31b5af4d315317fb67d8903c3",
"verifiedAtUpstreamCommit": "8301cdcaa4",
"verifiedAt": "2026-10-11",
"seams": {
"Usage.hasValidSubscriptionPlan": "newIDE/app/src/Utils/GDevelopServices/Usage.js:~605",
"LocalPreviewLauncher.%16": "newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js:~441",
"SubscriptionChecker": "newIDE/app/src/Profile/Subscription/SubscriptionChecker.js:~66",
"DebuggerEditorContainer": "newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js:~135",
"LoadingScreenEditor": "newIDE/app/src/ProjectManager/LoadingScreenEditor.js:~106,~162",
"electron.main.webPreferences": "newIDE/electron-app/app/main.js:~174",
"electron.UrlLoader": "newIDE/electron-app/app/Utils/UrlLoader.js"
}
}
+65
View File
@@ -0,0 +1,65 @@
# RFC draft: provider-agnostic AI interface for upstream GDevelop (Vector E)
Status: **draft for diplomacy, not on any critical path** (issue #7).
If rejected, close as wontfix-with-notes — M1/M2 delivery is unaffected
either way. This proposes an interface only. It pointedly does NOT
propose paywall-disable hooks, entitlement overrides, or any change to
subscription/credit monetization.
## Problem
The IDE's AI surface (`Utils/GDevelopServices/Generation.js`,
`AiGeneration/`) is hard-wired to the hosted generation API. There is
no seam where an alternative completion provider (self-hosted gateway,
enterprise proxy, local OpenAI-compatible server) can participate —
so BYOK-style integrations can only exist as forks.
## Proposal: `AiProvider` interface
A minimal provider contract, implemented once for hosted and open for
alternatives:
```ts
// Sketch — names illustrative, semantics load-bearing.
interface AiRequestSummary { id: string; status: string; }
interface AiProvider {
readonly id: string; // 'hosted' | 'custom-endpoint' | ...
createRequest(options): Promise<{ id: string }>;
getRequest(id: string): Promise<AiRequestSummary>;
addMessage(id: string, message): Promise<void>;
supportsStreaming(): boolean;
}
```
- `Generation.js` routes by request-id namespace (`local-*` vs hosted),
exactly as the reference implementation in `packages/unlock-shim/`
demonstrates — the shim's `selectBackend` / `assertNoLocalIdsLeak`
semantics are donated as the starting point.
- Provider registration is explicit and user-configured (endpoint URL +
opt-in flag in Preferences); there is no auto-discovery and no
silent fallback from hosted to custom or back.
- Guardrails that keep this mergeable:
1. **Credits/quota accounting stays server-side.** Providers report
usage; they never mint entitlements. A custom provider grants no
premium features — it only completes prompts.
2. **No entitlement surface.** The interface carries no plan,
subscription, or capability objects; paywall logic is untouched.
3. **Local calls are labeled.** Requests served by a non-hosted
provider carry a distinct id namespace and are excluded from
hosted feedback/training pipelines by construction.
4. **Enterprise framing:** BYOK as an enterprise/self-hosting feature
(data-residency, key control), not a consumer-unlock story.
## Reference implementation offered
- `packages/ai-studio/AI/CustomAIClient.js`: OpenAI-compatible client
(config, tool schema, SSE streaming, retries + fallback routing).
- `packages/unlock-shim/generation-shim.mjs`: routing + fail-closed
leak guard, dependency-free, tested.
- `packages/ai-studio/dev-harness/mock-ai-provider.js`: deterministic
offline provider for CI.
## Out of scope (will not be proposed)
Client-side entitlement unlocks, splash/watermark/debugger/preview
gating changes, build-quota changes, or any auto-updater/feed changes.
+55
View File
@@ -0,0 +1,55 @@
# launcher/ — Tier-1 runtime hook (Phase 3, issue #3)
Runtime guarantee of the Tier-1 unlock when `patches/` alone cannot hold
(e.g. a hunk drifted, or the operator runs a stock build). Defense in
depth, three layers:
| Layer | File | Mechanism |
|---|---|---|
| Preload seed | `preload-unlock.cjs` | `webPreferences.preload`, runs before the bundle: seeds `window.__HERETEK__`, wraps `fetch` + `XHR` for exactly two GET endpoints |
| Window wrap | `heretek-hook.cjs` | Wraps `electron.BrowserWindow` (loader runs before the main.js:6 destructure), `browser-window-created` + `did-finish-load` fallback for pop-outs/preview |
| Network fallback | `heretek-hook.cjs` | `webRequest.onBeforeRequest` redirects usage GETs to `data:` URLs |
## Why not `Module._load`?
The issue draft proposed intercepting `Module._load` for the Usage
module. Production renderers execute a **webpack bundle**, not Node
modules, so that intercept cannot reach `hasValidSubscriptionPlan`.
The app's axios client rides on XHR, and `AuthenticatedUserProvider`
hydrates context from `GET /limits` + `GET /subscription-v2` — spoofing
exactly those two responses unlocks every downstream gate on a stock
build. Only those two GETs are ever intercepted; generation, builds,
auth, and all mutations pass through untouched.
## Payload contract
`unlock-payloads.cjs` is the single source of truth, also serialized
into patch 01 by `scripts/generate-tier1-patches.mjs`:
- `GET {base}/limits` → `{quotas:{}, capabilities, credits:{userBalance:{amount:999999},…}}`
(satisfies `ensureObjectHasProperty(…, 'capabilities')`)
- `GET {base}/subscription-v2` → `{userId:'local-byok-user', planId:'gdevelop_startup',…}`
(satisfies `ensureObjectHasProperty(…, 'userId')`)
## Install
`node scripts/apply-patches.mjs --target <checkout>` (or `--asar`) applies
patch 06 (loader line at the top of `main.js`) and syncs these three
files next to it. Verify with `node scripts/apply-patches.mjs --check`.
## Auto-updater pin
`electron-updater` replaces the whole `app.asar` on update — the loader
line and these files vanish on restart. Mitigations, in order:
1. `watchUpdater` logs `UPDATER_PIN_MESSAGE` (loud, in all logs) when an
update is staged, and sets `globalThis.__HERETEK__.updatePending`.
2. After every update, re-run
`node scripts/apply-patches.mjs --asar <new-app.asar> --out <patched.asar>`
**before** launching. The driver refuses a silent no-op.
3. The Phase 1 seam suite (`npm test`) runs weekly against the upstream
baseline — a red suite after an upstream release means the loader
anchor or a hunk moved; regenerate before repacking.
Do not enable `autoUpdater.autoDownload = true` unattended on hooked
builds: a silent overnight update silently drops Tier-1.
+263
View File
@@ -0,0 +1,263 @@
// Phase 3 main-process hook (issue #3): preload injection for every
// window + webRequest spoof fallback + auto-updater pin.
//
// Installed by a 9-line loader at the TOP of electron-app/app/main.js
// (patches/06-electron-hook.patch) — before line 6 destructures
// `electron.BrowserWindow`, so the wrapped constructor below is the one
// main.js:226 and PreviewWindow.js:52 actually call. Loaded later, the
// wrap would miss those already-captured references (only the
// browser-window-created fallback would remain).
//
// Coverage:
// - First windows: wrapped BrowserWindow merges webPreferences.preload.
// - Portal pop-outs (main.js:361 did-create-window) + preview windows:
// same wrapped constructor; any window that still lacks the
// `__HERETEK__` seed gets the fallback source via did-finish-load +
// executeJavaScript (same mechanism as main.js:261-269).
// - Network fallback: session.defaultSession.webRequest.onBeforeRequest
// redirects GET usage/{limits,subscription-v2} to data: URLs.
// Firebase auth/JWT untouched.
// Testability: `electron` is injectable; tests/launcher/hook.test.cjs
// drives the whole module with fakes (no Electron needed).
'use strict';
const path = require('path');
const {
USAGE_API_BASE,
spoofBodyForUrl,
} = require('./unlock-payloads.cjs');
const {
getRendererFallbackSource,
} = require('./preload-unlock.cjs');
// Absolute path so asar relocation cannot silently drop the hook.
const PRELOAD_PATH = path.join(__dirname, 'preload-unlock.cjs');
const SPOOFED_USAGE_URLS = [
`${USAGE_API_BASE}/limits`,
`${USAGE_API_BASE}/subscription-v2`,
];
const UPDATER_PIN_MESSAGE =
'[heretek] update downloaded: the Tier-1 hook (main.js loader line + ' +
'launcher/*.cjs) is replaced by the updater payload on restart. ' +
'Re-run node scripts/apply-patches.mjs --asar <new-app.asar> --out ' +
'<patched.asar> before launching, or Tier-1 gates return. ' +
'See launcher/README.md ("auto-updater pin").';
/**
* Merge the unlock preload into window options. An explicitly set
* preload is never overridden (upstream ships none — frozen by the
* Phase 1 electron seam test).
*/
function withUnlockPreload(options) {
const opts = options || {};
const webPreferences = Object.assign({}, opts.webPreferences);
if (!webPreferences.preload) webPreferences.preload = PRELOAD_PATH;
return Object.assign({}, opts, { webPreferences });
}
function shouldSpoofUrl(url, method) {
if (!url || typeof url !== 'string') return false;
const upperMethod = (method || 'GET').toUpperCase();
if (upperMethod !== 'GET') return false;
return SPOOFED_USAGE_URLS.some(
prefix => url === prefix || url.indexOf(prefix + '?') === 0
);
}
/** data: URL carrying the unlocked JSON body for a spoofed request. */
function buildSpoofRedirectUrl(url) {
const body = spoofBodyForUrl(url, 'GET');
if (body === null) return null;
return (
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(body))
);
}
/**
* session.webRequest.onBeforeRequest handler. Spoofed endpoints redirect
* to data: URLs; everything else (generation, builds, auth, mutations)
* passes through with an empty action.
*/
function handleBeforeRequest(details, callback) {
const url = details && details.url;
const method = (details && details.method) || 'GET';
if (url && shouldSpoofUrl(url, method)) {
const redirectURL = buildSpoofRedirectUrl(url);
if (redirectURL) {
callback({ redirectURL });
return;
}
}
callback({});
}
/**
* did-finish-load fallback for windows that missed the preload
* (pop-outs, preview windows, late-created sessions): if the renderer
* has no `__HERETEK__` seed, execute the composed fallback source.
*/
function ensureRendererSeed(webContents, logger) {
if (!webContents || typeof webContents.executeJavaScript !== 'function')
return;
let source;
try {
source = getRendererFallbackSource();
} catch (error) {
if (logger) logger.error('[heretek] fallback source failed:', error);
return;
}
webContents
.executeJavaScript(
`(function(){ return !!(window.__HERETEK__ && window.__HERETEK__.unlocked); })()`
)
.then(seeded => {
if (seeded) return null;
return webContents.executeJavaScript(source);
})
.catch(error => {
if (logger) logger.error('[heretek] seed fallback failed:', error);
});
}
function watchWindowCreated(app, logger) {
if (!app || typeof app.on !== 'function') {
if (logger)
logger.error('[heretek] app.on missing — pop-out coverage disabled.');
return false;
}
app.on('browser-window-created', (event, win) => {
if (!win || !win.webContents) return;
win.webContents.once('did-finish-load', () => {
ensureRendererSeed(win.webContents, logger);
});
});
return true;
}
function watchWebRequest(session, logger) {
if (
!session ||
!session.webRequest ||
typeof session.webRequest.onBeforeRequest !== 'function'
) {
if (logger)
logger.error('[heretek] webRequest unavailable — network fallback off.');
return false;
}
session.webRequest.onBeforeRequest(
{ urls: [`${USAGE_API_BASE}/*`] },
(details, callback) => handleBeforeRequest(details, callback)
);
return true;
}
/**
* Loud warning when the updater stages a payload: on restart the asar
* (loader line + launcher files) is replaced and the hook drops.
* Never blocks the update — just makes the drop impossible to miss.
*/
function watchUpdater(autoUpdater, logger) {
if (!autoUpdater || typeof autoUpdater.on !== 'function') return false;
autoUpdater.on('update-downloaded', () => {
try {
globalThis.__HERETEK__ = Object.assign({}, globalThis.__HERETEK__, {
updatePending: true,
});
} catch (error) {
// Flag is observability only.
}
if (logger) logger.warn(UPDATER_PIN_MESSAGE);
});
return true;
}
function wrapBrowserWindow(electron, logger) {
if (!electron || typeof electron.BrowserWindow !== 'function') {
if (logger)
logger.error('[heretek] electron.BrowserWindow missing — no wrap.');
return false;
}
const OriginalBrowserWindow = electron.BrowserWindow;
if (OriginalBrowserWindow.__heretekWrapped) return true;
function HeretekBrowserWindow(options) {
return new OriginalBrowserWindow(withUnlockPreload(options));
}
HeretekBrowserWindow.prototype = OriginalBrowserWindow.prototype;
Object.setPrototypeOf(HeretekBrowserWindow, OriginalBrowserWindow);
Object.defineProperty(HeretekBrowserWindow, '__heretekWrapped', {
value: true,
});
try {
electron.BrowserWindow = HeretekBrowserWindow;
} catch (error) {
if (logger) logger.error('[heretek] BrowserWindow wrap failed:', error);
return false;
}
return true;
}
/**
* Main entry, called from the top of main.js (patch 06). `electronModule`
* defaults to require('electron') and is injectable for tests.
*/
function installPreloadHook(electronModule, opts) {
const options = opts || {};
const logger = options.logger || console;
let electron = electronModule;
if (!electron) {
try {
electron = require('electron');
} catch (error) {
logger.error('[heretek] require("electron") failed:', error);
return { wrapped: false };
}
}
const wrapped = wrapBrowserWindow(electron, logger);
const windowsWatched =
electron.app && watchWindowCreated(electron.app, logger);
const requestWatched =
electron.session &&
electron.session.defaultSession &&
watchWebRequest(electron.session.defaultSession, logger);
let updaterWatched = false;
if (!options.skipUpdaterWatch) {
try {
updaterWatched = watchUpdater(
require('electron-updater').autoUpdater,
logger
);
} catch (error) {
// Updater optional in tests and minimal shells.
}
}
logger.info(
`[heretek] hook installed (window-wrap: ${wrapped}, ` +
`pop-out fallback: ${!!windowsWatched}, ` +
`webRequest fallback: ${!!requestWatched}, ` +
`updater pin: ${!!updaterWatched}).`
);
return {
wrapped,
windowsWatched: !!windowsWatched,
requestWatched: !!requestWatched,
updaterWatched: !!updaterWatched,
};
}
module.exports = {
PRELOAD_PATH,
SPOOFED_USAGE_URLS,
UPDATER_PIN_MESSAGE,
withUnlockPreload,
shouldSpoofUrl,
buildSpoofRedirectUrl,
handleBeforeRequest,
ensureRendererSeed,
watchWindowCreated,
watchWebRequest,
watchUpdater,
wrapBrowserWindow,
installPreloadHook,
};
+268
View File
@@ -0,0 +1,268 @@
// Phase 3 renderer hook (issue #3): preload seed + fetch/XHR spoof.
//
// Loaded via `webPreferences.preload` (injected by heretek-hook.cjs into
// every BrowserWindow before `electron.BrowserWindow` is destructured).
// Runs before the webpack bundle, with full Node + DOM access
// (`nodeIntegration:true, contextIsolation:false` — frozen by
// tests/seams/electron.seam.test.mjs), sharing the renderer global.
//
// Mechanism (why not Module._load): production renderers execute a
// webpack bundle, not Node modules, so intercepting `Module._load` cannot
// reach `hasValidSubscriptionPlan`. The app's axios client rides on XHR
// and the provider layer (`AuthenticatedUserProvider`) hydrates
// `AuthenticatedUserContext` from GET /limits + GET /subscription-v2 —
// spoofing exactly those two responses unlocks every downstream gate
// (SubscriptionChecker, logo/watermark guards, %16, debugger) on a
// stock unpatched build. Nothing else is intercepted.
//
// Testability: all logic is exported pure functions; the file auto-installs
// only when `window` exists. tests/launcher/preload.test.cjs pins behavior
// and evaluates getRendererFallbackSource() in a vm sandbox (used by
// heretek-hook.cjs for pop-out windows the preload missed).
'use strict';
const {
USAGE_API_BASE,
SPOOFED_LIMITS_PATH,
SPOOFED_SUBSCRIPTION_PATH,
UNLOCKED_SUBSCRIPTION,
UNLOCKED_CAPABILITIES,
buildSubscriptionPayload,
buildLimitsPayload,
isSpoofedUsageUrl,
spoofBodyForUrl,
} = require('./unlock-payloads.cjs');
function seedIdentity(scope) {
scope.__HERETEK__ = {
unlocked: true,
planId: 'gdevelop_startup',
source: 'preload-unlock',
spoofedRequests: [],
};
}
function resolveUrlSafe(url, scope) {
if (!url || typeof url !== 'string') return null;
if (url.indexOf('http://') === 0 || url.indexOf('https://') === 0)
return url;
try {
const base =
scope && scope.location && scope.location.href
? scope.location.href
: USAGE_API_BASE + '/';
return new URL(url, base).toString();
} catch (error) {
return null;
}
}
function spoofBodyForRequest(rawUrl, method, scope) {
const url = resolveUrlSafe(rawUrl, scope);
if (!url) return null;
return spoofBodyForUrl(url, method);
}
function recordSpoof(scope, url) {
try {
if (scope && scope.__HERETEK__ && scope.__HERETEK__.spoofedRequests) {
scope.__HERETEK__.spoofedRequests.push(url);
}
} catch (error) {
// Observability only — never break the request path.
}
}
function createFetchWrapper(originalFetch, scope) {
return function heretekFetch(input, init) {
const rawUrl =
typeof input === 'string'
? input
: input && input.url
? input.url
: null;
const method =
(init && init.method) || (input && input.method) || 'GET';
const body = spoofBodyForRequest(rawUrl, method, scope);
if (body !== null) {
recordSpoof(scope, resolveUrlSafe(rawUrl, scope));
return Promise.resolve(
new scope.Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
return originalFetch.apply(this, arguments);
};
}
function setXhrResponse(xhr, body) {
const text = JSON.stringify(body);
try {
xhr.readyState = 4;
xhr.status = 200;
xhr.responseText = text;
xhr.response = text;
} catch (error) {
// Real XHR attributes are getter-only on the prototype: shadow them
// per-instance instead.
Object.defineProperty(xhr, 'readyState', {
value: 4,
configurable: true,
});
Object.defineProperty(xhr, 'status', { value: 200, configurable: true });
Object.defineProperty(xhr, 'responseText', {
value: text,
configurable: true,
});
Object.defineProperty(xhr, 'response', {
value: text,
configurable: true,
});
}
}
function fireXhrLoad(xhr) {
if (typeof xhr.onreadystatechange === 'function') {
try {
xhr.onreadystatechange();
} catch (error) {
// Listener errors must not break the spoof.
}
}
if (typeof xhr.onload === 'function') {
try {
xhr.onload();
} catch (error) {
// Listener errors must not break the spoof.
}
}
}
function patchXhr(XHRClass, scope) {
const originalOpen = XHRClass.prototype.open;
const originalSend = XHRClass.prototype.send;
XHRClass.prototype.open = function heretekOpen(method, url) {
this.__heretekMethod = method;
this.__heretekUrl = url;
return originalOpen.apply(this, arguments);
};
XHRClass.prototype.send = function heretekSend() {
const args = arguments;
const self = this;
const body = spoofBodyForRequest(
this.__heretekUrl,
this.__heretekMethod,
scope
);
if (body !== null) {
recordSpoof(scope, resolveUrlSafe(this.__heretekUrl, scope));
const deliver = function deliverSpoofedXhrResponse() {
setXhrResponse(self, body);
fireXhrLoad(self);
};
const scheduler =
scope && typeof scope.setTimeout === 'function'
? scope.setTimeout.bind(scope)
: typeof globalThis.setTimeout === 'function'
? globalThis.setTimeout.bind(globalThis)
: null;
if (scheduler) scheduler(deliver, 0);
else deliver();
return;
}
return originalSend.apply(this, args);
};
return XHRClass;
}
function installRendererHook(scope) {
seedIdentity(scope);
if (scope.fetch) {
try {
scope.fetch = createFetchWrapper(scope.fetch.bind(scope), scope);
} catch (error) {
// Leave the original fetch alone rather than breaking networking.
}
}
if (scope.XMLHttpRequest) {
try {
patchXhr(scope.XMLHttpRequest, scope);
} catch (error) {
// Leave the original XHR alone rather than breaking networking.
}
}
return scope.__HERETEK__;
}
// Names embedded into pop-out/preview windows that missed the preload
// (heretek-hook.cjs, did-finish-load fallback). Every name below must be
// self-contained apart from the embedded consts — the vm-sandbox test in
// tests/launcher/preload.test.cjs fails if a closure leaks.
const FALLBACK_EMBEDDED_CONSTS = [
'USAGE_API_BASE',
'SPOOFED_LIMITS_PATH',
'SPOOFED_SUBSCRIPTION_PATH',
'UNLOCKED_SUBSCRIPTION',
'UNLOCKED_CAPABILITIES',
];
const FALLBACK_EMBEDDED_FUNCTIONS = [
'isSpoofedUsageUrl',
'buildSubscriptionPayload',
'buildLimitsPayload',
'spoofBodyForUrl',
'seedIdentity',
'resolveUrlSafe',
'spoofBodyForRequest',
'recordSpoof',
'createFetchWrapper',
'setXhrResponse',
'fireXhrLoad',
'patchXhr',
'installRendererHook',
];
function getRendererFallbackSource() {
const payloads = require('./unlock-payloads.cjs');
const preload = module.exports;
const namespace = Object.assign({}, payloads, preload);
const consts = FALLBACK_EMBEDDED_CONSTS.map(
name => `var ${name} = ${JSON.stringify(payloads[name])};`
).join('\n');
const fns = FALLBACK_EMBEDDED_FUNCTIONS.map(name => {
const fn = namespace[name];
if (typeof fn !== 'function')
throw new Error(`[heretek] cannot embed missing function: ${name}`);
return fn.toString();
}).join('\n');
return (
'(function(){\n' +
consts +
'\n' +
fns +
'\ninstallRendererHook(window);\n' +
'})();'
);
}
// Auto-install in a real renderer only. In Node (tests, main process)
// `window` is undefined and this module is side-effect free.
if (typeof window !== 'undefined' && window && window.document) {
installRendererHook(window);
}
module.exports = {
seedIdentity,
resolveUrlSafe,
spoofBodyForRequest,
recordSpoof,
createFetchWrapper,
setXhrResponse,
fireXhrLoad,
patchXhr,
installRendererHook,
getRendererFallbackSource,
FALLBACK_EMBEDDED_CONSTS,
FALLBACK_EMBEDDED_FUNCTIONS,
};
+141
View File
@@ -0,0 +1,141 @@
// Single source of truth for Tier-1 unlock payloads (Phase 3).
//
// Consumed by:
// - scripts/generate-tier1-patches.mjs (serializes these objects into
// patch 01, so patches/ and the runtime hook can never disagree),
// - launcher/preload-unlock.cjs (renderer fetch/XHR spoof bodies +
// fallback-source composition),
// - launcher/heretek-hook.cjs (main-process webRequest spoof bodies).
//
// Shapes satisfy the upstream validators frozen in Phase 1:
// GET /limits -> ensureObjectHasProperty(..., 'capabilities')
// GET /subscription-v2 -> ensureObjectHasProperty(..., 'userId')
// Full URLs: https://api.gdevelop.io/usage/{limits,subscription-v2}.
// Firebase auth/JWT untouched — logged-out walls elsewhere remain.
'use strict';
const USAGE_API_BASE = 'https://api.gdevelop.io/usage';
const SPOOFED_LIMITS_PATH = '/limits';
const SPOOFED_SUBSCRIPTION_PATH = '/subscription-v2';
// Mirrors UNLOCKED_HERETEK_SUBSCRIPTION in patch 01 (and the fork).
const UNLOCKED_SUBSCRIPTION = {
userId: 'local-byok-user',
planId: 'gdevelop_startup',
pricingSystemId: 'HERETEK_UNLOCKED',
createdAt: 0,
updatedAt: 0,
};
// Mirrors UNLOCKED_HERETEK_CAPABILITIES in patch 01 (and the fork).
// Satisfies the upstream `Capabilities` Flow type.
const UNLOCKED_CAPABILITIES = {
analytics: {
sessions: true,
players: true,
retention: true,
sessionsTimeStats: true,
platforms: true,
},
cloudProjects: {
maximumCount: 999,
canMaximumCountBeIncreased: true,
maximumGuestCollaboratorsPerProject: 99,
projectRestorationTimeWindowInSeconds: -1,
deletedProjectRetentionInDays: 365,
},
leaderboards: {
maximumCountPerGame: 999,
canMaximumCountPerGameBeIncreased: true,
themeCustomizationCapabilities: 'FULL',
canUseCustomCss: true,
canDisableLoginInLeaderboard: true,
},
multiplayer: {
lobbiesCount: 99,
maxPlayersPerLobby: 64,
themeCustomizationCapabilities: 'FULL',
},
versionHistory: {
enabled: true,
retentionDays: 365,
},
ai: {
availablePresets: [],
versionHistory: {
enabled: true,
retentionDays: 365,
},
},
};
// NOTE: keep these builders dependency-free and self-contained (no
// module-scope references besides the UNLOCKED_* consts, which the
// fallback-source composer embeds by name). preload-unlock.cjs stringifies
// them into renderer windows, so closures over other locals WILL break
// the fallback path — tests/launcher/preload.test.cjs evaluates the
// composed source in a vm sandbox to pin this.
function buildSubscriptionPayload(
subscription /*: void | {||} */ = UNLOCKED_SUBSCRIPTION
) {
return subscription;
}
function buildLimitsPayload(
capabilities /*: void | {||} */ = UNLOCKED_CAPABILITIES
) {
return {
quotas: {},
capabilities: capabilities,
credits: {
userBalance: { amount: 999999 },
prices: {},
purchasableQuantities: [],
},
message: undefined,
};
}
/**
* True only for the two GET endpoints the Tier-1 runtime hook spoofs.
* Everything else (generation, builds, auth, POST/PUT/DELETE) passes
* through untouched — the hook never mints credits or touches hosted
* mutations.
*/
function isSpoofedUsageUrl(url, method) {
if (!url || typeof url !== 'string') return false;
const upperMethod = (method || 'GET').toUpperCase();
if (upperMethod !== 'GET') return false;
if (url.indexOf(USAGE_API_BASE) !== 0) return false;
const pathAndQuery = url.slice(USAGE_API_BASE.length);
return (
pathAndQuery === SPOOFED_LIMITS_PATH ||
pathAndQuery.indexOf(SPOOFED_LIMITS_PATH + '?') === 0 ||
pathAndQuery === SPOOFED_SUBSCRIPTION_PATH ||
pathAndQuery.indexOf(SPOOFED_SUBSCRIPTION_PATH + '?') === 0
);
}
function spoofBodyForUrl(url, method) {
if (!isSpoofedUsageUrl(url, method)) return null;
const pathAndQuery = url.slice(USAGE_API_BASE.length);
if (
pathAndQuery === SPOOFED_LIMITS_PATH ||
pathAndQuery.indexOf(SPOOFED_LIMITS_PATH + '?') === 0
) {
return buildLimitsPayload();
}
return buildSubscriptionPayload();
}
module.exports = {
USAGE_API_BASE,
SPOOFED_LIMITS_PATH,
SPOOFED_SUBSCRIPTION_PATH,
UNLOCKED_SUBSCRIPTION,
UNLOCKED_CAPABILITIES,
buildSubscriptionPayload,
buildLimitsPayload,
isSpoofedUsageUrl,
spoofBodyForUrl,
};
+20
View File
@@ -0,0 +1,20 @@
{
"name": "gdevelop-unlocked",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Surgical conversion of the GDevelop-Heretek fork: Tier-1 patcher + launcher hook, Tier-2 AI Studio package.",
"scripts": {
"test": "node --test tests/seams/*.test.mjs tests/patches/*.test.mjs tests/launcher/*.test.cjs tests/tier2/*.test.mjs tests/web/*.test.mjs",
"test:seams": "node --test tests/seams/*.test.mjs",
"test:patches": "node --test tests/patches/*.test.mjs",
"test:launcher": "node --test tests/launcher/*.test.cjs",
"test:tier2": "node --test tests/tier2/*.test.mjs",
"test:web": "node --test tests/web/*.test.mjs",
"patches:check": "node scripts/apply-patches.mjs --check",
"patches:generate": "node scripts/generate-tier1-patches.mjs --upstream review/GDevelop"
},
"engines": {
"node": ">=22"
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,34 @@
// @flow
import { getAllSubAgentFunctionCalls } from '../AiRequestUtils';
import {
type AiRequest,
type AiRequestMessageAssistantFunctionCall,
} from '../../Utils/GDevelopServices/Generation';
/**
* The `spawn_agent` call that launched a sub-agent, read back from its
* parent's output — or null when it cannot be resolved (a top-level request,
* a missing parent, pruned history, or a persisted null hole).
*
* The edit-approval prompt names the agent after this call (its short title,
* the same name shown in the chat); a null falls back to the tool labels of
* the batch itself. Lives here rather than inline in the dispatcher so a
* spec can pin the resolution: Utils.js pulls in three.js through the
* editor-function map and cannot be loaded by a test.
*/
export const getEditApprovalLaunchingCall = ({
aiRequest,
aiRequests,
}: {|
aiRequest: AiRequest,
aiRequests: { [string]: AiRequest },
|}): AiRequestMessageAssistantFunctionCall | null => {
if (!aiRequest.parentAiRequestId) return null;
const parentRequest = aiRequests[aiRequest.parentAiRequestId] || null;
if (!parentRequest) return null;
return (
getAllSubAgentFunctionCalls({ aiRequest: parentRequest }).find(
functionCall => functionCall.subAgentAiRequestId === aiRequest.id
) || null
);
};
@@ -0,0 +1,262 @@
// @flow
import { type AiRequest } from '../../Utils/GDevelopServices/Generation';
import { type EditorFunctionCallResult } from '../../EditorFunctions';
import {
getAllSubAgentFunctionCalls,
getFunctionCallsToProcess,
getPendingSubAgentFunctionCalls,
} from '../AiRequestUtils';
import { getStudioRole, type StudioRoleId } from './Roles';
import { isSpawnAgentCall, parseSpawnAgentArgs } from './SpawnSubAgents';
import { truncateAtCodePointBoundary } from './SafeTruncation';
/**
* The role a studio sub-agent was spawned with, read back from its parent's
* `spawn_agent` call. Null for a hosted sub-agent (`run_edit_agent` /
* `run_explorer_agent`) or when the launch call cannot be resolved.
*/
export const getSubAgentRoleId = ({
aiRequest,
aiRequests,
}: {|
aiRequest: AiRequest,
aiRequests: { [string]: AiRequest },
|}): StudioRoleId | null => {
if (!aiRequest.parentAiRequestId) return null;
const parentRequest = aiRequests[aiRequest.parentAiRequestId] || null;
if (!parentRequest) return null;
const launchingCall = getAllSubAgentFunctionCalls({
aiRequest: parentRequest,
}).find(functionCall => functionCall.subAgentAiRequestId === aiRequest.id);
if (!launchingCall || !isSpawnAgentCall(launchingCall)) return null;
const parsedArgs = parseSpawnAgentArgs(launchingCall);
return parsedArgs ? parsedArgs.role : null;
};
/** How many model turns a sub-agent has taken: one assistant message each. */
export const countAssistantTurns = (aiRequest: AiRequest): number =>
(aiRequest.output || []).filter(
// A persisted output is not shape-validated; a null/scalar entry would
// throw here, in the turn-cap check that stops a runaway sub-agent.
message =>
message && message.type === 'message' && message.role === 'assistant'
).length;
/**
* True when a studio sub-agent has used up its role's turn budget.
*
* The dispatcher stops processing its remaining calls at that point and the
* studio runtime finalizes it, so a runaway sub-agent cannot run forever.
*/
export const isSubAgentAtTurnCap = ({
aiRequest,
aiRequests,
}: {|
aiRequest: AiRequest,
aiRequests: { [string]: AiRequest },
|}): boolean => {
const roleId = getSubAgentRoleId({ aiRequest, aiRequests });
if (!roleId) return false;
return countAssistantTurns(aiRequest) >= getStudioRole(roleId).maxTurns;
};
/**
* Whether the parent request already carries a `function_call_output` for a
* call: the child is on its way out (`removeSubAgentIfDone` retires it) and
* must not be finalized again. The studio runtime checks this on every pass,
* so like the turn-cap counter it tolerates a null hole in the persisted
* output instead of throwing out of the hook effect.
*/
export const parentHasOutputForCall = (
parentRequest: AiRequest,
callId: string
): boolean =>
(parentRequest.output || []).some(
message =>
!!message &&
message.type === 'function_call_output' &&
message.call_id === callId
);
/**
* A sub-agent is finished when it has nothing left to do locally: it is not
* working, it has no unexecuted function calls, and it has no pending sub-agent
* of its own.
*
* A hosted sub-agent (`run_edit_agent`) is polled until the server retires it;
* this only decides when the *studio* may write its report back.
*/
export const isSubAgentFinished = ({
subAgentRequest,
editorFunctionCallResults,
aiRequests,
}: {|
subAgentRequest: AiRequest,
editorFunctionCallResults: Array<EditorFunctionCallResult> | null,
aiRequests?: { [string]: AiRequest } | null,
|}): boolean => {
// A sub-agent at its turn cap is finalized with its last message even if it
// still has calls left: the dispatcher has stopped issuing its turns.
if (
aiRequests &&
isSubAgentAtTurnCap({ aiRequest: subAgentRequest, aiRequests })
) {
return true;
}
// The loop guard's terminal state is 'error'; treat it as finished so the
// studio writes its (failed) report back instead of waiting forever.
if (
subAgentRequest.status !== 'ready' &&
subAgentRequest.status !== 'error'
) {
return false;
}
if (
getFunctionCallsToProcess({
aiRequest: subAgentRequest,
editorFunctionCallResults,
}).length > 0
) {
return false;
}
if (
getPendingSubAgentFunctionCalls({ aiRequest: subAgentRequest }).length > 0
) {
return false;
}
if (
editorFunctionCallResults &&
editorFunctionCallResults.some(result => result.status === 'working')
) {
return false;
}
// A result that was executed but whose output has not landed in the child's
// transcript yet: finalizing now would drop the child's real wrap-up forever.
const writtenBackCallIds = new Set(
(subAgentRequest.output || [])
.filter(message => message && message.type === 'function_call_output')
.map(message => (message: any).call_id)
);
if (
editorFunctionCallResults &&
editorFunctionCallResults.some(
result => !writtenBackCallIds.has(result.call_id)
)
) {
return false;
}
return true;
};
/** Reports are capped so one runaway sub-agent cannot flood the parent. */
export const MAX_SUB_AGENT_REPORT_LENGTH = 4000;
/**
* Cut a report to `maxLength` without splitting a surrogate pair.
*
* A plain `slice` can land between the halves of an astral character (an emoji
* or rare CJK in a sub-agent's report — realistic for a game studio naming
* scenes and objects), leaving a lone high surrogate that encodes to U+FFFD.
* The result is written into the parent transcript and sent to the model, so
* the replacement character would appear in place of the real text.
*/
export const truncateReport = (report: string, maxLength: number): string => {
if (report.length <= maxLength) return report;
return (
truncateAtCodePointBoundary(report, maxLength) + '\n…(report truncated)'
);
};
/**
* The text written into the parent's `function_call_output`: the sub-agent's
* last assistant message, prefixed with any failure or turn-limit context and
* suffixed with its failed calls, then truncated. The message's text is read
* from a `content` entry (`'output_text'` from the server, `'text'` from the
* local BYOK parser) with the message's own `text` field as fallback.
*/
export const buildSubAgentReport = (
subAgentRequest: AiRequest,
aiRequests?: { [string]: AiRequest } | null
): string => {
const output = subAgentRequest.output || [];
let report = '';
if (subAgentRequest.error) {
report += `Failed: ${subAgentRequest.error.message}\n\n`;
}
if (
aiRequests &&
isSubAgentAtTurnCap({ aiRequest: subAgentRequest, aiRequests })
) {
report += 'Reached its turn limit.\n\n';
}
// The sub-agent's last assistant message is the core of the report. Its text
// lives in a `content` entry whose type is `'output_text'` for a message the
// server produced, and `'text'` for one the local BYOK path parsed; both are
// read, and the message's own `text` field is the fallback.
let core = '(no report)';
for (let i = output.length - 1; i >= 0; i--) {
const message = output[i];
if (!message || typeof message !== 'object') continue;
if (message.type !== 'message' || message.role !== 'assistant') continue;
// A sub-agent message from the server may lack its content array; the
// report falls back to the message's own `text` field below.
const content = message.content;
const textContent = Array.isArray(content)
? content.find(
entry =>
entry && (entry.type === 'output_text' || entry.type === 'text')
)
: null;
const textFromContent = textContent ? (textContent: any).text : null;
if (typeof textFromContent === 'string' && textFromContent) {
core = textFromContent;
} else {
const messageText = (message: any).text;
core =
typeof messageText === 'string' && messageText
? messageText
: '(no report)';
}
break;
}
report += core;
// Surface failed tool calls so the parent sees what went wrong.
const failedMessages = output
.filter(
message =>
message &&
message.type === 'function_call_output' &&
(message: any).success === false
)
.map(message => {
const messageOutput = (message: any).output;
return (messageOutput && messageOutput.message) || '(failed call)';
});
if (failedMessages.length > 0) {
report += `\n\nFailed calls:\n- ${failedMessages.join('\n- ')}`;
}
if (report.length > MAX_SUB_AGENT_REPORT_LENGTH) {
return truncateReport(report, MAX_SUB_AGENT_REPORT_LENGTH);
}
return report;
};
/**
* The label shown for the sub-agent's report. Falls back to the generic name for
* a hosted sub-agent, whose role this fork does not know.
*/
export const getSubAgentReportLabel = ({
aiRequest,
aiRequests,
}: {|
aiRequest: AiRequest,
aiRequests: { [string]: AiRequest },
|}): string => {
const roleId = getSubAgentRoleId({ aiRequest, aiRequests });
return roleId ? getStudioRole(roleId).displayName : 'Agent';
};
@@ -0,0 +1,276 @@
// @flow
import jsSHA from '../../Utils/Sha256';
/**
* The local loop guard: a circuit breaker for a BYOK agent.
*
* Ported from munder-difflin's `src/main/breaker.ts`, narrowed to what this
* editor can actually observe. Under BYOK there is no server, so nothing counts
* a runaway local agent: the hosted `repeated-tool-call-loop` error can never
* fire, `MAX_AI_REQUEST_RETRIES_IN_A_ROW` is a UI gate the API enforces
* (`AiRequestUtils.js`), and a model looping on *fresh* call ids is otherwise
* unbounded.
*
* Three trips, all from munder-difflin:
* - `repeatedToolLimit` consecutive identical tool calls
* (`'looping: N identical calls to <name>'`);
* - `errorStormLimit` consecutive failed calls
* (`'error storm: N consecutive failures'`);
* - `maxTurnsWithoutUserInput` assistant turns, cleared only by `reset`
* (`'turn limit: N turns without user input'`) - the trip with no
* munder-difflin analogue that this editor needs.
*
* Escalation is munder-difflin's ladder (`breaker.ts` `tick`): one level per
* `evaluate`, capped at `constrained` (there is no `hardStop` here - the guard
* never kills a request, it steers and constrains). A non-tripping `evaluate`
* de-escalates one level. `action` is non-`'none'` only when the level escalated,
* so a durable steer is not re-sent every tick.
*
* Deliberately NOT ported: the cost and token-velocity trips (the BYOK path has
* no usage feed), the `hardStop` level, and the `PreCompact` exemption (there is
* no auto-compaction here).
*/
export type LoopGuardLevel = 'healthy' | 'steering' | 'constrained' | 'stopped';
export type LoopGuardAction = 'none' | 'steer' | 'constrain' | 'stop';
export type LoopGuardDecision = {|
level: LoopGuardLevel,
reason: string,
action: LoopGuardAction,
/** True when the level changed since the previous `evaluate` (either way). */
changed: boolean,
|};
export const DEFAULT_LOOP_GUARD_CONFIG = {
enabled: true,
/** Trips on the Nth consecutive identical call; N-1 are tolerated. */
repeatedToolLimit: 8,
/** Trips on the Nth consecutive failed call; N-1 are tolerated. */
errorStormLimit: 5,
/**
* Trips on the Nth assistant turn without a user message; N-1 are tolerated.
* Generous on purpose: a legitimate long task (a `run_script` doing hundreds
* of calls, a gameplay test) is not a loop.
*/
maxTurnsWithoutUserInput: 60,
/**
* Trips on the Nth consecutive turn with no project-modifying tool call; N-1
* are tolerated. A sub-agent that only inspects is stuck (this is the
* objective's "zero project mods over N turns" thrash signature). Never
* applied to the manager, whose job is to delegate without editing.
*/
maxTurnsWithoutProgress: 5,
};
export type LoopGuardConfig = {
enabled: boolean,
repeatedToolLimit: number,
errorStormLimit: number,
maxTurnsWithoutUserInput: number,
maxTurnsWithoutProgress: number,
};
const LEVELS: Array<LoopGuardLevel> = [
'healthy',
'steering',
'constrained',
'stopped',
];
const rank = (level: LoopGuardLevel): number => LEVELS.indexOf(level);
const actionFor = (level: LoopGuardLevel): LoopGuardAction => {
if (level === 'steering') return 'steer';
if (level === 'constrained') return 'constrain';
if (level === 'stopped') return 'stop';
return 'none';
};
/** Recursively stringify with object keys sorted, so key order is not identity. */
const stableStringify = (value: any): string => {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
if (value && typeof value === 'object') {
const keys = Object.keys(value).sort();
return `{${keys
.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
};
/**
* Parse `argsJson` and re-emit it canonically (keys sorted) so two argument
* objects that differ only in key order hash the same. A non-JSON string is
* hashed as-is.
*/
const canonicalizeArgs = (argsJson: string): string => {
try {
return stableStringify(JSON.parse(argsJson || 'null'));
} catch (error) {
return argsJson || '';
}
};
/**
* The identity of a tool call: its name and a hash of its arguments, taken
* whole and in canonical (key-sorted) form.
*
* Hashing the whole arguments is the point. munder-difflin's #377 records that
* its old `input.slice(0, 200)` collided on Bash commands sharing a long
* identical preamble - nine *different* measurements in a row read as nine
* identical calls and constrained the agent. Different arguments must produce
* different keys, however long their common prefix; canonicalizing means two
* calls that differ only in argument key order are the *same* call.
*/
export const toolCallKey = (name: string, argsJson: string): string => {
const shaObj = new jsSHA('SHA-256', 'TEXT', { encoding: 'UTF8' });
shaObj.update(canonicalizeArgs(argsJson));
return `${name}:${shaObj.getHash('HEX')}`;
};
export type LoopGuard = {|
recordToolCall: (call: {| name: string, arguments: string |}) => void,
recordSuccess: () => void,
recordError: () => void,
recordTurn: () => void,
/** Record whether the last turn changed the project (progress). */
recordProgress: (madeProgress: boolean) => void,
reset: () => void,
evaluate: () => LoopGuardDecision,
|};
export const createLoopGuard = (
config?: Partial<LoopGuardConfig>
): LoopGuard => {
const effectiveConfig: LoopGuardConfig = {
...DEFAULT_LOOP_GUARD_CONFIG,
...(config || {}),
};
let level: LoopGuardLevel = 'healthy';
let reason = '';
let repeatKey: string | null = null;
let repeatCount = 0;
let errorCount = 0;
let turnCount = 0;
let noProgressTurns = 0;
let actionEmittedForTrip = false;
const evaluateTrips = (): {| tripping: boolean, reason: string |} => {
if (repeatCount >= effectiveConfig.repeatedToolLimit) {
return {
tripping: true,
reason: `looping: ${repeatCount} identical calls to ${
repeatKey ? repeatKey.split(':')[0] : 'a tool'
}`,
};
}
if (errorCount >= effectiveConfig.errorStormLimit) {
return {
tripping: true,
reason: `error storm: ${errorCount} consecutive failures`,
};
}
if (noProgressTurns >= effectiveConfig.maxTurnsWithoutProgress) {
return {
tripping: true,
reason: `no progress: ${noProgressTurns} turns without changing the project`,
};
}
if (turnCount >= effectiveConfig.maxTurnsWithoutUserInput) {
return {
tripping: true,
reason: `turn limit: ${turnCount} turns without user input`,
};
}
return { tripping: false, reason: '' };
};
return {
recordToolCall: call => {
const key = toolCallKey(call.name, call.arguments);
if (key === repeatKey) {
repeatCount++;
} else {
repeatKey = key;
repeatCount = 1;
}
},
recordSuccess: () => {
// Only a real success clears the error storm: an attempt is not progress.
errorCount = 0;
},
recordError: () => {
errorCount++;
},
recordTurn: () => {
turnCount++;
},
recordProgress: madeProgress => {
// Any project change resets the streak: a task that reads for a while,
// then writes, is not stuck. Only a stretch of pure inspection trips.
noProgressTurns = madeProgress ? 0 : noProgressTurns + 1;
},
reset: () => {
// A new user input: the counters start over, but the ladder is NOT
// jumped back to healthy - it de-escalates one level per `evaluate`, so
// the caller is not handed a steer it already acted on, and a user who
// speaks during a constrained run sees it recover over the next ticks.
repeatKey = null;
repeatCount = 0;
errorCount = 0;
turnCount = 0;
noProgressTurns = 0;
actionEmittedForTrip = false;
},
evaluate: () => {
if (!effectiveConfig.enabled) {
const wasChanged = level !== 'healthy';
level = 'healthy';
reason = '';
return {
level,
reason,
action: 'none',
changed: wasChanged,
};
}
const trip = evaluateTrips();
// The ceiling has no `hardStop` here: the guard steers and constrains,
// it never kills a request.
const ceiling: LoopGuardLevel = 'constrained';
let target: LoopGuardLevel;
if (trip.tripping) {
target = LEVELS[Math.min(rank(level) + 1, rank(ceiling))];
} else {
target = LEVELS[Math.max(rank(level) - 1, 0)];
}
const changed = target !== level;
const escalated = rank(target) > rank(level);
level = target;
reason = trip.tripping ? trip.reason : changed ? 'recovering' : reason;
// Emit on an escalation (a new level carries its own action) or on a
// fresh trip after a reset - but never re-send a durable action on every
// tick. Re-armed by `reset` and on recovery.
const shouldEmit = escalated || (trip.tripping && !actionEmittedForTrip);
if (shouldEmit) {
actionEmittedForTrip = true;
} else if (!trip.tripping) {
actionEmittedForTrip = false;
}
return {
level,
reason,
action: shouldEmit ? actionFor(target) : 'none',
changed,
};
},
};
};
@@ -0,0 +1,95 @@
// @flow
import { type StudioPlanTask } from './PlanStore';
/**
* When the local studio's manager has stalled: it produced a plan, then ended
* its turn with an actionable task still undelegated (and no sub-agent running),
* so the multi-agent loop would sit idle forever under BYOK.
*
* This is the decision half of `useStudioNudge`, kept pure and dependency-light
* (`PlanStore`'s types only) so it can be unit-tested. The caller supplies the
* facts it already has: whether the request is the top-level manager, whether a
* turn is in flight, how many sub-agents are live and how many spawn calls are
* still unanswered.
*
* The nudge is deliberately conservative:
* - only the top-level manager (never a sub-agent);
* - never while a turn is in flight or work is pending;
* - never with a live or unanswered sub-agent;
* - never twice for the same plan state (the caller passes the signature it
* already nudged), so a stalled plan is nudged once, not every render;
* - a suspended/errored request is left alone (the user may have stopped it).
*/
export type StudioNudgeInput = {|
/** True for the top-level manager request (no parent, no studio role). */
isTopLevel: boolean,
status: string,
hasWorkInProgress: boolean,
isSending: boolean,
liveSubAgentCount: number,
pendingSubAgentCallCount: number,
/** Non-null only when an actionable (pending/in_progress) task exists. */
planSignature: string | null,
previouslyNudgedSignature: string | null,
/**
* True only for a request touched recently. Opening an old, abandoned chat
* must not resurrect its plan and spend tokens.
*/
isRecent: boolean,
|};
export const shouldNudgeStudioManager = (input: StudioNudgeInput): boolean => {
if (!input.isTopLevel) return false;
if (!input.isRecent) return false;
if (input.status === 'suspended' || input.status === 'error') return false;
if (input.isSending) return false;
if (input.hasWorkInProgress) return false;
if (input.liveSubAgentCount > 0) return false;
if (input.pendingSubAgentCallCount > 0) return false;
if (!input.planSignature) return false;
return input.planSignature !== input.previouslyNudgedSignature;
};
/**
* A stable signature of a plan's actionable state: the ids and statuses of the
* tasks that still need attention, in order. Null when there is no plan or
* nothing is actionable (every task is done or voided).
*
* `agentCallId` is included so a task that was delegated (but whose status has
* not flipped yet) is still distinguishable; a status-only signature would
* miss it only in the window before the spawn is stamped.
*/
export const getActionablePlanSignature = (
tasks: Array<StudioPlanTask> | null | void
): string | null => {
if (!Array.isArray(tasks) || tasks.length === 0) return null;
const actionable = tasks.filter(
task =>
task &&
typeof task === 'object' &&
(task.status === 'pending' || task.status === 'in_progress')
);
if (actionable.length === 0) return null;
return actionable
.map(task => `${task.id}:${task.status}:${task.agentCallId || ''}`)
.join('|');
};
/**
* The follow-up message the nudge sends to the stalled manager. Written as the
* user so it flows through the existing send path unchanged, and prefixed so
* the transcript makes clear it was the studio, not the person.
*/
export const buildStudioNudgeMessage = (
nextReadyTask: StudioPlanTask | null
): string => {
const taskHint = nextReadyTask
? ` Next ready task: ${nextReadyTask.id} (${nextReadyTask.title}).`
: '';
return (
'[Automatic reminder from the studio] Your plan still has a task that is ' +
`not delegated.${taskHint} Call spawn_agent for it now, or report plainly ` +
'why it cannot be done. Do not answer with prose only.'
);
};
@@ -0,0 +1,399 @@
// @flow
/**
* The local plan store: the shape `create_or_update_plan` writes and the studio
* reads back.
*
* The plan shape itself is upstream's (`AiRequestPlan` / `AiRequestPlanTask` in
* `Utils/GDevelopServices/Generation.js`), and so is the reading half:
* `AiRequestUtils.getLatestActivePlan` pulls the last `function_call_output`
* whose JSON has `.plan.tasks`, and `AiRequestChat/OrchestratorPlan.js` renders
* it. This module only builds that exact shape and answers questions about it,
* so the hosted and BYOK paths return the same thing for the same tool.
*/
export type StudioPlanTaskStatus =
| 'pending'
| 'in_progress'
| 'done'
| 'voided';
export type StudioPlanTask = {|
id: string,
title: string,
description: string,
status: StudioPlanTaskStatus,
dependsOn?: Array<string>,
/**
* Set by the studio when the task is delegated, so the plan row can be linked
* to the sub-agent's function call (matches
* `AiRequestMessageAssistantFunctionCall.taskId`).
*/
agentCallId?: string,
|};
const PLAN_TASK_STATUSES: Array<StudioPlanTaskStatus> = [
'pending',
'in_progress',
'done',
'voided',
];
export const isStudioPlanTaskStatus = (value: any): boolean =>
typeof value === 'string' && PLAN_TASK_STATUSES.includes((value: any));
/**
* The output of `create_or_update_plan`: the exact nesting
* `getLatestActivePlan` parses (`output.plan.tasks`).
*/
export const buildPlanOutput = (tasks: Array<StudioPlanTask>): Object => ({
success: true,
plan: { tasks },
});
/** Read a task array back out of a plan tool's output. Null when malformed. */
export const getPlanFromOutput = (output: Object): ?Array<StudioPlanTask> => {
if (!output || typeof output !== 'object') return null;
const plan: any = (output: any).plan;
if (!plan || typeof plan !== 'object') return null;
const tasks: any = plan.tasks;
if (!Array.isArray(tasks)) return null;
return tasks;
};
/**
* True when every id in `dependsOn` names a task that is done. An id matching no
* task is NOT satisfied: a dependency on missing work is not met.
*/
export const areTaskDependenciesSatisfied = (
task: StudioPlanTask,
tasks: Array<StudioPlanTask>
): boolean => {
const doneIds = new Set(
tasks
// A null/scalar entry is reachable: plan tasks come from model-authored
// JSON and persisted output, neither shape-validated per element.
.filter(
otherTask =>
otherTask &&
typeof otherTask === 'object' &&
otherTask.status === 'done'
)
.map(otherTask => otherTask.id)
);
return (task.dependsOn || []).every(dependencyId =>
doneIds.has(dependencyId)
);
};
/** The first pending task whose dependencies are satisfied, or null. */
export const getNextReadyTask = (
tasks: Array<StudioPlanTask>
): ?StudioPlanTask =>
tasks.find(
task =>
task &&
typeof task === 'object' &&
task.status === 'pending' &&
areTaskDependenciesSatisfied(task, tasks)
) || null;
/**
* Fold `incoming` tasks over `existing`, matching by `id`.
*
* The result is `incoming` — its order and its membership, so a task dropped
* from the list is gone. What survives are the fields on the matching existing
* task that `incoming` does not mention. A field the caller DOES send wins,
* including an explicit null: that is how a field is cleared. A task without a
* string id passes through untouched.
*
* Ported from munder-difflin's `mergeTaskLedger` (`src/shared/taskLedger.ts`),
* which exists because a writer holding a partial model of a card silently
* deleted every field it did not know about.
*/
export const mergePlanTasks = (
existing: Array<StudioPlanTask>,
incoming: Array<StudioPlanTask>
): Array<StudioPlanTask> => {
const existingList = Array.isArray(existing) ? existing : [];
const incomingList = Array.isArray(incoming) ? incoming : [];
const byId = new Map<string, any>();
for (const task of existingList) {
if (task && typeof task.id === 'string' && task.id && !byId.has(task.id)) {
byId.set(task.id, task);
}
}
return incomingList.map((task: any) => {
if (!task || typeof task.id !== 'string' || !task.id) return task;
const prior = byId.get(task.id);
return prior ? { ...prior, ...task } : task;
});
};
/**
* Fold the plan inside a finished tool result's `output` over the plan the
* request already has. `output` is the object `EditorFunctionCallRunner`
* builds (`{ success, meta, ...output }`), so it is read as an object - never
* parsed. A result without a plan, or a non-success one, passes through
* untouched.
*/
export const mergePlanResultOutput = (
result: Object,
existingTasks: Array<StudioPlanTask> | null
): Object => {
if (result.status !== 'finished' || !result.success) return result;
const output: any = result.output;
if (!output || !output.plan || !Array.isArray(output.plan.tasks)) {
return result;
}
if (!existingTasks) return result;
return {
...result,
output: {
...output,
plan: { tasks: mergePlanTasks(existingTasks, output.plan.tasks) },
},
};
};
/**
* Apply `patch` to one task, leaving every other task — and every other field of
* the patched task — untouched.
*
* This is what a status flip must use. Rebuilding the task array from a model
* strips whatever fields the model does not know about (`agentCallId`,
* `description`, a future field), which is exactly what
* `patchTaskInLedger` exists to prevent in munder-difflin
* (`src/shared/taskLedger.ts`).
*/
export const patchPlanTask = (
tasks: Array<StudioPlanTask>,
taskId: string,
patch: Partial<StudioPlanTask>
): Array<StudioPlanTask> => {
const list = Array.isArray(tasks) ? tasks : [];
return list.map(task =>
task && task.id === taskId ? { ...task, ...patch } : task
);
};
/**
* Mark the task a delegation named as delegated: link it to the sub-agent's
* call id and lift a still-pending task to in_progress.
*
* The status flip is the point. A plan task that stays `pending` does not
* render its linked function calls (`OrchestratorPlan.TaskRow`), so stamping
* `taskId` on the call without this would hide the spawn instead of showing it
* under its task. A task already done/voided keeps its status - only its link
* is set - so a late delegation cannot un-finish work.
*/
export const patchPlanTaskAsDelegated = (
tasks: Array<StudioPlanTask>,
taskId: string,
callId: string
): Array<StudioPlanTask> => {
const list = Array.isArray(tasks) ? tasks : [];
const target = list.find(task => task && task.id === taskId);
if (!target) return list;
const status = target.status === 'pending' ? 'in_progress' : target.status;
return patchPlanTask(list, taskId, { status, agentCallId: callId });
};
/**
* A copy of a request `output` with its latest plan message's tasks replaced,
* or null when there is no plan message to rewrite.
*
* Shared by the finalize writer (`UseStudioRuntime`) and the delegation writer
* (`Utils`), so both target the same message: the last `function_call_output`
* whose JSON carries `plan.tasks` - exactly what `getLatestActivePlan` reads.
*/
export const replacePlanMessage = (
output: Array<any>,
tasks: Array<StudioPlanTask>
): Array<any> | null => {
const list = Array.isArray(output) ? output : [];
const planOutput = JSON.stringify(buildPlanOutput(tasks));
for (let i = list.length - 1; i >= 0; i--) {
const message = list[i];
if (
!message ||
typeof message !== 'object' ||
message.type !== 'function_call_output' ||
!message.output
) {
continue;
}
try {
const parsed = JSON.parse(message.output);
if (parsed && parsed.plan && Array.isArray(parsed.plan.tasks)) {
const updated = [...list];
updated[i] = { ...message, output: planOutput };
return updated;
}
} catch (ignored) {
// A non-JSON output is skipped, like `getLatestActivePlan` does.
}
}
return null;
};
export type StudioPlanTaskValidation =
| {| success: true, tasks: Array<StudioPlanTask> |}
| {| success: false, message: string |};
const describeValue = (value: any): string => {
if (typeof value === 'string') return JSON.stringify(value);
if (value === null) return 'null';
if (Array.isArray(value)) return 'an array';
return typeof value;
};
/**
* Validate the `tasks` argument of `create_or_update_plan` as a local model
* sent it, naming the first offending index. A model mistake becomes a normal
* failed tool call, never a thrown error inside the editor.
*/
// Bounds on a plan the model sends, so a runaway tool call cannot flood the
// store (W12).
const MAX_PLAN_TASKS = 32;
const MAX_PLAN_TASK_ID_LENGTH = 64;
const MAX_PLAN_TASK_TITLE_LENGTH = 200;
const MAX_PLAN_TASK_DESCRIPTION_LENGTH = 1000;
const ALLOWED_TASK_FIELDS = [
'id',
'title',
'description',
'status',
'dependsOn',
'agentCallId',
];
export const validatePlanTasks = (rawTasks: any): StudioPlanTaskValidation => {
if (!Array.isArray(rawTasks)) {
return {
success: false,
message: 'create_or_update_plan requires a tasks array.',
};
}
if (rawTasks.length > MAX_PLAN_TASKS) {
return {
success: false,
message: `create_or_update_plan accepts at most ${MAX_PLAN_TASKS} tasks, got ${
rawTasks.length
}.`,
};
}
const tasks: Array<StudioPlanTask> = [];
const seenIds = new Set<string>();
for (let index = 0; index < rawTasks.length; index++) {
const rawTask = rawTasks[index];
const invalid = (detail: string): StudioPlanTaskValidation => ({
success: false,
message: `Invalid plan task at index ${index}: ${detail}`,
});
if (!rawTask || typeof rawTask !== 'object' || Array.isArray(rawTask)) {
return invalid(`expected an object, got ${describeValue(rawTask)}.`);
}
// Fail closed (I11): a field this validator does not model would be
// silently dropped by a rebuild, so reject the whole task instead.
for (const key of Object.keys(rawTask)) {
if (!ALLOWED_TASK_FIELDS.includes(key)) {
return invalid(`unexpected field ${JSON.stringify(key)}.`);
}
}
if ('agentCallId' in rawTask && typeof rawTask.agentCallId !== 'string') {
return invalid(
`\`agentCallId\` must be a string when present, got ${describeValue(
rawTask.agentCallId
)}.`
);
}
if (typeof rawTask.id !== 'string' || !rawTask.id.trim()) {
return invalid(
`\`id\` must be a non-empty string, got ${describeValue(rawTask.id)}.`
);
}
// Validate and store the trimmed id (I12); every later match is on it.
const id = rawTask.id.trim();
if (id.length > MAX_PLAN_TASK_ID_LENGTH) {
return invalid(
`\`id\` must be at most ${MAX_PLAN_TASK_ID_LENGTH} characters.`
);
}
if (seenIds.has(id)) {
return invalid(`duplicate \`id\` ${JSON.stringify(id)}.`);
}
seenIds.add(id);
if (typeof rawTask.title !== 'string' || !rawTask.title.trim()) {
return invalid(
`\`title\` must be a non-empty string, got ${describeValue(
rawTask.title
)}.`
);
}
if (rawTask.title.trim().length > MAX_PLAN_TASK_TITLE_LENGTH) {
return invalid(
`\`title\` must be at most ${MAX_PLAN_TASK_TITLE_LENGTH} characters.`
);
}
if (
typeof rawTask.description !== 'string' ||
!rawTask.description.trim()
) {
return invalid(
`\`description\` must be a non-empty string, got ${describeValue(
rawTask.description
)}.`
);
}
if (rawTask.description.trim().length > MAX_PLAN_TASK_DESCRIPTION_LENGTH) {
return invalid(
`\`description\` must be at most ${MAX_PLAN_TASK_DESCRIPTION_LENGTH} characters.`
);
}
if (!isStudioPlanTaskStatus(rawTask.status)) {
return invalid(
`\`status\` must be one of ${PLAN_TASK_STATUSES.join(
', '
)}, got ${describeValue(rawTask.status)}.`
);
}
const rawDependsOn: any = rawTask.dependsOn;
let dependsOn: Array<string>;
if (rawDependsOn == null) {
dependsOn = [];
} else if (
Array.isArray(rawDependsOn) &&
rawDependsOn.every((id: any) => typeof id === 'string')
) {
dependsOn = rawDependsOn.map((dependencyId: any) => dependencyId.trim());
} else {
return invalid(
`\`dependsOn\` must be an array of strings, got ${describeValue(
rawDependsOn
)}.`
);
}
const task: StudioPlanTask = {
id,
title: rawTask.title,
description: rawTask.description,
status: (rawTask.status: StudioPlanTaskStatus),
};
// `dependsOn` is set only when the caller sent it (W11): a re-plan that
// omits it must not clear dependencies another writer set.
if (rawDependsOn != null) {
task.dependsOn = dependsOn;
}
if (typeof rawTask.agentCallId === 'string') {
task.agentCallId = rawTask.agentCallId;
}
tasks.push(task);
}
return { success: true, tasks };
};
@@ -0,0 +1,176 @@
// @flow
/**
* One gate owns every write to an `AiRequest`.
*
* Ported from munder-difflin's "one gate, one writer" (`src/renderer/src/
* components/terminalAutomation.ts` + `docs/message-queue.md` §1). There, a
* second writer with its own idea of when the terminal was free landed its text
* on a half-written line; here, the studio runtime, the shared dispatcher and the
* user's own send all write to the same request, and a write that lands in the
* middle of a function-call batch can be silently replaced by the next one
* (`updateAiRequest(id, () => aiRequest)` discards previous state).
*
* The gate does two things:
* - `getRequestWriteBlock` says, with a named reason, why a write must wait;
* - `createRequestWriteQueue` serializes the writes per request, keeping at most
* one pending write (latest wins) and retrying a blocked one rather than
* dropping it.
*
* A `write` must never `await` an `enqueue` for the same `aiRequestId`: the drain
* loop cannot interleave and that promise would never settle.
*/
export type RequestWriteBlock =
// The request is suspended: the editor has cleared its function-call results
// and a write now would re-populate them (`AiRequestContext:1438`).
| 'suspended'
// A user message is in flight for this request.
| 'sending'
// A function call of this request is being processed by the editor.
| 'call-in-flight'
// Nothing blocks a write.
| null;
export const getRequestWriteBlock = ({
aiRequestId,
request,
isSending,
inFlightCallIds,
}: {|
aiRequestId: string,
request: ?{ +status?: string },
isSending: boolean,
inFlightCallIds: Set<string>,
|}): RequestWriteBlock => {
if (request && request.status === 'suspended') return 'suspended';
if (isSending) return 'sending';
const prefix = `${aiRequestId}:`;
for (const callId of inFlightCallIds) {
if (callId.startsWith(prefix)) return 'call-in-flight';
}
return null;
};
type PendingWrite = {|
write: () => Promise<void>,
isBlocked: () => boolean,
resolve: () => void,
reject: (error: Error) => void,
|};
type QueueEntry = {
isRunning: boolean,
/** At most one pending write per request: a newer one replaces it. */
pending: PendingWrite | null,
};
export type RequestWriteQueue = {|
enqueue: (
aiRequestId: string,
write: () => Promise<void>,
isBlocked: () => boolean
) => Promise<void>,
isIdle: (aiRequestId: string) => boolean,
|};
/**
* A per-request FIFO holding at most one pending write.
*
* A newer write for the same request replaces the older pending one (latest
* wins, matching munder-difflin's `MAX_PENDING_STEERS` drop-from-the-front rule
* in `src/main/control.ts`). A write whose `isBlocked()` is true is retried on
* the next `enqueue` for that request rather than dropped: the caller is not
* asked to poll, and the last enqueue always resolves when the queue drains.
*/
export const createRequestWriteQueue = (): RequestWriteQueue => {
const entries: Map<string, QueueEntry> = new Map();
const getEntry = (aiRequestId: string): QueueEntry => {
let entry = entries.get(aiRequestId);
if (!entry) {
entry = { isRunning: false, pending: null };
entries.set(aiRequestId, entry);
}
return entry;
};
const runEntry = async (aiRequestId: string): Promise<void> => {
const entry = getEntry(aiRequestId);
if (entry.isRunning) return;
entry.isRunning = true;
try {
// Keep draining until there is nothing left to run: a write enqueued
// while this loop awaited has to be picked up by the same loop, not left
// to a second `runEntry` that would see `isRunning` and return.
while (entry.pending) {
const pending = entry.pending;
if (pending.isBlocked()) {
// Do not drop it, and do not wait for another enqueue (none may ever
// come): schedule a self-recheck. The promise stays pending until the
// write actually runs.
setTimeout(() => {
if (!entry.isRunning && entry.pending) {
runEntry(aiRequestId).catch(error => {
console.error('[studio] request write queue failure:', error);
});
}
}, 50);
break;
}
entry.pending = null;
try {
await pending.write();
pending.resolve();
} catch (error) {
pending.reject(error);
}
}
} finally {
entry.isRunning = false;
if (!entry.pending) {
entries.delete(aiRequestId);
}
}
};
return {
enqueue: (
aiRequestId: string,
write: () => Promise<void>,
isBlocked: () => boolean
): Promise<void> => {
const entry = getEntry(aiRequestId);
// Latest wins: replace whatever was waiting, and settle it as superseded
// (rejected, not resolved - a replaced write never ran and must not report
// success).
if (entry.pending) {
const superseded = entry.pending;
entry.pending = null;
superseded.reject(
Object.assign(
new Error('Superseded by a newer write for this request.'),
{
code: 'superseded',
}
)
);
}
return new Promise<void>((resolve, reject) => {
entry.pending = { write, isBlocked, resolve, reject };
if (!entry.isRunning) {
runEntry(aiRequestId).catch(error => {
// `runEntry` handles its own per-write errors; this is a safety net
// so a bug there cannot produce an unhandled rejection.
console.error('[studio] request write queue failure:', error);
});
}
});
},
isIdle: (aiRequestId: string): boolean => {
const entry = entries.get(aiRequestId);
return !entry || (!entry.isRunning && !entry.pending);
},
};
};
@@ -0,0 +1,53 @@
// @flow
import { getStudioRole, isStudioRoleId } from './Roles';
/**
* The tool policy of the studio role stored on a sub-agent's request.
*
* `studioRoleId` is read from a persisted request and is not validated on load,
* so this resolves it defensively:
*
* - a declared role yields its own allowed-tool subset and display name;
* - an unrecognized id (the role was renamed or removed, or the cache is
* corrupt) yields an EMPTY allowed set, so every call is denied. That is the
* safe direction when the restriction cannot be determined — a read-only
* sub-agent must not regain mutations because its role disappeared — and it
* avoids passing the unknown id to `getStudioRole`, which throws and would
* stall the whole batch before a single call is dispatched;
* - `null`/absent means a top-level request with no role: `roleResolved` is
* false and `allowedToolNames` is empty; the caller only applies the filter
* when a role id is present.
*
* In its own module (importing only the leaf `Roles`) so it can be unit-tested:
* `AiGeneration/Utils.js` pulls in `EditorFunctions/index.js`, whose three.js
* dependency does not load under the test transform.
*/
export const getRoleToolPolicy = (
studioRoleId: string | null
): {|
allowedToolNames: Array<string>,
displayName: string | null,
roleResolved: boolean,
|} => {
if (studioRoleId && isStudioRoleId(studioRoleId)) {
const role = getStudioRole((studioRoleId: any));
return {
allowedToolNames: role.allowedToolNames,
displayName: role.displayName,
roleResolved: true,
};
}
return { allowedToolNames: [], displayName: null, roleResolved: false };
};
/**
* Whether a request's studio role is read-only. An unresolvable role id is
* treated as read-only: the restrictive direction (no edit-approval bypass, no
* full script surface).
*/
export const isRoleReadOnly = (studioRoleId: string | null): boolean => {
if (!studioRoleId) return false;
if (!isStudioRoleId(studioRoleId)) return true;
return getStudioRole((studioRoleId: any)).readOnly;
};
@@ -0,0 +1,270 @@
// @flow
/**
* The role registry of the local (BYOK) AI game studio.
*
* Upstream's hosted backend runs a multi-agent company server-side: it decides
* which sub-agents exist and what each may do. Under BYOK there is no server, so
* the fork has to declare that here: for every role, the prompt it runs with, the
* exact tools it may call, whether it may change the project, and how many
* assistant turns it gets before it is finalized.
*
* Roles are a fork concept. Nothing in the hosted path reads this file: the
* server's own `run_edit_agent`/`run_explorer_agent` split is unrelated and keeps
* working untouched (see `EditorFunctions/index.js`).
*/
export type StudioRoleId = 'manager' | 'designer' | 'developer' | 'tester';
export type StudioRole = {|
id: StudioRoleId,
/** Shown in the chat for the sub-agent. Becomes `short_title` of the spawn call. */
displayName: string,
/** Prepended to the shared system prompt for this role's requests. */
systemPrompt: string,
/**
* Exact tool names exposed to this role. Any name not listed here is never
* sent to the model (see `getToolsForRole`), so a role cannot be talked into
* calling a tool it was not granted. (`run_script` excepted: it reaches any
* registry function not in `NonScriptableFunctionNames`.)
*/
allowedToolNames: Array<string>,
/**
* True when the role's tools may not mutate the project. Drives the read-only
* `run_script` restriction and skips the edit approval prompt.
*/
readOnly: boolean,
/**
* Hard cap on assistant turns for one sub-agent request. On reaching it, the
* agent is finalized with its last message (see `FinalizeSubAgents`).
*/
maxTurns: number,
|};
/** The tools that only read the project, shared by the roles that inspect. */
export const READ_ONLY_TOOL_NAMES: Array<string> = [
'read_game_project_json',
'read_events_source',
'describe_instances',
'inspect_variables',
'inspect_object_properties_effects',
'inspect_scene_properties_layers_effects',
'inspect_project_properties_resources',
'inspect_behavior_properties',
'inspect_extension',
'search_object_asset_store',
'search_resource_store',
'search_docs',
'read_full_docs',
];
/** Every tool that changes the project, in schema order. */
export const MUTATING_TOOL_NAMES: Array<string> = [
'run_script',
'create_scene',
'create_or_replace_object',
'change_object_properties_effects',
'add_behavior',
'change_behavior_property',
'put_2d_instances',
'put_3d_instances',
// `generate_events` is a registry alias of this one, not a separate tool the
// schema exposes (see EditorFunctions/index.js).
'add_scene_events',
'change_scene_properties_layers_effects_groups',
'change_project_properties_resources',
'add_or_edit_variable',
'create_extension',
'change_extension_properties',
'create_custom_object',
'change_custom_object',
'create_custom_behavior',
'change_custom_behavior',
'create_custom_function',
'change_custom_function',
'run_gameplay_test',
'change_gameplay_tests',
'initialize_project',
];
export const STUDIO_ROLES: { [StudioRoleId]: StudioRole } = {
manager: {
id: 'manager',
displayName: 'Studio lead',
readOnly: false,
maxTurns: 60,
allowedToolNames: [
'create_or_update_plan',
'spawn_agent',
'read_game_project_json',
'describe_instances',
'read_events_source',
'inspect_variables',
'inspect_scene_properties_layers_effects',
'inspect_object_properties_effects',
'inspect_extension',
'inspect_project_properties_resources',
'run_gameplay_test',
'search_docs',
'read_full_docs',
],
systemPrompt: `You are the studio lead of a small game studio building a game in GDevelop.
Your job is to plan and delegate. You never edit the project yourself - you have no tool to do so.
Follow this loop, every time:
1. Decompose the goal into a short list of concrete tasks with create_or_update_plan. Give each task an id, a title, a description of what "done" means, and any dependsOn.
2. Delegate each ready task with spawn_agent. Pick the role that matches the work: designer for the design document, developer for anything that changes the project, tester for verifying a feature. Give each sub-agent a complete, self-contained instruction - it cannot see this conversation. Include the facts already established (verified object and behavior type strings, resource and API constraints discovered, approaches that failed) in the instruction or context, so the agent acts on them instead of re-deriving them.
3. Pass the plan task's id as related_task_id, so the task is marked done when the agent reports back.
4. Never end your turn with prose while a ready task is still undelegated: your very next action after creating or updating the plan must be a spawn_agent call for each ready task. Saying that you will delegate is not delegating.
5. Wait for a sub-agent's report before treating its task as done. Do not spawn a second agent for the same task.
6. If a report says the work failed or is incomplete, spawn a follow-up agent with the specifics, or report the problem to the user.
Finish with a short summary of what was built, what was verified, and what is left. If a task genuinely cannot be done with the tools available, say so plainly instead of delegating it again.`,
},
designer: {
id: 'designer',
displayName: 'Designer',
readOnly: false,
maxTurns: 30,
// The only mutating tool: a design document has to survive into the next
// role, and project variables are the only artifact a later agent can read.
allowedToolNames: [...READ_ONLY_TOOL_NAMES, 'add_or_edit_variable'],
systemPrompt: `You are the designer of a small game studio. You produce the game design document for the studio lead.
You write the design as GDevelop project variables: one variable per section, named with the GDD_ prefix (for example GDD_Overview, GDD_Economy, GDD_Progression, GDD_Controls), with a string value holding the section's text. Project variables are the only artifact the next role can read - never write your design only as a chat message.
You never create scenes, objects or events: another role does that. Your tools that change anything are limited to add_or_edit_variable.
Read the project first with read_game_project_json and describe_instances, so the design fits what already exists. Be specific and numeric: name the resources, the costs, the rates and the win condition, so the developer does not have to invent them. Report which GDD_ variables you wrote.`,
},
developer: {
id: 'developer',
displayName: 'Developer',
readOnly: false,
maxTurns: 80,
allowedToolNames: [
...MUTATING_TOOL_NAMES,
'read_game_project_json',
'read_events_source',
'describe_instances',
'inspect_variables',
'inspect_object_properties_effects',
'inspect_behavior_properties',
'inspect_scene_properties_layers_effects',
'inspect_extension',
'inspect_project_properties_resources',
'search_object_asset_store',
'search_resource_store',
'search_docs',
'read_full_docs',
],
systemPrompt: `You are the developer of a small game studio. You implement exactly the task you were given, and nothing else.
Read the GDD_ project variables first: they hold the design you are implementing. If the task you were given contradicts them, follow the task and say so in your report.
Prefer run_script when a change needs many calls (placing a grid of instances, setting up many objects): one script doing fifty calls beats fifty tool calls. Await every call inside the script, and keep the script under a few hundred calls. Inside a script the editor functions are async functions in scope - never probe for project access via 'gd', globals, or webpack internals.
Image textures can only enter the project inside asset-store objects (find them with search_object_asset_store, then create_or_replace_object imports the object with its assets); audio and font resources auto-install from the free library, images do not, and no tool uploads an image file. If no suitable texture exists, prefer objects that need no texture (PanelSprite, Text) instead of searching for an upload path.
Inspect before you write: read the project, describe the instances and inspect the objects you are about to change, so you edit what actually exists.
When you are done, report what you changed, naming the scenes, objects, variables and events you touched, and state anything you could not do. A partial implementation reported honestly is worth more than a silent failure.`,
},
tester: {
id: 'tester',
displayName: 'QA tester',
readOnly: true,
maxTurns: 30,
// Nothing here changes the project: the tester writes a test, runs it, and
// reports. `change_gameplay_tests` is the one exception - it edits a test
// definition, never the game.
allowedToolNames: [
'run_gameplay_test',
'change_gameplay_tests',
'read_game_project_json',
'read_events_source',
'describe_instances',
'inspect_variables',
'inspect_object_properties_effects',
'inspect_scene_properties_layers_effects',
'inspect_behavior_properties',
],
systemPrompt: `You are the QA tester of a small game studio. You verify the feature you were asked about, and you never change game content.
Write a gameplay test with run_gameplay_test, using harness assertions that actually exercise the named feature - assert on the state the feature is supposed to produce, not on it merely existing. Read the project first so your test names the real objects, scenes and variables.
Run the test, then report PASS or FAIL and quote the assertions and any errors verbatim. If it fails, say which assertion failed and what the game did instead. If the feature is not implemented at all, say that plainly: a missing feature is a finding, not a test to make pass.`,
},
};
/** The roles a `spawn_agent` call may name. A sub-agent never spawns sub-agents. */
export const SPAWNABLE_ROLE_IDS: Array<StudioRoleId> = [
'designer',
'developer',
'tester',
];
export const isStudioRoleId = (value: any): boolean =>
typeof value === 'string' &&
Object.prototype.hasOwnProperty.call(STUDIO_ROLES, (value: any));
export const isSpawnableRoleId = (value: any): boolean =>
isStudioRoleId(value) && SPAWNABLE_ROLE_IDS.includes((value: StudioRoleId));
export const getStudioRole = (roleId: StudioRoleId): StudioRole => {
if (!Object.prototype.hasOwnProperty.call(STUDIO_ROLES, (roleId: any))) {
throw new Error(`Unknown studio role: ${String(roleId)}.`);
}
return STUDIO_ROLES[roleId];
};
/**
* The subset of `allTools` a role may call, in `allTools` order.
*
* A role naming a tool that is not in `allTools` is not an error: the tool is
* skipped. That keeps a role declaration usable against an older tool schema
* (and lets `Roles.spec.js` state the invariant without importing a second
* source of truth).
*/
export const getToolsForRole = (
roleId: StudioRoleId,
allTools: Array<Object>
): Array<Object> => {
const allowedNames = new Set(getStudioRole(roleId).allowedToolNames);
return allTools.filter(
tool => !!tool && !!tool.function && allowedNames.has(tool.function.name)
);
};
/**
* The studio role that shapes a request's system prompt and tool subset.
*
* A sub-agent carries its role id. A top-level request carries none - but an
* `orchestrator`-mode chat is the studio's manager, so it must run with the
* manager's prompt and tool subset, not with every tool. Without this the
* "manager" ignored its own instructions and edited the project directly
* (feedback-loop Run 5).
*
* `invalid` distinguishes a present-but-unknown role id (a stale/corrupted
* persisted sub-agent) from no role: an invalid id must FAIL CLOSED to the
* read-only tool subset, while no role in a non-orchestrator chat keeps the
* full toolset.
*/
export const resolveStudioRoleId = (
studioRoleId: string | null | void,
mode: string | null | void
): {| roleId: StudioRoleId | null, invalid: boolean |} => {
if (studioRoleId) {
return isStudioRoleId(studioRoleId)
? { roleId: (studioRoleId: any), invalid: false }
: { roleId: null, invalid: true };
}
return {
roleId: mode === 'orchestrator' ? 'manager' : null,
invalid: false,
};
};
@@ -0,0 +1,68 @@
// @flow
/**
* Truncate text without splitting a user-perceived character.
*
* A plain `slice(0, maxLength)` cuts at a UTF-16 code-unit offset, which can
* fall between the two halves of an astral character (an emoji, or rare CJK —
* both realistic in GDevelop scene, object and variable names, and in the text
* a sub-agent writes back). The lone high surrogate left behind encodes to
* U+FFFD, so the replacement character reaches whatever the string feeds: a
* sub-agent prompt or the parent's transcript and hence the model.
*
* Surrogate pairs are only the common case: a cut can also land inside a
* multi-code-point grapheme (a ZWJ family 👨‍👩‍👧, a flag 🇫🇷, a base letter plus
* a combining mark), leaving a dangling joiner or accent. Rather than
* hand-rolling grapheme tables, this delegates to `Intl.Segmenter` (a
* platform API available everywhere the editor runs), with the old
* surrogate-pair step-back as the fallback for an environment without it.
*
* In its own module because both `FinalizeSubAgents` (the report cap) and
* `SpawnSubAgents` (the GDD context note cap) need it, and `FinalizeSubAgents`
* already imports from `SpawnSubAgents` — importing the other way would be a
* cycle.
*/
const getGraphemeEnds = (
text: string
): Array<{| index: number, length: number |}> | null => {
try {
// `(global: any)` so Flow never needs an `Intl` libdef, and so a missing
// global (rather than a missing property) also falls back instead of
// throwing at module scope. `SpawnSubAgents` already reads `global.gd`
// the same way.
const GlobalIntl: any = (global: any).Intl;
if (!GlobalIntl || !GlobalIntl.Segmenter) return null;
const ends = [];
const iterator: any = new GlobalIntl.Segmenter(undefined, {
granularity: 'grapheme',
}).segment(text);
for (const part of iterator) {
ends.push({ index: part.index, length: part.segment.length });
}
return ends;
} catch (error) {
return null;
}
};
export const truncateAtCodePointBoundary = (
text: string,
maxLength: number
): string => {
if (text.length <= maxLength) return text;
const graphemes = getGraphemeEnds(text);
if (!graphemes) {
let end = maxLength;
const lastCode = text.charCodeAt(end - 1);
if (lastCode >= 0xd800 && lastCode <= 0xdbff) end -= 1;
return text.slice(0, end);
}
let end = 0;
for (const grapheme of graphemes) {
// A grapheme starting inside the budget but ending outside it is dropped
// whole: keeping it would exceed maxLength, splitting it corrupt it.
if (grapheme.index + grapheme.length > maxLength) break;
end = grapheme.index + grapheme.length;
}
return text.slice(0, end);
};
@@ -0,0 +1,277 @@
// @flow
import { makeSimplifiedProjectBuilder } from '../../EditorFunctions/SimplifiedProject/SimplifiedProject';
import { customCreateSubAgentAiRequest } from '../../AI/CustomAIClient';
import { isSpawnableRoleId, type StudioRoleId } from './Roles';
import {
type AiRequest,
type AiRequestMessageAssistantFunctionCall,
} from '../../Utils/GDevelopServices/Generation';
import { truncateAtCodePointBoundary } from './SafeTruncation';
const gd: libGDevelop = global.gd;
/** Cap on live sub-agents one parent may have (W8). */
export const MAX_SUB_AGENTS_PER_PARENT = 8;
/**
* Live sub-agents of a parent: launched calls (stamped with
* `subAgentAiRequestId`) with no `function_call_output` for their call yet,
* whose child request still exists in storage.
*
* Finished children stay in storage (they are history), so counting every
* stored child turned the fan-out cap into a lifetime cap: after
* MAX_SUB_AGENTS_PER_PARENT spawns ever, no later task could ever be
* delegated again. Pruned or deleted children free their slot.
*/
export const countLiveSubAgentsForParent = (
parentAiRequest: AiRequest,
aiRequests: { [string]: AiRequest }
): number => {
const output = (parentAiRequest && parentAiRequest.output) || [];
const answeredCallIds = new Set<string>();
const launchedChildren = new Map<string, string>();
output.forEach((message: any) => {
if (!message || typeof message !== 'object') return;
if (
message.type === 'function_call_output' &&
typeof message.call_id === 'string'
) {
answeredCallIds.add(message.call_id);
return;
}
if (
message.type === 'message' &&
message.role === 'assistant' &&
Array.isArray(message.content)
) {
message.content.forEach((entry: any) => {
if (!entry || typeof entry !== 'object') return;
if (
entry.type === 'function_call' &&
typeof entry.subAgentAiRequestId === 'string' &&
typeof entry.call_id === 'string'
) {
launchedChildren.set(entry.subAgentAiRequestId, entry.call_id);
}
});
}
});
const requests = aiRequests || {};
let live = 0;
launchedChildren.forEach((callId, childId) => {
if (!requests[childId]) return;
if (answeredCallIds.has(callId)) return;
live++;
});
return live;
};
/** Whether a function call is the studio's delegation tool. */
export const isSpawnAgentCall = (
functionCall: AiRequestMessageAssistantFunctionCall
): boolean => functionCall.name === 'spawn_agent';
export type ParsedSpawnAgentArgs = {|
role: StudioRoleId,
shortTitle: string,
task: string,
context: string,
relatedTaskId: string | null,
|};
/**
* Read and validate a `spawn_agent` call's arguments. Null when the call is
* malformed: the caller then fails the call instead of spawning an agent with
* half an instruction.
*/
export const parseSpawnAgentArgs = (
functionCall: AiRequestMessageAssistantFunctionCall
): ?ParsedSpawnAgentArgs => {
let parsedArguments: any;
try {
parsedArguments = JSON.parse(functionCall.arguments);
} catch (error) {
return null;
}
if (
!parsedArguments ||
typeof parsedArguments !== 'object' ||
Array.isArray(parsedArguments)
) {
return null;
}
// The model's own JSON: each field is checked, and anything unexpected makes
// the whole call invalid (the caller then fails it instead of spawning with
// half an instruction).
const role: any = parsedArguments.role;
if (!isSpawnableRoleId(role)) return null;
const rawShortTitle: any = parsedArguments.short_title;
const rawTask: any = parsedArguments.task;
const rawContext: any = parsedArguments.context;
const rawRelatedTaskId: any = parsedArguments.related_task_id;
if (typeof rawShortTitle !== 'string') return null;
const shortTitle = rawShortTitle.trim();
if (!shortTitle) return null;
if (typeof rawTask !== 'string') return null;
const task = rawTask.trim();
if (!task) return null;
// Reject (do not coerce) a present-but-wrongly-typed optional field.
if (rawContext !== undefined && typeof rawContext !== 'string') return null;
if (rawRelatedTaskId !== undefined && typeof rawRelatedTaskId !== 'string')
return null;
const context = typeof rawContext === 'string' ? rawContext.trim() : '';
const relatedTaskId =
typeof rawRelatedTaskId === 'string' && rawRelatedTaskId.trim()
? rawRelatedTaskId.trim()
: null;
// Reject oversized strings (W6): a runaway argument must not become a child
// request the user cannot read.
if (shortTitle.length > 100 || task.length > 4000 || context.length > 4000) {
return null;
}
return { role, shortTitle, task, context, relatedTaskId };
};
/**
* The `GDD_*` project variables, as a note appended to a sub-agent's context.
*
* The designer writes the design document as project variables (it is the only
* artifact a later role can read), so the developer and the tester are handed
* them explicitly rather than having to discover them.
*
* Project variables live at `globalVariables` on the simplified project (the
* `properties` object holds resolution and orientation, not variables).
*/
export const buildGddContextNote = (project: ?gdProject): string => {
if (!project) return '';
let simplifiedProject: any;
try {
simplifiedProject = makeSimplifiedProjectBuilder(gd).getSimplifiedProject(
project,
{}
);
} catch (error) {
// A project the simplifier cannot read is not a reason to refuse to spawn:
// the agent simply gets no design note.
console.warn(
'Unable to read the project variables for a sub-agent:',
error
);
return '';
}
const projectVariables: Array<any> =
(simplifiedProject && simplifiedProject.globalVariables) || [];
const gddVariables = projectVariables.filter(
variable =>
variable &&
typeof variable.variableName === 'string' &&
variable.variableName.startsWith('GDD_')
);
if (gddVariables.length === 0) {
return 'No GDD_ project variable is set on this project yet: the design document has not been written.';
}
const lines = gddVariables.map(
variable =>
`- ${variable.variableName}: ${
typeof variable.value === 'string'
? variable.value
: JSON.stringify(variable.variableChildren || variable.type)
}`
);
const note = `The studio's design document, as GDD_ project variables:\n${lines.join(
'\n'
)}`;
return (
truncateAtCodePointBoundary(note, 4000) +
(note.length > 4000 ? '\n…(truncated)' : '')
);
};
export type SpawnSubAgentResult =
| {|
subAgentAiRequestId: string,
shortTitle: string,
callId: string,
/**
* The plan task the spawn named (`related_task_id`), so the caller can
* stamp `functionCall.taskId` and the chat can render the call inside its
* plan task row. `null` when the model delegated without a task.
*/
relatedTaskId: string | null,
|}
| {| error: string |};
/**
* Create the child `AiRequest` for one `spawn_agent` call.
*
* The caller is responsible for stamping `subAgentAiRequestId` onto the call
* (see `onStamped`) and for activating the sub-agent; this function only creates
* the request, so it stays testable without the React context.
*/
export const spawnSubAgent = async ({
parentAiRequestId,
functionCall,
gameProjectJson,
projectSpecificExtensionsSummaryJson,
project,
gddContextNote,
onStamped,
}: {|
parentAiRequestId: string,
functionCall: AiRequestMessageAssistantFunctionCall,
gameProjectJson: string | null,
projectSpecificExtensionsSummaryJson: string | null,
project: ?gdProject,
// The GDD note, computed once per batch and passed in (W7). When provided it
// is used verbatim instead of rebuilding the simplified project per call.
gddContextNote?: string | null,
onStamped: (subAgentAiRequestId: string) => void,
|}): Promise<SpawnSubAgentResult> => {
const parsedArgs = parseSpawnAgentArgs(functionCall);
if (!parsedArgs) {
return { error: 'Invalid spawn_agent arguments.' };
}
let userRequest = `Task: ${parsedArgs.task}`;
if (parsedArgs.context) {
userRequest += `\n\nContext: ${parsedArgs.context}`;
}
// The developer and the tester need the design document; the designer writes
// it, so handing it back to the designer would be noise.
const spawnContextNote =
parsedArgs.role === 'developer' || parsedArgs.role === 'tester'
? gddContextNote != null
? gddContextNote
: buildGddContextNote(project)
: null;
const subAgentRequest = await customCreateSubAgentAiRequest({
parentAiRequestId,
roleId: parsedArgs.role,
userRequest,
gameProjectJson,
projectSpecificExtensionsSummaryJson,
spawnContextNote,
});
onStamped(subAgentRequest.id);
return {
subAgentAiRequestId: subAgentRequest.id,
shortTitle: parsedArgs.shortTitle,
callId: functionCall.call_id,
relatedTaskId: parsedArgs.relatedTaskId,
};
};
@@ -0,0 +1,159 @@
// @flow
import * as React from 'react';
import { type AiRequest } from '../../Utils/GDevelopServices/Generation';
import { type EditorFunctionCallResult } from '../../EditorFunctions';
import {
aiRequestHasWorkInProgress,
getLatestActivePlan,
getPendingSubAgentFunctionCalls,
} from '../AiRequestUtils';
import { getNextReadyTask, type StudioPlanTask } from './PlanStore';
import {
shouldNudgeStudioManager,
getActionablePlanSignature,
buildStudioNudgeMessage,
} from './NudgePolicy';
type ActiveSubAgent = {|
parentAiRequestId: string,
callId: string,
|};
/**
* How long the manager must stay stalled before the nudge is sent. A grace
* period, not a poll interval: the effect is re-run (and the timer cleared) on
* every relevant state change, so this only has to outlast the sub-second gap
* between writing a tool result and the follow-up turn starting.
*/
export const STUDIO_NUDGE_GRACE_MS = 5000;
/** Hard cap on nudges for one request, so a pathological model cannot loop. */
export const MAX_STUDIO_NUDGES_PER_REQUEST = 5;
/**
* Only a request touched within this window may be nudged. Opening an old,
* abandoned chat must not resurrect its plan and spend tokens; the window is
* generous enough that a genuinely-stalled live run is always in range.
*/
export const STUDIO_NUDGE_MAX_AGE_MS = 10 * 60 * 1000;
/**
* Keep the local studio's manager moving.
*
* Under BYOK nothing resumes a manager that produced a plan and then ended its
* turn without delegating: the multi-agent loop waits forever. This hook sends
* at most one continuation per stalled plan state (`NudgePolicy` decides when),
* with a short grace period so it never races a turn already in flight.
*
* It is a no-op for the hosted path and for sub-agent requests (only the
* top-level, non-role request is nudged) and for a user-stopped request.
*/
export const useStudioNudge = ({
selectedAiRequest,
aiRequests,
activeSubAgents,
getEditorFunctionCallResults,
isSendingAiRequest,
onSendMessage,
}: {|
selectedAiRequest: ?AiRequest,
aiRequests: { [string]: AiRequest },
activeSubAgents: { [string]: ActiveSubAgent },
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
isSendingAiRequest: (aiRequestId: string | null) => boolean,
onSendMessage: (options: Object) => Promise<boolean>,
|}): void => {
// Per-request: the plan state already nudged, and how many nudges were sent.
const nudgedSignatureByRequestRef = React.useRef<Map<string, string>>(
new Map()
);
const nudgedCountByRequestRef = React.useRef<Map<string, number>>(new Map());
React.useEffect(
() => {
if (!selectedAiRequest) return;
const aiRequestId = selectedAiRequest.id;
const isTopLevel =
!selectedAiRequest.parentAiRequestId && !selectedAiRequest.studioRoleId;
if (!isTopLevel) return;
const plan = getLatestActivePlan(selectedAiRequest);
const tasks: Array<StudioPlanTask> = plan && plan.tasks ? plan.tasks : [];
const planSignature = getActionablePlanSignature(tasks);
const liveSubAgentCount = Object.keys(activeSubAgents).filter(
subAgentId =>
activeSubAgents[subAgentId] &&
activeSubAgents[subAgentId].parentAiRequestId === aiRequestId
).length;
const updatedAtMs = Date.parse(selectedAiRequest.updatedAt || '');
const isRecent =
Number.isFinite(updatedAtMs) &&
Date.now() - updatedAtMs <= STUDIO_NUDGE_MAX_AGE_MS;
const shouldNudge = shouldNudgeStudioManager({
isTopLevel,
isRecent,
status: selectedAiRequest.status,
hasWorkInProgress: aiRequestHasWorkInProgress(
selectedAiRequest,
getEditorFunctionCallResults(aiRequestId)
),
isSending: isSendingAiRequest(aiRequestId),
liveSubAgentCount,
pendingSubAgentCallCount: getPendingSubAgentFunctionCalls({
aiRequest: selectedAiRequest,
}).length,
planSignature,
previouslyNudgedSignature:
nudgedSignatureByRequestRef.current.get(aiRequestId) || null,
});
if (!shouldNudge || !planSignature) return;
const nudgedCount = nudgedCountByRequestRef.current.get(aiRequestId) || 0;
if (nudgedCount >= MAX_STUDIO_NUDGES_PER_REQUEST) return;
const nextReadyTask = getNextReadyTask(tasks);
const timer = setTimeout(() => {
// Mark before the send: a re-render landing during the await must not
// arm a second nudge for the same plan state.
nudgedSignatureByRequestRef.current.set(aiRequestId, planSignature);
nudgedCountByRequestRef.current.set(aiRequestId, nudgedCount + 1);
console.info(
`[studio] nudging the stalled manager #${aiRequestId} for plan ${planSignature}.`
);
// `onSendMessage` can return undefined (its own early-return path), so it
// is not necessarily a promise and may also throw synchronously.
Promise.resolve()
.then(() =>
onSendMessage({
aiRequestId,
userMessage: buildStudioNudgeMessage(nextReadyTask),
})
)
.catch(error => {
console.error('[studio] error while nudging the manager:', error);
// Let the next pass retry the same state.
if (
nudgedSignatureByRequestRef.current.get(aiRequestId) ===
planSignature
) {
nudgedSignatureByRequestRef.current.delete(aiRequestId);
}
});
}, STUDIO_NUDGE_GRACE_MS);
return () => clearTimeout(timer);
},
[
selectedAiRequest,
aiRequests,
activeSubAgents,
getEditorFunctionCallResults,
isSendingAiRequest,
onSendMessage,
]
);
};
@@ -0,0 +1,302 @@
// @flow
import * as React from 'react';
import { type AiRequest } from '../../Utils/GDevelopServices/Generation';
import { type EditorFunctionCallResult } from '../../EditorFunctions';
import {
getAllSubAgentFunctionCalls,
getLatestActivePlan,
} from '../AiRequestUtils';
import {
isSubAgentFinished,
buildSubAgentReport,
getSubAgentReportLabel,
getSubAgentRoleId,
parentHasOutputForCall,
} from './FinalizeSubAgents';
import {
patchPlanTask,
getNextReadyTask,
replacePlanMessage,
type StudioPlanTask,
} from './PlanStore';
import { isSpawnAgentCall, parseSpawnAgentArgs } from './SpawnSubAgents';
type ActiveSubAgent = {|
parentAiRequestId: string,
callId: string,
|};
/**
* How many sub-agents this hook may finalize in one pass. The pass runs on every
* render, so an unbounded loop over a large fleet would stall the chat.
*/
const MAX_FINALIZATIONS_PER_PASS = 8;
/**
* Flip the plan task a spawn call named to `done`, and the next ready task to
* `in_progress`.
*
* The plan message already in the parent's output is rewritten in place - same
* `call_id`, new JSON - rather than appended to: the plan tool call's own result
* is that message, and `getLatestActivePlan` reads the last output carrying
* `.plan.tasks`, so rewriting it keeps the transcript consistent and needs no
* new tool call the model never made.
*
* The tasks are patched, not rebuilt: rebuilding strips the fields the writer
* does not model (see `PlanStore.patchPlanTask`).
*
* Returns the new output array, or null when there is nothing to change.
*/
export const buildPlanStatusUpdateOutput = (
parentRequest: AiRequest,
callId: string
): ?Array<any> => {
const plan = getLatestActivePlan(parentRequest);
if (!plan || !Array.isArray(plan.tasks)) return null;
const tasks: Array<StudioPlanTask> = (plan.tasks: any);
if (tasks.length === 0) return null;
const spawnCall = getAllSubAgentFunctionCalls({
aiRequest: parentRequest,
}).find(functionCall => functionCall.call_id === callId);
if (!spawnCall || !isSpawnAgentCall(spawnCall)) return null;
const parsedArgs = parseSpawnAgentArgs(spawnCall);
const relatedTaskId = parsedArgs ? parsedArgs.relatedTaskId : null;
if (!relatedTaskId) return null;
if (!tasks.some(task => task && task.id === relatedTaskId)) return null;
let patchedTasks = patchPlanTask(tasks, relatedTaskId, { status: 'done' });
const nextReadyTask = getNextReadyTask(patchedTasks);
if (nextReadyTask) {
patchedTasks = patchPlanTask(patchedTasks, nextReadyTask.id, {
status: 'in_progress',
});
}
return replacePlanMessage(parentRequest.output || [], patchedTasks);
};
/**
* The studio runtime: closes the loop between a spawned sub-agent and its parent.
*
* A child `AiRequest` is polled and driven by the shared machinery
* (`AiRequestContext` + `useProcessFunctionCalls`), exactly like a hosted
* sub-agent. What is missing under BYOK is the write-back: the hosted backend
* posts a `function_call_output` for the sub-agent's call when its sub-agent is
* done, and the client only reacts to it. This hook does that write locally -
*
* 1. wait until the child has nothing left to do (`isSubAgentFinished`);
* 2. flip the plan task the spawn call named to `done`, and the next ready task
* to `in_progress`, patching the raw tasks so nothing else is lost;
* 3. post the child's report as its call's `function_call_output`, which the
* shared BYOK path turns into the parent's next model turn and which
* `removeSubAgentIfDone` reads to retire the child.
*
* A sub-agent cannot spawn a sub-agent: `spawn_agent` is not in any sub-agent
* role's tool subset (`Roles.getToolsForRole`), so the recursion depth is one.
*/
export const useStudioRuntime = ({
aiRequests,
activeSubAgents,
getEditorFunctionCallResults,
updateAiRequest,
onSendEditorFunctionCallResults,
enqueueRequestWrite,
}: {|
aiRequests: { [string]: AiRequest },
activeSubAgents: { [string]: ActiveSubAgent },
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
updateAiRequest: (
aiRequestId: string,
updateFn: (previousAiRequest: ?AiRequest) => AiRequest
) => void,
onSendEditorFunctionCallResults: (
aiRequestId: string,
editorFunctionCallResults: Array<EditorFunctionCallResult>,
options: Object
) => Promise<boolean>,
/**
* Runs a write once the request is free (see `RequestWriteGate`). Optional so
* the hook can be used without the gate; the write then runs immediately.
*/
enqueueRequestWrite?: (
aiRequestId: string,
write: () => Promise<void>
) => Promise<void>,
|}): void => {
// Call ids already finalized in this session. Without it, a render landing
// between writing the output and the parent's state propagating would finalize
// the same agent twice, and the parent would take two turns for one agent.
const finalizedCallIdsRef = React.useRef<Set<string>>(new Set());
// The pass awaits a message send, so a second pass can start before the first
// finished. Serialize them.
const isRunningRef = React.useRef<boolean>(false);
React.useEffect(
() => {
if (isRunningRef.current) return;
const subAgentIds = Object.keys(activeSubAgents);
if (subAgentIds.length === 0) return;
const pendingFinalizations: Array<{|
subAgentRequest: AiRequest,
parentAiRequestId: string,
callId: string,
|}> = [];
for (const subAgentId of subAgentIds) {
if (pendingFinalizations.length >= MAX_FINALIZATIONS_PER_PASS) break;
const subAgentInfo = activeSubAgents[subAgentId];
if (!subAgentInfo) continue;
// Already finalized in an earlier pass.
if (finalizedCallIdsRef.current.has(subAgentInfo.callId)) continue;
const subAgentRequest = aiRequests[subAgentId];
if (!subAgentRequest) continue;
// A hosted sub-agent is retired by the server, never finalized here.
if (!getSubAgentRoleId({ aiRequest: subAgentRequest, aiRequests })) {
continue;
}
const parentRequest = aiRequests[subAgentInfo.parentAiRequestId];
if (!parentRequest) continue;
// The parent already carries this call's output: the child is on its way
// out (`removeSubAgentIfDone` is what removes it). Stay compatible.
const parentAlreadyHasOutput = parentHasOutputForCall(
parentRequest,
subAgentInfo.callId
);
if (parentAlreadyHasOutput) continue;
if (
!isSubAgentFinished({
subAgentRequest,
editorFunctionCallResults: getEditorFunctionCallResults(subAgentId),
})
) {
continue;
}
pendingFinalizations.push({
subAgentRequest,
parentAiRequestId: subAgentInfo.parentAiRequestId,
callId: subAgentInfo.callId,
});
}
if (pendingFinalizations.length === 0) return;
isRunningRef.current = true;
(async () => {
const startedCallIds: Array<string> = [];
try {
for (const {
subAgentRequest,
parentAiRequestId,
callId,
} of pendingFinalizations) {
// Mark it before any await: a pass landing during the await must not
// pick the same call up again.
finalizedCallIdsRef.current.add(callId);
startedCallIds.push(callId);
if (finalizedCallIdsRef.current.size > 500) {
// Bound the guard like the other in-flight sets of the AI path.
finalizedCallIdsRef.current.clear();
finalizedCallIdsRef.current.add(callId);
}
console.info(
`[studio] finalizing sub-agent #${
subAgentRequest.id
} for call ${callId}.`
);
const parentRequest = aiRequests[parentAiRequestId];
if (parentRequest) {
const writePlan = async () => {
updateAiRequest(parentAiRequestId, currentRequest => {
const base =
currentRequest ||
({
id: parentAiRequestId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'local-byok-user',
status: 'ready',
error: null,
output: [],
}: any);
// Recompute inside the updater: two finalizations in one pass
// would otherwise write the first snapshot twice.
const updated = buildPlanStatusUpdateOutput(base, callId);
return updated ? { ...base, output: updated } : base;
});
};
if (enqueueRequestWrite) {
// Fire-and-forget: a blocked gate write no longer settles
// inline, so awaiting it would freeze every later finalization
// behind `isRunningRef`.
enqueueRequestWrite(parentAiRequestId, writePlan).catch(
error => {
if (error.code === 'superseded') {
console.info('[studio] plan write superseded');
} else {
console.error(
'[studio] error while writing the plan update:',
error
);
}
}
);
} else {
await writePlan();
}
}
const label = getSubAgentReportLabel({
aiRequest: subAgentRequest,
aiRequests,
});
const sent = await onSendEditorFunctionCallResults(
parentAiRequestId,
[
{
status: 'finished',
call_id: callId,
success: true,
output: {
message: `Report from the ${label}:\n\n${buildSubAgentReport(
subAgentRequest,
aiRequests
)}`,
},
},
],
{}
);
// Report delivery is not droppable: throw so the `catch` retries
// (it deletes `startedCallIds`, so the next pass redoes this).
if (!sent) throw new Error('Sub-agent report was not sent.');
}
} catch (error) {
console.error('[studio] error while finalizing a sub-agent:', error);
// Let the next pass retry: a failed write must not be marked done.
startedCallIds.forEach(callId =>
finalizedCallIdsRef.current.delete(callId)
);
} finally {
isRunningRef.current = false;
}
})();
},
[
aiRequests,
activeSubAgents,
getEditorFunctionCallResults,
updateAiRequest,
onSendEditorFunctionCallResults,
enqueueRequestWrite,
]
);
};
+100
View File
@@ -0,0 +1,100 @@
{
"donor": "/home/john/Projects/GDevelop-Unlocked/review/GDevelop-Heretek",
"files": {
"AI/CustomAIClient.js": {
"donor": "newIDE/app/src/AI/CustomAIClient.js",
"bytes": 208828,
"sha256": "2dd976b936908115d08374851121a077d2b2b6c314a029a4bc52ab7125f51723"
},
"AI/CustomAIClient.spec.js": {
"donor": "newIDE/app/src/AI/CustomAIClient.spec.js",
"bytes": 257794,
"sha256": "0152072739b6590f004e886b8a5787cd769a529f044f14e48ca7e78ecc2a560a"
},
"AiGeneration/Studio/EditApprovalLabel.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/EditApprovalLabel.js",
"bytes": 1308,
"sha256": "a924efc714123ab45e7d649d581b4d54557e6452fc0df3bcf8ad387c27c9f194"
},
"AiGeneration/Studio/FinalizeSubAgents.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/FinalizeSubAgents.js",
"bytes": 9111,
"sha256": "708de36210d000af56e795280eaa5dd450e32cedb4a54b7b031353a6cc4a679f"
},
"AiGeneration/Studio/LoopGuard.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/LoopGuard.js",
"bytes": 9401,
"sha256": "0ad4c6f5d6312a38f56e0385b8408adec72634c95d7252a3ff193af1e667101e"
},
"AiGeneration/Studio/NudgePolicy.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/NudgePolicy.js",
"bytes": 3821,
"sha256": "c13a8af43b3b3d23ccb2dff2bc3438755d502090fd99f2bad1061d86f8d3c925"
},
"AiGeneration/Studio/PlanStore.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/PlanStore.js",
"bytes": 13166,
"sha256": "3218ec230b6786d9105fd2854b3389b20b6c68a0889ccc6134f533106b1a3be6"
},
"AiGeneration/Studio/RequestWriteGate.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/RequestWriteGate.js",
"bytes": 5904,
"sha256": "bcd78ad8d0a22ca58ea413b2d1bbdbd07814184a0e28726dcfe8d466707c5064"
},
"AiGeneration/Studio/Roles.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/Roles.js",
"bytes": 12493,
"sha256": "dac71987229c63fcea55fabd049b43ab48beff68cb19813de9d1c77090c6798f"
},
"AiGeneration/Studio/RoleToolPolicy.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/RoleToolPolicy.js",
"bytes": 2097,
"sha256": "66a7d577f12fccaa1b23d89bc7ba02c9bca07058358cc53d3ecfe50d604ef1b8"
},
"AiGeneration/Studio/SafeTruncation.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/SafeTruncation.js",
"bytes": 2743,
"sha256": "24e4e11fb5ee360374cd3bfa092fd01755b99228c34df74547473336d4a799ce"
},
"AiGeneration/Studio/SpawnSubAgents.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.js",
"bytes": 9089,
"sha256": "ffc0658c57b027d20cc50c88bc350ac013369c3fb09ed366ff8736a7aa78d2ae"
},
"AiGeneration/Studio/UseStudioNudge.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js",
"bytes": 5824,
"sha256": "12df5f3f54a52e3c2c72646e6825360206eec0521dd6f15d73cd39660b21e0d2"
},
"AiGeneration/Studio/UseStudioRuntime.js": {
"donor": "newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.js",
"bytes": 11187,
"sha256": "ecc51b28d00baf1ccd7d38d542e4a9237cd145cd863586c6fae6e587d03564c3"
},
"dev-harness/mock-ai-provider.js": {
"donor": "scripts/dev/mock-ai-provider.js",
"bytes": 9858,
"sha256": "8840bec1a98385c106deaa78aaec5b18fdf98b54a944fc9413ee5916679150ca"
},
"dev-harness/mock-script.example.json": {
"donor": "scripts/dev/mock-script.example.json",
"bytes": 887,
"sha256": "45c2915841b0a6c0f17db7a297a39ca1dfb8b889d74f018f87d1815cadf17c26"
},
"dev-harness/mock-script-multi.json": {
"donor": "scripts/dev/mock-script-multi.json",
"bytes": 1314,
"sha256": "c94da74ac1251e07ae7a4576ea7354a1ef9f76a9294cca4bf473c0807d5ed824"
},
"dev-harness/mock-script-specialists.json": {
"donor": "scripts/dev/mock-script-specialists.json",
"bytes": 960,
"sha256": "31fe97f889935e92bfb3af5056e2579105fb7b48b325fd3dfcc9a5847b4ee6b7"
},
"dev-harness/mock-child-scripts.example.json": {
"donor": "scripts/dev/mock-child-scripts.example.json",
"bytes": 532,
"sha256": "8f785710966f26a7923793f47a785b18d8e8d326bb381f78682a7eb50ac6bdd7"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
# packages/ai-studio — Tier-2 extraction (Phase 4, issue #4)
~8.9k-line BYOK AI + multi-agent Studio, moved **verbatim** out of the
fork. The fork is a donor, not a delivery vehicle. Never hand-edit
files under `AI/` or `AiGeneration/` here — re-run the extractor:
```bash
node scripts/extract-tier2.mjs --fork <fork-checkout>
```
`MANIFEST.json` pins every file (sha256); `tests/tier2/extract.test.mjs`
fails if any byte drifts from the donor.
## Contents
| Path | Donor | Notes |
|---|---|---|
| `AI/CustomAIClient.js` | `newIDE/app/src/AI/CustomAIClient.js` (5870 lines) | OpenAI-compatible client, 39-tool `GDEVELOP_OPENAI_TOOLS`, SSE streaming, `gd-custom-ai-*` localStorage keys. **Zero `api.gdevelop.io` references** (pinned by test) |
| `AI/CustomAIClient.spec.js` | co-located spec | Run inside `newIDE/app` (`react-app-rewired test`), not here |
| `AiGeneration/Studio/*.js` (12 prod) | `AiGeneration/Studio/` (2371 LOC) | Roles, PlanStore, LoopGuard, SpawnSubAgents, FinalizeSubAgents, UseStudioRuntime, UseStudioNudge, NudgePolicy, RequestWriteGate, RoleToolPolicy, SafeTruncation, EditApprovalLabel |
| `dev-harness/mock-ai-provider.js` | `scripts/dev/mock-ai-provider.js` | Committed OpenAI-compatible mock for offline E2E (+ `mock-script*.json` fixtures) |
## Wiring
Import rewiring is limited to `packages/unlock-shim/generation-shim.mjs`
(routing predicates + fail-closed leak guard) plus the generated glue
patches `07-generation-glue.patch` (Generation.js branches) and
`08-preferences-ai.patch` (`ai` prefs tab + storage). The extracted
modules themselves are byte-identical to the donor.
## Desktop E2E (requires a built app — procedure, not automation)
1. Apply all patches to a checkout (`node scripts/apply-patches.mjs --target …`).
2. Start the offline harness: `node packages/ai-studio/dev-harness/mock-ai-provider.js`
(or real Ollama at `http://localhost:11434/v1`; set `ORIGINS` for CORS).
3. In Preferences → AI, set Base URL to the harness, complete a generation.
4. Assert: subagent spawn/finalize loop runs (Roles/PlanStore/LoopGuard
paths); **zero requests to `api.gdevelop.io/generation`** in BYOK mode
(the hook logs spoofed usage calls in `window.__HERETEK__.spoofedRequests`;
generation traffic is observable in the harness log).
@@ -0,0 +1,289 @@
#!/usr/bin/env node
'use strict';
/**
* A tiny OpenAI-compatible mock provider for the BYOK / multi-agent harness.
*
* Lets the editor's AI path be exercised end-to-end without credentials: the
* @feedback-loop skill and manual WebUI audits can point the editor at this
* server and get deterministic replies, while the routing/telemetry code paths
* run exactly as they do against a real gateway.
*
* node scripts/dev/mock-ai-provider.js # listens on :11435
* PORT=11500 node scripts/dev/mock-ai-provider.js
*
* Then set the editor's custom AI base URL to http://localhost:11435/v1.
*
* It streams (`stream: true`) and answers non-streamed requests, and emits
* `x-omniroute-*` telemetry headers. Two details matter and are easy to get
* wrong in a real proxy too:
* - the response must be permissive CORS (the editor is served from :3000);
* - custom response headers are invisible to browser JS unless listed in
* `Access-Control-Expose-Headers`.
* Without the latter the editor cannot read the telemetry at all.
*
* No API key, no outbound network, no persistence: safe to leave running while
* developing.
*/
const http = require('http');
const PORT = Number(process.env.PORT) || 11435;
const MODEL = process.env.MODEL || 'mock-city-builder';
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'content-type, authorization',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Expose-Headers':
'x-omniroute-model, x-omniroute-latency-ms, x-omniroute-tokens-in, x-omniroute-tokens-out, x-omniroute-cache',
};
/**
* Optional deterministic script: a JSON array of turns, consumed one per
* request (the last turn repeats). Each turn is either
* { "content": "..." } a plain answer, or
* { "content": "...", "toolCalls": [ {"id","name","arguments"} ] }
* with `arguments` a JSON string (or object) as the OpenAI API expects. Set
* `MOCK_SCRIPT=/path/to/script.json` to drive a fixed multi-step run (e.g.
* plan -> spawn_agent -> report) through the harness without a real model.
*/
const loadScript = () => {
const path = process.env.MOCK_SCRIPT;
if (!path) return null;
try {
const parsed = JSON.parse(require('fs').readFileSync(path, 'utf8'));
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
} catch (error) {
console.error('MOCK_SCRIPT could not be read:', error.message);
return null;
}
};
const script = loadScript();
let requestCount = 0;
const nextTurn = () => {
if (!script) return null;
const turn = script[Math.min(requestCount, script.length - 1)];
requestCount += 1;
return turn;
};
/**
* Optional PER-ROLE child scripts: MOCK_CHILD_SCRIPTS points at a JSON object
* { "designer": [turns...], "developer": [...], "tester": [...] }. A sub-agent
* request (its system prompt names a studio role) is matched to its role and
* gets that role's turns, consumed per role; a role with no script gets a plain
* reply. This lets a deterministic run script what each SPECIALIST does (e.g. a
* tool call) instead of one shared sequence.
*/
const loadChildScripts = () => {
const path = process.env.MOCK_CHILD_SCRIPTS;
if (!path) return null;
try {
const parsed = JSON.parse(require('fs').readFileSync(path, 'utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed
: null;
} catch (error) {
console.error('MOCK_CHILD_SCRIPTS could not be read:', error.message);
return null;
}
};
const childScripts = loadChildScripts();
const childCounters = {};
const detectRole = messages => {
const system = messages.find(
m => m && m.role === 'system' && typeof m.content === 'string'
);
const prompt = system ? system.content.toLowerCase() : '';
if (prompt.includes('the designer')) return 'designer';
if (prompt.includes('the developer')) return 'developer';
if (prompt.includes('the qa tester')) return 'tester';
return null;
};
const nextChildTurn = messages => {
if (!childScripts) return null;
const role = detectRole(messages);
const turns = role ? childScripts[role] : null;
if (!Array.isArray(turns) || turns.length === 0) return null;
const index = childCounters[role] || 0;
childCounters[role] = index + 1;
return turns[Math.min(index, turns.length - 1)];
};
const json = (res, status, body, extraHeaders) => {
res.writeHead(
status,
Object.assign({ 'Content-Type': 'application/json' }, CORS, extraHeaders || {})
);
res.end(JSON.stringify(body));
};
// A sub-agent request carries a studio ROLE prompt ("... the developer of a
// small game studio"). The top-level manager ALSO names "small game studio" (its
// role prompt became the manager's in the editor's role fix), so the reliable
// discriminator is a detected ROLE, not the shared phrase.
const isSubAgentRequest = messages => detectRole(messages) !== null;
const buildReply = (messages, rawBody) => {
const lastUser = [...messages].reverse().find(m => m && m.role === 'user');
const text =
lastUser && typeof lastUser.content === 'string' ? lastUser.content : '';
return (
'Mock provider online. I received ' +
messages.length +
' messages. Your last request starts: "' +
text.slice(0, 120).replace(/\s+/g, ' ') +
'". In a real run I would call create_or_update_plan and spawn_agent.'
);
};
const telemetry = (rawBody, reply) => ({
'x-omniroute-model': MODEL,
'x-omniroute-latency-ms': '42',
'x-omniroute-tokens-in': String(Math.ceil(rawBody.length / 4)),
'x-omniroute-tokens-out': String(Math.ceil(reply.length / 4)),
'x-omniroute-cache': 'MISS',
});
const server = http.createServer((req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS);
return res.end();
}
let rawBody = '';
req.on('data', chunk => {
rawBody += chunk;
});
req.on('end', () => {
if (req.method === 'GET' && req.url.startsWith('/v1/models')) {
return json(res, 200, {
object: 'list',
data: [{ id: MODEL, object: 'model', owned_by: 'mock' }],
});
}
if (req.method === 'POST' && req.url.startsWith('/v1/chat/completions')) {
let payload = {};
try {
payload = JSON.parse(rawBody || '{}');
} catch (ignored) {
// A malformed body still gets a deterministic reply.
}
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const isChild = isSubAgentRequest(messages);
const turn = isChild ? nextChildTurn(messages) : nextTurn();
const reply =
turn && typeof turn.content === 'string'
? turn.content
: buildReply(messages, rawBody);
const scriptedToolCalls =
turn && Array.isArray(turn.toolCalls) ? turn.toolCalls : [];
const toolCalls = scriptedToolCalls.map((call, index) => ({
id: call.id || `call_${index}`,
type: 'function',
function: {
name: call.name,
arguments:
typeof call.arguments === 'string'
? call.arguments
: JSON.stringify(call.arguments || {}),
},
}));
const headers = telemetry(rawBody, reply);
if (payload.stream === true) {
res.writeHead(
200,
Object.assign({ 'Content-Type': 'text/event-stream' }, CORS, headers)
);
const chunks = reply.match(/.{1,24}/g) || (reply ? [reply] : []);
// MOCK_STREAM_DELAY_MS spaces out chunks so UI that only shows while a
// stream is live (e.g. the streaming token counter) can be observed.
const streamDelayMs = Number(process.env.MOCK_STREAM_DELAY_MS) || 0;
const writeChunk = index => {
if (index >= chunks.length) return sendTail();
res.write(
'data: ' +
JSON.stringify({ choices: [{ delta: { content: chunks[index] } }] }) +
'\n\n'
);
if (streamDelayMs > 0) {
setTimeout(() => writeChunk(index + 1), streamDelayMs);
} else {
writeChunk(index + 1);
}
};
const sendTail = () => {
if (toolCalls.length > 0) {
res.write(
'data: ' +
JSON.stringify({
choices: [
{
delta: {
tool_calls: toolCalls.map((call, index) => ({
index,
id: call.id,
function: call.function,
})),
},
},
],
}) +
'\n\n'
);
}
res.write(
'data: ' +
JSON.stringify({
choices: [
{
delta: {},
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
},
],
}) +
'\n\n'
);
res.write('data: [DONE]\n\n');
return res.end();
};
writeChunk(0);
return;
}
const message = { role: 'assistant', content: reply };
if (toolCalls.length > 0) message.tool_calls = toolCalls;
return json(
res,
200,
{
id: 'mock-1',
object: 'chat.completion',
model: MODEL,
choices: [
{
index: 0,
message,
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
},
],
},
headers
);
}
json(res, 404, { error: { message: 'not found: ' + req.url } });
});
});
server.listen(PORT, '127.0.0.1', () => {
console.log(
`mock AI provider listening on http://127.0.0.1:${PORT}/v1 (model ${MODEL})`
);
});
@@ -0,0 +1,23 @@
{
"designer": [
{
"content": "Reading the project to ground the design.",
"toolCalls": [
{
"id": "d_read",
"name": "read_game_project_json",
"arguments": { "path": "scenes" }
}
]
},
{
"content": "GDD_Overview written: a small harbor town."
}
],
"developer": [
{ "content": "Implementation done: HarborTown scene and objects built." }
],
"tester": [
{ "content": "Tests pass: Population increased after placing a house." }
]
}
@@ -0,0 +1,33 @@
[
{
"content": "Planning three tasks.",
"toolCalls": [
{ "id": "c_plan", "name": "create_or_update_plan", "arguments": { "tasks": [
{ "id": "design", "title": "Design", "description": "Write GDD_Overview.", "status": "pending", "dependsOn": [] },
{ "id": "build", "title": "Build", "description": "Place tiles.", "status": "pending", "dependsOn": ["design"] },
{ "id": "test", "title": "Test", "description": "Verify.", "status": "pending", "dependsOn": ["build"] }
] } }
]
},
{
"content": "Delegating design.",
"toolCalls": [
{ "id": "c_spawn1", "name": "spawn_agent", "arguments": { "role": "designer", "short_title": "Design", "task": "Write GDD_Overview.", "related_task_id": "design" } }
]
},
{
"content": "Delegating build.",
"toolCalls": [
{ "id": "c_spawn2", "name": "spawn_agent", "arguments": { "role": "developer", "short_title": "Build", "task": "Place the tiles.", "related_task_id": "build" } }
]
},
{
"content": "Delegating test.",
"toolCalls": [
{ "id": "c_spawn3", "name": "spawn_agent", "arguments": { "role": "tester", "short_title": "Test", "task": "Verify the build.", "related_task_id": "test" } }
]
},
{
"content": "All three tasks are delegated and reported."
}
]
@@ -0,0 +1,24 @@
[
{
"content": "Planning design and build.",
"toolCalls": [
{ "id": "c_plan", "name": "create_or_update_plan", "arguments": { "tasks": [
{ "id": "design", "title": "Design", "description": "Write GDD_Overview.", "status": "pending", "dependsOn": [] },
{ "id": "build", "title": "Build", "description": "Build the scene.", "status": "pending", "dependsOn": ["design"] }
] } }
]
},
{
"content": "Delegating design.",
"toolCalls": [
{ "id": "c_design", "name": "spawn_agent", "arguments": { "role": "designer", "short_title": "Design", "task": "Write GDD_Overview.", "related_task_id": "design" } }
]
},
{
"content": "Delegating build.",
"toolCalls": [
{ "id": "c_build", "name": "spawn_agent", "arguments": { "role": "developer", "short_title": "Build", "task": "Build the scene.", "related_task_id": "build" } }
]
},
{ "content": "Both tasks delegated and reported." }
]
@@ -0,0 +1,40 @@
[
{
"content": "Planning first.",
"toolCalls": [
{
"id": "call_plan_1",
"name": "create_or_update_plan",
"arguments": {
"tasks": [
{
"id": "design",
"title": "Design the town",
"description": "Write a one-sentence design.",
"status": "pending",
"dependsOn": []
}
]
}
}
]
},
{
"content": "Delegating the design task.",
"toolCalls": [
{
"id": "call_spawn_1",
"name": "spawn_agent",
"arguments": {
"role": "designer",
"short_title": "Design the town",
"task": "Write GDD_Overview in one sentence.",
"related_task_id": "design"
}
}
]
},
{
"content": "The designer reported back; the design task is done."
}
]
+20
View File
@@ -0,0 +1,20 @@
# packages/unlock-shim — injected seams (Tier-1 + Tier-2 support)
Thin, tested modules that sit between stock upstream code and the
unlocked runtime. Everything here is framework-free and covered by
`tests/` — no React, no Electron, no GDevelop core imports.
| Module | Role | Tests |
|---|---|---|
| `generation-shim.mjs` | Tier-2 routing: `local-ai-*` → custom client, hosted → hosted API, fail-closed leak guard (`assertNoLocalIdsLeak`, `guardHostedCall`) | `tests/tier2/shim.test.mjs` |
## Wiring contract (what the glue patches connect)
- Patch 07 calls `selectBackend({aiRequestId(s), customConfig})` at each
Generation.js branch point; every hosted call site is wrapped with
`guardHostedCall(fn, endpoint, pickIds)` so a `local-ai-*` id can
never leak to `api.gdevelop.io/generation`, even if a branch is missed.
- `isCustomEndpointEnabled(config)` is pure: the caller supplies the
endpoint config (read via `CustomAIClient.getCustomEndpointConfig`,
backed by `gd-custom-ai-config`). No localStorage access in this module
keeps it runnable in Node tests.
+92
View File
@@ -0,0 +1,92 @@
// Injected Generation shim (Phase 4, issue #4): the ONLY rewiring
// between stock upstream Generation.js and packages/ai-studio/.
//
// The fork's Generation.js delta (patches/07) branches every request
// path on `local-ai-*` ids / `isCustomEndpointEnabled()`. This module
// owns the three invariants behind those branches as pure, tested
// functions so the routing can never silently degrade:
//
// 1. local-ai-* requests route to the custom (BYOK) client, always.
// 2. Hosted requests route to the hosted API, unchanged.
// 3. A local-ai-* id NEVER reaches the hosted generation API
// (no local-id leakage) — guarded, not just branched.
//
// `isCustomEndpointEnabled` here is pure (takes the endpoint config);
// reading localStorage (`gd-custom-ai-config`) stays with the caller
// (CustomAIClient.getCustomEndpointConfig), which the patch wires in.
const LOCAL_AI_REQUEST_ID_PREFIX = 'local-ai-';
/** True for BYOK-local request ids (`local-ai-*`). */
export function isLocalAiRequestId(aiRequestId) {
return (
typeof aiRequestId === 'string' &&
aiRequestId.startsWith(LOCAL_AI_REQUEST_ID_PREFIX)
);
}
/** Split a batch into local vs hosted ids (mirrors the fork's 07 split). */
export function partitionAiRequestIds(aiRequestIds) {
const localIds = [];
const hostedIds = [];
for (const id of aiRequestIds || []) {
if (isLocalAiRequestId(id)) localIds.push(id);
else hostedIds.push(id);
}
return { localIds, hostedIds };
}
/**
* Pure endpoint switch. `config` is the CustomAIConfig shape
* ({enabled, baseUrl, ...}); absent/disabled config means hosted.
*/
export function isCustomEndpointEnabled(config) {
return !!(
config &&
config.enabled &&
typeof config.baseUrl === 'string' &&
config.baseUrl.length > 0
);
}
/**
* Backend selection for one request path.
* - Any local-ai-* id -> 'custom' (config-independent: local
* requests have no hosted representation at all).
* - Custom endpoint enabled -> 'custom'.
* - Otherwise -> 'hosted'.
*/
export function selectBackend({ aiRequestId, aiRequestIds, customConfig }) {
const ids =
aiRequestIds || (aiRequestId !== undefined ? [aiRequestId] : []);
if (ids.some(isLocalAiRequestId)) return 'custom';
if (isCustomEndpointEnabled(customConfig)) return 'custom';
return 'hosted';
}
/**
* Fail-closed guard: throw before any hosted generation call that
* would carry a local-ai-* id (leakage). Callers wrap every hosted
* fetch with this; the fork's 07 branches rely on it at each site.
*/
export function assertNoLocalIdsLeak(aiRequestIds, hostedEndpoint) {
const ids = aiRequestIds || [];
const leaked = ids.filter(isLocalAiRequestId);
if (leaked.length > 0) {
throw new Error(
`[unlock-shim] refusing hosted call to ${hostedEndpoint} with ` +
`local-ai-* ids (${leaked.join(', ')}): route to the custom ` +
`client instead.`
);
}
}
/**
* Wrap a hosted generation function with the leak guard. `pickIds(args)`
* extracts the request id(s) from the hosted function's arguments.
*/
export function guardHostedCall(hostedFn, hostedEndpoint, pickIds) {
return function guardedHostedCall(...args) {
assertNoLocalIdsLeak(pickIds(...args), hostedEndpoint);
return hostedFn.apply(this, args);
};
}
+74
View File
@@ -0,0 +1,74 @@
--- a/newIDE/app/src/Utils/GDevelopServices/Usage.js
+++ b/newIDE/app/src/Utils/GDevelopServices/Usage.js
@@ -602,21 +602,59 @@
return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER';
};
+export const UNLOCKED_HERETEK_SUBSCRIPTION: Subscription = {
+ userId: 'local-byok-user',
+ planId: 'gdevelop_startup',
+ pricingSystemId: 'HERETEK_UNLOCKED',
+ createdAt: 0,
+ updatedAt: 0,
+};
+
+export const UNLOCKED_HERETEK_CAPABILITIES: Capabilities = {
+ analytics: {
+ sessions: true,
+ players: true,
+ retention: true,
+ sessionsTimeStats: true,
+ platforms: true,
+ },
+ cloudProjects: {
+ maximumCount: 999,
+ canMaximumCountBeIncreased: true,
+ maximumGuestCollaboratorsPerProject: 99,
+ projectRestorationTimeWindowInSeconds: -1,
+ deletedProjectRetentionInDays: 365,
+ },
+ leaderboards: {
+ maximumCountPerGame: 999,
+ canMaximumCountPerGameBeIncreased: true,
+ themeCustomizationCapabilities: 'FULL',
+ canUseCustomCss: true,
+ canDisableLoginInLeaderboard: true,
+ },
+ multiplayer: {
+ lobbiesCount: 99,
+ maxPlayersPerLobby: 64,
+ themeCustomizationCapabilities: 'FULL',
+ },
+ versionHistory: {
+ enabled: true,
+ retentionDays: 365,
+ },
+ ai: {
+ availablePresets: [],
+ versionHistory: {
+ enabled: true,
+ retentionDays: 365,
+ },
+ },
+};
+
export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
- const hasValidSubscription =
- !!subscription &&
- !!subscription.planId &&
- (!subscription.redemptionCodeValidUntil || // No redemption code
- subscription.redemptionCodeValidUntil > Date.now()); // Redemption code is still valid
-
- if (hasValidSubscription) {
- // The user has a subscription registered in the backend (classic "Registered" user).
- return true;
- }
-
- return false;
+ // In GDevelop-Heretek, all client-side and offline features are unlocked for all users.
+ return true;
};
type UploadType = 'build' | 'preview';
+57
View File
@@ -0,0 +1,57 @@
--- a/newIDE/app/src/Profile/AuthenticatedUserContext.js
+++ b/newIDE/app/src/Profile/AuthenticatedUserContext.js
@@ -23,6 +23,8 @@
type Subscription,
type SubscriptionPlanPricingSystem,
type UserEarningsBalance,
+ UNLOCKED_HERETEK_SUBSCRIPTION,
+ UNLOCKED_HERETEK_CAPABILITIES,
} from '../Utils/GDevelopServices/Usage';
import {
type AssetShortHeader,
@@ -132,11 +134,20 @@
coursePurchases: null,
bundlePurchases: null,
recommendations: null,
- subscription: null,
+ subscription: UNLOCKED_HERETEK_SUBSCRIPTION,
subscriptionPricingSystem: null,
usages: null,
userEarningsBalance: null,
- limits: null,
+ limits: {
+ quotas: {},
+ capabilities: UNLOCKED_HERETEK_CAPABILITIES,
+ credits: {
+ userBalance: { amount: 999999 },
+ prices: {},
+ purchasableQuantities: [],
+ },
+ message: undefined,
+ },
...authenticatedUserPropertiesLoadingState,
authenticationError: null,
onLogin: async () => {},
@@ -190,11 +201,20 @@
receivedAssetShortHeaders: ([]: Array<empty>), // Initialize to empty array to indicate that the loading is done.
receivedGameTemplates: ([]: Array<empty>), // Initialize to empty array to indicate that the loading is done.
receivedBundles: ([]: Array<empty>), // Initialize to empty array to indicate that the loading is done.
- subscription: null,
+ subscription: UNLOCKED_HERETEK_SUBSCRIPTION,
subscriptionPricingSystem: null,
userEarningsBalance: null,
usages: null,
- limits: null,
+ limits: {
+ quotas: {},
+ capabilities: UNLOCKED_HERETEK_CAPABILITIES,
+ credits: {
+ userBalance: { amount: 999999 },
+ prices: {},
+ purchasableQuantities: [],
+ },
+ message: undefined,
+ },
};
const AuthenticatedUserContext: React.Context<AuthenticatedUser> = React.createContext<AuthenticatedUser>(
+78
View File
@@ -0,0 +1,78 @@
--- a/newIDE/app/src/Profile/Subscription/SubscriptionChecker.js
+++ b/newIDE/app/src/Profile/Subscription/SubscriptionChecker.js
@@ -1,19 +1,11 @@
// @flow
-
import * as React from 'react';
import { Trans } from '@lingui/macro';
import FlatButton from '../../UI/FlatButton';
import Dialog from '../../UI/Dialog';
-import AuthenticatedUserContext from '../AuthenticatedUserContext';
import { Column } from '../../UI/Grid';
-import {
- sendSubscriptionCheckDialogShown,
- sendSubscriptionCheckDismiss,
-} from '../../Utils/Analytics/EventSender';
+import { sendSubscriptionCheckDismiss } from '../../Utils/Analytics/EventSender';
import Text from '../../UI/Text';
-import { hasValidSubscriptionPlan } from '../../Utils/GDevelopServices/Usage';
-import { isNativeMobileApp } from '../../Utils/Platform';
-import InAppTutorialContext from '../../InAppTutorial/InAppTutorialContext';
import GetSubscriptionCard from './GetSubscriptionCard';
import { ColumnStackLayout } from '../../UI/Layout';
import { type SubscriptionPlacementId } from '../../Utils/Analytics/EventSender';
@@ -40,18 +32,7 @@
...Props,
+ref?: React.RefSetter<SubscriptionCheckerInterface>,
}> = React.forwardRef<Props, SubscriptionCheckerInterface>(
- (
- {
- mode,
- id,
- title,
- onChangeSubscription,
- placementId,
- isNotShownDuringInAppTutorial,
- },
- ref
- ) => {
- const authenticatedUser = React.useContext(AuthenticatedUserContext);
+ ({ mode, id, title, onChangeSubscription, placementId }, ref) => {
const [dialogOpen, setDialogOpen] = React.useState<boolean>(false);
const closeDialog = () => {
@@ -59,31 +40,13 @@
setDialogOpen(false);
};
- const { currentlyRunningInAppTutorial } = React.useContext(
- InAppTutorialContext
- );
-
const checkUserHasSubscription = () => {
- if (
- hasValidSubscriptionPlan(authenticatedUser.subscription) ||
- (isNotShownDuringInAppTutorial && currentlyRunningInAppTutorial)
- ) {
- setDialogOpen(false);
- return true;
- }
-
- if (isNativeMobileApp()) {
- // Would present App Store screen.
- } else {
- setDialogOpen(true);
- sendSubscriptionCheckDialogShown({ mode, id });
- }
-
- return false;
+ setDialogOpen(false);
+ return true;
};
const hasUserSubscription = () => {
- return hasValidSubscriptionPlan(authenticatedUser.subscription);
+ return true;
};
React.useImperativeHandle(ref, () => ({
+73
View File
@@ -0,0 +1,73 @@
--- a/newIDE/app/src/ProjectManager/LoadingScreenEditor.js
+++ b/newIDE/app/src/ProjectManager/LoadingScreenEditor.js
@@ -105,16 +105,6 @@
}
checked={loadingScreen.isGDevelopLogoShownDuringLoadingScreen()}
onCheck={(e, checked) => {
- if (
- !checked &&
- !watermark.isGDevelopWatermarkShown() &&
- subscriptionChecker.current &&
- !subscriptionChecker.current.checkUserHasSubscription()
- ) {
- // If user wants to deactivate GDevelop splash screen although
- // watermark is hidden, we don't allow it if they have no subscription.
- return;
- }
loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);
onUpdate();
}}
@@ -161,53 +151,7 @@
}
checked={watermark.isGDevelopWatermarkShown()}
onCheck={(e, checked) => {
- if (
- !checked &&
- !loadingScreen.isGDevelopLogoShownDuringLoadingScreen() &&
- subscriptionChecker.current &&
- !subscriptionChecker.current.checkUserHasSubscription()
- ) {
- // If user wants to deactivate watermark although GDevelop splash
- // screen is hidden, we don't allow it if they have no subscription.
- return;
- }
watermark.showGDevelopWatermark(checked);
- if (checked) {
- loadingScreen.setMinDuration(
- timeSettings.current.minDuration
- );
- loadingScreen.setLogoAndProgressFadeInDuration(
- timeSettings.current.logoAndProgressFadeInDuration
- );
- loadingScreen.setLogoAndProgressLogoFadeInDelay(
- timeSettings.current.logoAndProgressLogoFadeInDelay
- );
- } else if (
- subscriptionChecker.current &&
- !subscriptionChecker.current.hasUserSubscription()
- ) {
- if (
- loadingScreen.getMinDuration() < forcedLogo.minDuration
- ) {
- loadingScreen.setMinDuration(forcedLogo.minDuration);
- }
- if (
- loadingScreen.getLogoAndProgressFadeInDuration() >
- forcedLogo.logoAndProgressFadeInDuration
- ) {
- loadingScreen.setLogoAndProgressFadeInDuration(
- forcedLogo.logoAndProgressFadeInDuration
- );
- }
- if (
- loadingScreen.getLogoAndProgressLogoFadeInDelay() >
- forcedLogo.logoAndProgressLogoFadeInDelay
- ) {
- loadingScreen.setLogoAndProgressLogoFadeInDelay(
- forcedLogo.logoAndProgressLogoFadeInDelay
- );
- }
- }
onUpdate();
}}
/>
+46
View File
@@ -0,0 +1,46 @@
--- a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js
+++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js
@@ -437,12 +437,6 @@
});
}
if (!previewOptions.isForInGameEdition) {
- if (
- this.state.hotReloadsCount % 16 === 0 &&
- this._hotReloadSubscriptionChecker
- ) {
- this._hotReloadSubscriptionChecker.checkUserHasSubscription();
- }
this.setState(state => ({
hotReloadsCount: state.hotReloadsCount + 1,
}));
@@ -487,9 +481,7 @@
}
_checkSubscriptionForNetworkPreview = (): any => {
- if (!this._networkPreviewSubscriptionChecker) return true;
-
- return this._networkPreviewSubscriptionChecker.checkUserHasSubscription();
+ return true;
};
render(): any {
--- a/newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js
+++ b/newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js
@@ -133,16 +133,7 @@
}
_checkUserHasSubscription() {
- if (
- this._subscriptionChecker &&
- this.props.isActive &&
- !this.state.subscriptionChecked
- ) {
- this._subscriptionChecker.checkUserHasSubscription();
- this.setState({
- subscriptionChecked: true,
- });
- }
+ // Unlocked in GDevelop-Heretek
}
render(): any {
+19
View File
@@ -0,0 +1,19 @@
--- a/newIDE/electron-app/app/main.js
+++ b/newIDE/electron-app/app/main.js
@@ -1,3 +1,16 @@
+// GDevelop-Unlocked (Phase 3, issue #3): install the Tier-1 runtime hook
+// before any module destructures `electron.BrowserWindow` (main.js:6,
+// PreviewWindow.js:1-6). Re-applied by scripts/apply-patches.mjs, which
+// also syncs launcher/*.cjs next to this file. Updates drop the hook:
+// see launcher/README.md ("auto-updater pin").
+try {
+ require('./heretek-hook.cjs').installPreloadHook();
+} catch (error) {
+ console.error(
+ '[heretek] hook install failed (Tier-1 runtime fallback only):',
+ error
+ );
+}
const electron = require('electron');
const path = require('path');
const fs = require('fs');
+713
View File
@@ -0,0 +1,713 @@
--- a/newIDE/app/src/Utils/GDevelopServices/Generation.js
+++ b/newIDE/app/src/Utils/GDevelopServices/Generation.js
@@ -5,11 +5,26 @@
import { type MessageByLocale } from '../i18n/MessageByLocale';
import { getIDEVersionWithHash } from '../../Version';
import { extractNextPageUriFromLinkHeader } from './Play';
+import { ensureIsArray, ensureObjectHasProperty } from '../DataValidator';
import {
- ensureIsArray,
- ensureIsObject,
- ensureObjectHasProperty,
-} from '../DataValidator';
+ isCustomEndpointEnabled,
+ customCreateAiRequest,
+ customAddMessageToAiRequest,
+ customGetAiRequest,
+ customGetAiRequests,
+ customGetAiRequestStatuses,
+ customSuspendAiRequest,
+ customForkAiRequest,
+ customDeleteAiRequest,
+ customPatchAiRequestAttributes,
+ customRetryAiRequest,
+ customGetAiRequestSuggestions,
+ customCreateAiGeneratedEvent,
+ customCreateAssetSearch,
+ customCreateResourceSearch,
+ DEFAULT_LOCAL_AI_SETTINGS,
+ LOCAL_BYOK_USER_ID,
+} from '../../AI/CustomAIClient';
export type Environment = 'staging' | 'live';
@@ -83,15 +98,6 @@
projectVersionIdAfterMessage?: string,
};
-export type AiRequestAttachment = {
- attachmentId: string,
- name: string,
- mimeType: string,
- size: number,
- // How the AI reads the file: an image or a text (otherwise, only its name).
- readableAs: 'image' | 'text' | null,
-};
-
export type AiRequestUserMessage = {
type: 'message',
status: 'completed',
@@ -101,7 +107,6 @@
status: 'completed',
text: string,
}>,
- attachments?: Array<AiRequestAttachment>,
messageId?: string,
projectVersionIdBeforeMessage?: string,
};
@@ -162,6 +167,9 @@
forkedAfterOriginalMessageId?: string | null,
forkedAfterNewMessageId?: string | null,
parentAiRequestId?: string | null,
+ // The studio role a sub-agent was spawned with (fork-only; set only on local
+ // sub-agent requests) so its tool subset and prompt survive every turn.
+ studioRoleId?: string | null,
error: AiRequestError | null,
contextStats?: AiRequestContextStats | null,
@@ -252,9 +260,6 @@
// sub-events), computed by the editor, that the backend compares the
// anchor against.
placementTargetEventSource: string | null,
- // `eventScript` was built by applying `edits` to the current source of the
- // event: a compile error is always reported (never generated by the LLM).
- isEventScriptFromEdits: boolean,
};
export type AiGeneratedEvent = {
@@ -340,6 +345,10 @@
outputFromMessageId?: ?string,
|}
): Promise<AiRequest> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customGetAiRequest(aiRequestId);
+ }
+
const authorizationHeader = await getAuthorizationHeader();
// $FlowFixMe[underconstrained-implicit-instantiation]
const response = await axios.get(
@@ -380,25 +389,42 @@
> => {
if (aiRequestIds.length === 0) return [];
- const authorizationHeader = await getAuthorizationHeader();
- // $FlowFixMe[underconstrained-implicit-instantiation]
- const response = await axios.get(
- `${GDevelopGenerationApi.baseUrl}/ai-request`,
- {
- params: {
- userId,
- ids: aiRequestIds.join(','),
- include: 'status',
- },
- headers: {
- Authorization: authorizationHeader,
- },
+ const localIds = aiRequestIds.filter(id => id.startsWith('local-ai-'));
+ const hostedIds = aiRequestIds.filter(id => !id.startsWith('local-ai-'));
+
+ const results = [];
+ if (localIds.length > 0) {
+ const localStatuses = await customGetAiRequestStatuses(localIds);
+ results.push(...localStatuses);
+ }
+ if (hostedIds.length > 0) {
+ const authorizationHeader = await getAuthorizationHeader();
+ // $FlowFixMe[underconstrained-implicit-instantiation]
+ const response = await axios.get(
+ `${GDevelopGenerationApi.baseUrl}/ai-request`,
+ {
+ params: {
+ userId,
+ ids: hostedIds.join(','),
+ include: 'status',
+ },
+ headers: {
+ Authorization: authorizationHeader,
+ },
+ }
+ );
+ // The response body is unvalidated: a truthy non-array (an object, a
+ // number) makes the spread throw, and a non-object element breaks the
+ // consumer that destructures `{ id, status }`. Keep only object entries.
+ if (Array.isArray(response.data)) {
+ results.push(
+ ...response.data.filter(
+ item => item && typeof item === 'object' && !Array.isArray(item)
+ )
+ );
}
- );
- return ensureIsArray({
- data: response.data,
- endpointName: '/ai-request?ids=...&include=status of Generation API',
- });
+ }
+ return results;
};
/**
@@ -469,6 +495,36 @@
all: 'any',
};
+/**
+ * Local BYOK chats from the client cache, shaped like the hosted
+ * /ai-request-summary contract (summaries only, no sub-agents), filtered by
+ * archived state and game when asked. Always available offline — independent
+ * of whether the custom endpoint toggle is currently on.
+ */
+const getLocalAiRequestSummaries = ({
+ filter,
+ gameId,
+}: {|
+ filter: AiRequestSummariesFilter,
+ gameId?: ?string,
+|}): Array<AiRequestSummary> => {
+ const { aiRequests } = customGetAiRequests();
+ return aiRequests
+ .filter(aiRequest => {
+ if (aiRequest.parentAiRequestId) return false;
+ if (
+ gameId !== undefined &&
+ gameId !== null &&
+ (aiRequest.gameId || null) !== gameId
+ )
+ return false;
+ if (filter === 'active') return !aiRequest.archivedAt;
+ if (filter === 'archived') return !!aiRequest.archivedAt;
+ return true;
+ })
+ .map(getAiRequestSummary);
+};
+
export const getAiRequestSummaries = async (
getAuthorizationHeader: () => Promise<string>,
{
@@ -489,33 +545,54 @@
aiRequestSummaries: Array<AiRequestSummary>,
nextPageUri: ?string,
}> => {
+ // Always list local BYOK chats (from the client cache) so toggling the
+ // custom endpoint off or logging out does not hide them. The hosted page is
+ // fetched only when a real profile userId is given — LOCAL_BYOK is offline.
+ const localSummaries = getLocalAiRequestSummaries({ filter, gameId });
+ if (userId === LOCAL_BYOK_USER_ID) {
+ return { aiRequestSummaries: localSummaries, nextPageUri: null };
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const uri = forceUri || '/ai-request-summary';
- // $FlowFixMe[incompatible-type]
- const response = await apiClient.get(uri, {
- headers: {
- Authorization: authorizationHeader,
- },
- params: forceUri
- ? { userId }
- : {
- userId,
- perPage: 10,
- archived: archivedParameterByFilter[filter],
- gameId: gameId || undefined,
- },
- });
- const nextPageUri = response.headers.link
- ? extractNextPageUriFromLinkHeader(response.headers.link)
- : null;
- return {
- aiRequestSummaries: ensureIsArray({
+ try {
+ // $FlowFixMe[incompatible-type]
+ const response = await apiClient.get(uri, {
+ headers: {
+ Authorization: authorizationHeader,
+ },
+ params: forceUri
+ ? { userId }
+ : {
+ userId,
+ perPage: 10,
+ archived: archivedParameterByFilter[filter],
+ gameId: gameId || undefined,
+ },
+ });
+ const nextPageUri = response.headers.link
+ ? extractNextPageUriFromLinkHeader(response.headers.link)
+ : null;
+ const hostedSummaries = ensureIsArray({
data: response.data,
endpointName: '/ai-request-summary of Generation API',
- }),
- nextPageUri,
- };
+ });
+ // Merge (statuses already do this for local-ai-* ids): local first, then
+ // the hosted page. The UI re-sorts by createdAt before display.
+ return {
+ aiRequestSummaries: [...localSummaries, ...hostedSummaries],
+ nextPageUri,
+ };
+ } catch (error) {
+ // Offline / hosted outage: still show whatever local chats exist rather
+ // than blanking the history. Re-throw only when there is nothing to fall
+ // back to, so the context can surface its retry UI.
+ if (localSummaries.length > 0 || isCustomEndpointEnabled()) {
+ return { aiRequestSummaries: localSummaries, nextPageUri: null };
+ }
+ throw error;
+ }
};
export const createAiRequest = async (
@@ -523,7 +600,6 @@
{
userId,
userRequest,
- attachmentIds,
gameProjectJson,
gameProjectJsonUserRelativeKey,
projectSpecificExtensionsSummaryJson,
@@ -539,7 +615,6 @@
}: {|
userId: string,
userRequest: string,
- attachmentIds: Array<string>,
gameProjectJson: string | null,
gameProjectJsonUserRelativeKey: string | null,
projectSpecificExtensionsSummaryJson: string | null,
@@ -559,13 +634,25 @@
toolsVersion: string,
|}
): Promise<AiRequest> => {
+ // Offline BYOK (local-byok-user) must create locally even if the endpoint
+ // toggle was flipped off after the session started.
+ if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) {
+ return customCreateAiRequest({
+ userRequest,
+ gameProjectJson,
+ projectSpecificExtensionsSummaryJson,
+ mode,
+ aiConfiguration,
+ gameId,
+ });
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
'/ai-request',
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
userRequest,
- attachmentIds,
gameProjectJson,
gameProjectJsonUserRelativeKey,
projectSpecificExtensionsSummaryJson,
@@ -603,7 +690,6 @@
aiRequestId,
functionCallOutputs,
userMessage,
- attachmentIds,
gameId,
projectVersionIdBeforeMessage,
payWithCredits,
@@ -618,7 +704,6 @@
userId: string,
aiRequestId: string,
userMessage: string,
- attachmentIds?: Array<string>,
gameId?: string,
projectVersionIdBeforeMessage?: string | null,
functionCallOutputs: Array<AiRequestFunctionCallOutput>,
@@ -632,6 +717,17 @@
toolsVersion?: string,
|}
): Promise<AiRequest> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customAddMessageToAiRequest({
+ aiRequestId,
+ userMessage,
+ functionCallOutputs,
+ gameProjectJson,
+ projectSpecificExtensionsSummaryJson,
+ mode,
+ });
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/add-message`,
@@ -639,7 +735,6 @@
gdevelopVersionWithHash: getIDEVersionWithHash(),
functionCallOutputs,
userMessage,
- attachmentIds,
gameId,
projectVersionIdBeforeMessage,
payWithCredits: !!payWithCredits,
@@ -676,6 +771,10 @@
getAuthorizationHeader: () => Promise<string>,
{ userId, aiRequestId }: {| userId: string, aiRequestId: string |}
): Promise<AiRequest> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customRetryAiRequest(aiRequestId);
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/retry`,
@@ -693,6 +792,10 @@
getAuthorizationHeader: () => Promise<string>,
{ userId, aiRequestId }: {| userId: string, aiRequestId: string |}
): Promise<AiRequest> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customSuspendAiRequest(aiRequestId);
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/suspend`,
@@ -725,6 +828,10 @@
archived?: boolean,
|}
): Promise<AiRequest> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customPatchAiRequestAttributes(aiRequestId, { title, archived });
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const attributes: { title?: string | null, archived?: boolean } = {};
if (title !== undefined) attributes.title = title;
@@ -745,6 +852,11 @@
getAuthorizationHeader: () => Promise<string>,
{ userId, aiRequestId }: {| userId: string, aiRequestId: string |}
): Promise<void> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ customDeleteAiRequest(aiRequestId);
+ return;
+ }
+
const authorizationHeader = await getAuthorizationHeader();
await apiClient.delete(`/ai-request/${aiRequestId}`, {
params: { userId },
@@ -768,6 +880,9 @@
projectVersionIdAfterMessage?: ?string,
|}
): Promise<void> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return;
+ }
const authorizationHeader = await getAuthorizationHeader();
await apiClient.patch(
`/ai-request/${aiRequestId}/message/${aiRequestMessageId}`,
@@ -804,6 +919,11 @@
freeFormDetails?: string,
|}
): Promise<AiRequest> => {
+ // Local BYOK chats have no hosted feedback store — never POST local-ai-*
+ // ids to the Generation API (same short-circuit as retry/suspend/delete).
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customGetAiRequest(aiRequestId);
+ }
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/set-feedback`,
@@ -850,6 +970,10 @@
projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null,
|}
): Promise<AiRequest> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customGetAiRequestSuggestions(aiRequestId);
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/get-suggestions`,
@@ -919,7 +1043,7 @@
functionName: string | null,
sceneName: string,
eventsDescription: string | null,
- eventBatches: Array<AiGeneratedEventBatch> | null,
+ eventBatches: ?EventsBatchToGenerate,
extensionNamesList: string,
objectsList: string,
existingEventsJson: string | null,
@@ -929,6 +1053,24 @@
estimatedComplexity: number | null,
|}
): Promise<CreateAiGeneratedEventResult> => {
+ // Local chats (local-ai-*) and offline BYOK users must not hit the hosted
+ // event-generation API when the endpoint toggle is off (mixed history).
+ if (
+ isCustomEndpointEnabled() ||
+ (userId && userId.startsWith('local-')) ||
+ (relatedAiRequestId && relatedAiRequestId.startsWith('local-ai-'))
+ ) {
+ return customCreateAiGeneratedEvent({
+ sceneName,
+ eventsDescription,
+ eventBatches,
+ extensionNamesList,
+ objectsList,
+ existingEventsAsText: existingEventsJson || undefined,
+ aiRequestId: relatedAiRequestId || null,
+ });
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-generated-event`,
@@ -998,6 +1140,28 @@
aiGeneratedEventId: string,
|}
): Promise<AiGeneratedEvent> => {
+ if (aiGeneratedEventId.startsWith('local-evt-')) {
+ return {
+ id: aiGeneratedEventId,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ userId: LOCAL_BYOK_USER_ID,
+ status: 'ready',
+ partialGameProjectJson: '',
+ eventsDescription: null,
+ eventBatches: null,
+ extensionNamesList: '',
+ objectsList: '',
+ existingEventsAsText: '',
+ existingEventsJson: null,
+ existingEventsJsonUserRelativeKey: null,
+ resultMessage: null,
+ changes: [],
+ error: null,
+ stats: null,
+ };
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.get(
`/ai-generated-event/${aiGeneratedEventId}`,
@@ -1022,8 +1186,8 @@
{
userId,
searchTerms,
- description,
objectType,
+ description,
twoDimensionalViewKind,
exactOrPartialAssetId,
relatedAiRequestId,
@@ -1032,23 +1196,30 @@
}: {|
userId: string,
searchTerms: string,
- description: string,
- objectType: string | null,
- twoDimensionalViewKind: string,
- exactOrPartialAssetId?: string | null,
- relatedAiRequestId?: string | null,
- lastUserMessage?: string | null,
+ objectType?: ?string,
+ description?: ?string,
+ twoDimensionalViewKind?: ?string,
+ exactOrPartialAssetId?: ?string,
+ relatedAiRequestId?: ?string,
+ lastUserMessage?: ?string,
lastAssistantMessages?: string[],
|}
): Promise<AssetSearch> => {
+ if (
+ isCustomEndpointEnabled() ||
+ (userId && userId.startsWith('local-')) ||
+ (relatedAiRequestId && relatedAiRequestId.startsWith('local-ai-'))
+ ) {
+ return customCreateAssetSearch({ searchTerms, objectType });
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/asset-search`,
{
- gdevelopVersionWithHash: getIDEVersionWithHash(),
searchTerms,
- description,
objectType,
+ description,
twoDimensionalViewKind,
exactOrPartialAssetId,
relatedAiRequestId,
@@ -1083,11 +1254,14 @@
resourceKind: string,
|}
): Promise<ResourceSearch> => {
+ if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) {
+ return customCreateResourceSearch({ searchTerms, resourceKind });
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/resource-search`,
{
- gdevelopVersionWithHash: getIDEVersionWithHash(),
searchTerms,
resourceKind,
},
@@ -1130,11 +1304,20 @@
eventsJsonHash: string | null,
|}
): Promise<AiUserContentPresignedUrlsResult> => {
+ if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) {
+ return {
+ gameProjectJsonSignedUrl: undefined,
+ gameProjectJsonUserRelativeKey: undefined,
+ projectSpecificExtensionsSummaryJsonSignedUrl: undefined,
+ projectSpecificExtensionsSummaryJsonUserRelativeKey: undefined,
+ eventsJsonSignedUrl: undefined,
+ eventsJsonUserRelativeKey: undefined,
+ };
+ }
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-user-content/action/create-presigned-urls`,
{
- gdevelopVersionWithHash: getIDEVersionWithHash(),
gameProjectJsonHash,
projectSpecificExtensionsSummaryJsonHash,
eventsJsonHash,
@@ -1148,88 +1331,14 @@
},
}
);
- return ensureIsObject({
+ return ensureObjectHasProperty({
data: response.data,
+ propertyName: 'gameProjectJsonSignedUrl',
endpointName:
'/ai-user-content/action/create-presigned-urls of Generation API',
});
};
-export type AiAttachmentUpload = {|
- attachmentId: string,
- uploadUrl: string,
- visionCopyUploadUrl: string | null,
-|};
-
-export const createAiAttachmentUploads = async (
- getAuthorizationHeader: () => Promise<string>,
- {
- userId,
- files,
- }: {|
- userId: string,
- files: Array<{|
- name: string,
- mimeType: string,
- size: number,
- visionCopy: {| mimeType: string, size: number |} | null,
- |}>,
- |}
-): Promise<Array<AiAttachmentUpload>> => {
- const authorizationHeader = await getAuthorizationHeader();
- const response = await apiClient.post(
- `/ai-user-content/action/create-attachment-uploads`,
- { files },
- {
- params: { userId },
- headers: { Authorization: authorizationHeader },
- }
- );
- return ensureIsArray({
- data: response.data.attachmentUploads,
- endpointName:
- '/ai-user-content/action/create-attachment-uploads of Generation API',
- });
-};
-
-export type AiAttachmentDownload =
- | {|
- attachmentId: string,
- url: string,
- // A downscaled copy for an image, if any (otherwise the file itself).
- previewUrl: string,
- name: string,
- mimeType: string,
- size: number,
- |}
- | {| attachmentId: string, error: 'not-found' |};
-
-export const createAiAttachmentDownloads = async (
- getAuthorizationHeader: () => Promise<string>,
- {
- userId,
- attachmentIds,
- }: {|
- userId: string,
- attachmentIds: Array<string>,
- |}
-): Promise<Array<AiAttachmentDownload>> => {
- const authorizationHeader = await getAuthorizationHeader();
- const response = await apiClient.post(
- `/ai-user-content/action/create-attachment-downloads`,
- { attachmentIds },
- {
- params: { userId },
- headers: { Authorization: authorizationHeader },
- }
- );
- return ensureIsArray({
- data: response.data.attachmentDownloads,
- endpointName:
- '/ai-user-content/action/create-attachment-downloads of Generation API',
- });
-};
-
export type AiConfigurationPreset = {|
mode: 'chat' | 'agent' | 'orchestrator',
id: string,
@@ -1258,6 +1367,10 @@
upToMessageId?: string,
|}
): Promise<AiRequest> => {
+ if (aiRequestId.startsWith('local-ai-')) {
+ return customForkAiRequest(aiRequestId, upToMessageId);
+ }
+
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/fork`,
@@ -1285,13 +1398,25 @@
}: {|
environment: Environment,
|}): Promise<AiSettings> => {
- // $FlowFixMe[underconstrained-implicit-instantiation]
- const response = await axios.get(
- `${GDevelopAiCdn.baseUrl[environment]}/ai-settings-v2.json`
- );
- return ensureObjectHasProperty({
- data: response.data,
- propertyName: 'aiRequest',
- endpointName: '/ai-settings-v2.json of Generation API',
- });
+ // Local/BYOK sessions never need the hosted preset catalog: plan gating is
+ // unused (all presets available) and DEFAULT_LOCAL_AI_SETTINGS already has
+ // the orchestrator/chat/agent defaults. Skip the CDN so offline custom
+ // endpoint users do not wait on axios timeouts every editor open.
+ if (isCustomEndpointEnabled()) {
+ return DEFAULT_LOCAL_AI_SETTINGS;
+ }
+ try {
+ // $FlowFixMe[underconstrained-implicit-instantiation]
+ const response = await axios.get(
+ `${GDevelopAiCdn.baseUrl[environment]}/ai-settings-v2.json`,
+ { timeout: 5000 }
+ );
+ return ensureObjectHasProperty({
+ data: response.data,
+ propertyName: 'aiRequest',
+ endpointName: '/ai-settings-v2.json of Generation API',
+ });
+ } catch (err) {
+ return DEFAULT_LOCAL_AI_SETTINGS;
+ }
};
+760
View File
@@ -0,0 +1,760 @@
--- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js
+++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js
@@ -21,7 +21,7 @@
} from './PreferencesContext';
import Text from '../../UI/Text';
import EmptyMessage from '../../UI/EmptyMessage';
-import { ColumnStackLayout } from '../../UI/Layout';
+import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout';
import { Tabs } from '../../UI/Tabs';
import SettingsRow, { useSettingsRowControlIds } from '../../UI/SettingsRow';
import VerticalTabButton from '../../UI/VerticalTabButton';
@@ -41,6 +41,9 @@
import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
import { adaptAcceleratorString } from '../../UI/AcceleratorString';
+import RaisedButton from '../../UI/RaisedButton';
+import TextField from '../../UI/TextField';
+import { testConnection, MAX_TIMEOUT_MS } from '../../AI/CustomAIClient';
import {
getElectronAccelerator,
getShortcutDisplayName,
@@ -91,7 +94,7 @@
| 'extensions'
| 'developer';
-export type PreferencesTabName = 'preferences' | 'shortcuts';
+export type PreferencesTabName = 'preferences' | 'ai' | 'shortcuts';
type GetIconFunction = ({
color: string,
@@ -339,6 +342,7 @@
type Props = {|
i18n: I18n,
onClose: (options: {| languageDidChange: boolean |}) => void,
+ onOpenQuickCustomizationDialog: () => void,
/** The tab displayed when the dialog opens. Defaults to the preferences. */
initialTab?: PreferencesTabName,
/** The preferences section displayed when the dialog opens. Defaults to the general one. */
@@ -378,6 +382,7 @@
const PreferencesDialog = ({
i18n,
onClose,
+ onOpenQuickCustomizationDialog,
initialTab,
initialSection,
isDesktop = !!electron,
@@ -444,8 +449,26 @@
setDisableNpmScriptConfirmation,
setUseBackgroundSerializerForSaving,
setShowJsTypeError,
+ setAiCustomEndpointEnabled,
+ setAiCustomBaseUrl,
+ setAiCustomApiKey,
+ setAiCustomModel,
+ setAiCustomFallbackModel,
+ setAiCustomTemperature,
+ setAiCustomTimeoutMs,
+ setAiCustomStreaming,
} = React.useContext(PreferencesContext);
+ const [
+ isTestingAiConnection,
+ setIsTestingAiConnection,
+ ] = React.useState<boolean>(false);
+ const [aiTestResult, setAiTestResult] = React.useState<null | {|
+ success: boolean,
+ message: string,
+ models?: Array<string>,
+ |}>(null);
+
const initialUse3DEditor = React.useRef<boolean>(values.use3DEditor);
const isSearching = searchText.trim() !== '';
@@ -554,7 +577,7 @@
if (sectionNameAtTop && sectionNameAtTop !== currentSection) {
setCurrentSection((sectionNameAtTop: any));
}
- } else {
+ } else if (currentTab === 'shortcuts') {
const areaNameAtTop = getEntryKeyAtTop(
shortcutAreas.map(area => area.name),
getShortcutAreaElementId
@@ -563,6 +586,7 @@
setCurrentShortcutArea((areaNameAtTop: any));
}
}
+ // The 'ai' tab has a single section with no scroll-spy: nothing to update.
};
const hasCustomizedShortcuts = Object.keys(values.userShortcutMap).some(
@@ -1237,6 +1261,16 @@
setUseGDJSDevelopmentWatcher
),
},
+ {
+ id: 'quick-customization',
+ label: i18n._(t`Quick customization`),
+ renderControl: () => (
+ <FlatButton
+ onClick={onOpenQuickCustomizationDialog}
+ label={<Trans>Open</Trans>}
+ />
+ ),
+ },
],
};
case 'contributor':
@@ -1323,6 +1357,152 @@
* sections list on the left (not displayed on mobile) scrolls to a section.
*/
const renderCurrentTabContent = () => {
+ if (currentTab === 'ai') {
+ return (
+ <ColumnStackLayout noMargin>
+ <Text size="block-title">
+ <Trans>Custom AI Provider (BYOK)</Trans>
+ </Text>
+ <Text size="body" color="secondary">
+ <Trans>
+ Connect GDevelop to a local AI server (such as Ollama, llama.cpp,
+ LM Studio, LocalAI) or any OpenAI-compatible API endpoint.
+ </Trans>
+ </Text>
+ <CompactToggleField
+ checked={values.aiCustomEndpointEnabled}
+ onCheck={setAiCustomEndpointEnabled}
+ label={<Trans>Enable Custom / Local AI Endpoint (BYOK)</Trans>}
+ />
+ <LineStackLayout alignItems="center" noMargin>
+ <Text size="body-small" color="secondary">
+ <Trans>Local server presets:</Trans>
+ </Text>
+ {[
+ { label: 'Ollama', baseUrl: 'http://localhost:11434/v1' },
+ { label: 'LM Studio', baseUrl: 'http://localhost:1234/v1' },
+ { label: 'llama.cpp', baseUrl: 'http://localhost:8080/v1' },
+ ].map(preset => (
+ <RaisedButton
+ key={preset.label}
+ label={preset.label}
+ onClick={() => setAiCustomBaseUrl(preset.baseUrl)}
+ />
+ ))}
+ </LineStackLayout>
+ <TextField
+ floatingLabelText={<Trans>Base URL</Trans>}
+ helperMarkdownText={i18n._(
+ t`Examples: \`http://localhost:11434/v1\` (Ollama), \`http://localhost:8080/v1\` (llama.cpp), \`http://localhost:1234/v1\` (LM Studio), \`https://api.openai.com/v1\`, \`https://openrouter.ai/api/v1\``
+ )}
+ fullWidth
+ value={values.aiCustomBaseUrl}
+ onChange={(e, value) => setAiCustomBaseUrl(value)}
+ />
+ <TextField
+ type="password"
+ floatingLabelText={<Trans>API Key</Trans>}
+ helperMarkdownText={i18n._(
+ t`Bearer token for authentication. Leave empty for local servers (Ollama / llama.cpp).`
+ )}
+ fullWidth
+ value={values.aiCustomApiKey}
+ onChange={(e, value) => setAiCustomApiKey(value)}
+ />
+ <TextField
+ floatingLabelText={<Trans>Model Identifier</Trans>}
+ helperMarkdownText={i18n._(
+ t`Examples: \`qwen2.5-coder\`, \`gpt-4o\`, \`llama3.2\`, \`deepseek-chat\`, \`claude-3-5-sonnet\`, \`openrouter/poolside/laguna-s-2.1:free\``
+ )}
+ fullWidth
+ value={values.aiCustomModel}
+ onChange={(e, value) => setAiCustomModel(value)}
+ />
+ <TextField
+ floatingLabelText={<Trans>Fallback model (optional)</Trans>}
+ helperMarkdownText={i18n._(
+ t`Used once if the primary model keeps returning a transient provider error (502/503/429). Leave empty to disable.`
+ )}
+ fullWidth
+ value={values.aiCustomFallbackModel}
+ onChange={(e, value) => setAiCustomFallbackModel(value)}
+ />
+ <TextField
+ type="number"
+ floatingLabelText={<Trans>Temperature (0.0 to 1.0)</Trans>}
+ value={values.aiCustomTemperature}
+ onChange={(e, value) => {
+ const parsed = parseFloat(value);
+ if (Number.isNaN(parsed)) {
+ setAiCustomTemperature(0.7);
+ } else {
+ setAiCustomTemperature(Math.max(0.0, Math.min(1.0, parsed)));
+ }
+ }}
+ />
+ <CompactToggleField
+ checked={!!values.aiCustomStreaming}
+ onCheck={setAiCustomStreaming}
+ label={
+ <Trans>
+ Stream responses (experimental — falls back automatically if the
+ endpoint does not support it)
+ </Trans>
+ }
+ />
+ <TextField
+ type="number"
+ floatingLabelText={<Trans>Request timeout in milliseconds</Trans>}
+ helperMarkdownText={i18n._(
+ t`Time before a request to the AI endpoint is aborted. Increase this for slow local models on large contexts. Default: 120000.`
+ )}
+ fullWidth
+ value={values.aiCustomTimeoutMs}
+ onChange={(e, value) => {
+ const parsed = parseInt(value, 10);
+ if (Number.isNaN(parsed) || parsed <= 0) {
+ setAiCustomTimeoutMs(120000);
+ } else {
+ setAiCustomTimeoutMs(Math.min(parsed, MAX_TIMEOUT_MS));
+ }
+ }}
+ />
+ <LineStackLayout alignItems="center">
+ <RaisedButton
+ label={<Trans>Test Connection</Trans>}
+ primary
+ disabled={isTestingAiConnection}
+ onClick={async () => {
+ setIsTestingAiConnection(true);
+ setAiTestResult(null);
+ try {
+ const res = await testConnection({
+ enabled: true,
+ baseUrl: values.aiCustomBaseUrl,
+ apiKey: values.aiCustomApiKey,
+ model: values.aiCustomModel,
+ temperature: values.aiCustomTemperature,
+ });
+ setAiTestResult(res);
+ } catch (err) {
+ setAiTestResult({
+ success: false,
+ message: err.message || 'Connection test failed',
+ });
+ } finally {
+ setIsTestingAiConnection(false);
+ }
+ }}
+ />
+ </LineStackLayout>
+ {aiTestResult && (
+ <AlertMessage kind={aiTestResult.success ? 'info' : 'error'}>
+ {aiTestResult.message}
+ </AlertMessage>
+ )}
+ </ColumnStackLayout>
+ );
+ }
if (currentTab === 'shortcuts') {
return (
<ColumnStackLayout noMargin expand>
@@ -1424,7 +1604,9 @@
setCurrentTab(tabName);
// Start from the top of the new tab.
if (tabName === 'preferences') setCurrentSection(visibleSections[0].name);
- else setCurrentShortcutArea(shortcutAreas[0].name);
+ else if (tabName === 'shortcuts')
+ setCurrentShortcutArea(shortcutAreas[0].name);
+ // The 'ai' tab has no sub-navigation (no sections list on the left).
pendingScrollElementIdRef.current = '';
};
@@ -1442,6 +1624,7 @@
// The entries of the sections list, on the left of the content: the
// preferences sections or the shortcut areas, depending on the current tab.
+ // The 'ai' tab is a single page with no sub-sections, so it has no entries.
const sectionListEntries =
currentTab === 'preferences'
? visibleSections.map(section => ({
@@ -1451,13 +1634,15 @@
isActive: !isSearching && highlightedSectionName === section.name,
onSelect: () => onSelectSection(section.name),
}))
- : shortcutAreas.map(area => ({
+ : currentTab === 'shortcuts'
+ ? shortcutAreas.map(area => ({
key: area.name,
label: i18n._(commandAreas[area.name]),
getIcon: area.getIcon,
isActive: !isSearching && currentShortcutArea === area.name,
onSelect: () => onSelectShortcutArea(area.name),
- }));
+ }))
+ : [];
return (
<Dialog
@@ -1512,6 +1697,7 @@
onChange={onSelectTab}
options={[
{ value: 'preferences', label: <Trans>Preferences</Trans> },
+ { value: 'ai', label: <Trans>AI Settings</Trans> },
{ value: 'shortcuts', label: <Trans>Keyboard Shortcuts</Trans> },
]}
/>
@@ -1527,7 +1713,7 @@
}
>
<div style={styles.body}>
- {!isMobile && (
+ {!isMobile && sectionListEntries.length > 0 && (
<>
<div style={styles.sectionsColumn}>
<ColumnStackLayout noMargin>
--- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js
+++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js
@@ -218,7 +218,6 @@
hasProjectOpened: boolean,
userShortcutMap: ShortcutMap,
newObjectDialogDefaultTab: 'asset-store' | 'new-object',
- newEffectDialogDefaultTab: 'asset-store' | 'from-scratch',
shareDialogDefaultTab: 'invite' | 'publish',
isMenuBarHiddenInPreview: boolean,
isAlwaysOnTopInPreview: boolean,
@@ -259,6 +258,14 @@
disableNpmScriptConfirmation: boolean,
showJsTypeError: boolean,
canonicalEventSerialization: boolean,
+ aiCustomEndpointEnabled: boolean,
+ aiCustomBaseUrl: string,
+ aiCustomApiKey: string,
+ aiCustomModel: string,
+ aiCustomFallbackModel: string,
+ aiCustomTemperature: number,
+ aiCustomTimeoutMs: number,
+ aiCustomStreaming: boolean,
|};
/**
@@ -323,8 +330,6 @@
setShortcutForCommand: (commandName: CommandName, shortcut: string) => void,
getNewObjectDialogDefaultTab: () => 'asset-store' | 'new-object',
setNewObjectDialogDefaultTab: ('asset-store' | 'new-object') => void,
- getNewEffectDialogDefaultTab: () => 'asset-store' | 'from-scratch',
- setNewEffectDialogDefaultTab: ('asset-store' | 'from-scratch') => void,
getShareDialogDefaultTab: () => 'invite' | 'publish',
setShareDialogDefaultTab: ('invite' | 'publish') => void,
getIsMenuBarHiddenInPreview: () => boolean,
@@ -394,6 +399,14 @@
setUseBackgroundSerializerForSaving: (enabled: boolean) => void,
setShowJsTypeError: (enabled: boolean) => void,
setCanonicalEventSerialization: (enabled: boolean) => void,
+ setAiCustomEndpointEnabled: (enabled: boolean) => void,
+ setAiCustomBaseUrl: (baseUrl: string) => void,
+ setAiCustomApiKey: (apiKey: string) => void,
+ setAiCustomModel: (model: string) => void,
+ setAiCustomFallbackModel: (model: string) => void,
+ setAiCustomTemperature: (temperature: number) => void,
+ setAiCustomTimeoutMs: (timeoutMs: number) => void,
+ setAiCustomStreaming: (streaming: boolean) => void,
|};
export const initialPreferences = {
@@ -428,8 +441,6 @@
newObjectDialogDefaultTab: ((electron
? 'new-object'
: 'asset-store'): string),
- // Show the effects of the store first, as they are not otherwise visible.
- newEffectDialogDefaultTab: 'asset-store',
shareDialogDefaultTab: 'publish',
isMenuBarHiddenInPreview: true,
isAlwaysOnTopInPreview: false,
@@ -465,6 +476,14 @@
disableNpmScriptConfirmation: false,
showJsTypeError: false,
canonicalEventSerialization: false,
+ aiCustomEndpointEnabled: false,
+ aiCustomBaseUrl: 'http://localhost:11434/v1',
+ aiCustomApiKey: '',
+ aiCustomModel: 'qwen2.5-coder',
+ aiCustomFallbackModel: '',
+ aiCustomTemperature: 0.7,
+ aiCustomTimeoutMs: 120000,
+ aiCustomStreaming: false,
},
setMultipleValues: () => {},
setLanguage: () => {},
@@ -507,8 +526,6 @@
setShortcutForCommand: (commandName: CommandName, shortcut: string) => {},
getNewObjectDialogDefaultTab: (): string => 'asset-store',
setNewObjectDialogDefaultTab: () => {},
- getNewEffectDialogDefaultTab: (): string => 'asset-store',
- setNewEffectDialogDefaultTab: () => {},
getShareDialogDefaultTab: (): string => 'invite',
setShareDialogDefaultTab: () => {},
getIsMenuBarHiddenInPreview: (): boolean => true,
@@ -563,6 +580,14 @@
setUseBackgroundSerializerForSaving: (enabled: boolean) => {},
setShowJsTypeError: (enabled: boolean) => {},
setCanonicalEventSerialization: (enabled: boolean) => {},
+ setAiCustomEndpointEnabled: (enabled: boolean) => {},
+ setAiCustomBaseUrl: (baseUrl: string) => {},
+ setAiCustomApiKey: (apiKey: string) => {},
+ setAiCustomModel: (model: string) => {},
+ setAiCustomFallbackModel: (model: string) => {},
+ setAiCustomTemperature: (temperature: number) => {},
+ setAiCustomTimeoutMs: (timeoutMs: number) => {},
+ setAiCustomStreaming: (streaming: boolean) => {},
};
const PreferencesContext: React.Context<Preferences> = React.createContext<Preferences>(
--- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js
+++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js
@@ -34,6 +34,11 @@
CHECK_APP_UPDATES_TIMEOUT,
PERIODIC_APP_UPDATES_TIMEOUT,
} from '../../Utils/GlobalFetchTimeouts';
+import { setCustomEndpointConfig } from '../../AI/CustomAIClient';
+import {
+ getPersistablePreferences,
+ mergeStoredPreferencesWithDefaults,
+} from './PreferencesStorage';
const electron = optionalRequire('electron');
const ipcRenderer = electron ? electron.ipcRenderer : null;
@@ -51,35 +56,13 @@
const persistedState = localStorage.getItem(localStorageItem);
if (!persistedState) return null;
- const values = JSON.parse(persistedState);
-
- // "Migrate" non existing properties to their default values
- // (useful when upgrading the preferences to a new version where
- // a new preference was added).
- for (const key in initialPreferences.values) {
- if (
- initialPreferences.values.hasOwnProperty(key) &&
- typeof values[key] === 'undefined'
- ) {
- // $FlowFixMe[invalid-computed-prop]
- values[key] = initialPreferences.values[key];
- }
- }
-
- // Migrate renamed themes.
- if (values.themeName === 'GDevelop default') {
- values.themeName = 'GDevelop default Light';
- } else if (values.themeName === 'Dark') {
- values.themeName = 'Blue Dark';
- }
-
- if (typeof values.showDeprecatedInstructionWarning === 'boolean') {
- values.showDeprecatedInstructionWarning = values.showDeprecatedInstructionWarning
- ? 'icon'
- : 'no';
- }
-
- return values;
+ // Backfill missing keys with defaults and apply the stored-value
+ // migrations. The merge lives in PreferencesStorage so its contract
+ // (backfill undefined only; never clobber a present value) is unit-tested.
+ return mergeStoredPreferencesWithDefaults(
+ JSON.parse(persistedState),
+ initialPreferences.values
+ );
} catch (e) {
return null;
}
@@ -118,7 +101,6 @@
lastLaunchedVersion: void,
newFeaturesAcknowledgements: {},
newObjectDialogDefaultTab: any,
- newEffectDialogDefaultTab: string,
newProjectsDefaultFolder: any,
newProjectsDefaultStorageProviderName: string,
openDiagnosticReportAutomatically: boolean,
@@ -277,14 +259,6 @@
this
): any),
// $FlowFixMe[method-unbinding]
- getNewEffectDialogDefaultTab: (this._getNewEffectDialogDefaultTab.bind(
- this
- ): any),
- // $FlowFixMe[method-unbinding]
- setNewEffectDialogDefaultTab: (this._setNewEffectDialogDefaultTab.bind(
- this
- ): any),
- // $FlowFixMe[method-unbinding]
getShareDialogDefaultTab: (this._getShareDialogDefaultTab.bind(this): any),
// $FlowFixMe[method-unbinding]
setShareDialogDefaultTab: (this._setShareDialogDefaultTab.bind(this): any),
@@ -436,9 +410,39 @@
setCanonicalEventSerialization: (this._setCanonicalEventSerialization.bind(
this
): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomEndpointEnabled: (this._setAiCustomEndpointEnabled.bind(
+ this
+ ): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomBaseUrl: (this._setAiCustomBaseUrl.bind(this): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomApiKey: (this._setAiCustomApiKey.bind(this): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomModel: (this._setAiCustomModel.bind(this): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomFallbackModel: (this._setAiCustomFallbackModel.bind(this): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomTemperature: (this._setAiCustomTemperature.bind(this): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomTimeoutMs: (this._setAiCustomTimeoutMs.bind(this): any),
+ // $FlowFixMe[method-unbinding]
+ setAiCustomStreaming: (this._setAiCustomStreaming.bind(this): any),
};
componentDidMount() {
+ // Sync custom AI config on mount
+ setCustomEndpointConfig({
+ enabled: this.state.values.aiCustomEndpointEnabled,
+ baseUrl: this.state.values.aiCustomBaseUrl,
+ apiKey: this.state.values.aiCustomApiKey,
+ model: this.state.values.aiCustomModel,
+ fallbackModel: this.state.values.aiCustomFallbackModel,
+ temperature: this.state.values.aiCustomTemperature,
+ timeoutMs: this.state.values.aiCustomTimeoutMs,
+ streaming: this.state.values.aiCustomStreaming,
+ });
+
this._periodicUpdateCheckTimeout = setTimeout(
() => this._checkUpdates(),
CHECK_APP_UPDATES_TIMEOUT
@@ -980,9 +984,11 @@
_persistValuesToLocalStorage(preferences: Preferences): any {
try {
+ // The API key is never persisted in cleartext (see
+ // `getPersistablePreferences`).
localStorage.setItem(
localStorageItem,
- JSON.stringify(preferences.values)
+ JSON.stringify(getPersistablePreferences(preferences.values))
);
} catch (e) {
console.warn('Unable to persist preferences', e);
@@ -1160,21 +1166,6 @@
);
}
- _getNewEffectDialogDefaultTab(): any {
- return this.state.values.newEffectDialogDefaultTab;
- }
-
- _setNewEffectDialogDefaultTab(
- newEffectDialogDefaultTab: 'asset-store' | 'from-scratch'
- ) {
- this.setState(
- state => ({
- values: { ...state.values, newEffectDialogDefaultTab },
- }),
- () => this._persistValuesToLocalStorage(this.state)
- );
- }
-
_getShareDialogDefaultTab(): any {
return this.state.values.shareDialogDefaultTab;
}
@@ -1508,6 +1499,125 @@
);
}
+ _setAiCustomEndpointEnabled(newValue: boolean) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomEndpointEnabled: newValue,
+ },
+ }),
+ () => {
+ this._persistValuesToLocalStorage(this.state);
+ setCustomEndpointConfig({ enabled: newValue });
+ }
+ );
+ }
+
+ _setAiCustomBaseUrl(newValue: string) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomBaseUrl: newValue,
+ },
+ }),
+ () => {
+ this._persistValuesToLocalStorage(this.state);
+ setCustomEndpointConfig({ baseUrl: newValue });
+ }
+ );
+ }
+
+ _setAiCustomApiKey(newValue: string) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomApiKey: newValue,
+ },
+ }),
+ () => {
+ setCustomEndpointConfig({ apiKey: newValue });
+ }
+ );
+ }
+
+ _setAiCustomModel(newValue: string) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomModel: newValue,
+ },
+ }),
+ () => {
+ this._persistValuesToLocalStorage(this.state);
+ setCustomEndpointConfig({ model: newValue });
+ }
+ );
+ }
+
+ _setAiCustomFallbackModel(newValue: string) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomFallbackModel: newValue,
+ },
+ }),
+ () => {
+ this._persistValuesToLocalStorage(this.state);
+ setCustomEndpointConfig({ fallbackModel: newValue });
+ }
+ );
+ }
+
+ _setAiCustomTemperature(newValue: number) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomTemperature: newValue,
+ },
+ }),
+ () => {
+ this._persistValuesToLocalStorage(this.state);
+ setCustomEndpointConfig({ temperature: newValue });
+ }
+ );
+ }
+
+ _setAiCustomStreaming(newValue: boolean) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomStreaming: newValue,
+ },
+ }),
+ () => {
+ this._persistValuesToLocalStorage(this.state);
+ setCustomEndpointConfig({ streaming: newValue });
+ }
+ );
+ }
+
+ _setAiCustomTimeoutMs(newValue: number) {
+ this.setState(
+ state => ({
+ values: {
+ ...state.values,
+ aiCustomTimeoutMs: newValue,
+ },
+ }),
+ () => {
+ this._persistValuesToLocalStorage(this.state);
+ setCustomEndpointConfig({ timeoutMs: newValue });
+ }
+ );
+ }
+
render(): any {
return (
<PreferencesContext.Provider value={this.state}>
--- a/newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js
+++ b/newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js
@@ -0,0 +1,65 @@
+// @flow
+
+/**
+ * The preferences that may be written to localStorage.
+ *
+ * `aiCustomApiKey` is deliberately omitted: the user's API key must never be
+ * persisted in cleartext (it is kept in memory only, for the session). Both the
+ * preferences payload and the custom-AI config payload drop it — see
+ * `CustomAIClient.setCustomEndpointConfig`, which does the same for its own
+ * store.
+ *
+ * A pure module so the exclusion can be unit-tested: `PreferencesProvider.js`
+ * imports React, Electron and the GDevelop core, which a spec cannot load.
+ */
+export const getPersistablePreferences = (values: Object): Object => {
+ const { aiCustomApiKey, ...persistableValues } = values;
+ return persistableValues;
+};
+
+/**
+ * Merge preferences read from localStorage with the defaults, mirroring the
+ * provider's upgrade path: a key that is *missing* (undefined) is backfilled
+ * from the defaults; a key that is present is kept as-is, even if falsy or
+ * wrong-typed (the app validates at the point of use, not here). Also applies
+ * the two stored-value migrations (renamed themes, the deprecated-warning
+ * tri-state). Returns null when the stored value is not a plain object, which
+ * the caller treats as "no stored preferences".
+ *
+ * Extracted from `PreferencesProvider.loadPreferencesFromLocalStorage` so the
+ * backfill contract can be unit-tested (that file imports React, Electron and
+ * the GDevelop core).
+ */
+export const mergeStoredPreferencesWithDefaults = (
+ storedValues: any,
+ defaults: Object
+): ?Object => {
+ if (
+ !storedValues ||
+ typeof storedValues !== 'object' ||
+ Array.isArray(storedValues)
+ ) {
+ return null;
+ }
+ const values = { ...storedValues };
+ for (const key in defaults) {
+ if (
+ Object.prototype.hasOwnProperty.call(defaults, key) &&
+ typeof values[key] === 'undefined'
+ ) {
+ values[key] = defaults[key];
+ }
+ }
+ // Migrate renamed themes.
+ if (values.themeName === 'GDevelop default') {
+ values.themeName = 'GDevelop default Light';
+ } else if (values.themeName === 'Dark') {
+ values.themeName = 'Blue Dark';
+ }
+ if (typeof values.showDeprecatedInstructionWarning === 'boolean') {
+ values.showDeprecatedInstructionWarning = values.showDeprecatedInstructionWarning
+ ? 'icon'
+ : 'no';
+ }
+ return values;
+};
+39
View File
@@ -0,0 +1,39 @@
# patches/ — Tier-1 entitlement unlocks (Phase 2, issue #2)
Stock upstream `app.asar` + these hunks = fork parity for Tier-1, with no
205-file merge treadmill. Applied by `scripts/apply-patches.mjs`
(extract -> patch -> repack); every hunk is gated by the Phase 1 seam suite.
## Files
| Patch | Upstream target(s) | Unlock |
|---|---|---|
| `01-usage-predicate.patch` | `Utils/GDevelopServices/Usage.js` | `hasValidSubscriptionPlan` -> `return true`; seeds `UNLOCKED_HERETEK_SUBSCRIPTION` / `UNLOCKED_HERETEK_CAPABILITIES` (`planId: 'gdevelop_startup'`) |
| `02-auth-context-seed.patch` | `Profile/AuthenticatedUserContext.js` | Import + logged-in/logged-out default seeds |
| `03-subscription-checker.patch` | `Profile/Subscription/SubscriptionChecker.js` | Both closures stubbed to `return true` |
| `04-loading-screen.patch` | `ProjectManager/LoadingScreenEditor.js` | Logo guard + watermark guard/clamp deleted |
| `05-preview-debugger.patch` | `.../LocalPreviewLauncher/index.js`, `.../DebuggerEditorContainer.js` | `% 16` block deleted; network-preview + debugger stubs |
| `06-electron-hook.patch` | `electron-app/app/main.js` (loader line only) + `launcher/*.cjs` sync by the driver | Main-process hook install before the `BrowserWindow` destructure |
| `07-generation-glue.patch` | `Utils/GDevelopServices/Generation.js` (verbatim fork delta) | Tier-2 branch points: `local-ai-*` / `isCustomEndpointEnabled()` routing into the extracted client |
| `08-preferences-ai.patch` | `Preferences{Dialog,Context,Provider}.js` (verbatim fork deltas) + fork-only `PreferencesStorage.js` (new file) | `ai` preferences tab + persistable-preferences leaf (API key never persisted) |
Out of scope: local HTML5/desktop exports (never gated upstream);
online-build quotas (server-enforced); the duration-slider gate
(`LoadingScreenEditor` ~:387, fork-kept).
## Regeneration
Patches are generated, not hand-edited — anchors assert a single match
and fail loudly on drift:
```bash
node scripts/generate-tier1-patches.mjs --upstream review/GDevelop
```
Each generated patch round-trips (`patch -p1 --dry-run` on a pristine
copy) before it is written. Patched output matches the fork byte-for-byte
on 3 of 6 files; the only residuals are intentional non-Tier-1 exclusions:
- `Usage.js` quota type fields (`nextCreditsBackAt`, …) — fork cleanup
- `AuthenticatedUserContext.js` `type` import tweak — fork cleanup
- `LocalPreviewLauncher` `previewContext`/`sourceGameId` — upstream-sync drift
+387
View File
@@ -0,0 +1,387 @@
// Phase 2 driver (issue #2): stock upstream tree -> apply `patches/*.patch`.
//
// node scripts/apply-patches.mjs --check
// Seam gate only (Phase 1 suite). No filesystem changes.
//
// node scripts/apply-patches.mjs --target <upstream-checkout-or-extracted-asar>
// Extract-free mode: `patch -p1 --dry-run` every hunk first (loud abort
// with the failing hunk on drift), then apply, then verify unlock
// markers. Re-running against an already-patched target FAILS
// (patches are not idempotent by design — a green second run would
// mean the hunks never matched).
//
// node scripts/apply-patches.mjs --asar app.asar --out app.patched.asar
// Full CI path: extract with the Electron `asar` tool, apply as above,
// repack. NOTE: a production asar holds bundled/minified JS, so the
// source-level hunks in patches/ will NOT match bundle output — the
// driver says so loudly instead of pretending. Bundle-mapped hunks
// must be authored from a real `electron-app` build before this mode
// can go green (tracked in issue #2 acceptance: smoke launch).
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..');
const PATCHES_DIR = path.join(ROOT, 'patches');
const LAUNCHER_DIR = path.join(ROOT, 'launcher');
const LAUNCHER_FILES = [
'heretek-hook.cjs',
'preload-unlock.cjs',
'unlock-payloads.cjs',
];
// Post-apply contract: what a correctly patched tree must (and must not)
// contain. `excludes` are chosen to avoid fork-kept residuals (e.g.
// LoadingScreenEditor still references `subscriptionChecker` for other
// paywalled settings; preview/debugger still assign checker refs).
const MARKERS = [
{
patch: '01-usage-predicate.patch',
file: 'newIDE/app/src/Utils/GDevelopServices/Usage.js',
includes: [
'export const UNLOCKED_HERETEK_SUBSCRIPTION: Subscription',
"planId: 'gdevelop_startup'",
'export const UNLOCKED_HERETEK_CAPABILITIES: Capabilities',
'// In GDevelop-Heretek, all client-side and offline features are unlocked for all users.\n return true;',
],
excludes: ['redemptionCodeValidUntil > Date.now()'],
},
{
patch: '02-auth-context-seed.patch',
file: 'newIDE/app/src/Profile/AuthenticatedUserContext.js',
includes: [
'UNLOCKED_HERETEK_SUBSCRIPTION,\n UNLOCKED_HERETEK_CAPABILITIES,',
'subscription: UNLOCKED_HERETEK_SUBSCRIPTION,',
'capabilities: UNLOCKED_HERETEK_CAPABILITIES,',
],
excludes: ['subscription: null,', 'limits: null,'],
},
{
patch: '03-subscription-checker.patch',
file: 'newIDE/app/src/Profile/Subscription/SubscriptionChecker.js',
includes: [
'const checkUserHasSubscription = () => {\n setDialogOpen(false);\n return true;',
'const hasUserSubscription = () => {\n return true;',
],
excludes: [
'hasValidSubscriptionPlan',
'AuthenticatedUserContext',
'isNativeMobileApp',
'sendSubscriptionCheckDialogShown',
'setDialogOpen(true)',
],
},
{
patch: '04-loading-screen.patch',
file: 'newIDE/app/src/ProjectManager/LoadingScreenEditor.js',
includes: [
'loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);\n onUpdate();',
'watermark.showGDevelopWatermark(checked);\n onUpdate();',
],
excludes: [
// NOTE: the duration-slider gate (~:387, `newMinDuration <
// forcedLogo.minDuration`) is fork-kept behavior, out of scope —
// only the watermark-disable write-back is stubbed.
'setMinDuration(forcedLogo.minDuration)',
'// If user wants to deactivate GDevelop splash screen although',
'// If user wants to deactivate watermark although GDevelop splash',
],
},
{
patch: '05-preview-debugger.patch',
file:
'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js',
includes: [
' _checkSubscriptionForNetworkPreview = (): any => {\n return true;',
],
excludes: [
'hotReloadsCount % 16',
'this._hotReloadSubscriptionChecker.checkUserHasSubscription()',
'this._networkPreviewSubscriptionChecker.checkUserHasSubscription()',
],
},
{
patch: '05-preview-debugger.patch',
file: 'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js',
includes: ['// Unlocked in GDevelop-Heretek'],
excludes: ['this._subscriptionChecker.checkUserHasSubscription()'],
},
{
patch: '06-electron-hook.patch',
file: 'newIDE/electron-app/app/main.js',
includes: [
"require('./heretek-hook.cjs').installPreloadHook();",
'const newWindow = new BrowserWindow(options);',
],
excludes: [],
},
{
patch: '07-generation-glue.patch',
file: 'newIDE/app/src/Utils/GDevelopServices/Generation.js',
includes: [
'isCustomEndpointEnabled',
"startsWith('local-ai-')",
'customCreateAiRequest',
],
excludes: [],
},
{
patch: '08-preferences-ai.patch',
file: 'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js',
includes: ["'preferences' | 'ai' | 'shortcuts'"],
excludes: [],
},
{
patch: '08-preferences-ai.patch',
file: 'newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js',
includes: ['getPersistablePreferences', 'aiCustomApiKey'],
excludes: [],
},
];
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--check') out.check = true;
else if (a === '--target') out.target = path.resolve(argv[++i]);
else if (a === '--asar') out.asar = path.resolve(argv[++i]);
else if (a === '--out') out.out = path.resolve(argv[++i]);
else if (a === '--no-verify') out.noVerify = true;
}
return out;
}
function runSeamTests() {
console.log('[apply-patches] running Phase 1 seam suite...');
// The node:test child-process detector (NODE_TEST_CONTEXT) would make a
// `node --test` spawned from inside a test run silently skip. Strip it
// so the gate really runs even when the driver is invoked by the suite.
const env = Object.fromEntries(
Object.entries(process.env).filter(([k]) => k !== 'NODE_TEST_CONTEXT')
);
const r = spawnSync('node', ['--test', 'tests/seams/*.test.mjs'], {
cwd: ROOT,
shell: true,
stdio: 'inherit',
env,
});
if (r.status !== 0) {
console.error(
'[apply-patches] SEAM SUITE RED — upstream drifted. See file:line ' +
'pointers above; regenerate patches/ before repacking.'
);
process.exit(r.status ?? 1);
}
console.log('[apply-patches] seam suite green.');
}
function listPatches() {
if (!fs.existsSync(PATCHES_DIR)) return [];
return fs
.readdirSync(PATCHES_DIR)
.filter(f => f.endsWith('.patch'))
.sort()
.map(f => path.join(PATCHES_DIR, f));
}
function runPatch(cwd, patchFile, dryRun) {
const args = ['-p1', dryRun ? '--dry-run' : '-s', '-i', patchFile];
return spawnSync('patch', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
});
}
function applyToTarget(target) {
const patches = listPatches();
if (patches.length === 0) {
console.error('[apply-patches] no patches/*.patch found. Nothing to do.');
process.exit(2);
}
if (!fs.existsSync(path.join(target, 'newIDE'))) {
console.error(
`[apply-patches] target ${target} has no newIDE/ — pass an upstream ` +
'checkout root (or extracted asar with newIDE/ at top level).'
);
process.exit(2);
}
// Gate 1: every hunk dry-runs clean before anything is touched.
for (const p of patches) {
const r = runPatch(target, p, true);
if (r.status !== 0) {
console.error(
`[apply-patches] DRY-RUN FAILED: ${path.basename(p)}\n` +
`${r.stdout}${r.stderr}\n` +
'Upstream drifted under this hunk. Regenerate with ' +
'node scripts/generate-tier1-patches.mjs --upstream <checkout>.'
);
process.exit(1);
}
console.log(`[apply-patches] dry-run clean: ${path.basename(p)}`);
}
for (const p of patches) {
const r = runPatch(target, p, false);
if (r.status !== 0) {
console.error(
`[apply-patches] APPLY FAILED: ${path.basename(p)}\n` +
`${r.stdout}${r.stderr}`
);
process.exit(1);
}
console.log(`[apply-patches] applied: ${path.basename(p)}`);
}
}
/**
* Copy launcher/*.cjs next to the patched main.js (patch 06 requires
* ./heretek-hook.cjs relative to electron-app/app/). Runs after every
* successful apply so the loader line can never dangle.
*/
function syncLauncherFiles(target) {
const destDir = path.join(target, 'newIDE/electron-app/app');
if (!fs.existsSync(destDir)) {
console.error(
`[apply-patches] cannot sync launcher files: ${destDir} missing.`
);
process.exit(2);
}
for (const file of LAUNCHER_FILES) {
const src = path.join(LAUNCHER_DIR, file);
if (!fs.existsSync(src)) {
console.error(`[apply-patches] launcher file missing: ${src}`);
process.exit(2);
}
fs.copyFileSync(src, path.join(destDir, file));
console.log(`[apply-patches] synced launcher/${file}`);
}
}
function verifyTarget(target) {
let failed = 0;
for (const file of LAUNCHER_FILES) {
const abs = path.join(target, 'newIDE/electron-app/app', file);
if (!fs.existsSync(abs)) {
console.error(`[verify] MISSING launcher file: electron-app/app/${file}`);
failed++;
continue;
}
const src = fs.readFileSync(path.join(LAUNCHER_DIR, file), 'utf8');
if (fs.readFileSync(abs, 'utf8') !== src) {
console.error(`[verify] STALE launcher file: electron-app/app/${file}`);
failed++;
}
}
for (const m of MARKERS) {
const abs = path.join(target, m.file);
if (!fs.existsSync(abs)) {
console.error(`[verify] MISSING file: ${m.file} (${m.patch})`);
failed++;
continue;
}
const text = fs.readFileSync(abs, 'utf8');
for (const inc of m.includes) {
if (!text.includes(inc)) {
console.error(
`[verify] MISSING marker in ${m.file} (${m.patch}):\n${inc}`
);
failed++;
}
}
for (const exc of m.excludes) {
if (text.includes(exc)) {
console.error(
`[verify] LEFTOVER gate in ${m.file} (${m.patch}):\n${exc}`
);
failed++;
}
}
}
if (failed > 0) {
console.error(`[verify] ${failed} marker check(s) failed.`);
process.exit(1);
}
console.log('[verify] all Tier-1 unlock markers present, gates gone.');
}
function extractAsar(asar, dest) {
const r = spawnSync(
'npx',
['--yes', '@electron/asar', 'extract', asar, dest],
{ stdio: 'inherit' }
);
if (r.status !== 0) {
console.error(
'[apply-patches] asar extraction failed. Install it with ' +
'`npm i -g @electron/asar` (npm only, per repo policy) and retry.'
);
process.exit(1);
}
}
function packAsar(src, out) {
const r = spawnSync('npx', ['--yes', '@electron/asar', 'pack', src, out], {
stdio: 'inherit',
});
if (r.status !== 0) {
console.error('[apply-patches] asar repack failed.');
process.exit(1);
}
}
const args = parseArgs(process.argv.slice(2));
if (args.check && !args.target && !args.asar) {
runSeamTests();
process.exit(0);
}
runSeamTests();
if (args.asar) {
if (!args.out) {
console.error(
'Usage: node scripts/apply-patches.mjs --asar <app.asar> --out <patched.asar>'
);
process.exit(2);
}
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gdevelop-unlock-'));
try {
console.log(`[apply-patches] extracting ${args.asar} ...`);
extractAsar(args.asar, workdir);
console.log(
'[apply-patches] NOTE: production asars hold bundled/minified JS; ' +
'source-level hunks are expected to fail here until bundle-mapped ' +
'hunks are authored from a real electron-app build (issue #2).'
);
applyToTarget(workdir);
syncLauncherFiles(workdir);
if (!args.noVerify) verifyTarget(workdir);
// Auto-updater pin (issue #3 pre-note): never ship a repacked asar
// without recording which upstream version it came from.
packAsar(workdir, args.out);
console.log(`[apply-patches] repacked: ${args.out}`);
} finally {
fs.rmSync(workdir, { recursive: true, force: true });
}
process.exit(0);
}
if (args.target) {
applyToTarget(args.target);
syncLauncherFiles(args.target);
if (!args.noVerify) verifyTarget(args.target);
process.exit(0);
}
console.error(
'Usage:\n' +
' node scripts/apply-patches.mjs --check\n' +
' node scripts/apply-patches.mjs --target <upstream-checkout>\n' +
' node scripts/apply-patches.mjs --asar <app.asar> --out <patched.asar>'
);
process.exit(2);
+88
View File
@@ -0,0 +1,88 @@
// Extracts Tier-2 donor files VERBATIM from the fork into
// packages/ai-studio/ (Phase 4, issue #4). The fork is a donor, not a
// delivery vehicle: these files are never hand-edited here.
//
// node scripts/extract-tier2.mjs --fork review/GDevelop-Heretek
//
// Writes MANIFEST.json (sha256 per file); tests/tier2/extract.test.mjs
// re-verifies byte-identity. To refresh after a fork sync, re-run with
// the new fork checkout — any content change shows up as a manifest
// diff in code review.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..');
const DEST = path.join(ROOT, 'packages', 'ai-studio');
// Donor paths, relative to the fork root. Kept explicit (not a glob)
// so additions/removals in the fork are noticed, not silently absorbed.
const DONOR_FILES = [
'newIDE/app/src/AI/CustomAIClient.js',
'newIDE/app/src/AI/CustomAIClient.spec.js',
'newIDE/app/src/AiGeneration/Studio/EditApprovalLabel.js',
'newIDE/app/src/AiGeneration/Studio/FinalizeSubAgents.js',
'newIDE/app/src/AiGeneration/Studio/LoopGuard.js',
'newIDE/app/src/AiGeneration/Studio/NudgePolicy.js',
'newIDE/app/src/AiGeneration/Studio/PlanStore.js',
'newIDE/app/src/AiGeneration/Studio/RequestWriteGate.js',
'newIDE/app/src/AiGeneration/Studio/Roles.js',
'newIDE/app/src/AiGeneration/Studio/RoleToolPolicy.js',
'newIDE/app/src/AiGeneration/Studio/SafeTruncation.js',
'newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.js',
'newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js',
'newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.js',
'scripts/dev/mock-ai-provider.js',
'scripts/dev/mock-script.example.json',
'scripts/dev/mock-script-multi.json',
'scripts/dev/mock-script-specialists.json',
'scripts/dev/mock-child-scripts.example.json',
];
// Fork-relative -> package-relative layout.
function destFor(rel) {
if (rel.startsWith('newIDE/app/src/'))
return rel.slice('newIDE/app/src/'.length);
if (rel.startsWith('scripts/dev/'))
return 'dev-harness/' + rel.slice('scripts/dev/'.length);
throw new Error(`No destination mapping for donor file: ${rel}`);
}
function parseArgs(argv) {
const out = { fork: path.join(ROOT, 'review', 'GDevelop-Heretek') };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--fork') out.fork = path.resolve(argv[++i]);
}
return out;
}
const args = parseArgs(process.argv.slice(2));
const manifest = { donor: args.fork, files: {} };
for (const rel of DONOR_FILES) {
const src = path.join(args.fork, rel);
if (!fs.existsSync(src)) {
throw new Error(
`Donor file missing: ${rel} (fork checkout at ${args.fork}). ` +
`If the fork moved it, update DONOR_FILES explicitly.`
);
}
const content = fs.readFileSync(src);
const dest = path.join(DEST, destFor(rel));
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, content);
manifest.files[destFor(rel)] = {
donor: rel,
bytes: content.length,
sha256: crypto.createHash('sha256').update(content).digest('hex'),
};
console.log(`extracted ${destFor(rel)} (${content.length} bytes)`);
}
fs.writeFileSync(
path.join(DEST, 'MANIFEST.json'),
JSON.stringify(manifest, null, 2) + '\n'
);
console.log(`wrote MANIFEST.json (${DONOR_FILES.length} files)`);
+490
View File
@@ -0,0 +1,490 @@
// Generates patches/*.patch (Tier-1 unlocks, Phase 2) from a stock upstream
// checkout by applying the exact Tier-1 replacements.
//
// node scripts/generate-tier1-patches.mjs --upstream review/GDevelop
//
// Every replacement asserts a single match (loud on drift). Non-Tier-1 fork
// deltas are deliberately NOT reproduced (Usage quota type fields,
// LocalPreviewLauncher previewContext/sourceGameId, the FirebaseUser
// `type` import tweak) — verify with: diff the patched tree against the
// fork and confirm only those exclusions remain.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..');
const requireCjs = createRequire(import.meta.url);
const payloads = requireCjs('../launcher/unlock-payloads.cjs');
/**
* Serialize a plain-JS payload object to prettier-style source
* (single quotes, 2-space indent, es5 trailing commas) — byte-identical
* to the fork's UNLOCKED_* blocks, which were written in that style.
*/
function serializeJs(value, indent) {
if (Array.isArray(value)) {
if (value.length === 0) return '[]';
const pad = ' '.repeat(indent);
const inner = ' '.repeat(indent + 2);
return `[\n${value.map(v => `${inner}${serializeJs(v, indent + 2)}`).join(',\n')},\n${pad}]`;
}
if (value && typeof value === 'object') {
const keys = Object.keys(value);
if (keys.length === 0) return '{}';
const pad = ' '.repeat(indent);
const inner = ' '.repeat(indent + 2);
return `{\n${keys.map(k => `${inner}${k}: ${serializeJs(value[k], indent + 2)}`).join(',\n')},\n${pad}}`;
}
if (typeof value === 'string')
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
return String(value);
}
function parseArgs(argv) {
const out = { upstream: path.join(ROOT, 'review', 'GDevelop') };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--upstream') out.upstream = path.resolve(argv[++i]);
else if (argv[i] === '--out') out.out = path.resolve(argv[++i]);
else out.out = path.join(ROOT, 'patches');
}
if (!out.out) out.out = path.join(ROOT, 'patches');
return out;
}
function replaceOnce(text, oldStr, newStr, label) {
const count = text.split(oldStr).length - 1;
if (count !== 1) {
throw new Error(
`GENERATOR DRIFT [${label}]: expected 1 match, found ${count}. ` +
`Upstream moved — update the replacement anchor.`
);
}
return text.replace(oldStr, newStr);
}
const UNLOCKED_SUBSCRIPTION_BLOCK =
'export const UNLOCKED_HERETEK_SUBSCRIPTION: Subscription = ' +
serializeJs(payloads.UNLOCKED_SUBSCRIPTION, 0) +
';\n\n' +
'export const UNLOCKED_HERETEK_CAPABILITIES: Capabilities = ' +
serializeJs(payloads.UNLOCKED_CAPABILITIES, 0) +
';';
const LIMITS_SEED = `limits: {
quotas: {},
capabilities: UNLOCKED_HERETEK_CAPABILITIES,
credits: {
userBalance: { amount: 999999 },
prices: {},
purchasableQuantities: [],
},
message: undefined,
},`;
// Each entry: files touched, and a transform(upstreamText) -> patchedText.
const PATCHES = [
{
name: '01-usage-predicate.patch',
files: ['newIDE/app/src/Utils/GDevelopServices/Usage.js'],
transform(text) {
let out = replaceOnce(
text,
` return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER';
};
export const hasValidSubscriptionPlan = (`,
` return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER';
};
${UNLOCKED_SUBSCRIPTION_BLOCK}
export const hasValidSubscriptionPlan = (`,
'01/seed-insert'
);
out = replaceOnce(
out,
`export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
const hasValidSubscription =
!!subscription &&
!!subscription.planId &&
(!subscription.redemptionCodeValidUntil || // No redemption code
subscription.redemptionCodeValidUntil > Date.now()); // Redemption code is still valid
if (hasValidSubscription) {
// The user has a subscription registered in the backend (classic "Registered" user).
return true;
}
return false;
};`,
`export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
// In GDevelop-Heretek, all client-side and offline features are unlocked for all users.
return true;
};`,
'01/predicate-stub'
);
return out;
},
},
{
name: '02-auth-context-seed.patch',
files: ['newIDE/app/src/Profile/AuthenticatedUserContext.js'],
transform(text) {
let out = replaceOnce(
text,
` type UserEarningsBalance,
} from '../Utils/GDevelopServices/Usage';`,
` type UserEarningsBalance,
UNLOCKED_HERETEK_SUBSCRIPTION,
UNLOCKED_HERETEK_CAPABILITIES,
} from '../Utils/GDevelopServices/Usage';`,
'02/import'
);
out = replaceOnce(
out,
` subscription: null,
subscriptionPricingSystem: null,
usages: null,
userEarningsBalance: null,
limits: null,
...authenticatedUserPropertiesLoadingState,`,
` subscription: UNLOCKED_HERETEK_SUBSCRIPTION,
subscriptionPricingSystem: null,
usages: null,
userEarningsBalance: null,
${LIMITS_SEED}
...authenticatedUserPropertiesLoadingState,`,
'02/logged-in-seed'
);
out = replaceOnce(
out,
` receivedBundles: ([]: Array<empty>), // Initialize to empty array to indicate that the loading is done.
subscription: null,
subscriptionPricingSystem: null,
userEarningsBalance: null,
usages: null,
limits: null,
};`,
` receivedBundles: ([]: Array<empty>), // Initialize to empty array to indicate that the loading is done.
subscription: UNLOCKED_HERETEK_SUBSCRIPTION,
subscriptionPricingSystem: null,
userEarningsBalance: null,
usages: null,
${LIMITS_SEED}
};`,
'02/logged-out-seed'
);
return out;
},
},
{
name: '03-subscription-checker.patch',
files: ['newIDE/app/src/Profile/Subscription/SubscriptionChecker.js'],
transform(text) {
let out = replaceOnce(
text,
`// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import FlatButton from '../../UI/FlatButton';
import Dialog from '../../UI/Dialog';
import AuthenticatedUserContext from '../AuthenticatedUserContext';
import { Column } from '../../UI/Grid';
import {
sendSubscriptionCheckDialogShown,
sendSubscriptionCheckDismiss,
} from '../../Utils/Analytics/EventSender';
import Text from '../../UI/Text';
import { hasValidSubscriptionPlan } from '../../Utils/GDevelopServices/Usage';
import { isNativeMobileApp } from '../../Utils/Platform';
import InAppTutorialContext from '../../InAppTutorial/InAppTutorialContext';
import GetSubscriptionCard from './GetSubscriptionCard';`,
`// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import FlatButton from '../../UI/FlatButton';
import Dialog from '../../UI/Dialog';
import { Column } from '../../UI/Grid';
import { sendSubscriptionCheckDismiss } from '../../Utils/Analytics/EventSender';
import Text from '../../UI/Text';
import GetSubscriptionCard from './GetSubscriptionCard';`,
'03/imports'
);
out = replaceOnce(
out,
`}> = React.forwardRef<Props, SubscriptionCheckerInterface>(
(
{
mode,
id,
title,
onChangeSubscription,
placementId,
isNotShownDuringInAppTutorial,
},
ref
) => {
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const [dialogOpen, setDialogOpen] = React.useState<boolean>(false);`,
`}> = React.forwardRef<Props, SubscriptionCheckerInterface>(
({ mode, id, title, onChangeSubscription, placementId }, ref) => {
const [dialogOpen, setDialogOpen] = React.useState<boolean>(false);`,
'03/signature'
);
out = replaceOnce(
out,
` const { currentlyRunningInAppTutorial } = React.useContext(
InAppTutorialContext
);
const checkUserHasSubscription = () => {`,
` const checkUserHasSubscription = () => {`,
'03/tutorial-context'
);
out = replaceOnce(
out,
` const checkUserHasSubscription = () => {
if (
hasValidSubscriptionPlan(authenticatedUser.subscription) ||
(isNotShownDuringInAppTutorial && currentlyRunningInAppTutorial)
) {
setDialogOpen(false);
return true;
}
if (isNativeMobileApp()) {
// Would present App Store screen.
} else {
setDialogOpen(true);
sendSubscriptionCheckDialogShown({ mode, id });
}
return false;
};
const hasUserSubscription = () => {
return hasValidSubscriptionPlan(authenticatedUser.subscription);
};`,
` const checkUserHasSubscription = () => {
setDialogOpen(false);
return true;
};
const hasUserSubscription = () => {
return true;
};`,
'03/closures'
);
return out;
},
},
{
name: '04-loading-screen.patch',
files: ['newIDE/app/src/ProjectManager/LoadingScreenEditor.js'],
transform(text) {
let out = replaceOnce(
text,
` onCheck={(e, checked) => {
if (
!checked &&
!watermark.isGDevelopWatermarkShown() &&
subscriptionChecker.current &&
!subscriptionChecker.current.checkUserHasSubscription()
) {
// If user wants to deactivate GDevelop splash screen although
// watermark is hidden, we don't allow it if they have no subscription.
return;
}
loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);`,
` onCheck={(e, checked) => {
loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);`,
'04/logo-guard'
);
// Watermark guard + forced-duration clamp: remove everything from the
// guard `if` through the clamp, keeping only the show + onUpdate lines.
const guardStartMarker = ` if (
!checked &&
!loadingScreen.isGDevelopLogoShownDuringLoadingScreen() &&`;
const showLine = ` watermark.showGDevelopWatermark(checked);`;
const clampTail = `
onUpdate();`;
const guardStartIdx = out.indexOf(guardStartMarker);
if (guardStartIdx === -1)
throw new Error('GENERATOR DRIFT [04/watermark-guard]: no match.');
if (out.indexOf(guardStartMarker, guardStartIdx + 1) !== -1)
throw new Error(
'GENERATOR DRIFT [04/watermark-guard]: anchor not unique.'
);
const tailIdx = out.indexOf(clampTail, guardStartIdx);
if (tailIdx === -1)
throw new Error('GENERATOR DRIFT [04/watermark-tail]: no match.');
const removed = out.slice(guardStartIdx, tailIdx);
for (const marker of [
'checkUserHasSubscription()',
'if (checked) {',
'forcedLogo.minDuration',
'setMinDuration(forcedLogo.minDuration)',
'hasUserSubscription()',
'watermark.showGDevelopWatermark(checked);',
]) {
if (!removed.includes(marker))
throw new Error(
`GENERATOR DRIFT [04/clamp-span]: missing ${JSON.stringify(marker)}.`
);
}
out = out.slice(0, guardStartIdx) + showLine + out.slice(tailIdx);
return out;
},
},
{
name: '05-preview-debugger.patch',
files: [
'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js',
'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js',
],
transform(text, relPath) {
if (relPath.endsWith('LocalPreviewLauncher/index.js')) {
let out = replaceOnce(
text,
` if (!previewOptions.isForInGameEdition) {
if (
this.state.hotReloadsCount % 16 === 0 &&
this._hotReloadSubscriptionChecker
) {
this._hotReloadSubscriptionChecker.checkUserHasSubscription();
}
this.setState(state => ({`,
` if (!previewOptions.isForInGameEdition) {
this.setState(state => ({`,
'05/hot-reload-gate'
);
out = replaceOnce(
out,
` _checkSubscriptionForNetworkPreview = (): any => {
if (!this._networkPreviewSubscriptionChecker) return true;
return this._networkPreviewSubscriptionChecker.checkUserHasSubscription();
};`,
` _checkSubscriptionForNetworkPreview = (): any => {
return true;
};`,
'05/network-stub'
);
return out;
}
return replaceOnce(
text,
` _checkUserHasSubscription() {
if (
this._subscriptionChecker &&
this.props.isActive &&
!this.state.subscriptionChecked
) {
this._subscriptionChecker.checkUserHasSubscription();
this.setState({
subscriptionChecked: true,
});
}
}`,
` _checkUserHasSubscription() {
// Unlocked in GDevelop-Heretek
}`,
'05/debugger-stub'
);
},
},
{
name: '06-electron-hook.patch',
files: ['newIDE/electron-app/app/main.js'],
transform(text) {
// The loader MUST precede the `electron.BrowserWindow` destructure
// (main.js:6): the hook wraps the export before any module captures
// the original reference (main.js:226, PreviewWindow.js:52).
return replaceOnce(
text,
`const electron = require('electron');
const path = require('path');`,
`// GDevelop-Unlocked (Phase 3, issue #3): install the Tier-1 runtime hook
// before any module destructures \`electron.BrowserWindow\` (main.js:6,
// PreviewWindow.js:1-6). Re-applied by scripts/apply-patches.mjs, which
// also syncs launcher/*.cjs next to this file. Updates drop the hook:
// see launcher/README.md ("auto-updater pin").
try {
require('./heretek-hook.cjs').installPreloadHook();
} catch (error) {
console.error(
'[heretek] hook install failed (Tier-1 runtime fallback only):',
error
);
}
const electron = require('electron');
const path = require('path');`,
'06/loader'
);
},
},
];
const args = parseArgs(process.argv.slice(2));
const outDir = args.out;
fs.mkdirSync(outDir, { recursive: true });
for (const patch of PATCHES) {
let combined = '';
for (const rel of patch.files) {
const abs = path.join(args.upstream, rel);
if (!fs.existsSync(abs))
throw new Error(`Upstream file missing: ${abs}`);
const upstreamText = fs.readFileSync(abs, 'utf8');
const patchedText = patch.transform(upstreamText, rel);
if (patchedText === upstreamText)
throw new Error(`Patch ${patch.name} changed nothing in ${rel}.`);
const tmpOld = path.join('/tmp/opencode', `gen-old-${Date.now()}.txt`);
const tmpNew = path.join('/tmp/opencode', `gen-new-${Date.now()}.txt`);
fs.mkdirSync('/tmp/opencode', { recursive: true });
fs.writeFileSync(tmpOld, upstreamText);
fs.writeFileSync(tmpNew, patchedText);
const r = spawnSync(
'diff',
['-U3', `--label=a/${rel}`, `--label=b/${rel}`, tmpOld, tmpNew],
{ encoding: 'utf8' }
);
fs.unlinkSync(tmpOld);
fs.unlinkSync(tmpNew);
if (r.status !== 1 || !r.stdout)
throw new Error(
`diff failed for ${rel} (status ${r.status}): ${r.stderr}`
);
combined += r.stdout;
// Round-trip check: the hunk must apply cleanly to a pristine copy.
const checkDir = path.join('/tmp/opencode', `gen-rt-${Date.now()}`);
const checkFile = path.join(checkDir, rel);
fs.mkdirSync(path.dirname(checkFile), { recursive: true });
fs.writeFileSync(checkFile, upstreamText);
const tmpPatch = path.join('/tmp/opencode', `gen-rt-${Date.now()}.patch`);
fs.writeFileSync(tmpPatch, r.stdout);
const dry = spawnSync('patch', ['-p1', '--dry-run', '-i', tmpPatch], {
cwd: checkDir,
encoding: 'utf8',
});
fs.rmSync(checkDir, { recursive: true, force: true });
fs.unlinkSync(tmpPatch);
if (dry.status !== 0)
throw new Error(
`Round-trip FAILED for ${patch.name} :: ${rel}:\n${dry.stdout}\n${dry.stderr}`
);
}
fs.writeFileSync(path.join(outDir, patch.name), combined);
console.log(
`wrote ${patch.name} (${patch.files.length} file(s), round-trip clean)`
);
}
console.log('done.');
+103
View File
@@ -0,0 +1,103 @@
// Generates Tier-2 glue patches as VERBATIM fork deltas (Phase 4).
// Unlike Tier-1 (curated minimal hunks), the Generation + Preferences
// deltas are woven through their files, so the patch carries the exact
// fork diff — no hand-editing, no silent shaping.
//
// node scripts/generate-tier2-patches.mjs --upstream review/GDevelop --fork review/GDevelop-Heretek
//
// Each patch round-trips (`patch -p1 --dry-run` on a pristine copy)
// before it is written. Regeneration is deterministic: committed output
// must equal fresh output (pinned by tests/tier2/tier2-patches.test.mjs).
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..');
const PATCHES = [
{
name: '07-generation-glue.patch',
files: ['newIDE/app/src/Utils/GDevelopServices/Generation.js'],
},
{
name: '08-preferences-ai.patch',
files: [
'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesContext.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js',
// Fork-only: carried as a new-file hunk.
'newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js',
],
},
];
function parseArgs(argv) {
const out = {
upstream: path.join(ROOT, 'review', 'GDevelop'),
fork: path.join(ROOT, 'review', 'GDevelop-Heretek'),
out: path.join(ROOT, 'patches'),
};
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--upstream') out.upstream = path.resolve(argv[++i]);
else if (argv[i] === '--fork') out.fork = path.resolve(argv[++i]);
else if (argv[i] === '--out') out.out = path.resolve(argv[++i]);
}
return out;
}
function diffFile(upstreamAbs, forkAbs, rel, isNew) {
const args = isNew
? ['-U3', `--label=a/${rel}`, `--label=b/${rel}`, '/dev/null', forkAbs]
: ['-U3', `--label=a/${rel}`, `--label=b/${rel}`, upstreamAbs, forkAbs];
const r = spawnSync('diff', args, { encoding: 'utf8' });
if (r.status === 0)
throw new Error(
`No delta for ${rel} — fork stopped diverging there. ` +
`Drop it from the glue list instead of shipping an empty patch.`
);
if (r.status !== 1 || !r.stdout)
throw new Error(`diff failed for ${rel} (status ${r.status}): ${r.stderr}`);
return r.stdout;
}
const args = parseArgs(process.argv.slice(2));
fs.mkdirSync(args.out, { recursive: true });
for (const patch of PATCHES) {
let combined = '';
for (const rel of patch.files) {
const upstreamAbs = path.join(args.upstream, rel);
const forkAbs = path.join(args.fork, rel);
const isNew = !fs.existsSync(upstreamAbs);
if (!fs.existsSync(forkAbs))
throw new Error(`Fork file missing: ${rel}`);
combined += diffFile(upstreamAbs, forkAbs, rel, isNew);
}
const checkDir = path.join('/tmp/opencode', `tier2-rt-${Date.now()}`);
fs.mkdirSync(checkDir, { recursive: true });
const tmpPatch = path.join('/tmp/opencode', `tier2-rt-${Date.now()}.patch`);
fs.writeFileSync(tmpPatch, combined);
// Round-trip against pristine upstream copies of exactly these files.
for (const rel of patch.files) {
if (!fs.existsSync(path.join(args.upstream, rel))) continue;
const dest = path.join(checkDir, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(path.join(args.upstream, rel), dest);
}
const dry = spawnSync('patch', ['-p1', '--dry-run', '-i', tmpPatch], {
cwd: checkDir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
fs.rmSync(checkDir, { recursive: true, force: true });
fs.unlinkSync(tmpPatch);
if (dry.status !== 0)
throw new Error(
`Round-trip FAILED for ${patch.name}:\n${dry.stdout}\n${dry.stderr}`
);
fs.writeFileSync(path.join(args.out, patch.name), combined);
console.log(`wrote ${patch.name} (${patch.files.length} file(s), round-trip clean)`);
}
console.log('done.');
+188
View File
@@ -0,0 +1,188 @@
// Main-process hook tests (Phase 3): the whole heretek-hook.cjs
// surface driven with fake Electron objects — no Electron needed.
const { describe, it, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const hook = require('../../launcher/heretek-hook.cjs');
const {
USAGE_API_BASE,
} = require('../../launcher/unlock-payloads.cjs');
function makeLogger() {
const records = { info: [], warn: [], error: [] };
return {
logger: {
info: msg => records.info.push(String(msg)),
warn: msg => records.warn.push(String(msg)),
error: (msg, ...rest) =>
records.error.push(String(msg) + ' ' + rest.join(' ')),
},
records,
};
}
function makeFakeElectron() {
const listeners = {};
const webRequests = [];
class FakeWindow {
constructor(options) {
this.options = options;
this.id = 7;
}
}
return {
electron: {
BrowserWindow: FakeWindow,
app: { on: (event, cb) => (listeners[event] = cb) },
session: {
defaultSession: {
webRequest: {
onBeforeRequest: (filter, cb) => webRequests.push({ filter, cb }),
},
},
},
},
listeners,
webRequests,
FakeWindow,
};
}
describe('hook: window wrap', () => {
it('injects the preload into every constructed window', () => {
const { electron, FakeWindow } = makeFakeElectron();
const { logger } = makeLogger();
const result = hook.installPreloadHook(electron, {
logger,
skipUpdaterWatch: true,
});
assert.equal(result.wrapped, true);
const win = new electron.BrowserWindow({ width: 800 });
assert.ok(win instanceof FakeWindow, 'must still construct the original');
assert.ok(
String(win.options.webPreferences.preload).endsWith(
'preload-unlock.cjs'
)
);
assert.equal(win.options.width, 800);
});
it('never overrides an explicitly set preload', () => {
const out = hook.withUnlockPreload({
webPreferences: { preload: '/custom/preload.js' },
});
assert.equal(out.webPreferences.preload, '/custom/preload.js');
});
it('wrap is idempotent across double install', () => {
const { electron } = makeFakeElectron();
const { logger } = makeLogger();
hook.installPreloadHook(electron, { logger, skipUpdaterWatch: true });
const second = hook.installPreloadHook(electron, {
logger,
skipUpdaterWatch: true,
});
assert.equal(second.wrapped, true);
const win = new electron.BrowserWindow({});
assert.ok(
String(win.options.webPreferences.preload).endsWith(
'preload-unlock.cjs'
)
);
});
});
describe('hook: pop-out fallback', () => {
it('seeds windows that missed the preload via executeJavaScript', async () => {
const { electron, listeners } = makeFakeElectron();
const { logger } = makeLogger();
hook.installPreloadHook(electron, { logger, skipUpdaterWatch: true });
assert.ok(
listeners['browser-window-created'],
'pop-out listener registered'
);
const executed = [];
const fakeContents = {
once: (event, cb) => {
assert.equal(event, 'did-finish-load');
fakeContents._loaded = cb;
},
executeJavaScript: source => {
executed.push(source);
// First probe: not seeded. Second: the fallback source.
return Promise.resolve(executed.length > 1);
},
};
listeners['browser-window-created']({}, { webContents: fakeContents });
fakeContents._loaded();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(executed.length, 2);
assert.ok(executed[1].includes('__HERETEK__'));
assert.ok(executed[1].includes(USAGE_API_BASE));
});
});
describe('hook: webRequest fallback', () => {
it('registers a usage-scoped filter and spoofs the two GETs', () => {
const { electron, webRequests } = makeFakeElectron();
const { logger } = makeLogger();
const result = hook.installPreloadHook(electron, {
logger,
skipUpdaterWatch: true,
});
assert.equal(result.requestWatched, true);
assert.deepEqual(webRequests[0].filter, {
urls: [`${USAGE_API_BASE}/*`],
});
const handler = webRequests[0].cb;
let redirected = null;
handler(
{ url: `${USAGE_API_BASE}/limits?userId=x`, method: 'GET' },
action => (redirected = action)
);
assert.ok(
redirected.redirectURL.startsWith('data:application/json'),
'limits redirected to data: URL'
);
const limits = JSON.parse(
decodeURIComponent(redirected.redirectURL.split(',', 2)[1])
);
assert.equal(limits.capabilities.cloudProjects.maximumCount, 999);
let sub = null;
handler({ url: `${USAGE_API_BASE}/subscription-v2`, method: 'GET' }, a => (sub = a));
const subBody = JSON.parse(
decodeURIComponent(sub.redirectURL.split(',', 2)[1])
);
assert.equal(subBody.userId, 'local-byok-user');
let passthrough = null;
handler(
{ url: `${USAGE_API_BASE}/generation`, method: 'POST' },
a => (passthrough = a)
);
assert.deepEqual(passthrough, {});
});
});
describe('hook: updater pin', () => {
afterEach(() => {
delete globalThis.__HERETEK__;
});
it('warns loudly on update-downloaded and flags the drop', () => {
const handlers = {};
const { logger, records } = makeLogger();
const watched = hook.watchUpdater(
{ on: (event, cb) => (handlers[event] = cb) },
logger
);
assert.equal(watched, true);
handlers['update-downloaded']();
assert.equal(globalThis.__HERETEK__.updatePending, true);
assert.equal(records.warn.length, 1);
assert.ok(records.warn[0].includes('apply-patches.mjs --asar'));
});
});
+78
View File
@@ -0,0 +1,78 @@
// Payload contract tests (Phase 3): shapes satisfy the upstream
// validators (limits -> `capabilities`, subscription-v2 -> `userId`)
// and only the two Tier-1 GET endpoints are ever spoofed.
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
USAGE_API_BASE,
UNLOCKED_SUBSCRIPTION,
UNLOCKED_CAPABILITIES,
buildSubscriptionPayload,
buildLimitsPayload,
isSpoofedUsageUrl,
spoofBodyForUrl,
} = require('../../launcher/unlock-payloads.cjs');
describe('payloads: unlock bodies', () => {
it('subscription payload carries the startup plan identity', () => {
const body = buildSubscriptionPayload();
assert.equal(body.userId, 'local-byok-user');
assert.equal(body.planId, 'gdevelop_startup');
assert.equal(body.pricingSystemId, 'HERETEK_UNLOCKED');
});
it('limits payload satisfies the upstream capabilities validator', () => {
const body = buildLimitsPayload();
assert.ok(body.capabilities, 'ensureObjectHasProperty(..., capabilities)');
assert.equal(body.capabilities.cloudProjects.maximumCount, 999);
assert.equal(body.capabilities.versionHistory.retentionDays, 365);
assert.deepEqual(body.quotas, {});
assert.equal(body.credits.userBalance.amount, 999999);
});
it('builders default to the UNLOCKED_* single source', () => {
assert.deepEqual(buildSubscriptionPayload(), UNLOCKED_SUBSCRIPTION);
assert.deepEqual(
buildLimitsPayload().capabilities,
UNLOCKED_CAPABILITIES
);
});
});
describe('payloads: spoof surface', () => {
const limits = `${USAGE_API_BASE}/limits`;
const sub = `${USAGE_API_BASE}/subscription-v2`;
it('spoofs exactly the two Tier-1 GET endpoints (query strings ok)', () => {
assert.equal(isSpoofedUsageUrl(limits, 'GET'), true);
assert.equal(isSpoofedUsageUrl(`${limits}?userId=abc`, 'GET'), true);
assert.equal(isSpoofedUsageUrl(sub, 'GET'), true);
assert.equal(isSpoofedUsageUrl(`${sub}?userId=abc`, 'GET'), true);
assert.equal(isSpoofedUsageUrl(limits, undefined), true);
});
it('passes through mutations, other endpoints, other hosts', () => {
assert.equal(isSpoofedUsageUrl(limits, 'POST'), false);
assert.equal(isSpoofedUsageUrl(sub, 'DELETE'), false);
assert.equal(
isSpoofedUsageUrl(`${USAGE_API_BASE}/generation`, 'GET'),
false
);
assert.equal(isSpoofedUsageUrl(`${USAGE_API_BASE}/usage`, 'GET'), false);
assert.equal(
isSpoofedUsageUrl('https://evil.example.com/usage/limits', 'GET'),
false
);
assert.equal(isSpoofedUsageUrl(null, 'GET'), false);
assert.equal(isSpoofedUsageUrl(`${USAGE_API_BASE}/limits-evil`, 'GET'), false);
});
it('spoofBodyForUrl returns validator-satisfying bodies', () => {
assert.ok(spoofBodyForUrl(limits, 'GET').capabilities);
assert.equal(spoofBodyForUrl(sub, 'GET').userId, 'local-byok-user');
assert.equal(
spoofBodyForUrl(`${USAGE_API_BASE}/generation`, 'GET'),
null
);
});
});
+171
View File
@@ -0,0 +1,171 @@
// Renderer hook tests (Phase 3): fetch/XHR spoofing on fakes, plus a
// vm-sandbox evaluation of getRendererFallbackSource() — the exact
// string heretek-hook.cjs injects into pop-out windows. If an embedded
// function closes over module scope, the sandbox run throws and this
// goes red.
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const preload = require('../../launcher/preload-unlock.cjs');
const { USAGE_API_BASE } = require('../../launcher/unlock-payloads.cjs');
const LIMITS = `${USAGE_API_BASE}/limits?userId=abc`;
const SUB = `${USAGE_API_BASE}/subscription-v2?userId=abc`;
const OTHER = `${USAGE_API_BASE}/generation`;
class FakeResponse {
constructor(body, init) {
this._body = body;
this.status = init.status;
}
json() {
return Promise.resolve(JSON.parse(this._body));
}
}
function makeScope() {
const calls = [];
return {
scope: {
location: { href: 'https://editor.gdevelop.io/' },
Response: FakeResponse,
setTimeout: (fn, ms) => setTimeout(fn, ms),
fetch: url => {
calls.push(url);
return Promise.resolve({ passthrough: true, url });
},
},
calls,
};
}
function makeXhrClass() {
return class FakeXHR {
constructor() {
this.readyState = 0;
this.status = 0;
this.responseText = '';
this.response = '';
this.onreadystatechange = null;
this.onload = null;
this.sent = [];
}
open(method, url) {
this.method = method;
this.url = url;
}
send() {
this.sent.push(this.url);
}
};
}
describe('preload: fetch wrapper', () => {
it('spoofs limits + subscription GETs, logs them, passes the rest through', async () => {
const { scope, calls } = makeScope();
preload.installRendererHook(scope);
assert.equal(scope.__HERETEK__.unlocked, true);
assert.equal(scope.__HERETEK__.planId, 'gdevelop_startup');
const limitsRes = await scope.fetch(LIMITS, { method: 'GET' });
assert.equal(limitsRes.status, 200);
const limits = await limitsRes.json();
assert.equal(limits.capabilities.cloudProjects.maximumCount, 999);
const subRes = await scope.fetch(SUB);
assert.equal((await subRes.json()).userId, 'local-byok-user');
const other = await scope.fetch(OTHER);
assert.equal(other.passthrough, true);
assert.deepEqual(calls, [OTHER]);
const post = await scope.fetch(LIMITS, { method: 'POST' });
assert.equal(post.passthrough, true);
assert.deepEqual(scope.__HERETEK__.spoofedRequests, [LIMITS, SUB]);
});
});
describe('preload: XHR patch', () => {
it('spoofs usage GETs asynchronously, delegates the rest', async () => {
const { scope } = makeScope();
const XHR = makeXhrClass();
preload.patchXhr(XHR, scope);
const xhr = new XHR();
let loaded = null;
xhr.onload = () => {
loaded = { status: xhr.status, body: JSON.parse(xhr.responseText) };
};
xhr.open('GET', LIMITS);
xhr.send();
await new Promise(resolve => setTimeout(resolve, 20));
assert.ok(loaded, 'spoofed onload never fired');
assert.equal(loaded.status, 200);
assert.equal(loaded.body.capabilities.versionHistory.retentionDays, 365);
const other = new XHR();
other.open('POST', LIMITS);
other.send();
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(other.sent, [LIMITS]);
});
});
describe('preload: fallback source (vm sandbox)', () => {
it('embeds every referenced name (no module-scope leaks)', () => {
const payloads = require('../../launcher/unlock-payloads.cjs');
const namespace = Object.assign({}, payloads, preload);
for (const name of preload.FALLBACK_EMBEDDED_FUNCTIONS) {
assert.equal(
typeof namespace[name],
'function',
`fallback embeds missing function: ${name}`
);
}
for (const name of preload.FALLBACK_EMBEDDED_CONSTS) {
assert.ok(
payloads[name] !== undefined,
`fallback embeds missing const: ${name}`
);
}
});
it('evaluates standalone and spoofs in the sandbox', async () => {
const source = preload.getRendererFallbackSource();
assert.ok(source.includes('__HERETEK__'));
assert.ok(source.includes(USAGE_API_BASE));
const { scope } = makeScope();
scope.XMLHttpRequest = makeXhrClass();
const sandbox = {
window: scope,
URL,
Promise,
JSON,
Object,
setTimeout,
};
vm.createContext(sandbox);
vm.runInContext(source, sandbox);
assert.equal(sandbox.window.__HERETEK__.unlocked, true);
const res = await sandbox.window.fetch(LIMITS);
assert.equal(res.status, 200);
assert.equal(
(await res.json()).capabilities.leaderboards.maximumCountPerGame,
999
);
const passthrough = await sandbox.window.fetch(OTHER);
assert.equal(passthrough.passthrough, true);
const XHR = sandbox.window.XMLHttpRequest;
const xhr = new XHR();
const loaded = await new Promise(resolve => {
xhr.onload = () =>
resolve({ status: xhr.status, body: JSON.parse(xhr.response) });
xhr.open('GET', SUB);
xhr.send();
});
assert.equal(loaded.status, 200);
assert.equal(loaded.body.planId, 'gdevelop_startup');
});
});
+118
View File
@@ -0,0 +1,118 @@
// Phase 2 tests: the Tier-1 patcher applies cleanly to a pristine
// upstream tree, produces the unlock markers, and refuses a second run
// (non-idempotent by design — a green re-apply would mean the hunks
// never matched in the first place).
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..', '..');
const UPSTREAM = process.env.UPSTREAM_DIR || path.join(ROOT, 'review', 'GDevelop');
const TIER1_FILES = [
'newIDE/app/src/Utils/GDevelopServices/Usage.js',
'newIDE/app/src/Profile/AuthenticatedUserContext.js',
'newIDE/app/src/Profile/Subscription/SubscriptionChecker.js',
'newIDE/app/src/ProjectManager/LoadingScreenEditor.js',
'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js',
'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js',
// Patch 06 (loader line) needs its anchor file too.
'newIDE/electron-app/app/main.js',
// Tier-2 glue targets (07/08; the fork-only Storage.js needs no target).
'newIDE/app/src/Utils/GDevelopServices/Generation.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesContext.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js',
];
describe('patcher: Tier-1 hunks apply to pristine upstream', () => {
let target;
before(() => {
target = fs.mkdtempSync(path.join(os.tmpdir(), 'tier1-patch-test-'));
for (const rel of TIER1_FILES) {
const src = path.join(UPSTREAM, rel);
assert.ok(fs.existsSync(src), `upstream file missing: ${rel}`);
const dest = path.join(target, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
});
after(() => fs.rmSync(target, { recursive: true, force: true }));
it('dry-run is clean on the pristine tree', () => {
const r = spawnSync(
'node',
['scripts/apply-patches.mjs', '--target', target, '--no-verify'],
{ cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }
);
assert.equal(
r.status,
0,
`patcher failed on pristine tree:\n${r.stdout}\n${r.stderr}`
);
});
it('patched tree carries the unlock markers and no gates', () => {
const read = rel => fs.readFileSync(path.join(target, rel), 'utf8');
const usage = read(TIER1_FILES[0]);
assert.ok(usage.includes("planId: 'gdevelop_startup'"));
assert.ok(
usage.includes(
'// In GDevelop-Heretek, all client-side and offline features are unlocked for all users.\n return true;'
)
);
assert.ok(!usage.includes('redemptionCodeValidUntil > Date.now()'));
const auth = read(TIER1_FILES[1]);
assert.ok(auth.includes('subscription: UNLOCKED_HERETEK_SUBSCRIPTION,'));
assert.ok(!auth.includes('subscription: null,'));
const checker = read(TIER1_FILES[2]);
assert.ok(
checker.includes(
'const hasUserSubscription = () => {\n return true;'
)
);
assert.ok(!checker.includes('hasValidSubscriptionPlan'));
const loading = read(TIER1_FILES[3]);
assert.ok(
loading.includes(
'watermark.showGDevelopWatermark(checked);\n onUpdate();'
)
);
assert.ok(!loading.includes('setMinDuration(forcedLogo.minDuration)'));
const preview = read(TIER1_FILES[4]);
assert.ok(
preview.includes(
' _checkSubscriptionForNetworkPreview = (): any => {\n return true;'
)
);
assert.ok(!preview.includes('hotReloadsCount % 16'));
const dbg = read(TIER1_FILES[5]);
assert.ok(dbg.includes('// Unlocked in GDevelop-Heretek'));
assert.ok(
!dbg.includes('this._subscriptionChecker.checkUserHasSubscription()')
);
});
it('second apply fails (hunks already consumed)', () => {
const r = spawnSync(
'node',
['scripts/apply-patches.mjs', '--target', target, '--no-verify'],
{ cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }
);
assert.notEqual(
r.status,
0,
're-apply succeeded — hunks may not be matching (silent no-op risk)'
);
});
});
+55
View File
@@ -0,0 +1,55 @@
// Phase 1 seam: in-game debugger gate.
// Audit: `MainFrame/EditorContainers/DebuggerEditorContainer.js:135-146`.
import { describe, it } from 'node:test';
import {
readUpstream,
mustContain,
mustMatchWord,
mustBeNearLine,
} from './helpers.mjs';
const REL =
'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js';
describe('seam: DebuggerEditorContainer gate', () => {
const file = readUpstream(REL);
it('keeps the _checkUserHasSubscription guard', () => {
mustMatchWord(file, '_checkUserHasSubscription', {
what: 'debugger paywall entry point',
hint: 'Phase 2 hunk 5 stubs the body',
});
});
it('guard sits at its audited hunk (~line 135, tolerance ±30)', () => {
mustBeNearLine(
file,
/_checkUserHasSubscription\(\) \{/,
135,
30,
'_checkUserHasSubscription'
);
});
it('guard shape: checker + isActive + one-shot flag', () => {
for (const token of [
'this._subscriptionChecker',
'this.props.isActive',
'subscriptionChecked',
]) {
mustContain(file, token, { what: 'guard condition' });
}
mustMatchWord(file, 'checkUserHasSubscription', {
what: 'checker invocation inside the guard',
});
});
it('debugger tab mounts a SubscriptionChecker with id="Debugger"', () => {
mustContain(file, 'id="Debugger"', {
what: 'checker placement id for the debugger tab',
});
mustContain(file, 'placementId="debugger"', {
what: 'analytics placement id',
});
});
});
+138
View File
@@ -0,0 +1,138 @@
// Phase 1 seam: Electron hookability + absence of plugin architecture.
// Audit: `electron-app/app/main.js:174-187` (webPreferences),
// `Utils/UrlLoader.js:5-53` (gdide:// + file://), `main.js:361`
// (did-create-window), PreviewWindow.js, zero preload files.
// Also freezes the "no IDE plugin registry" verdict: `newIDE/app/src`
// must contain zero plugin-registry matches.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { UPSTREAM_DIR, readUpstream, mustContain, mustMatchWord, mustBeNearLine } from './helpers.mjs';
const MAIN = 'newIDE/electron-app/app/main.js';
const URL_LOADER = 'newIDE/electron-app/app/Utils/UrlLoader.js';
function walkJsFiles(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
walkJsFiles(abs, out);
} else if (/\.m?js$/.test(entry.name)) {
out.push(abs);
}
}
return out;
}
describe('seam: Electron launcher hook surface', () => {
const main = readUpstream(MAIN);
const urlLoader = readUpstream(URL_LOADER);
it('renderer stays hookable: nodeIntegration + no isolation + no webSecurity', () => {
mustContain(main, 'nodeIntegration: true', {
what: 'renderer Node access (Vector A precondition)',
});
mustContain(main, 'contextIsolation: false', {
what: 'shared-global precondition for preload seed',
});
mustContain(main, 'webSecurity: false', {
what: 'local-file access',
});
});
it('covers window-creation funnels the hook must attach to', () => {
mustMatchWord(main, 'createNewWindow', {
what: 'single funnel Phase 3 hooks (~10 lines)',
});
mustContain(main, 'did-create-window', {
what: 'portal pop-out coverage (main.js:361)',
});
mustContain(main, 'PreviewWindow', {
what: 'preview-window coverage',
});
});
it('main.js require anchor is stable (06 loader insertion point)', () => {
// Patch 06 inserts the hook loader BEFORE this line: the wrap must
// precede the `electron.BrowserWindow` destructure (main.js:6) or the
// already-captured references (main.js:226, PreviewWindow.js:52)
// escape it.
mustBeNearLine(
main,
/^const electron = require\('electron'\);/,
1,
3,
'electron require anchor'
);
mustContain(main, 'const newWindow = new BrowserWindow(options);', {
what: 'wrapped-constructor call site',
});
});
it('preview windows flow through the wrapped constructor', () => {
const preview = readUpstream('newIDE/electron-app/app/PreviewWindow.js');
mustContain(preview, 'new BrowserWindow(browserWindowOptions)', {
what: 'preview constructor the hook wraps',
});
});
it('UrlLoader keeps the gdide:// + file:// loading paths', () => {
mustContain(urlLoader, 'registerGdideProtocol', {
what: 'custom-scheme loader',
});
mustContain(urlLoader, 'registerBufferProtocol', {
what: 'production gdide:// path',
});
mustContain(urlLoader, 'file://', { what: 'production file load' });
});
it('documents the auto-updater pin (hook must survive updates)', () => {
mustContain(main, 'autoUpdater', {
what: 'updater path Phase 3 must pin/document',
});
});
it('flags any new preload file (hook-attachment point changed)', () => {
const appDir = path.join(UPSTREAM_DIR, 'newIDE/electron-app/app');
const withPreloadOption = walkJsFiles(appDir).filter(abs =>
/preload\s*:/.test(fs.readFileSync(abs, 'utf8'))
);
assert.deepEqual(
withPreloadOption,
[],
`SEAM DRIFT: upstream added a preload option: ${withPreloadOption.join(', ')}. ` +
`Re-evaluate the Phase 3 attachment (preload-unlock.cjs) against the new loader.`
);
});
});
describe('seam: no IDE plugin registry (verdict freeze)', () => {
it('newIDE/app/src contains zero plugin-registry seams', () => {
const srcDir = path.join(UPSTREAM_DIR, 'newIDE/app/src');
const hits = walkJsFiles(srcDir)
.filter(abs =>
/registerIDEPlugin|pluginRegistry|registerPlugin|extensionPoint/i.test(
fs.readFileSync(abs, 'utf8')
)
)
.map(abs => path.relative(UPSTREAM_DIR, abs));
assert.deepEqual(
hits,
[],
`SEAM DRIFT: a plugin-registry-like seam appeared: ${hits.join(', ')}. ` +
`Re-evaluate the no-plugin-architecture verdict before building Tier-2 as a package.`
);
});
it('webpack chunk-splitting convention still exists (Vector D tap)', () => {
const index = readUpstream('newIDE/app/src/index.js');
mustContain(index, 'webpackChunkName: "local-app"', {
what: 'CRA chunk convention the userscript taps',
});
mustContain(index, 'webpackChunkName: "browser-app"', {
what: 'web-editor chunk the userscript taps',
});
});
});
+108
View File
@@ -0,0 +1,108 @@
// Shared helpers for Phase 1 seam (drift-tripwire) tests.
// Each assertion failure prints a `file:line` pointer so upstream drift
// is loud, not silent. Shape assertions only — never behavior.
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
// Repo root = two levels above tests/seams/.
export const REPO_ROOT = path.resolve(here, '..', '..');
export const UPSTREAM_DIR =
process.env.UPSTREAM_DIR || path.join(REPO_ROOT, 'review', 'GDevelop');
export function readUpstream(relPath) {
const abs = path.join(UPSTREAM_DIR, relPath);
assert.ok(
fs.existsSync(abs),
`Missing upstream file: ${relPath} (looked in ${abs}). ` +
`Set UPSTREAM_DIR or refresh review/GDevelop.`
);
return { abs, rel: relPath, text: fs.readFileSync(abs, 'utf8') };
}
export function lines(text) {
return text.split('\n');
}
/** 1-based line number of the first line matching `re`, or -1. */
export function findLine(text, re) {
const ls = lines(text);
for (let i = 0; i < ls.length; i++) {
if (re.test(ls[i])) return i + 1;
}
return -1;
}
/** All 1-based line numbers matching `re`. */
export function findAllLines(text, re) {
const out = [];
const ls = lines(text);
for (let i = 0; i < ls.length; i++) {
if (re.test(ls[i])) out.push(i + 1);
}
return out;
}
export function mustContain(
{ abs, rel, text },
substring,
{ what, hint } = {}
) {
assert.ok(
text.includes(substring),
`SEAM DRIFT: ${rel} no longer contains ${JSON.stringify(substring)}` +
`${what ? ` (${what})` : ''}. File: ${abs}.` +
`${hint ? ` Hint: ${hint}` : ''}`
);
}
export function mustMatch({ abs, rel, text }, re, { what, hint } = {}) {
assert.ok(
re.test(text),
`SEAM DRIFT: ${rel} no longer matches ${re}` +
`${what ? ` (${what})` : ''}. File: ${abs}.` +
`${hint ? ` Hint: ${hint}` : ''}`
);
}
export function mustMatchWord(file, symbol, { what, hint } = {}) {
const { abs, rel, text } = file;
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Block prefix-collisions: a rename to `${symbol}_RENAMED` must NOT match.
const re = new RegExp(escaped + '(?![\\w$])');
assert.ok(
re.test(text),
`SEAM DRIFT: ${rel} lost symbol ${JSON.stringify(symbol)}` +
`${what ? ` (${what})` : ''}. File: ${abs}.` +
`${hint ? ` Hint: ${hint}` : ''}`
);
}
/**
* Assert a seam symbol lives near its audited line (tolerance guards
* against hunk shifts; a move beyond tolerance fails loudly).
*/
export function mustBeNearLine(
file,
re,
expectedLine,
tolerance,
symbolName
) {
const actual = findLine(file.text, re);
assert.ok(
actual !== -1,
`SEAM DRIFT: ${file.rel} lost symbol ${symbolName} (${re}). ` +
`File: ${file.abs}. Expected near line ${expectedLine}.`
);
const drift = Math.abs(actual - expectedLine);
assert.ok(
drift <= tolerance,
`SEAM DRIFT: ${symbolName} moved in ${file.rel} ` +
`from ~line ${expectedLine} to line ${actual} (drift ${drift} > tolerance ${tolerance}). ` +
`File: ${file.abs}:${actual}. Update patches/ + launcher to the new hunk.`
);
return actual;
}
+76
View File
@@ -0,0 +1,76 @@
// Phase 1 seam: branding/clamp gates (Phase 2 hunk 4 targets).
// Audit: `ProjectManager/LoadingScreenEditor.js` — logo guard (~`:106-117`),
// watermark guard + forced-duration clamp (~`:162-213`).
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
readUpstream,
mustContain,
mustMatchWord,
mustBeNearLine,
findAllLines,
} from './helpers.mjs';
const REL = 'newIDE/app/src/ProjectManager/LoadingScreenEditor.js';
describe('seam: LoadingScreenEditor branding gates', () => {
const file = readUpstream(REL);
it('keeps the startup-logo paywall guard', () => {
mustMatchWord(file, 'isGDevelopLogoShownDuringLoadingScreen', {
what: 'logo-branding predicate',
});
mustBeNearLine(
file,
/checked=\{loadingScreen\.isGDevelopLogoShownDuringLoadingScreen/,
106,
40,
'logo checkbox guard'
);
});
it('keeps the watermark paywall guard + clamp', () => {
mustMatchWord(file, 'isGDevelopWatermarkShown', {
what: 'watermark predicate',
});
mustBeNearLine(
file,
/checked=\{watermark\.isGDevelopWatermarkShown/,
162,
40,
'watermark checkbox guard'
);
mustContain(file, 'forcedLogo.minDuration', {
what: 'forced splash-duration clamp',
});
mustContain(file, 'setMinDuration(forcedLogo.minDuration)', {
what: 'clamp write-back on watermark disable',
});
});
it('both guards consult the subscription checker', () => {
mustMatchWord(file, 'subscriptionChecker', {
what: 'checker ref in branding panel',
});
mustMatchWord(file, 'checkUserHasSubscription', {
what: 'dialog path when unchecking without subscription',
});
mustMatchWord(file, 'hasUserSubscription', {
what: 'silent path for the duration clamp',
});
});
it('predicates are referenced pervasively (not dead code)', () => {
const logoHits = findAllLines(
file.text,
/isGDevelopLogoShownDuringLoadingScreen/
).length;
const watermarkHits = findAllLines(file.text, /isGDevelopWatermarkShown/)
.length;
assert.ok(
logoHits >= 3 && watermarkHits >= 3,
`SEAM DRIFT: branding predicates look dead (logo x${logoHits}, watermark x${watermarkHits}). ` +
`File: ${file.abs}. Expected >= 3 references each.`
);
});
});
+56
View File
@@ -0,0 +1,56 @@
// Phase 1 seam: hot-reload + network-preview gates.
// Audit: `LocalPreviewLauncher/index.js:439-448` (`% 16` block) and
// `:489-493` (`_checkSubscriptionForNetworkPreview`).
import { describe, it } from 'node:test';
import {
readUpstream,
mustContain,
mustMatchWord,
mustBeNearLine,
} from './helpers.mjs';
const REL =
'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js';
describe('seam: LocalPreviewLauncher gates', () => {
const file = readUpstream(REL);
it('keeps the % 16 hot-reload paywall block', () => {
mustContain(file, 'hotReloadsCount % 16 === 0', {
what: 'hot-reload paywall cadence',
hint: 'Phase 2 hunk 5 deletes this block',
});
mustMatchWord(file, '_hotReloadSubscriptionChecker', {
what: 'checker ref driving the % 16 popup',
});
mustMatchWord(file, 'checkUserHasSubscription', {
what: 'checker call inside the % 16 block',
});
});
it('% 16 block sits at its audited hunk (~line 441, tolerance ±40)', () => {
mustBeNearLine(
file,
/hotReloadsCount % 16 === 0/,
441,
40,
'% 16 hot-reload gate'
);
});
it('keeps the network-preview subscription stub point', () => {
mustMatchWord(file, '_checkSubscriptionForNetworkPreview', {
what: 'wifi/LAN preview gate',
hint: 'Phase 2 hunk 5 stubs this to `return true`',
});
mustMatchWord(file, '_networkPreviewSubscriptionChecker', {
what: 'network preview checker ref',
});
});
it('increments hotReloadsCount after the gate', () => {
mustContain(file, 'hotReloadsCount: state.hotReloadsCount + 1', {
what: 'counter increment following the gate',
});
});
});
@@ -0,0 +1,59 @@
// Phase 1 seam: subscription dialog gate.
// Audit: `Profile/Subscription/SubscriptionChecker.js:66-87` — both
// closures funnel through `hasValidSubscriptionPlan`.
import { describe, it } from 'node:test';
import {
readUpstream,
mustContain,
mustBeNearLine,
} from './helpers.mjs';
const REL = 'newIDE/app/src/Profile/Subscription/SubscriptionChecker.js';
describe('seam: SubscriptionChecker dialog gate', () => {
const file = readUpstream(REL);
it('exposes the checker interface shape', () => {
mustContain(file, 'checkUserHasSubscription: () => boolean', {
what: 'SubscriptionCheckerInterface.checkUserHasSubscription',
});
mustContain(file, 'hasUserSubscription: () => boolean', {
what: 'SubscriptionCheckerInterface.hasUserSubscription',
});
});
it('checkUserHasSubscription sits at its audited hunk (~line 66, tolerance ±40)', () => {
mustBeNearLine(
file,
/const checkUserHasSubscription(?![\w$])/,
66,
40,
'checkUserHasSubscription'
);
});
it('both closures consult hasValidSubscriptionPlan', () => {
mustContain(
file,
'hasValidSubscriptionPlan(authenticatedUser.subscription)',
{ what: 'funnel shared by both closures' }
);
mustBeNearLine(
file,
/const hasUserSubscription(?![\w$])/,
85,
40,
'hasUserSubscription'
);
});
it('denial opens the paywall dialog', () => {
mustContain(file, 'setDialogOpen(true)', {
what: 'dialog-open on failed check',
hint: 'Phase 2 hunk 3 stubs both closures to `return true` so this never fires',
});
mustContain(file, 'sendSubscriptionCheckDialogShown', {
what: 'analytics ping on dialog show',
});
});
});
+61
View File
@@ -0,0 +1,61 @@
// Phase 1 seam: Tier-1 entitlement funnel.
// Audit: `newIDE/app/src/Utils/GDevelopServices/Usage.js:605-620` —
// every paywall funnels through `hasValidSubscriptionPlan`.
// Shape asserted here; Phase 2 hunk 1 rewrites the body to `return true`.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
readUpstream,
mustContain,
mustMatchWord,
mustBeNearLine,
} from './helpers.mjs';
const REL = 'newIDE/app/src/Utils/GDevelopServices/Usage.js';
describe('seam: Usage.hasValidSubscriptionPlan', () => {
const file = readUpstream(REL);
it('exports the hasValidSubscriptionPlan predicate', () => {
// Word-boundary match: a rename (even to a superstring) must trip.
mustMatchWord(file, 'export const hasValidSubscriptionPlan', {
what: 'Tier-1 funnel predicate',
hint: 'Phase 2 hunk 1 + preload Module._load intercept both target this export',
});
});
it('sits at its audited hunk (~line 605, tolerance ±40)', () => {
mustBeNearLine(
file,
/export const hasValidSubscriptionPlan(?![\w$])/,
605,
40,
'hasValidSubscriptionPlan'
);
});
it('keeps the planId + redemption-code gate shape', () => {
for (const token of [
'subscription.planId',
'redemptionCodeValidUntil',
'Date.now()',
]) {
mustContain(file, token, {
what: 'gate condition inside hasValidSubscriptionPlan',
});
}
mustContain(file, 'return true', { what: 'valid-subscription branch' });
mustContain(file, 'return false', { what: 'default-deny branch' });
});
it('predicate takes a Subscription and returns boolean', () => {
const idx = file.text.indexOf('export const hasValidSubscriptionPlan');
assert.ok(idx !== -1, `SEAM DRIFT: predicate missing. File: ${file.abs}`);
const window = file.text.slice(idx, idx + 600);
assert.ok(
window.includes('Subscription') && window.includes('boolean'),
`SEAM DRIFT: predicate signature drifted (expected (subscription: ?Subscription) => boolean). ` +
`File: ${file.abs}. Slice: ${JSON.stringify(window.slice(0, 200))}`
);
});
});
+99
View File
@@ -0,0 +1,99 @@
// Tier-2 extraction tests (Phase 4): packages/ai-studio/ stays
// byte-identical to the fork donor, and the extracted client keeps the
// BYOK invariant (no hosted generation references).
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..', '..');
const PACKAGE = path.join(ROOT, 'packages', 'ai-studio');
const FORK =
process.env.FORK_DIR || path.join(ROOT, 'review', 'GDevelop-Heretek');
function sha256(abs) {
return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
}
describe('tier-2: verbatim extraction', () => {
const manifest = JSON.parse(
fs.readFileSync(path.join(PACKAGE, 'MANIFEST.json'), 'utf8')
);
it('every manifest file matches the donor byte-for-byte', () => {
assert.ok(
Object.keys(manifest.files).length >= 19,
'manifest looks truncated'
);
for (const [pkgRel, meta] of Object.entries(manifest.files)) {
const pkgAbs = path.join(PACKAGE, pkgRel);
const donorAbs = path.join(FORK, meta.donor);
assert.ok(fs.existsSync(pkgAbs), `package file missing: ${pkgRel}`);
assert.ok(
fs.existsSync(donorAbs),
`donor moved in fork: ${meta.donor} (update scripts/extract-tier2.mjs explicitly)`
);
assert.equal(
sha256(pkgAbs),
sha256(donorAbs),
`drift vs donor: ${pkgRel}`
);
assert.equal(
sha256(pkgAbs),
meta.sha256,
`manifest stale for ${pkgRel} (re-run the extractor)`
);
}
});
it('CustomAIClient has zero hosted-generation references', () => {
const text = fs.readFileSync(
path.join(PACKAGE, 'AI', 'CustomAIClient.js'),
'utf8'
);
assert.ok(
!text.includes('api.gdevelop.io'),
'BYOK client must not hardcode the hosted API'
);
assert.ok(
text.includes('GDEVELOP_OPENAI_TOOLS'),
'tool schema missing from extraction'
);
assert.ok(
text.includes('gd-custom-ai-config'),
'localStorage config key missing from extraction'
);
});
it('all 12 Studio prod modules extracted (no silent drops)', () => {
const expected = [
'EditApprovalLabel',
'FinalizeSubAgents',
'LoopGuard',
'NudgePolicy',
'PlanStore',
'RequestWriteGate',
'Roles',
'RoleToolPolicy',
'SafeTruncation',
'SpawnSubAgents',
'UseStudioNudge',
'UseStudioRuntime',
];
for (const name of expected) {
assert.ok(
fs.existsSync(path.join(PACKAGE, 'AiGeneration', 'Studio', `${name}.js`)),
`Studio module missing: ${name}`
);
}
});
it('offline E2E harness extracted', () => {
assert.ok(
fs.existsSync(path.join(PACKAGE, 'dev-harness', 'mock-ai-provider.js'))
);
});
});
+98
View File
@@ -0,0 +1,98 @@
// Generation shim tests (Phase 4): routing predicates + the
// fail-closed leak guard. These invariants are what stop `local-ai-*`
// ids from ever reaching the hosted generation API.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
isLocalAiRequestId,
partitionAiRequestIds,
isCustomEndpointEnabled,
selectBackend,
assertNoLocalIdsLeak,
guardHostedCall,
} from '../../packages/unlock-shim/generation-shim.mjs';
const CUSTOM = { enabled: true, baseUrl: 'http://localhost:11434/v1' };
describe('shim: id classification', () => {
it('recognizes local-ai-* ids exactly', () => {
assert.equal(isLocalAiRequestId('local-ai-abc123'), true);
assert.equal(isLocalAiRequestId('hosted-uuid'), false);
assert.equal(isLocalAiRequestId('LOCAL-AI-x'), false);
assert.equal(isLocalAiRequestId(null), false);
assert.equal(isLocalAiRequestId(undefined), false);
});
it('partitions mixed batches (fork 07 split)', () => {
assert.deepEqual(
partitionAiRequestIds(['local-ai-1', 'h-1', 'local-ai-2', 'h-2']),
{ localIds: ['local-ai-1', 'local-ai-2'], hostedIds: ['h-1', 'h-2'] }
);
assert.deepEqual(partitionAiRequestIds([]), { localIds: [], hostedIds: [] });
});
});
describe('shim: backend selection', () => {
it('local ids win over everything (no hosted representation)', () => {
assert.equal(
selectBackend({ aiRequestId: 'local-ai-1', customConfig: null }),
'custom'
);
assert.equal(
selectBackend({ aiRequestIds: ['h-1', 'local-ai-2'], customConfig: null }),
'custom'
);
});
it('enabled endpoint routes hosted-shaped traffic to custom', () => {
assert.equal(
selectBackend({ aiRequestId: 'h-1', customConfig: CUSTOM }),
'custom'
);
});
it('disabled/absent config stays hosted', () => {
assert.equal(
selectBackend({ aiRequestId: 'h-1', customConfig: null }),
'hosted'
);
assert.equal(
selectBackend({
aiRequestId: 'h-1',
customConfig: { enabled: false, baseUrl: 'http://x/v1' },
}),
'hosted'
);
assert.equal(
selectBackend({
aiRequestId: 'h-1',
customConfig: { enabled: true, baseUrl: '' },
}),
'hosted'
);
});
});
describe('shim: leak guard (fail-closed)', () => {
it('throws on local ids aimed at hosted endpoints', () => {
assert.throws(
() => assertNoLocalIdsLeak(['h-1', 'local-ai-9'], 'POST /generation'),
/refusing hosted call/
);
assert.doesNotThrow(() => assertNoLocalIdsLeak(['h-1'], 'POST /generation'));
assert.doesNotThrow(() => assertNoLocalIdsLeak([], 'POST /generation'));
});
it('guardHostedCall blocks before the hosted fn runs', () => {
let ran = false;
const guarded = guardHostedCall(
() => (ran = true),
'POST /generation',
(...args) => args[0]
);
guarded(['h-1']);
assert.equal(ran, true);
assert.throws(() => guarded(['local-ai-1']), /refusing hosted call/);
assert.equal(ran, true);
});
});
+103
View File
@@ -0,0 +1,103 @@
// Tier-2 glue patch tests (Phase 4): committed 07/08 patches equal
// fresh generator output (deterministic — any hand-edit or fork drift
// goes red), and dry-run clean on pristine upstream copies.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..', '..');
const UPSTREAM =
process.env.UPSTREAM_DIR || path.join(ROOT, 'review', 'GDevelop');
const FORK =
process.env.FORK_DIR || path.join(ROOT, 'review', 'GDevelop-Heretek');
const GLUE = [
{
patch: '07-generation-glue.patch',
files: ['newIDE/app/src/Utils/GDevelopServices/Generation.js'],
},
{
patch: '08-preferences-ai.patch',
files: [
'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesContext.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js',
'newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js',
],
},
];
function run(cmd, args, cwd) {
return spawnSync(cmd, args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
});
}
describe('tier-2: glue patches are generated, not hand-edited', () => {
let regenDir;
before(() => {
regenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tier2-regen-'));
const r = run(
'node',
[
'scripts/generate-tier2-patches.mjs',
'--upstream',
UPSTREAM,
'--fork',
FORK,
'--out',
regenDir,
],
ROOT
);
assert.equal(r.status, 0, `generator failed:\n${r.stdout}\n${r.stderr}`);
});
after(() => fs.rmSync(regenDir, { recursive: true, force: true }));
it('committed patches equal fresh output byte-for-byte', () => {
for (const { patch } of GLUE) {
const committed = fs.readFileSync(
path.join(ROOT, 'patches', patch),
'utf8'
);
const fresh = fs.readFileSync(path.join(regenDir, patch), 'utf8');
assert.equal(fresh, committed, `${patch} was hand-edited or is stale`);
}
});
it('glue dry-runs clean on pristine upstream copies', () => {
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'tier2-dry-'));
try {
for (const { files } of GLUE) {
for (const rel of files) {
const src = path.join(UPSTREAM, rel);
if (!fs.existsSync(src)) continue; // new-file hunks need no target
const dest = path.join(target, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
}
for (const { patch } of GLUE) {
const r = run(
'patch',
['-p1', '--dry-run', '-i', path.join(ROOT, 'patches', patch)],
target
);
assert.equal(
r.status,
0,
`${patch} does not apply to pristine upstream:\n${r.stdout}\n${r.stderr}`
);
}
} finally {
fs.rmSync(target, { recursive: true, force: true });
}
});
});
+47
View File
@@ -0,0 +1,47 @@
// Web userscript demo tests (Phase 5, issue #5): syntax validity,
// Tampermonkey header contract, spoof surface, and documented limits.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..', '..');
const SCRIPT = path.join(ROOT, 'web-userscript', 'heretek-unlock.user.js');
const text = fs.readFileSync(SCRIPT, 'utf8');
describe('web demo: userscript contract', () => {
it('parses as valid JavaScript', () => {
const r = spawnSync('node', ['--check', SCRIPT], { encoding: 'utf8' });
assert.equal(r.status, 0, `syntax error:\n${r.stderr}`);
});
it('targets the web editor only, at document start', () => {
assert.ok(text.includes('@match https://editor.gdevelop.io/*'));
assert.ok(text.includes('@run-at document-start'));
assert.ok(text.includes('DEMO ONLY'));
});
it('spoofs exactly the Tier-1 usage endpoints', () => {
assert.ok(text.includes('https://api.gdevelop.io/usage'));
assert.ok(text.includes("'/limits'") || text.includes('/limits'));
assert.ok(text.includes('/subscription-v2'));
assert.ok(!text.includes('/generation'), 'demo must not touch generation');
});
it('documents all three known limits in the header', () => {
assert.ok(text.includes('OLLAMA_ORIGINS'), 'Ollama CORS setup missing');
assert.ok(text.includes('LAN preview'), 'LAN limitation missing');
assert.ok(
text.includes('chunk') || text.includes('Chunk'),
'hash-churn limitation missing'
);
});
it('taps webpackChunk without module-factory surgery', () => {
assert.ok(text.includes('webpackChunk'));
assert.ok(!text.includes('.m =') && !text.includes('__webpack_require__'));
});
});
+35
View File
@@ -0,0 +1,35 @@
# web-userscript — web demo, optional (Phase 5, issue #5)
Zero-install demo on `editor.gdevelop.io` only — not a delivery path for
the Studio loop. Non-blocking for Phase 6.
## Mechanism (Vector D)
`heretek-unlock.user.js` (Tampermonkey/Violentmonkey, `@run-at
document-start`): taps the `window.webpackChunk*` registry (convention
frozen by `tests/seams/electron.seam.test.mjs`) to **re-apply** `fetch` /
`XHR` wrappers on every chunk push — deliberately no module-factory
surgery (hash churn would break it on each rebuild). Spoofs only the two
Tier-1 GETs (`usage/limits`, `usage/subscription-v2`); generation is
untouched. A floating widget persists the BYOK base URL to the same
`gd-custom-ai-config` key the desktop client reads.
## Ollama CORS setup (required for any BYOK call from the web demo)
The editor page is `https`; Ollama serves `http://localhost:11434`, so
the browser blocks calls unless Ollama allows the origin:
```bash
OLLAMA_ORIGINS=https://editor.gdevelop.io ollama serve
```
Without this, Tier-1 unlocks still work but every BYOK generation fails
with a CORS error in the console. Desktop builds do not need this.
## Known limits
- LAN preview unreachable from the hosted page (private IPs are not
routable from `editor.gdevelop.io`) — use desktop for wifi preview.
- No filesystem access; the Studio sub-agent loop is desktop-only.
- Chunk-hash churn may require reinstalling wrappers (handled
automatically) but can break the demo on any web rebuild.
+190
View File
@@ -0,0 +1,190 @@
// ==UserScript==
// @name GDevelop-Unlocked (web demo)
// @namespace https://github.com/Heretek-AI/GDevelop-Unlocked
// @version 0.1.0
// @description DEMO ONLY: Tier-1 unlock (?Tier-2 BYOK config widget) on editor.gdevelop.io. Not a delivery path for the Studio loop.
// @match https://editor.gdevelop.io/*
// @grant none
// @run-at document-start
// ==/UserScript==
//
// DEMO ONLY — see web-userscript/README.md. Known limits:
// - Ollama CORS: this page is https, Ollama is http://localhost:11434.
// Start Ollama with OLLAMA_ORIGINS=https://editor.gdevelop.io (or a
// CORS proxy), otherwise the browser blocks the BYOK calls.
// - LAN preview is unreachable from the hosted page (private IPs are not
// routable from editor.gdevelop.io); use desktop for wifi preview.
// - No filesystem access; every web rebuild may change chunk hashes and
// break the webpackChunk tap below (re-install wrappers on each push).
(function () {
'use strict';
var USAGE_BASE = 'https://api.gdevelop.io/usage';
var LIMITS_PATH = '/limits';
var SUB_PATH = '/subscription-v2';
var UNLOCKED_SUBSCRIPTION = {
userId: 'local-byok-user',
planId: 'gdevelop_startup',
pricingSystemId: 'HERETEK_UNLOCKED',
createdAt: 0,
updatedAt: 0,
};
var UNLOCKED_CAPABILITIES = {
analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true },
cloudProjects: { maximumCount: 999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 99, projectRestorationTimeWindowInSeconds: -1, deletedProjectRetentionInDays: 365 },
leaderboards: { maximumCountPerGame: 999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true },
multiplayer: { lobbiesCount: 99, maxPlayersPerLobby: 64, themeCustomizationCapabilities: 'FULL' },
versionHistory: { enabled: true, retentionDays: 365 },
ai: { availablePresets: [], versionHistory: { enabled: true, retentionDays: 365 } },
};
function isSpoofed(url, method) {
if (typeof url !== 'string') return false;
if ((method || 'GET').toUpperCase() !== 'GET') return false;
if (url.indexOf(USAGE_BASE) !== 0) return false;
var rest = url.slice(USAGE_BASE.length);
return (
rest === LIMITS_PATH || rest.indexOf(LIMITS_PATH + '?') === 0 ||
rest === SUB_PATH || rest.indexOf(SUB_PATH + '?') === 0
);
}
function bodyFor(url) {
var rest = url.slice(USAGE_BASE.length);
if (rest === LIMITS_PATH || rest.indexOf(LIMITS_PATH + '?') === 0) {
return { quotas: {}, capabilities: UNLOCKED_CAPABILITIES, credits: { userBalance: { amount: 999999 }, prices: {}, purchasableQuantities: [] } };
}
return UNLOCKED_SUBSCRIPTION;
}
function absUrl(url) {
if (/^https?:\/\//.test(url)) return url;
try {
return new URL(url, location.href).toString();
} catch (e) {
return null;
}
}
function installWrappers() {
if (window.__HERETEK_WEB__ && window.__HERETEK_WEB__.wrapped) return;
var originalFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
var raw = typeof input === 'string' ? input : input && input.url;
var method = (init && init.method) || (input && input.method) || 'GET';
var url = absUrl(raw);
if (url && isSpoofed(url, method)) {
return Promise.resolve(
new Response(JSON.stringify(bodyFor(url)), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
return originalFetch(input, init);
};
var XHR = window.XMLHttpRequest;
if (XHR && !XHR.prototype.__heretekWrapped) {
var origOpen = XHR.prototype.open;
var origSend = XHR.prototype.send;
XHR.prototype.open = function (method, url) {
this.__heretekMethod = method;
this.__heretekUrl = url;
return origOpen.apply(this, arguments);
};
XHR.prototype.send = function () {
var self = this;
var args = arguments;
var url = absUrl(this.__heretekUrl);
if (url && isSpoofed(url, this.__heretekMethod)) {
var text = JSON.stringify(bodyFor(url));
setTimeout(function () {
self.readyState = 4;
self.status = 200;
try {
self.responseText = text;
self.response = text;
} catch (e) {
/* getter-only prototype: best effort */
}
if (self.onreadystatechange) self.onreadystatechange();
if (self.onload) self.onload();
}, 0);
return;
}
return origSend.apply(this, args);
};
XHR.prototype.__heretekWrapped = true;
}
window.__HERETEK_WEB__ = { unlocked: true, wrapped: true };
}
// webpackChunk tap: every lazily pushed chunk re-runs the app bootstrap
// paths that capture fetch/XHR, so re-install (idempotent) on each push.
// Hash churn breaks module-index surgery on every rebuild — this tap
// deliberately does NOT touch module factories, only re-applies the
// transport wrappers above.
function tapWebpackChunks() {
try {
Object.keys(window)
.filter(function (k) { return k.indexOf('webpackChunk') === 0; })
.forEach(function (k) {
var arr = window[k];
if (arr && arr.push && !arr.__heretekTapped) {
var origPush = arr.push.bind(arr);
arr.push = function () {
var r = origPush.apply(null, arguments);
installWrappers();
return r;
};
arr.__heretekTapped = true;
}
});
} catch (e) {
/* DOM not ready or chunks absent yet — retry below */
}
}
// Minimal BYOK config widget: persists the custom endpoint base URL to
// the same `gd-custom-ai-config` key the desktop client reads. Cosmetic
// demo aid — the Studio loop itself is desktop-only.
function mountConfigWidget() {
if (document.getElementById('heretek-byok-demo')) return;
var box = document.createElement('div');
box.id = 'heretek-byok-demo';
box.style.cssText =
'position:fixed;bottom:12px;right:12px;z-index:99999;background:#111;color:#eee;' +
'border:1px solid #6c6;font:12px sans-serif;padding:8px 10px;border-radius:8px;opacity:.92';
box.innerHTML =
'<b>Heretek web demo</b> (Tier-1 unlocked)<br/>' +
'<label>BYOK base URL <input id="heretek-base" size="28" placeholder="http://localhost:11434/v1"/></label> ' +
'<button id="heretek-save">Save</button> <span id="heretek-msg"></span>';
document.documentElement.appendChild(box);
document.getElementById('heretek-save').onclick = function () {
var base = document.getElementById('heretek-base').value.trim();
try {
var raw = localStorage.getItem('gd-custom-ai-config');
var cfg = raw ? JSON.parse(raw) : {};
cfg.enabled = true;
cfg.baseUrl = base;
localStorage.setItem('gd-custom-ai-config', JSON.stringify(cfg));
document.getElementById('heretek-msg').textContent = 'saved (needs Ollama CORS ORIGINS, see header)';
} catch (e) {
document.getElementById('heretek-msg').textContent = 'save failed: ' + e.message;
}
};
}
installWrappers();
tapWebpackChunks();
var chunksTimer = setInterval(function () {
tapWebpackChunks();
if (document.documentElement) {
mountConfigWidget();
clearInterval(chunksTimer);
}
}, 500);
})();