Skip to content

Commit 7a03ca0

Browse files
committed
feat(studio-bridge): add Linux/Wine support for headless Studio
Adds the linux/ module tree for running Roblox Studio under Wine on Linux, including virtual display management, shader patching, FFlag configuration, credential injection, and headless launch. Pre-registers ExecuteAction in the plugin at boot so execute messages work without needing registerAction. Accepts empty heartbeat payloads with defaults, surfaces plugin errors in the script execution listener, and extracts captured output from scriptComplete responses.
1 parent 78aa686 commit 7a03ca0

41 files changed

Lines changed: 2466 additions & 212 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: studio-linux-e2e
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
paths:
7+
- 'tools/studio-bridge/src/linux/**'
8+
- 'tools/studio-bridge/src/process/**'
9+
- 'tools/studio-bridge/src/commands/linux/**'
10+
- 'tools/nevermore-cli-helpers/src/auth/**'
11+
- '.github/workflows/studio-linux-e2e.yml'
12+
13+
jobs:
14+
studio-linux-e2e:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 30
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v6
20+
21+
- name: Setup node
22+
uses: actions/setup-node@v6
23+
with:
24+
node-version: '21'
25+
26+
- name: Setup pnpm
27+
uses: pnpm/action-setup@v4
28+
with:
29+
cache: true
30+
31+
- name: Setup registries
32+
run: |
33+
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc
34+
echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> ~/.npmrc
35+
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
36+
echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
37+
env:
38+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
40+
41+
- name: Install dependencies
42+
run: pnpm install --frozen-lockfile
43+
44+
- name: Setup Aftman
45+
uses: ok-nick/setup-aftman@v0.4.2
46+
with:
47+
version: 'v0.3.0'
48+
token: ${{ secrets.GITHUB_TOKEN }}
49+
cache: true
50+
51+
- name: Build all tools
52+
run: pnpm -r --filter './tools/**' run build
53+
54+
- name: Setup Linux environment
55+
run: node tools/studio-bridge/dist/src/cli/cli.js linux setup --install-deps
56+
57+
- name: Verify environment health (pre-auth)
58+
run: node tools/studio-bridge/dist/src/cli/cli.js linux status
59+
60+
- name: Inject authentication
61+
if: ${{ env.ROBLOSECURITY != '' }}
62+
run: node tools/studio-bridge/dist/src/cli/cli.js linux auth
63+
env:
64+
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
65+
66+
- name: Execute script through Studio bridge
67+
if: ${{ env.ROBLOSECURITY != '' }}
68+
run: node tools/studio-bridge/dist/src/cli/cli.js process run 'print("E2E test passed!")'
69+
timeout-minutes: 5
70+
env:
71+
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}

docs/testing/testing.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,23 @@ When tests fail in CI, the `post-test-results` command parses Jest-lua output an
204204

205205
The resolver code lives in `tools/nevermore-cli/src/utils/sourcemap/` and is shared with the `strip-sourcemap-jest` command.
206206

207+
## Linux headless testing
208+
209+
Studio can run headlessly on Linux via Wine, enabling E2E tests in devcontainers and GitHub Actions without a display or GPU. The `studio-bridge` CLI handles all environment setup:
210+
211+
```bash
212+
# One-time setup
213+
studio-bridge linux setup --install-deps
214+
studio-bridge linux auth # reads $ROBLOSECURITY env var
215+
216+
# Run tests the same as on Windows/macOS
217+
nevermore test
218+
```
219+
220+
Prerequisites (Wine 11, Xvfb, openbox, Mesa llvmpipe) are documented in `tools/studio-bridge/src/linux/README.md`. The `linux setup --install-deps` flag installs everything on Debian/Ubuntu but is opt-in — it never runs sudo automatically.
221+
222+
For CI, set `ROBLOSECURITY` as a repository or Codespace secret. The `.github/workflows/studio-linux-e2e.yml` workflow demonstrates the full flow.
223+
207224
## CI design principles
208225

209226
- **Workflows should be thin.** All logic lives in `nevermore-cli` commands — GitHub Actions workflows just call them. This keeps CI debuggable locally.

pnpm-lock.yaml

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/nevermore-cli-helpers/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
],
2424
"dependencies": {
2525
"@quenty/cli-output-helpers": "workspace:*",
26+
"inquirer": "^13.2.0",
2627
"latest-version": "^9.0.0",
2728
"semver": "^7.6.0"
2829
},
@@ -31,12 +32,15 @@
3132
"@types/semver": "^7.5.0",
3233
"prettier": "2.7.1",
3334
"typescript": "^5.9.3",
34-
"typescript-memoize": "^1.1.1"
35+
"typescript-memoize": "^1.1.1",
36+
"vitest": "^3.0.0"
3537
},
3638
"scripts": {
3739
"build": "tsc --build",
3840
"build:watch": "tsc --build --watch",
3941
"build:clean": "tsc --build --clean",
42+
"test": "vitest run",
43+
"test:watch": "vitest",
4044
"preinstall": "npx only-allow pnpm"
4145
},
4246
"publishConfig": {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { COOKIE_NAME, parseStudioCookieValue } from './cookie-parser.js';
3+
4+
describe('COOKIE_NAME', () => {
5+
it('equals .ROBLOSECURITY', () => {
6+
expect(COOKIE_NAME).toBe('.ROBLOSECURITY');
7+
});
8+
});
9+
10+
describe('parseStudioCookieValue', () => {
11+
it('parses COOK::<value> format with angle brackets', () => {
12+
const result = parseStudioCookieValue('COOK::<abc123>');
13+
expect(result).toBe('abc123');
14+
});
15+
16+
it('parses value from comma-separated list', () => {
17+
const result = parseStudioCookieValue('OTHER::stuff,COOK::<secret>');
18+
expect(result).toBe('secret');
19+
});
20+
21+
it('returns undefined for plain text', () => {
22+
expect(parseStudioCookieValue('just a string')).toBeUndefined();
23+
});
24+
25+
it('returns undefined for COOK:: without angle brackets', () => {
26+
expect(parseStudioCookieValue('COOK::noBrackets')).toBeUndefined();
27+
});
28+
29+
it('returns undefined for empty string', () => {
30+
expect(parseStudioCookieValue('')).toBeUndefined();
31+
});
32+
33+
it('handles a realistic cookie value', () => {
34+
const cookie = '_|WARNING:-DO-NOT-SHARE|_abc123def456';
35+
const result = parseStudioCookieValue(`COOK::<${cookie}>`);
36+
expect(result).toBe(cookie);
37+
});
38+
});

tools/nevermore-cli/src/utils/auth/roblox-auth/cookie-parser.ts renamed to tools/nevermore-cli-helpers/src/auth/roblox-auth/cookie-parser.ts

File renamed without changes.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Cookie auth and place creation for Roblox legacy APIs.
3+
* Based on Mantle: https://github.com/blake-mealey/mantle
4+
*/
5+
6+
import inquirer from 'inquirer';
7+
import { OutputHelper } from '@quenty/cli-output-helpers';
8+
import { COOKIE_NAME } from './cookie-parser.js';
9+
import { readCookie as readWindowsCookie } from './windows.js';
10+
import { readCookie as readMacOSCookie } from './macos.js';
11+
import { readCookie as readLinuxCookie } from './linux.js';
12+
13+
/**
14+
* Resolve the .ROBLOSECURITY cookie for legacy Roblox API calls.
15+
*
16+
* Resolution order (matching Mantle's rbx_cookie crate):
17+
* 1. ROBLOSECURITY environment variable
18+
* 2. Platform credential store (Windows Credential Manager / macOS HTTPStorages / Wine Credential Manager)
19+
* 3. Platform legacy store (Windows Registry / macOS plist)
20+
* 4. Interactive prompt
21+
*/
22+
export async function getRobloxCookieAsync(): Promise<string> {
23+
const envCookie = process.env.ROBLOSECURITY;
24+
if (envCookie) {
25+
return envCookie;
26+
}
27+
28+
const platformCookie = readPlatformCookie();
29+
if (platformCookie) {
30+
return platformCookie;
31+
}
32+
33+
// No interactive prompt in non-TTY environments (CI)
34+
if (!process.stdin.isTTY) {
35+
throw new Error(
36+
'No .ROBLOSECURITY cookie available (set ROBLOSECURITY env var for CI)'
37+
);
38+
}
39+
40+
const { cookie } = await inquirer.prompt([
41+
{
42+
type: 'password',
43+
name: 'cookie',
44+
message: 'Enter your .ROBLOSECURITY cookie (from browser or Studio):',
45+
mask: '*',
46+
validate: (input: string) => input.length > 0 || 'Cookie cannot be empty',
47+
},
48+
]);
49+
50+
return cookie;
51+
}
52+
53+
function readPlatformCookie(): string | undefined {
54+
switch (process.platform) {
55+
case 'win32':
56+
return readWindowsCookie();
57+
case 'darwin':
58+
return readMacOSCookie();
59+
case 'linux':
60+
return readLinuxCookie();
61+
default:
62+
return undefined;
63+
}
64+
}
65+
66+
/**
67+
* Make a cookie-authenticated request to Roblox, handling CSRF token exchange.
68+
*/
69+
async function fetchWithCsrfAsync(
70+
url: string,
71+
cookie: string,
72+
options: RequestInit = {}
73+
): Promise<Response> {
74+
const headers: Record<string, string> = {
75+
Cookie: `${COOKIE_NAME}=${cookie}`,
76+
'User-Agent': 'Roblox/WinInet',
77+
...(options.headers as Record<string, string> | undefined),
78+
};
79+
80+
let response = await fetch(url, {
81+
...options,
82+
headers,
83+
});
84+
85+
if (response.status === 403) {
86+
const csrfToken = response.headers.get('x-csrf-token');
87+
if (csrfToken) {
88+
headers['X-CSRF-TOKEN'] = csrfToken;
89+
response = await fetch(url, {
90+
...options,
91+
headers,
92+
});
93+
}
94+
}
95+
96+
return response;
97+
}
98+
99+
/**
100+
* Create a new place in a universe using the legacy cookie-authenticated API.
101+
* Returns the new place ID.
102+
*/
103+
export async function createPlaceInUniverseAsync(
104+
cookie: string,
105+
universeId: number,
106+
placeName: string
107+
): Promise<number> {
108+
OutputHelper.verbose(
109+
`Creating place "${placeName}" in universe ${universeId}...`
110+
);
111+
112+
const createResponse = await fetchWithCsrfAsync(
113+
`https://apis.roblox.com/universes/v1/user/universes/${universeId}/places`,
114+
cookie,
115+
{
116+
method: 'POST',
117+
headers: {
118+
'Content-Type': 'application/json',
119+
},
120+
body: JSON.stringify({ templatePlaceId: 95206881 }),
121+
}
122+
);
123+
124+
if (!createResponse.ok) {
125+
const text = await createResponse.text();
126+
throw new Error(
127+
`Failed to create place: ${createResponse.status} ${createResponse.statusText}: ${text}`
128+
);
129+
}
130+
131+
const createData = (await createResponse.json()) as { placeId: number };
132+
const placeId = createData.placeId;
133+
134+
const renameResponse = await fetchWithCsrfAsync(
135+
`https://develop.roblox.com/v2/places/${placeId}`,
136+
cookie,
137+
{
138+
method: 'PATCH',
139+
headers: {
140+
'Content-Type': 'application/json',
141+
},
142+
body: JSON.stringify({ name: placeName }),
143+
}
144+
);
145+
146+
if (!renameResponse.ok) {
147+
OutputHelper.warn(
148+
`Place created (${placeId}) but rename failed — you can rename it manually.`
149+
);
150+
}
151+
152+
OutputHelper.verbose(`Created place "${placeName}" — ID: ${placeId}`);
153+
return placeId;
154+
}
155+
156+
export interface RenamePlaceResult {
157+
success: boolean;
158+
reason?: 'no_cookie' | 'api_error';
159+
status?: number;
160+
}
161+
162+
/**
163+
* Try to rename an existing place via the develop.roblox.com API.
164+
*/
165+
export async function tryRenamePlaceAsync(
166+
placeId: number,
167+
placeName: string
168+
): Promise<RenamePlaceResult> {
169+
let cookie: string;
170+
try {
171+
cookie = await getRobloxCookieAsync();
172+
} catch {
173+
return { success: false, reason: 'no_cookie' };
174+
}
175+
176+
const response = await fetchWithCsrfAsync(
177+
`https://develop.roblox.com/v2/places/${placeId}`,
178+
cookie,
179+
{
180+
method: 'PATCH',
181+
headers: {
182+
'Content-Type': 'application/json',
183+
},
184+
body: JSON.stringify({ name: placeName }),
185+
}
186+
);
187+
188+
if (response.ok) {
189+
OutputHelper.verbose(`Renamed place ${placeId} to "${placeName}"`);
190+
return { success: true };
191+
}
192+
193+
return { success: false, reason: 'api_error', status: response.status };
194+
}

0 commit comments

Comments
 (0)