1- import { randomBytes } from 'node:crypto'
21import { createServer , type Server } from 'node:http'
32import { readFile , writeFile , rename } from 'node:fs/promises'
43import { join } from 'node:path'
@@ -25,17 +24,12 @@ export class Workbench {
2524 }
2625 private server : Server | undefined
2726 private starting : Promise < string > | undefined
28- private readonly sessionTokens = new Map < string , string > ( )
29- private readonly requestTokens = new Map < string , string > ( )
3027 private readonly nativeOrigins = new Map < string , string > ( )
3128 constructor ( readonly runtime : PhoneRuntime ) { }
3229 async open ( owner ?: string ) : Promise < string > {
3330 this . starting ??= this . start ( )
3431 const base = await this . starting
35- if ( ! owner ) return base
36- let token = this . sessionTokens . get ( owner )
37- if ( ! token ) { token = randomBytes ( 32 ) . toString ( 'base64url' ) ; this . sessionTokens . set ( owner , token ) }
38- return base + 'session/' + token + '/'
32+ return owner ? base + '?owner=' + encodeURIComponent ( owner ) : base
3933 }
4034 async close ( ) : Promise < void > { this . server ?. closeAllConnections ( ) ; await new Promise < void > ( resolve => this . server ? this . server . close ( ( ) => resolve ( ) ) : resolve ( ) ) ; await this . draftWrites }
4135 private async saveDraft ( value : unknown ) : Promise < void > {
@@ -55,15 +49,13 @@ export class Workbench {
5549 if ( typeof saved . goal === 'string' && saved . goal . length <= 8000 ) this . draft = saved . goal
5650 } catch ( error ) { if ( ( error as NodeJS . ErrnoException ) . code !== 'ENOENT' ) throw error }
5751
58- const token = randomBytes ( 32 ) . toString ( 'base64url' )
5952 let origin = ''
60- const prefix = '/' + token + '/'
6153 const server = createServer ( ( req , res ) => {
6254 if ( this . maintenance ) { res . writeHead ( 503 ) ; res . end ( ) ; return }
6355 this . pendingRequests ++
6456 void ( async ( ) => {
6557 const requested = new URL ( req . url ?? '/' , origin )
66- const nativeEntry = ( ( this . runtime . options . host === 'workbuddy' && requested . searchParams . get ( 'mcpApp' ) === '1' ) || ( this . runtime . options . host === 'codex' && requested . searchParams . get ( 'mcpApp' ) === 'codex' ) ) && req . method === 'GET' && / ^ s e s s i o n \/ [ A - Z a - z 0 - 9 _ - ] + \/ $ / . test ( requested . pathname . slice ( prefix . length ) )
58+ const nativeEntry = ( ( this . runtime . options . host === 'workbuddy' && requested . searchParams . get ( 'mcpApp' ) === '1' ) || ( this . runtime . options . host === 'codex' && requested . searchParams . get ( 'mcpApp' ) === 'codex' ) ) && req . method === 'GET' && requested . pathname === '/'
6759 let nativeOrigin = ''
6860 if ( nativeEntry && requested . searchParams . has ( 'hostOrigin' ) ) {
6961 try {
@@ -75,27 +67,12 @@ export class Workbench {
7567 res . setHeader ( 'X-Content-Type-Options' , 'nosniff' )
7668 res . setHeader ( 'Content-Security-Policy' , `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; frame-src http://127.0.0.1:*; connect-src 'self'; frame-ancestors ${ nativeOrigin ? `file: ${ nativeOrigin } ` : this . embedOrigin ?? "'none'" } ; base-uri 'none'` )
7769 const send = ( status : number , data : unknown ) => { res . writeHead ( status , { 'Content-Type' : 'application/json' } ) ; res . end ( JSON . stringify ( data ) ) }
78- const embedded = req . method === 'GET' && req . headers [ 'sec-fetch-dest' ] === 'iframe' && this . embedOrigin && req . headers . referer ?. startsWith ( this . embedOrigin + '/' )
79- // A native MCP App may navigate to its owner-bound capability URL.
80- // WorkBuddy uses a file renderer and a loopback sandbox ancestor. Only the
81- // bound page GET may embed; API requests retain same-origin checks.
82- const nativeDocument = nativeEntry && ( req . headers [ 'sec-fetch-dest' ] === 'document' || ( req . headers [ 'sec-fetch-dest' ] === 'iframe' && Boolean ( nativeOrigin ) ) )
83- if ( req . headers . host !== new URL ( origin ) . host || ! req . url ?. startsWith ( prefix ) || ( ! embedded && ! nativeDocument && req . headers [ 'sec-fetch-site' ] === 'cross-site' ) || ( req . headers . origin && req . headers . origin !== origin ) ) { send ( 403 , { error : 'Forbidden' } ) ; return }
8470 this . lastRead = Date . now ( )
85- let route = req . url . slice ( prefix . length )
86- let owner = 'workbench'
87- if ( route . startsWith ( 'session/' ) ) {
88- const match = / ^ s e s s i o n \/ ( [ A - Z a - z 0 - 9 _ - ] + ) \/ ( .* ) $ / . exec ( route )
89- const session = match && [ ...this . sessionTokens ] . find ( ( [ , token ] ) => token === match [ 1 ] )
90- if ( ! session ) { send ( 403 , { error : 'Unknown workbench session' } ) ; return }
91- owner = session [ 0 ] ; route = match ! [ 2 ] !
92- if ( nativeEntry ) {
93- route = ''
94- if ( nativeOrigin ) {
95- this . nativeOrigins . set ( owner , nativeOrigin )
96- if ( ! this . requestTokens . has ( owner ) ) this . requestTokens . set ( owner , randomBytes ( 32 ) . toString ( 'base64url' ) )
97- }
98- }
71+ const route = requested . pathname . slice ( 1 )
72+ const owner = requested . searchParams . get ( 'owner' ) || 'workbench'
73+ if ( owner . length > 256 ) { send ( 400 , { error : 'Invalid owner' } ) ; return }
74+ if ( nativeEntry && nativeOrigin ) {
75+ this . nativeOrigins . set ( owner , nativeOrigin )
9976 }
10077 if ( req . method === 'GET' ) {
10178 if ( ! route ) { res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ; res . end ( workbenchPage ) ; return }
@@ -105,7 +82,7 @@ export class Workbench {
10582 if ( native ) for ( const task of tasks ) {
10683 if ( task . owner === owner && task . viewerId && [ 'preparing' , 'running' , 'waiting' , 'stopping' ] . includes ( task . phase ) ) this . runtime . viewers . allowNativeEmbedding ( task . viewerId , task . id , native )
10784 }
108- send ( 200 , { host : this . runtime . options . host , workbenchOwner : owner , nativeRequestToken : this . requestTokens . get ( owner ) , embedOrigin : this . embedOrigin , executionMode : this . runtime . hostDriven ? 'host' : 'byok' , draft : this . draft , tasks, profiles : this . runtime . hostDriven ? [ ] : [ ...this . runtime . profiles . values ( ) ] } ) ; return
85+ send ( 200 , { host : this . runtime . options . host , workbenchOwner : owner , embedOrigin : this . embedOrigin , executionMode : this . runtime . hostDriven ? 'host' : 'byok' , draft : this . draft , tasks, profiles : this . runtime . hostDriven ? [ ] : [ ...this . runtime . profiles . values ( ) ] } ) ; return
10986 }
11087 if ( route === 'devices' ) { send ( 200 , await this . runtime . options . hardware . listDevices ( AbortSignal . timeout ( 10_000 ) ) ) ; return }
11188 const history = / ^ e v e n t s \/ ( [ a - f 0 - 9 - ] + ) $ / . exec ( route )
@@ -117,13 +94,7 @@ export class Workbench {
11794 }
11895 send ( 404 , { error : 'Not found' } ) ; return
11996 }
120- // WorkBuddy's native container omits Origin on same-origin fetches.
121- // A separate owner-bound token plus Fetch Metadata permits only that case.
122- const nativePost = this . runtime . options . host === 'workbuddy' && this . nativeOrigins . has ( owner ) &&
123- req . headers . origin === undefined && req . headers [ 'sec-fetch-site' ] === 'same-origin' &&
124- req . headers [ 'sec-fetch-dest' ] === 'empty' && Boolean ( this . requestTokens . get ( owner ) ) &&
125- req . headers [ 'x-opengui-request-token' ] === this . requestTokens . get ( owner )
126- if ( req . method !== 'POST' || ( req . headers . origin !== origin && ! nativePost ) || req . headers [ 'content-type' ] !== 'application/json' ) { send ( 403 , { error : 'Same-origin JSON required' } ) ; return }
97+ if ( req . method !== 'POST' || req . headers [ 'content-type' ] !== 'application/json' ) { send ( 405 , { error : 'JSON POST required' } ) ; return }
12798 let body = ''
12899 for await ( const part of req ) { body += String ( part ) ; if ( Buffer . byteLength ( body ) > 65536 ) { send ( 413 , { error : 'Request too large' } ) ; req . destroy ( ) ; return } }
129100 const args = JSON . parse ( body ) as Record < string , unknown >
@@ -142,11 +113,14 @@ export class Workbench {
142113 } ) ( ) . catch ( ( ) => { if ( ! res . headersSent ) res . writeHead ( 400 , { 'Content-Type' : 'application/json' } ) ; res . end ( JSON . stringify ( { error : this . runtime . hostDriven ? '请求未完成,请检查手机连接和宿主任务状态。' : '请求未完成,请检查手机、模型配置与任务状态。' } ) ) } ) . finally ( ( ) => { this . pendingRequests -- } )
143114 } )
144115 this . server = server
145- await new Promise < void > ( ( resolve , reject ) => { server . once ( 'error' , reject ) ; server . listen ( 0 , '127.0.0.1' , resolve ) } )
116+ const configuredPort = process . env . OPENGUI_WORKBENCH_PORT
117+ const port = configuredPort ? Number ( configuredPort ) : 0
118+ if ( ! Number . isInteger ( port ) || port < 0 || port > 65535 ) throw new Error ( 'Invalid workbench port' )
119+ await new Promise < void > ( ( resolve , reject ) => { server . once ( 'error' , reject ) ; server . listen ( port , '127.0.0.1' , resolve ) } )
146120 const address = server . address ( )
147121 if ( ! address || typeof address === 'string' ) throw new Error ( 'Workbench bind failed' )
148122 origin = `http://127.0.0.1:${ address . port } `
149123 this . runtime . viewers . allowEmbedding ( origin )
150- return origin + prefix
124+ return origin + '/'
151125 }
152126}
0 commit comments