Skip to content

Commit dde393c

Browse files
committed
feat(DMY-59): open local workbench directly and auto-show devices
1 parent e4a7c7b commit dde393c

22 files changed

Lines changed: 126 additions & 167 deletions

File tree

‎packages/device-runtime/src/viewer.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ interface Connection {
1717
}
1818
interface Viewer {
1919
id: string; token: string; owner: string; devices: readonly ViewerDevice[]
20-
phase: Phase; deadline: number; established: boolean; ended: boolean
20+
phase: Phase; openedAt: number; deadline: number; established: boolean; ended: boolean
2121
firstFrameMs?: number; error?: string; connections: Map<string, Connection>; pages: Set<ScrcpyStreamSink>; lastPage: number
2222
preparation?: Promise<void>; readyDevices: Set<string>
2323
nativeOrigins?: Set<string>
@@ -29,7 +29,7 @@ export class ViewerServer {
2929
private starting: Promise<void> | undefined
3030
private origin = ''
3131
private readonly embedOrigins = new Set<string>()
32-
/** Only the host's own authenticated local workbench may embed this viewer. */
32+
/** The local workbench may embed this viewer. */
3333
allowEmbedding(origin: string): void {
3434
const url = new URL(origin)
3535
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.origin !== origin) throw new Error('Invalid workbench origin')
@@ -69,13 +69,13 @@ export class ViewerServer {
6969
if (viewer && !this.same(viewer, devices)) throw new Error('device_frozen')
7070
if (!viewer) {
7171
if (this.viewers.size >= 100) throw new Error('viewer_capacity')
72-
viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() }
72+
viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', openedAt: this.now(), deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() }
7373
this.viewers.set(viewer.id, viewer)
7474
const current = viewer
7575
viewer.preparation = (async () => { try {
7676
await this.streams.prepare(signal)
7777
await this.start()
78-
current.deadline = this.now() + 30_000
78+
current.deadline = owner.startsWith('workbench-preview:') ? 0 : this.now() + 30_000
7979
current.phase = 'waiting_for_frame'
8080
} catch (error) {
8181
current.phase = 'error'
@@ -195,7 +195,7 @@ export class ViewerServer {
195195
if (!v.error) {
196196
v.readyDevices.add(c.deviceId)
197197
if (v.devices.every(d => [...v.connections.values()].some(x => x.deviceId === d.id && x.media && this.now() - x.painted < 2000))) {
198-
v.firstFrameMs ??= this.now() - (v.deadline - 30_000)
198+
v.firstFrameMs ??= this.now() - v.openedAt
199199
v.established = true; v.phase = 'ready'
200200
}
201201
}

‎packages/phone-agent/src/workbench.ts‎

Lines changed: 14 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { randomBytes } from 'node:crypto'
21
import { createServer, type Server } from 'node:http'
32
import { readFile, writeFile, rename } from 'node:fs/promises'
43
import { 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' && /^session\/[A-Za-z0-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 = /^session\/([A-Za-z0-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 = /^events\/([a-f0-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
}

‎packages/task-service/src/client.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { lstat } from 'node:fs/promises'
22
import { createConnection } from 'node:net'
3-
import { readFile } from 'node:fs/promises'
43
import { spawn } from 'node:child_process'
54
import { fileURLToPath } from 'node:url'
65
import { setTimeout as delay } from 'node:timers/promises'
76
import { isTaskTool } from '../../phone-agent/src/host.ts'
8-
import { TASK_SERVICE_PROTOCOL, serviceRoot, socketPath, tokenPath } from './paths.ts'
7+
import { TASK_SERVICE_PROTOCOL, serviceRoot, socketPath } from './paths.ts'
98

109
export type TaskHostName = 'codex' | 'workbuddy' | 'dsh'
1110
export interface PhoneTasks {
@@ -23,16 +22,14 @@ interface Reply { result?: unknown; error?: string }
2322
const entry = fileURLToPath(new URL('./task-service.js', import.meta.url))
2423

2524
async function request(root: string, name: string, args: Record<string, unknown>, owner: string, host: TaskHostName): Promise<unknown> {
26-
const token = (await readFile(tokenPath(root), 'utf8').catch(() => '')).trim()
27-
if (!token) throw Object.assign(new Error('task service is not installed'), { code: 'ENOENT' })
2825
return new Promise((resolve, reject) => {
2926
const socket = createConnection(socketPath(root))
3027
let body = ''
3128
const fail = (error: Error) => { socket.destroy(); reject(error) }
3229
socket.setEncoding('utf8')
3330
socket.setTimeout(125_000, () => fail(new Error('task service timeout')))
3431
socket.once('error', fail)
35-
socket.once('connect', () => socket.write(JSON.stringify({ protocol: TASK_SERVICE_PROTOCOL, token, host, name, args, owner }) + '\n'))
32+
socket.once('connect', () => socket.write(JSON.stringify({ protocol: TASK_SERVICE_PROTOCOL, host, name, args, owner }) + '\n'))
3633
socket.on('data', chunk => {
3734
body += chunk
3835
if (!body.includes('\n')) return

‎packages/task-service/src/launchd.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { TASK_SERVICE_LABEL, serviceRoot } from './paths.ts'
55

66
const xml = (value: string) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
77

8-
/** User launch agent. The plist holds paths only; the service token stays in a 0600 file. */
8+
/** User launch agent for the shared service. */
99
export function taskServicePlist(options: { node: string; entry: string; root?: string }): string {
1010
for (const value of [options.node, options.entry]) {
1111
if (!isAbsolute(value) || /[\x00-\x1f]/.test(value)) throw new Error('task service paths must be absolute')
@@ -17,7 +17,7 @@ export function taskServicePlist(options: { node: string; entry: string; root?:
1717
<plist version="1.0"><dict>
1818
<key>Label</key>${str(TASK_SERVICE_LABEL)}
1919
<key>ProgramArguments</key><array>${[options.node, options.entry].map(str).join('')}</array>
20-
<key>EnvironmentVariables</key><dict><key>OPENGUI_TASK_SERVICE_ROOT</key>${str(root)}</dict>
20+
<key>EnvironmentVariables</key><dict><key>OPENGUI_TASK_SERVICE_ROOT</key>${str(root)}<key>OPENGUI_WORKBENCH_PORT</key>${str('58894')}</dict>
2121
<key>RunAtLoad</key><true/>
2222
<key>KeepAlive</key><true/>
2323
<key>ThrottleInterval</key><integer>10</integer>

‎packages/task-service/src/paths.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { homedir } from 'node:os'
22
import { join } from 'node:path'
33

4-
export const TASK_SERVICE_PROTOCOL = 1
4+
export const TASK_SERVICE_PROTOCOL = 2
55
export const TASK_SERVICE_LABEL = 'org.opengui.task-service'
66

77
/** User-level service root. Tests override it; installers do not point this at a plugin directory. */
@@ -10,5 +10,4 @@ export function serviceRoot(): string {
1010
}
1111

1212
export const socketPath = (root: string): string => join(root, 'service.sock')
13-
export const tokenPath = (root: string): string => join(root, 'service.token')
1413
export const dataPath = (root: string): string => join(root, 'data')

‎packages/task-service/src/server.ts‎

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { randomBytes, timingSafeEqual } from 'node:crypto'
2-
import { chmod, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
1+
import { chmod, lstat, mkdir, rm } from 'node:fs/promises'
32
import { createServer, type Socket } from 'node:net'
43
import { TaskHost } from '../../phone-agent/src/host.ts'
54
import { PhoneRuntime } from '../../phone-agent/src/runtime.ts'
65
import { keychain } from '../../phone-agent/src/credentials.ts'
76
import type { Credentials, Executor, Hardware } from '../../phone-agent/src/contracts.ts'
87
import type { ViewerServer } from '../../device-runtime/src/viewer.ts'
9-
import { TASK_SERVICE_PROTOCOL, dataPath, socketPath, tokenPath } from './paths.ts'
8+
import { TASK_SERVICE_PROTOCOL, dataPath, socketPath } from './paths.ts'
109

11-
export interface TaskService { endpoint: string; token: string; close: () => Promise<void> }
10+
export interface TaskService { endpoint: string; close: () => Promise<void> }
1211
export interface TaskServiceOptions {
1312
root: string
1413
hardware: Hardware
@@ -20,7 +19,6 @@ export interface TaskServiceOptions {
2019

2120
interface Request {
2221
protocol?: number
23-
token?: string
2422
host?: string
2523
name?: string
2624
args?: Record<string, unknown>
@@ -29,24 +27,12 @@ interface Request {
2927

3028
const hosts = new Set(['codex', 'workbuddy', 'dsh'])
3129

32-
function same(left: string, right: string): boolean {
33-
const a = Buffer.from(left)
34-
const b = Buffer.from(right)
35-
return a.length === b.length && timingSafeEqual(a, b)
36-
}
37-
3830
/** One macOS process owns phone tasks for every host. Closing a client does not cancel accepted work. */
3931
export async function startTaskService(options: TaskServiceOptions): Promise<TaskService> {
4032
await mkdir(options.root, { recursive: true, mode: 0o700 })
4133
const info = await lstat(options.root)
4234
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('task service root must be a private directory')
4335
await chmod(options.root, 0o700)
44-
const tokenFile = tokenPath(options.root)
45-
let token = await readFile(tokenFile, 'utf8').catch(() => '')
46-
if (!/^[a-f0-9]{64}$/.test(token)) {
47-
token = randomBytes(32).toString('hex')
48-
await writeFile(tokenFile, token, { mode: 0o600 })
49-
}
5036
const runtime = new PhoneRuntime({
5137
root: dataPath(options.root), host: 'shared', hardware: options.hardware,
5238
credentials: options.credentials ?? keychain('shared'), executor: options.executor,
@@ -78,7 +64,6 @@ export async function startTaskService(options: TaskServiceOptions): Promise<Tas
7864
try {
7965
const request = JSON.parse(body) as Request
8066
if (request.protocol !== TASK_SERVICE_PROTOCOL) throw new Error('task service protocol mismatch')
81-
if (typeof request.token !== 'string' || !same(request.token, token)) throw new Error('task service authentication failed')
8267
if (request.name === '__ping__') { send({ result: { protocol: TASK_SERVICE_PROTOCOL, activeTasks: tasks.activeCount, watching: tasks.watching } }); return }
8368
if (request.name === '__prepare_upgrade__') { send({ result: { ready: tasks.prepareMaintenance() } }); return }
8469
if (request.name === '__shutdown__') {
@@ -109,5 +94,5 @@ export async function startTaskService(options: TaskServiceOptions): Promise<Tas
10994
})
11095
await chmod(endpoint, 0o600)
11196
const close = (): Promise<void> => closing ??= (async () => { server.close(); await tasks.close(); await options.hardware.dispose() })()
112-
return { endpoint, token, close }
97+
return { endpoint, close }
11398
}

‎packages/workbench/src/native-bridge.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ export const nativeBridgeScript = String.raw`
2525
if(typeof value!=='string'||! /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value))return false;
2626
const result=await request('tools/call',{name:'opengui_open_workbench',arguments:{}});
2727
if(result?.isError)return false;
28-
try{const url=new URL(result?.structuredContent?.url);return url.origin===location.origin&&url.pathname===location.pathname&&!url.search&&!url.hash}catch{return false}
28+
try{const url=new URL(result?.structuredContent?.url);return url.origin===location.origin&&url.pathname===location.pathname&&url.searchParams.get('owner')===new URLSearchParams(location.search).get('owner')&&!url.hash}catch{return false}
2929
}
3030
async function connect(){
3131
const initialized=await request('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'OpenGUI',version:'1'},appCapabilities:{}});
3232
window.parent.postMessage({jsonrpc:'2.0',method:'ui/notifications/initialized'},'*');
33-
const response=await fetch(location.pathname+'state');if(!response.ok)throw Error('工作台状态不可用');const state=await response.json();
33+
const response=await fetch(location.pathname+'state'+location.search);if(!response.ok)throw Error('工作台状态不可用');const state=await response.json();
3434
if(!await identityMatches(state.workbenchOwner))throw Error('工作台与宿主聊天不匹配,请从原聊天重新打开。');
3535
if(!initialized?.hostCapabilities?.message)throw Error('当前宿主没有开放工作台消息入口。');
3636
owner=state.workbenchOwner;

0 commit comments

Comments
 (0)