-
Notifications
You must be signed in to change notification settings - Fork 76
Studio Web: live client-side preview of what the agent builds #3866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
lezama
wants to merge
4
commits into
trunk
Choose a base branch
from
add-studio-web-live-preview
base: trunk
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
9b264f9
Studio Web: live client-side Playground preview of what the agent bui…
lezama bff327d
Drop internal jargon from preview comments
lezama 8dd26b7
Validate session id before using it in a workspace path (fixes CodeQL…
lezama 060e810
Add path-containment barrier so CodeQL recognizes the workspace path …
lezama File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| import { execFileSync } from 'node:child_process'; | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; | ||
|
|
||
| /** | ||
| * Per-session workspace for Studio Web — the cloud analog of Studio App's local | ||
| * site layer, and the answer to "where does the agent build, and where does it | ||
| * persist". Each session gets its own git-backed working directory that the | ||
| * forked agent operates on as its active site. | ||
| * | ||
| * "git-backed" is deliberate: it's the project container Studio Web is intended | ||
| * to use (Matt B's git-as-project-container proposal) — `git status` is the | ||
| * change set and a later `git push` is the publish/deploy step. This increment | ||
| * only reads the workspace for the live preview; the draft/publish surface is a | ||
| * follow-up. | ||
| * | ||
| * (The workspace lives under STUDIO_SITES_ROOT so the agent's file tools — | ||
| * scoped to that root — can reach it without relocating HOME, which would move | ||
| * session storage too.) | ||
| */ | ||
|
|
||
| const WORKSPACE_PREFIX = 'studio-web'; | ||
|
|
||
| export interface Workspace { | ||
| name: string; | ||
| slug: string; | ||
| path: string; | ||
| } | ||
|
|
||
| const GIT_ENV = { | ||
| GIT_AUTHOR_NAME: 'Studio Web', | ||
| GIT_AUTHOR_EMAIL: 'studio-web@local', | ||
| GIT_COMMITTER_NAME: 'Studio Web', | ||
| GIT_COMMITTER_EMAIL: 'studio-web@local', | ||
| }; | ||
|
|
||
| function git( cwd: string, args: string[] ): void { | ||
| execFileSync( 'git', args, { cwd, stdio: 'ignore', env: { ...process.env, ...GIT_ENV } } ); | ||
| } | ||
|
|
||
| function gitOut( cwd: string, args: string[] ): string { | ||
| return execFileSync( 'git', args, { | ||
| cwd, | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| encoding: 'utf8', | ||
| env: { ...process.env, ...GIT_ENV }, | ||
| } ).trim(); | ||
| } | ||
|
|
||
| // Session ids come from HTTP requests. They're UUIDs, but validate before using | ||
| // one in a filesystem path — derive the slug from an allowlisted slice only. | ||
| function safeSessionToken( sessionId: string ): string { | ||
| if ( ! /^[a-zA-Z0-9-]+$/.test( sessionId ) ) { | ||
| throw new Error( `Invalid session id: ${ sessionId }` ); | ||
| } | ||
| // The slug only ever contains these chars — no path separators can survive. | ||
| return sessionId.replace( /[^a-zA-Z0-9]/g, '' ).slice( 0, 8 ); | ||
| } | ||
|
|
||
| /** The workspace identity for a session (slug/name/path), without touching disk. */ | ||
| export function workspaceFor( sessionId: string ): Workspace { | ||
| const token = safeSessionToken( sessionId ); | ||
| const slug = `${ WORKSPACE_PREFIX }-${ token }`; | ||
| const workspacePath = path.join( STUDIO_SITES_ROOT, slug ); | ||
| // Containment check: even with the token allowlist above, confirm the | ||
| // resolved path stays inside STUDIO_SITES_ROOT before anything touches the | ||
| // filesystem with it — a crafted session id must not escape the sites root | ||
| // (defends against CodeQL js/path-injection). | ||
| const relative = path.relative( STUDIO_SITES_ROOT, workspacePath ); | ||
| if ( relative.startsWith( '..' ) || path.isAbsolute( relative ) ) { | ||
| throw new Error( `Workspace path escapes the sites root: ${ sessionId }` ); | ||
| } | ||
| return { name: `Studio Web (${ token })`, slug, path: workspacePath }; | ||
| } | ||
|
|
||
| /** | ||
| * Ensure a git-backed workspace exists for `sessionId` and return it. Idempotent: | ||
| * an existing workspace is returned untouched so re-runs of a session reuse it. | ||
| */ | ||
| export function ensureWorkspace( sessionId: string ): Workspace { | ||
| const workspace = workspaceFor( sessionId ); | ||
| const workspacePath = workspace.path; | ||
|
|
||
| if ( ! fs.existsSync( workspacePath ) ) { | ||
| fs.mkdirSync( workspacePath, { recursive: true } ); | ||
| } | ||
| if ( ! fs.existsSync( path.join( workspacePath, '.git' ) ) ) { | ||
| fs.writeFileSync( | ||
| path.join( workspacePath, '.gitignore' ), | ||
| // Track deployable code only, like a WordPress.com GitHub Deployments repo. | ||
| [ '/wp-content/uploads/', '/wp-content/database/', '**/*.sqlite', '.DS_Store', '' ].join( | ||
| '\n' | ||
| ) | ||
| ); | ||
| git( workspacePath, [ 'init', '-b', 'main' ] ); | ||
| git( workspacePath, [ 'add', '-A' ] ); | ||
| git( workspacePath, [ 'commit', '--allow-empty', '-m', 'studio-web: workspace baseline' ] ); | ||
| } | ||
|
|
||
| return workspace; | ||
| } | ||
|
|
||
| export interface WorkspaceFile { | ||
| /** Path relative to the workspace root, POSIX-separated (e.g. 'wp-content/themes/foo/style.css'). */ | ||
| path: string; | ||
| /** File contents, base64-encoded so binary assets survive the JSON round-trip. */ | ||
| contentBase64: string; | ||
| } | ||
|
|
||
| // Guardrails so a runaway workspace can't produce an unbounded preview payload. | ||
| const MAX_PREVIEW_FILES = 2000; | ||
| const MAX_PREVIEW_FILE_BYTES = 10 * 1024 * 1024; // 10 MB per file (covers a small SQLite DB) | ||
|
|
||
| // The SQLite database, when the agent built a real WP install in the workspace. | ||
| // It's gitignored (not deployable code), but the preview overlays it so the | ||
| // browser shows the agent's actual content, not an empty install. | ||
| const PREVIEW_DB_PATH = 'wp-content/database/.ht.sqlite'; | ||
|
|
||
| function readWorkspaceFile( cwd: string, relPath: string ): WorkspaceFile | undefined { | ||
| const absPath = path.join( cwd, relPath ); | ||
| let stat: fs.Stats; | ||
| try { | ||
| stat = fs.statSync( absPath ); | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| } catch { | ||
| return undefined; // listed but gone (race with a concurrent agent edit) | ||
| } | ||
| if ( ! stat.isFile() || stat.size > MAX_PREVIEW_FILE_BYTES ) { | ||
| return undefined; | ||
| } | ||
| return { path: relPath, contentBase64: fs.readFileSync( absPath ).toString( 'base64' ) }; | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| } | ||
|
|
||
| /** | ||
| * The workspace's files for the client-side Playground preview: the deployable | ||
| * code the agent wrote (tracked + untracked, .gitignore respected) plus the | ||
| * SQLite database if one exists, so the preview reflects real content. Empty for | ||
| * sessions whose workspace hasn't been created yet. Files over the per-file cap | ||
| * are skipped. | ||
| */ | ||
| export function getWorkspaceFiles( sessionId: string ): WorkspaceFile[] { | ||
| const { path: cwd } = workspaceFor( sessionId ); | ||
| if ( ! fs.existsSync( path.join( cwd, '.git' ) ) ) { | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return []; | ||
| } | ||
| // `--cached --others --exclude-standard` = tracked + untracked, honoring | ||
| // .gitignore — the same deployable set publish will operate on. | ||
| const listing = gitOut( cwd, [ 'ls-files', '--cached', '--others', '--exclude-standard' ] ); | ||
| const relPaths = listing ? listing.split( '\n' ) : []; | ||
| // The DB is gitignored, so add it explicitly (preview needs it for content). | ||
| if ( fs.existsSync( path.join( cwd, PREVIEW_DB_PATH ) ) ) { | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| relPaths.push( PREVIEW_DB_PATH ); | ||
| } | ||
|
|
||
| const files: WorkspaceFile[] = []; | ||
| for ( const relPath of relPaths ) { | ||
| if ( files.length >= MAX_PREVIEW_FILES ) { | ||
| break; | ||
| } | ||
| const file = readWorkspaceFile( cwd, relPath ); | ||
| if ( file ) { | ||
| files.push( file ); | ||
| } | ||
| } | ||
| return files; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.