Skip to content

Commit fac635c

Browse files
committed
feat(studio-bridge): transparent Docker delegation for process run
When `process run` detects Linux without Wine but with Docker available, it transparently delegates to the pre-built container image. Cookie validation runs before delegation to fail fast on bad credentials. Also adds environment guards to linux auth/setup/status commands with clear error messages, bakes aftman/rojo into the Docker image, and moves validateCookieAsync to nevermore-cli-helpers for shared use.
1 parent be19859 commit fac635c

14 files changed

Lines changed: 568 additions & 23 deletions

File tree

.github/workflows/studio-linux-e2e.yml

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,6 @@ jobs:
4343
- name: Install dependencies
4444
run: pnpm install --frozen-lockfile
4545

46-
- name: Setup Aftman
47-
uses: ok-nick/setup-aftman@v0.4.2
48-
with:
49-
token: ${{ secrets.GITHUB_TOKEN }}
50-
5146
- name: Build all tools
5247
run: pnpm -r --filter './tools/**' run build
5348

@@ -58,20 +53,6 @@ jobs:
5853
- name: Verify environment health (pre-auth)
5954
run: studio-bridge linux status
6055

61-
- name: Validate cookie
62-
if: ${{ env.ROBLOSECURITY != '' }}
63-
run: |
64-
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
65-
-H "Cookie: .ROBLOSECURITY=$ROBLOSECURITY" \
66-
https://users.roblox.com/v1/users/authenticated)
67-
if [ "$STATUS" != "200" ]; then
68-
echo "::error::ROBLOSECURITY cookie is invalid (HTTP $STATUS). Update the repository secret."
69-
exit 1
70-
fi
71-
echo "Cookie validated (HTTP $STATUS)"
72-
env:
73-
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
74-
7556
- name: Inject authentication
7657
if: ${{ env.ROBLOSECURITY != '' }}
7758
run: studio-bridge linux auth --verbose

tools/nevermore-cli-helpers/src/auth/roblox-auth/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,34 @@ export async function createPlaceInUniverseAsync(
153153
return placeId;
154154
}
155155

156+
/**
157+
* Validates the ROBLOSECURITY cookie against the Roblox API.
158+
* Exits with an error if the cookie is invalid. Continues with a
159+
* warning if the network request itself fails (offline scenario).
160+
*/
161+
export async function validateCookieAsync(cookie: string): Promise<void> {
162+
try {
163+
const response = await fetch('https://users.roblox.com/v1/users/authenticated', {
164+
headers: {
165+
Cookie: `.ROBLOSECURITY=${cookie}`,
166+
},
167+
});
168+
169+
if (response.status !== 200) {
170+
OutputHelper.error(
171+
`ROBLOSECURITY cookie is invalid or expired (HTTP ${response.status}). Update the cookie and try again.`,
172+
);
173+
process.exit(1);
174+
}
175+
176+
OutputHelper.verbose('ROBLOSECURITY cookie validated successfully.');
177+
} catch {
178+
OutputHelper.warn(
179+
'Could not validate ROBLOSECURITY cookie (network error). Continuing anyway.',
180+
);
181+
}
182+
}
183+
156184
export interface RenamePlaceResult {
157185
success: boolean;
158186
reason?: 'no_cookie' | 'api_error';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
3+
vi.mock('@quenty/cli-output-helpers', () => ({
4+
OutputHelper: {
5+
error: vi.fn(),
6+
warn: vi.fn(),
7+
info: vi.fn(),
8+
verbose: vi.fn(),
9+
},
10+
}));
11+
12+
import { validateCookieAsync } from './index.js';
13+
import { OutputHelper } from '@quenty/cli-output-helpers';
14+
15+
const mockedOutputHelper = vi.mocked(OutputHelper);
16+
17+
describe('validateCookieAsync', () => {
18+
const originalFetch = globalThis.fetch;
19+
20+
beforeEach(() => {
21+
vi.restoreAllMocks();
22+
});
23+
24+
afterEach(() => {
25+
globalThis.fetch = originalFetch;
26+
});
27+
28+
it('continues without error when cookie is valid (HTTP 200)', async () => {
29+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
30+
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 });
31+
32+
await validateCookieAsync('valid-cookie');
33+
34+
expect(exitSpy).not.toHaveBeenCalled();
35+
expect(mockedOutputHelper.error).not.toHaveBeenCalled();
36+
expect(mockedOutputHelper.warn).not.toHaveBeenCalled();
37+
});
38+
39+
it('exits with error when cookie is invalid (HTTP 401)', async () => {
40+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
41+
globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 });
42+
43+
await validateCookieAsync('expired-cookie');
44+
45+
expect(mockedOutputHelper.error).toHaveBeenCalledWith(
46+
'ROBLOSECURITY cookie is invalid or expired (HTTP 401). Update the cookie and try again.',
47+
);
48+
expect(exitSpy).toHaveBeenCalledWith(1);
49+
});
50+
51+
it('continues with warning when fetch throws (network error)', async () => {
52+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
53+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error'));
54+
55+
await validateCookieAsync('some-cookie');
56+
57+
expect(mockedOutputHelper.warn).toHaveBeenCalledWith(
58+
'Could not validate ROBLOSECURITY cookie (network error). Continuing anyway.',
59+
);
60+
expect(exitSpy).not.toHaveBeenCalled();
61+
expect(mockedOutputHelper.error).not.toHaveBeenCalled();
62+
});
63+
});

tools/nevermore-cli-helpers/src/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
getRobloxCookieAsync,
55
createPlaceInUniverseAsync,
66
tryRenamePlaceAsync,
7+
validateCookieAsync,
78
} from './auth/roblox-auth/index.js';
89
export type { RenamePlaceResult } from './auth/roblox-auth/index.js';
910
export { COOKIE_NAME, parseStudioCookieValue } from './auth/roblox-auth/cookie-parser.js';

tools/studio-bridge/docker/Dockerfile

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,24 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
3232
&& corepack enable pnpm \
3333
&& apt-get clean && rm -rf /var/lib/apt/lists/*
3434

35+
# --- Aftman binary ---
36+
RUN curl -fsSL https://github.com/LPGhatguy/aftman/releases/download/v0.3.0/aftman-0.3.0-linux-x86_64.zip \
37+
-o /tmp/aftman.zip \
38+
&& unzip -o /tmp/aftman.zip -d /tmp/aftman \
39+
&& install -m 755 /tmp/aftman/aftman /usr/local/bin/aftman \
40+
&& rm -rf /tmp/aftman.zip /tmp/aftman
41+
3542
# --- Non-root user ---
3643
RUN useradd -m -s /bin/bash studio
3744
USER studio
3845
WORKDIR /home/studio
3946

47+
# --- Install Aftman tools (rojo, lune, etc.) ---
48+
# aftman.toml lives in $HOME so shims can find it from any CWD
49+
COPY --from=workspace --chown=studio:studio aftman.toml /home/studio/aftman.toml
50+
RUN mkdir -p /home/studio/.aftman/bin \
51+
&& aftman install --no-trust-check
52+
4053
# --- Build studio-bridge from source (via named build context "workspace") ---
4154
COPY --from=workspace --chown=studio:studio package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.json /home/studio/build/
4255
COPY --from=workspace --chown=studio:studio tools/ /home/studio/build/tools/
@@ -81,7 +94,7 @@ ENV STUDIO_DIR=/home/studio/roblox-studio \
8194
MESA_GL_VERSION_OVERRIDE=4.5 \
8295
MESA_GLSL_VERSION_OVERRIDE=450 \
8396
NPM_CONFIG_PREFIX=/home/studio/.npm-global \
84-
PATH=/home/studio/.npm-global/bin:$PATH
97+
PATH=/home/studio/.aftman/bin:/home/studio/.npm-global/bin:$PATH
8598

8699
COPY --chown=studio:studio entrypoint.sh /home/studio/entrypoint.sh
87100
RUN chmod +x /home/studio/entrypoint.sh

tools/studio-bridge/src/cli/script-executor.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface ExecuteScriptOptions {
2424
timeoutMs: number;
2525
verbose: boolean;
2626
showLogs: boolean;
27+
filePath?: string;
2728
}
2829

2930
/**
@@ -52,6 +53,15 @@ export async function resolvePlacePathAsync(
5253
export async function executeScriptAsync(
5354
options: ExecuteScriptOptions
5455
): Promise<void> {
56+
const { shouldDelegateToDockerAsync, delegateToDockerAsync } =
57+
await import('../docker/docker-delegator.js');
58+
59+
if (await shouldDelegateToDockerAsync()) {
60+
OutputHelper.verbose('[StudioBridge] No Wine detected, delegating to Docker');
61+
await delegateToDockerAsync(options);
62+
return; // unreachable — delegateToDockerAsync calls process.exit
63+
}
64+
5565
const { scriptContent, packageName, placePath, timeoutMs, verbose, showLogs } =
5666
options;
5767

tools/studio-bridge/src/commands/linux/auth/auth.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { defineCommand } from '../../framework/define-command.js';
77
import { arg } from '../../framework/arg-builder.js';
88
import { OutputHelper } from '@quenty/cli-output-helpers';
99
import { getRobloxCookieAsync } from '@quenty/nevermore-cli-helpers';
10+
import { checkLinuxEnvironmentAsync } from '../../../linux/linux-env-guard.js';
1011

1112
// ---------------------------------------------------------------------------
1213
// Types
@@ -43,6 +44,12 @@ function readStdinAsync(): Promise<string> {
4344

4445
export async function authHandlerAsync(args: AuthArgs): Promise<AuthResult> {
4546
try {
47+
const envError = await checkLinuxEnvironmentAsync();
48+
if (envError) {
49+
OutputHelper.error(envError);
50+
process.exit(1);
51+
}
52+
4653
const linux = await import('../../../linux/index.js');
4754
const config = linux.resolveLinuxConfig();
4855

@@ -61,6 +68,10 @@ export async function authHandlerAsync(args: AuthArgs): Promise<AuthResult> {
6168
cookie = await getRobloxCookieAsync();
6269
}
6370

71+
// Validate cookie before attempting Wine injection
72+
const { validateCookieAsync } = await import('@quenty/nevermore-cli-helpers');
73+
await validateCookieAsync(cookie);
74+
6475
// Ensure display is running (Wine needs it for credential write)
6576
await linux.ensureDisplayAsync(config);
6677

@@ -87,7 +98,7 @@ export async function authHandlerAsync(args: AuthArgs): Promise<AuthResult> {
8798
export const linuxAuthCommand = defineCommand<AuthArgs, AuthResult>({
8899
group: 'linux',
89100
name: 'auth',
90-
description: 'Inject .ROBLOSECURITY cookie into Wine Credential Manager',
101+
description: 'Inject .ROBLOSECURITY cookie into Wine Credential Manager (within Docker image or Linux with Wine)',
91102
category: 'infrastructure',
92103
safety: 'none',
93104
scope: 'standalone',

tools/studio-bridge/src/commands/linux/setup/setup.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { defineCommand } from '../../framework/define-command.js';
77
import { arg } from '../../framework/arg-builder.js';
88
import { OutputHelper } from '@quenty/cli-output-helpers';
9+
import { checkLinuxEnvironmentAsync } from '../../../linux/linux-env-guard.js';
910

1011
// ---------------------------------------------------------------------------
1112
// Types
@@ -30,6 +31,12 @@ interface SetupResult {
3031

3132
export async function setupHandlerAsync(args: SetupArgs): Promise<SetupResult> {
3233
try {
34+
const envError = await checkLinuxEnvironmentAsync();
35+
if (envError) {
36+
OutputHelper.error(envError);
37+
process.exit(1);
38+
}
39+
3340
const linux = await import('../../../linux/index.js');
3441
const config = linux.resolveLinuxConfig();
3542

@@ -119,7 +126,7 @@ export async function setupHandlerAsync(args: SetupArgs): Promise<SetupResult> {
119126
export const linuxSetupCommand = defineCommand<SetupArgs, SetupResult>({
120127
group: 'linux',
121128
name: 'setup',
122-
description: 'Install Wine dependencies and Roblox Studio for headless Linux operation',
129+
description: 'Install Wine + Roblox Studio for headless Linux operation (within Docker image or Linux with Wine)',
123130
category: 'infrastructure',
124131
safety: 'none',
125132
scope: 'standalone',

tools/studio-bridge/src/commands/linux/status/status.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as fs from 'fs/promises';
77
import { defineCommand } from '../../framework/define-command.js';
88
import { OutputHelper } from '@quenty/cli-output-helpers';
9+
import { checkLinuxEnvironmentAsync } from '../../../linux/linux-env-guard.js';
910

1011
// ---------------------------------------------------------------------------
1112
// Types
@@ -48,6 +49,12 @@ async function fileExistsAsync(filePath: string): Promise<boolean> {
4849

4950
export async function statusHandlerAsync(_args: StatusArgs): Promise<StatusResult> {
5051
try {
52+
const envError = await checkLinuxEnvironmentAsync();
53+
if (envError) {
54+
OutputHelper.error(envError);
55+
process.exit(1);
56+
}
57+
5158
const linux = await import('../../../linux/index.js');
5259
const config = linux.resolveLinuxConfig();
5360
let allOk = true;
@@ -176,7 +183,7 @@ export async function statusHandlerAsync(_args: StatusArgs): Promise<StatusResul
176183
export const linuxStatusCommand = defineCommand<StatusArgs, StatusResult>({
177184
group: 'linux',
178185
name: 'status',
179-
description: 'Check Linux/Wine environment health for Studio',
186+
description: 'Check Linux/Wine environment health for Studio (within Docker image or Linux with Wine)',
180187
category: 'infrastructure',
181188
safety: 'none',
182189
scope: 'standalone',

tools/studio-bridge/src/commands/process/run/run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface ProcessRunOptions {
2121
timeoutMs: number;
2222
verbose: boolean;
2323
showLogs: boolean;
24+
filePath?: string;
2425
}
2526

2627
export interface ProcessRunResult {
@@ -107,6 +108,7 @@ export const processRunCommand = defineCommand<ProcessRunArgs, ProcessRunResult>
107108
timeoutMs: args.timeout ?? 120_000,
108109
verbose: false,
109110
showLogs: true,
111+
filePath: args.file,
110112
});
111113
},
112114
// No MCP config -- process run is CLI-only

0 commit comments

Comments
 (0)