mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
/** Client-realm executor for the typed Runtime command protocol. */
|
|
|
|
import type {
|
|
ClientCallArgument,
|
|
ClientRuntimeCapability,
|
|
ClientRuntimeCommand,
|
|
ClientRuntimeCompletion,
|
|
ClientRuntimeError,
|
|
ClientRuntimeExceptionDetails,
|
|
ClientRuntimeRequestFrame,
|
|
ClientRuntimeResponseFrame,
|
|
ClientRuntimeResult,
|
|
ClientRuntimeRemoteObject,
|
|
} from '../../shared/bridge/messages/runtime/index.ts'
|
|
import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts'
|
|
import { isJsonValue, jsonByteLength } from '../../shared/json.ts'
|
|
import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts'
|
|
import { ClientRuntimeExecutionError } from './errors.ts'
|
|
import type { RuntimeConsoleBackendEvent, RuntimeConsoleType, RuntimeStackTrace } from '../../shared/cdp/index.ts'
|
|
import { ClientObjectStore, type ClientObjectAllocation } from './objects.ts'
|
|
import { getClientProperties } from './properties.ts'
|
|
import { clientErrorStack, type ClientScriptKeyResolver } from './stack.ts'
|
|
|
|
const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 2_048
|
|
|
|
/**
|
|
* Describe browser-side Runtime execution.
|
|
* @param origin - Origin assigned to the synthetic execution context.
|
|
* @returns The Runtime capability advertised by a browser Client source.
|
|
*/
|
|
export function runtimeBridgeCapability(origin: string): ClientRuntimeCapability {
|
|
return { type: 'client-runtime', origin }
|
|
}
|
|
|
|
/** Client-side limits injected by the Host deployment. */
|
|
export interface ClientRuntimeLimits {
|
|
readonly maxObjectsPerSession: number
|
|
readonly maxPropertiesPerResult: number
|
|
readonly maxResponseBytes: number
|
|
}
|
|
|
|
/** Executes Runtime requests while isolating object handles by DevTools session. */
|
|
export class ClientRuntimeExecutor {
|
|
private readonly sessions = new Map<ClientRuntimeSessionId, ClientRuntimeSession>()
|
|
|
|
constructor(
|
|
private readonly limits: ClientRuntimeLimits,
|
|
private readonly resolveScript: ClientScriptKeyResolver = () => undefined,
|
|
) {}
|
|
|
|
/**
|
|
* Execute one request and preserve its source, generation, session, and request identities.
|
|
* @param frame - Validated command envelope from the Worker.
|
|
* @returns A success or transport-error response for the same request.
|
|
*/
|
|
async execute(frame: ClientRuntimeRequestFrame): Promise<ClientRuntimeResponseFrame> {
|
|
const session = this.session(frame.sessionId)
|
|
const allocation = session.beginAllocation()
|
|
try {
|
|
const result = await session.execute(frame.command, allocation)
|
|
const response = responseFrame(frame, { ok: true, result })
|
|
if (!isJsonValue(response) || jsonByteLength(response) > this.limits.maxResponseBytes) {
|
|
session.rollback(allocation)
|
|
return responseFrame(frame, {
|
|
ok: false,
|
|
error: { code: 'result-too-large', message: 'Client Runtime result exceeds the source-frame byte limit' },
|
|
})
|
|
}
|
|
session.commitAllocation(allocation)
|
|
return response
|
|
} catch (error) {
|
|
session.rollback(allocation)
|
|
return responseFrame(frame, { ok: false, error: runtimeError(error) })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release all values retained for one closed DevTools connection.
|
|
* @param sessionId - Runtime session owned by that DevTools connection.
|
|
*/
|
|
closeSession(sessionId: ClientRuntimeSessionId): void {
|
|
this.sessions.get(sessionId)?.close()
|
|
this.sessions.delete(sessionId)
|
|
}
|
|
|
|
/**
|
|
* Release one object group without closing the surrounding Runtime session.
|
|
* @param sessionId - Session that owns the retained objects.
|
|
* @param group - Object-group name to release.
|
|
*/
|
|
releaseObjectGroup(sessionId: ClientRuntimeSessionId, group: string): void {
|
|
this.sessions.get(sessionId)?.releaseObjectGroup(group)
|
|
}
|
|
|
|
/**
|
|
* Serialize one Console call for a specific DevTools Runtime session.
|
|
* @param sessionId - Session receiving the Console event.
|
|
* @param type - Console API operation.
|
|
* @param values - Original arguments from the page call.
|
|
* @param timestamp - Epoch timestamp in milliseconds.
|
|
* @param stackTrace - Browser call frames captured before deferred delivery.
|
|
* @returns A wire-safe event whose object handles belong only to this session.
|
|
*/
|
|
consoleEvent(
|
|
sessionId: ClientRuntimeSessionId,
|
|
type: RuntimeConsoleType,
|
|
values: readonly unknown[],
|
|
timestamp: number,
|
|
stackTrace?: RuntimeStackTrace,
|
|
): RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> | undefined {
|
|
const session = this.session(sessionId)
|
|
const allocation = session.beginAllocation()
|
|
try {
|
|
const event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> = {
|
|
type: 'console-api',
|
|
event: {
|
|
type,
|
|
arguments: session.serializeAll(values, 'console', allocation),
|
|
timestamp,
|
|
...(stackTrace === undefined ? {} : { stackTrace }),
|
|
},
|
|
}
|
|
if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) {
|
|
session.rollback(allocation)
|
|
return undefined
|
|
}
|
|
session.commitAllocation(allocation)
|
|
return event
|
|
} catch (error) {
|
|
session.rollback(allocation)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize one uncaught Client exception for a DevTools Runtime session.
|
|
* @param sessionId - Session receiving the exception event.
|
|
* @param error - Thrown or rejected value.
|
|
* @param timestamp - Epoch timestamp in milliseconds.
|
|
* @param stackTrace - Browser call frames attached to the failure.
|
|
* @returns A wire-safe exception event.
|
|
*/
|
|
exceptionEvent(
|
|
sessionId: ClientRuntimeSessionId,
|
|
error: unknown,
|
|
timestamp: number,
|
|
stackTrace?: RuntimeStackTrace,
|
|
): RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> | undefined {
|
|
const session = this.session(sessionId)
|
|
const allocation = session.beginAllocation()
|
|
try {
|
|
const event: RuntimeConsoleBackendEvent<ClientRemoteObjectHandle> = {
|
|
type: 'exception',
|
|
event: {
|
|
timestamp,
|
|
details: session.describeException(error, 'console', stackTrace, allocation),
|
|
},
|
|
}
|
|
if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) {
|
|
session.rollback(allocation)
|
|
return undefined
|
|
}
|
|
session.commitAllocation(allocation)
|
|
return event
|
|
} catch (serializationError) {
|
|
session.rollback(allocation)
|
|
throw serializationError
|
|
}
|
|
}
|
|
|
|
/** Release all sessions when a source generation ends or reconnects. */
|
|
reset(): void {
|
|
for (const session of this.sessions.values()) session.close()
|
|
this.sessions.clear()
|
|
}
|
|
|
|
private session(sessionId: ClientRuntimeSessionId): ClientRuntimeSession {
|
|
let session = this.sessions.get(sessionId)
|
|
if (session === undefined) {
|
|
session = new ClientRuntimeSession(
|
|
this.limits.maxObjectsPerSession,
|
|
this.limits.maxPropertiesPerResult,
|
|
this.resolveScript,
|
|
)
|
|
this.sessions.set(sessionId, session)
|
|
}
|
|
return session
|
|
}
|
|
}
|
|
|
|
class ClientRuntimeSession {
|
|
private readonly objects: ClientObjectStore
|
|
|
|
constructor(
|
|
maxObjects: number,
|
|
private readonly maxProperties: number,
|
|
private readonly resolveScript: ClientScriptKeyResolver,
|
|
) {
|
|
this.objects = new ClientObjectStore(maxObjects)
|
|
}
|
|
|
|
beginAllocation(): ClientObjectAllocation {
|
|
return this.objects.beginAllocation()
|
|
}
|
|
|
|
commitAllocation(allocation: ClientObjectAllocation): void {
|
|
this.objects.commitAllocation(allocation)
|
|
}
|
|
|
|
rollback(allocation: ClientObjectAllocation): void {
|
|
this.objects.rollback(allocation)
|
|
}
|
|
|
|
async execute(command: ClientRuntimeCommand, allocation: ClientObjectAllocation): Promise<ClientRuntimeResult> {
|
|
switch (command.op) {
|
|
case 'evaluate':
|
|
return { op: command.op, completion: await this.evaluate(command, allocation) }
|
|
case 'get-properties': {
|
|
const result = getClientProperties(this.objects, command, this.maxProperties, allocation)
|
|
return { op: command.op, ...result }
|
|
}
|
|
case 'call-function':
|
|
return { op: command.op, completion: await this.callFunction(command, allocation) }
|
|
case 'await-promise':
|
|
return { op: command.op, completion: await this.awaitPromise(command, allocation) }
|
|
case 'release-object':
|
|
this.objects.release(command.handle)
|
|
return { op: command.op }
|
|
case 'release-object-group':
|
|
this.releaseObjectGroup(command.objectGroup)
|
|
return { op: command.op }
|
|
case 'global-lexical-scope-names':
|
|
return { op: command.op, names: [] }
|
|
default:
|
|
return assertNever(command)
|
|
}
|
|
}
|
|
|
|
close(): void {
|
|
this.objects.clear()
|
|
}
|
|
|
|
releaseObjectGroup(group: string): void {
|
|
this.objects.releaseGroup(group)
|
|
}
|
|
|
|
serializeAll(
|
|
values: readonly unknown[],
|
|
group: string,
|
|
allocation: ClientObjectAllocation,
|
|
): ClientRuntimeRemoteObject[] {
|
|
return values.map(value => this.objects.serialize(value, { group, generatePreview: true }, allocation))
|
|
}
|
|
|
|
describeException(
|
|
error: unknown,
|
|
group: string | undefined,
|
|
stackTrace?: RuntimeStackTrace,
|
|
allocation?: ClientObjectAllocation,
|
|
): ClientRuntimeExceptionDetails {
|
|
const options = { ...(group === undefined ? {} : { group }) }
|
|
const resolvedStackTrace = stackTrace ?? clientErrorStack(error, this.resolveScript)
|
|
const firstFrame = resolvedStackTrace?.callFrames[0]
|
|
return {
|
|
text: 'Uncaught',
|
|
lineNumber: firstFrame?.lineNumber ?? 0,
|
|
columnNumber: firstFrame?.columnNumber ?? 0,
|
|
...(firstFrame === undefined ? clientUrl() : { url: firstFrame.url }),
|
|
...(resolvedStackTrace === undefined ? {} : { stackTrace: resolvedStackTrace }),
|
|
exception: this.objects.serialize(error, options, allocation),
|
|
}
|
|
}
|
|
|
|
private async evaluate(
|
|
command: Extract<ClientRuntimeCommand, { op: 'evaluate' }>,
|
|
allocation: ClientObjectAllocation,
|
|
): Promise<ClientRuntimeCompletion> {
|
|
let value: unknown
|
|
try {
|
|
value = globalThis.eval(command.expression) as unknown
|
|
if (command.awaitPromise === true) value = await awaitWithTimeout(value, command.timeoutMs)
|
|
} catch (error) {
|
|
if (error instanceof ClientRuntimeExecutionError) throw error
|
|
return this.exception(error, command.objectGroup, allocation)
|
|
}
|
|
return this.completion(
|
|
value,
|
|
allocation,
|
|
command.objectGroup,
|
|
command.generatePreview,
|
|
command.returnByValue,
|
|
)
|
|
}
|
|
|
|
private async callFunction(
|
|
command: Extract<ClientRuntimeCommand, { op: 'call-function' }>,
|
|
allocation: ClientObjectAllocation,
|
|
): Promise<ClientRuntimeCompletion> {
|
|
const receiver = command.receiver === undefined ? globalThis : this.objects.get(command.receiver)
|
|
const inheritedGroup = command.receiver === undefined ? undefined : this.objects.group(command.receiver)
|
|
const group = command.objectGroup ?? inheritedGroup
|
|
const args = (command.arguments ?? []).map(argument => this.resolveArgument(argument))
|
|
let value: unknown
|
|
try {
|
|
const fn = globalThis.eval(`(${command.functionDeclaration}\n)`) as unknown
|
|
if (typeof fn !== 'function') throw new TypeError('functionDeclaration did not evaluate to a function')
|
|
value = Reflect.apply(fn, receiver, args)
|
|
if (command.awaitPromise === true) value = await value
|
|
} catch (error) {
|
|
return this.exception(error, group, allocation)
|
|
}
|
|
return this.completion(value, allocation, group, command.generatePreview, command.returnByValue)
|
|
}
|
|
|
|
private async awaitPromise(
|
|
command: Extract<ClientRuntimeCommand, { op: 'await-promise' }>,
|
|
allocation: ClientObjectAllocation,
|
|
): Promise<ClientRuntimeCompletion> {
|
|
const group = this.objects.group(command.promise)
|
|
let value: unknown
|
|
try {
|
|
value = await this.objects.get(command.promise)
|
|
} catch (error) {
|
|
if (error instanceof ClientRuntimeExecutionError) throw error
|
|
return this.exception(error, group, allocation)
|
|
}
|
|
return this.completion(value, allocation, group, command.generatePreview, command.returnByValue)
|
|
}
|
|
|
|
private resolveArgument(argument: ClientCallArgument): unknown {
|
|
switch (argument.kind) {
|
|
case 'value': return argument.value
|
|
case 'object': return this.objects.get(argument.handle)
|
|
case 'undefined': return undefined
|
|
case 'unserializable': return parseUnserializable(argument.value)
|
|
default: return assertNever(argument)
|
|
}
|
|
}
|
|
|
|
private exception(
|
|
error: unknown,
|
|
group: string | undefined,
|
|
allocation: ClientObjectAllocation,
|
|
): ClientRuntimeCompletion {
|
|
const options = { ...(group === undefined ? {} : { group }) }
|
|
const details = this.describeException(error, group, undefined, allocation)
|
|
return { result: this.objects.serialize(error, options, allocation), exceptionDetails: details }
|
|
}
|
|
|
|
private completion(
|
|
value: unknown,
|
|
allocation: ClientObjectAllocation,
|
|
group: string | undefined,
|
|
generatePreview: boolean | undefined,
|
|
returnByValue: boolean | undefined,
|
|
): ClientRuntimeCompletion {
|
|
return {
|
|
result: this.objects.serialize(value, {
|
|
...(group === undefined ? {} : { group }),
|
|
...(generatePreview === undefined ? {} : { generatePreview }),
|
|
...(returnByValue === undefined ? {} : { returnByValue }),
|
|
}, allocation),
|
|
}
|
|
}
|
|
}
|
|
|
|
function responseFrame(
|
|
request: ClientRuntimeRequestFrame,
|
|
outcome: ClientRuntimeResponseFrame['outcome'],
|
|
): ClientRuntimeResponseFrame {
|
|
return {
|
|
v: INSPECTOR_PROTOCOL_VERSION,
|
|
t: 'client-runtime/response',
|
|
sourceId: request.sourceId,
|
|
generation: request.generation,
|
|
sessionId: request.sessionId,
|
|
requestId: request.requestId,
|
|
outcome,
|
|
}
|
|
}
|
|
|
|
function runtimeError(error: unknown): ClientRuntimeError {
|
|
const code = error instanceof ClientRuntimeExecutionError ? error.code : 'internal-error'
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return { code, message: message.slice(0, MAX_RUNTIME_ERROR_MESSAGE_LENGTH) }
|
|
}
|
|
|
|
function parseUnserializable(value: string): unknown {
|
|
if (value === 'NaN') return Number.NaN
|
|
if (value === 'Infinity') return Number.POSITIVE_INFINITY
|
|
if (value === '-Infinity') return Number.NEGATIVE_INFINITY
|
|
if (value === '-0') return -0
|
|
if (/^-?(?:0|[1-9]\d*)n$/u.test(value)) return BigInt(value.slice(0, -1))
|
|
throw new ClientRuntimeExecutionError('invalid-request', `Unsupported unserializable value ${JSON.stringify(value)}`)
|
|
}
|
|
|
|
function clientUrl(): { readonly url?: string } {
|
|
const location = Reflect.get(globalThis, 'location') as unknown
|
|
if (typeof location !== 'object' || location === null) return {}
|
|
const href = Reflect.get(location, 'href') as unknown
|
|
return typeof href === 'string' ? { url: href } : {}
|
|
}
|
|
|
|
async function awaitWithTimeout(value: unknown, timeoutMs: number | undefined): Promise<unknown> {
|
|
if (timeoutMs === undefined) return await value
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
return await Promise.race([
|
|
Promise.resolve(value),
|
|
new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new ClientRuntimeExecutionError('timeout', `Client evaluation exceeded ${String(timeoutMs)}ms`))
|
|
}, timeoutMs)
|
|
}),
|
|
])
|
|
} finally {
|
|
if (timer !== undefined) clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
function assertNever(value: never): never {
|
|
throw new Error(`Unexpected Client Runtime variant: ${JSON.stringify(value)}`)
|
|
}
|