@crosshands/platform-windows 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,646 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { createInterface } from 'node:readline'
5
+ import { isAbsolute, win32 } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ import {
9
+ COMPUTER_OPERATIONS,
10
+ CONTRACT_VERSIONS,
11
+ createComputerError,
12
+ type ComputerOperationName,
13
+ type ComputerProvider,
14
+ type ProviderHandshake,
15
+ type ProviderRequest,
16
+ type ProviderResponse,
17
+ type ReferenceBindings
18
+ } from '@crosshands/contract'
19
+ import type { StableAppIdentity } from '@crosshands/runtime'
20
+
21
+ type JsonRecord = Record<string, unknown>
22
+
23
+ type NativeProcessIdentity = {
24
+ pid: number
25
+ startedAt: string
26
+ sessionId: number
27
+ desktop: string
28
+ executablePath: string
29
+ integrityRid: number
30
+ publisher: string
31
+ sha256: string
32
+ }
33
+
34
+ export type NativeFrame = { ok: boolean; error?: string } & JsonRecord
35
+
36
+ export type PowerShellLaunchSpec = {
37
+ executable: string
38
+ args: string[]
39
+ env: NodeJS.ProcessEnv
40
+ }
41
+
42
+ export function windowsPowerShellLaunchSpec(
43
+ scriptPath: string,
44
+ env: NodeJS.ProcessEnv = process.env
45
+ ): PowerShellLaunchSpec {
46
+ const systemRoot = env.SystemRoot ?? env.SYSTEMROOT ?? 'C:\\Windows'
47
+ const executable = win32.join(
48
+ systemRoot,
49
+ 'System32',
50
+ 'WindowsPowerShell',
51
+ 'v1.0',
52
+ 'powershell.exe'
53
+ )
54
+ const absoluteScript = win32.isAbsolute(scriptPath)
55
+ ? win32.normalize(scriptPath)
56
+ : win32.resolve(scriptPath)
57
+ return {
58
+ executable,
59
+ args: [
60
+ '-NoLogo',
61
+ '-NoProfile',
62
+ '-NonInteractive',
63
+ '-ExecutionPolicy',
64
+ 'Bypass',
65
+ '-File',
66
+ absoluteScript
67
+ ],
68
+ env: {
69
+ SystemRoot: systemRoot,
70
+ WINDIR: env.WINDIR ?? systemRoot,
71
+ TEMP: env.TEMP ?? win32.join(systemRoot, 'Temp'),
72
+ TMP: env.TMP ?? env.TEMP ?? win32.join(systemRoot, 'Temp'),
73
+ USERPROFILE: env.USERPROFILE,
74
+ LOCALAPPDATA: env.LOCALAPPDATA,
75
+ APPDATA: env.APPDATA,
76
+ PSModuleAutoLoadingPreference: 'None',
77
+ POWERSHELL_TELEMETRY_OPTOUT: '1'
78
+ }
79
+ }
80
+ }
81
+
82
+ export interface NativeWindowsTransport {
83
+ start(): Promise<NativeFrame>
84
+ request(payload: JsonRecord, deadlineAt: number): Promise<NativeFrame>
85
+ cancel(): Promise<void>
86
+ close(): Promise<void>
87
+ }
88
+
89
+ export class PowerShellStdioTransport implements NativeWindowsTransport {
90
+ readonly #launch: PowerShellLaunchSpec
91
+ #child: ChildProcessWithoutNullStreams | undefined
92
+ #ready: Promise<NativeFrame> | undefined
93
+ #resolveReady: ((frame: NativeFrame) => void) | undefined
94
+ #rejectReady: ((cause: unknown) => void) | undefined
95
+ #active:
96
+ | { resolve(frame: NativeFrame): void; reject(cause: unknown): void; timer: NodeJS.Timeout }
97
+ | undefined
98
+ #tail: Promise<void> = Promise.resolve()
99
+
100
+ constructor(scriptPath: string) {
101
+ this.#launch = windowsPowerShellLaunchSpec(scriptPath)
102
+ }
103
+
104
+ async start(): Promise<NativeFrame> {
105
+ if (this.#ready !== undefined) return this.#ready
106
+ this.#ready = new Promise<NativeFrame>((resolveReady, rejectReady) => {
107
+ this.#resolveReady = resolveReady
108
+ this.#rejectReady = rejectReady
109
+ })
110
+ const child = spawn(this.#launch.executable, this.#launch.args, {
111
+ env: this.#launch.env,
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ windowsHide: true,
114
+ shell: false
115
+ })
116
+ this.#child = child
117
+ const lines = createInterface({ input: child.stdout, crlfDelay: Number.POSITIVE_INFINITY })
118
+ lines.on('line', (line) => this.#receive(line))
119
+ let stderr = ''
120
+ child.stderr.setEncoding('utf8')
121
+ child.stderr.on('data', (chunk: string) => {
122
+ if (stderr.length < 8_192) stderr += chunk.slice(0, 8_192 - stderr.length)
123
+ })
124
+ child.once('error', (cause) => this.#fail(cause, child))
125
+ child.once('exit', (code) =>
126
+ this.#fail(
127
+ createComputerError(
128
+ 'provider_crashed',
129
+ `Windows provider exited with code ${String(code)}`,
130
+ {
131
+ stderr
132
+ }
133
+ ),
134
+ child
135
+ )
136
+ )
137
+ return this.#ready
138
+ }
139
+
140
+ request(payload: JsonRecord, deadlineAt: number): Promise<NativeFrame> {
141
+ const result = this.#tail.then(async () => {
142
+ await this.start()
143
+ if (deadlineAt <= Date.now()) throw createComputerError('timeout', 'Request deadline elapsed')
144
+ const child = this.#child
145
+ if (child === undefined || !child.stdin.writable)
146
+ throw createComputerError('provider_unavailable', 'Windows provider stdin is unavailable')
147
+ return new Promise<NativeFrame>((resolveFrame, rejectFrame) => {
148
+ const timer = setTimeout(
149
+ () => {
150
+ this.#active = undefined
151
+ if (this.#child === child) {
152
+ this.#child = undefined
153
+ this.#ready = undefined
154
+ this.#resolveReady = undefined
155
+ this.#rejectReady = undefined
156
+ }
157
+ child.kill()
158
+ rejectFrame(createComputerError('timeout', 'Windows provider request timed out'))
159
+ },
160
+ Math.max(1, deadlineAt - Date.now())
161
+ )
162
+ timer.unref()
163
+ this.#active = { resolve: resolveFrame, reject: rejectFrame, timer }
164
+ child.stdin.write(`${JSON.stringify(payload)}\n`, 'utf8', (cause) => {
165
+ if (cause !== null && cause !== undefined) this.#fail(cause, child)
166
+ })
167
+ })
168
+ })
169
+ this.#tail = result.then(
170
+ () => undefined,
171
+ () => undefined
172
+ )
173
+ return result
174
+ }
175
+
176
+ async cancel(): Promise<void> {
177
+ this.#child?.kill()
178
+ }
179
+
180
+ async close(): Promise<void> {
181
+ const child = this.#child
182
+ this.#child = undefined
183
+ this.#ready = undefined
184
+ this.#resolveReady = undefined
185
+ this.#rejectReady = undefined
186
+ child?.stdin.end()
187
+ child?.kill()
188
+ }
189
+
190
+ #receive(line: string): void {
191
+ let frame: NativeFrame
192
+ try {
193
+ frame = JSON.parse(line) as NativeFrame
194
+ } catch {
195
+ this.#fail(
196
+ createComputerError('provider_crashed', 'Windows provider emitted non-JSON stdout')
197
+ )
198
+ return
199
+ }
200
+ if (this.#resolveReady !== undefined) {
201
+ const resolveReady = this.#resolveReady
202
+ this.#resolveReady = undefined
203
+ this.#rejectReady = undefined
204
+ resolveReady(frame)
205
+ return
206
+ }
207
+ const active = this.#active
208
+ if (active === undefined) {
209
+ this.#fail(
210
+ createComputerError('provider_crashed', 'Windows provider emitted an unsolicited frame')
211
+ )
212
+ return
213
+ }
214
+ this.#active = undefined
215
+ clearTimeout(active.timer)
216
+ active.resolve(frame)
217
+ }
218
+
219
+ #fail(cause: unknown, source?: ChildProcessWithoutNullStreams): void {
220
+ if (source !== undefined && this.#child !== source) return
221
+ this.#child = undefined
222
+ this.#ready = undefined
223
+ const rejectReady = this.#rejectReady
224
+ this.#resolveReady = undefined
225
+ this.#rejectReady = undefined
226
+ rejectReady?.(cause)
227
+ const active = this.#active
228
+ this.#active = undefined
229
+ if (active !== undefined) {
230
+ clearTimeout(active.timer)
231
+ active.reject(cause)
232
+ }
233
+ }
234
+ }
235
+
236
+ type WindowsProviderOptions = {
237
+ scriptPath?: string
238
+ manifestPath?: string
239
+ transport?: NativeWindowsTransport
240
+ graphicalSessionId?: string
241
+ skipPayloadVerification?: boolean
242
+ }
243
+
244
+ const PACKAGED_SCRIPT = fileURLToPath(new URL('../assets/runtime.ps1', import.meta.url))
245
+ const PACKAGED_MANIFEST = fileURLToPath(new URL('../assets/payload.json', import.meta.url))
246
+
247
+ export async function verifyWindowsPayload(
248
+ scriptPath = PACKAGED_SCRIPT,
249
+ manifestPath = PACKAGED_MANIFEST
250
+ ): Promise<void> {
251
+ if (!isAbsolute(scriptPath) || !isAbsolute(manifestPath)) {
252
+ throw createComputerError('provider_unavailable', 'Windows payload paths must be absolute')
253
+ }
254
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
255
+ productVersion?: unknown
256
+ files?: Record<string, unknown>
257
+ }
258
+ if (manifest.productVersion !== CONTRACT_VERSIONS.product) {
259
+ throw createComputerError('version_incompatible', 'Windows payload product version mismatch')
260
+ }
261
+ const expected = manifest.files?.['runtime.ps1']
262
+ if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) {
263
+ throw createComputerError('provider_unavailable', 'Windows payload manifest is malformed')
264
+ }
265
+ const actual = createHash('sha256')
266
+ .update(await readFile(scriptPath))
267
+ .digest('hex')
268
+ if (actual !== expected) {
269
+ throw createComputerError('provider_unavailable', 'Windows provider payload hash mismatch')
270
+ }
271
+ }
272
+
273
+ function record(value: unknown): JsonRecord {
274
+ return value !== null && typeof value === 'object' ? (value as JsonRecord) : {}
275
+ }
276
+
277
+ function errorCode(message: string): Parameters<typeof createComputerError>[0] {
278
+ const normalized = message.toLowerCase()
279
+ if (normalized.includes('appblocked')) return 'app_blocked'
280
+ if (normalized.includes('appnotfound')) return 'app_not_found'
281
+ if (normalized.includes('windownotfound')) return 'window_not_found'
282
+ if (normalized.includes('window_not_focused')) return 'window_not_focused'
283
+ if (normalized.includes('stale_target') || normalized.includes('stale element'))
284
+ return 'stale_target'
285
+ if (normalized.includes('session_unavailable')) return 'session_unavailable'
286
+ if (normalized.includes('unsupported_capability')) return 'unsupported_capability'
287
+ if (normalized.includes('not settable')) return 'value_not_settable'
288
+ if (normalized.includes('unknown element')) return 'element_not_found'
289
+ if (normalized.includes('unsupported')) return 'action_not_supported'
290
+ return 'accessibility_error'
291
+ }
292
+
293
+ function isPreDispatchFailure(message: string): boolean {
294
+ return /^(app|window).*notfound|appblocked|session_unavailable|unsupported_capability|stale_target|window_not_focused/i.test(
295
+ message
296
+ )
297
+ }
298
+
299
+ function executableId(identity: NativeProcessIdentity): string {
300
+ const raw = `${identity.executablePath}|il:${identity.integrityRid}|pub:${identity.publisher}|sha256:${identity.sha256}`
301
+ return raw.length <= 512 ? raw : `sha256:${createHash('sha256').update(raw).digest('hex')}`
302
+ }
303
+
304
+ function targetReference(input: JsonRecord): JsonRecord {
305
+ const candidates = [input.target, input.from, input.to]
306
+ for (const candidate of candidates) {
307
+ const candidateRecord = record(candidate)
308
+ if (candidateRecord.kind === 'element') return record(candidateRecord.ref)
309
+ if ('contextToken' in candidateRecord) return candidateRecord
310
+ if (candidateRecord.kind === 'coordinate') return record(candidateRecord.window)
311
+ }
312
+ return {}
313
+ }
314
+
315
+ function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
316
+ map.delete(key)
317
+ map.set(key, value)
318
+ while (map.size > 32) {
319
+ const oldest = map.keys().next().value as K | undefined
320
+ if (oldest === undefined) return
321
+ map.delete(oldest)
322
+ }
323
+ }
324
+
325
+ export class WindowsComputerProvider implements ComputerProvider {
326
+ readonly generation = `windows-${randomUUID()}`
327
+ readonly #transport: NativeWindowsTransport
328
+ readonly #graphicalSessionId: string
329
+ readonly #scriptPath: string
330
+ readonly #manifestPath: string
331
+ readonly #skipPayloadVerification: boolean
332
+ readonly #snapshots = new Map<string, JsonRecord>()
333
+ readonly #identities = new Map<string, NativeProcessIdentity>()
334
+ #handshake: ProviderHandshake | undefined
335
+
336
+ constructor(options: WindowsProviderOptions = {}) {
337
+ this.#scriptPath = options.scriptPath ?? PACKAGED_SCRIPT
338
+ this.#manifestPath = options.manifestPath ?? PACKAGED_MANIFEST
339
+ this.#skipPayloadVerification =
340
+ options.skipPayloadVerification ?? options.transport !== undefined
341
+ this.#transport = options.transport ?? new PowerShellStdioTransport(this.#scriptPath)
342
+ this.#graphicalSessionId =
343
+ options.graphicalSessionId ??
344
+ process.env.CROSSHANDS_GRAPHICAL_SESSION_ID ??
345
+ process.env.SESSIONNAME ??
346
+ 'win32:interactive'
347
+ }
348
+
349
+ async start(): Promise<ProviderHandshake> {
350
+ if (this.#handshake !== undefined) return this.#handshake
351
+ if (!this.#skipPayloadVerification) {
352
+ await verifyWindowsPayload(this.#scriptPath, this.#manifestPath)
353
+ }
354
+ const ready = await this.#transport.start()
355
+ if (!ready.ok || ready.ready !== true)
356
+ throw createComputerError('provider_unavailable', 'Windows provider readiness failed')
357
+ this.#handshake = {
358
+ provider: 'crosshands-platform-windows',
359
+ generation: this.generation,
360
+ graphicalSessionId: this.#graphicalSessionId,
361
+ providerProtocol: CONTRACT_VERSIONS.providerProtocol,
362
+ publicContract: CONTRACT_VERSIONS.publicContract,
363
+ capabilities: {
364
+ platform: 'win32',
365
+ provider: 'crosshands-platform-windows',
366
+ providerVersion: CONTRACT_VERSIONS.product,
367
+ operations: Object.fromEntries(
368
+ Object.keys(COMPUTER_OPERATIONS).map((operation) => [operation, true])
369
+ ),
370
+ permissions: { accessibility: 'not_required', screenshots: 'not_required' }
371
+ }
372
+ }
373
+ return this.#handshake
374
+ }
375
+
376
+ async dispatch(request: ProviderRequest): Promise<ProviderResponse> {
377
+ await this.start()
378
+ if (request.operation === 'capabilities')
379
+ return {
380
+ requestId: request.requestId,
381
+ dispatched: false,
382
+ result: this.#handshake!.capabilities
383
+ }
384
+ if (request.operation === 'permissions')
385
+ return {
386
+ requestId: request.requestId,
387
+ dispatched: false,
388
+ result: { permissions: this.#handshake!.capabilities.permissions }
389
+ }
390
+ const input = record(request.input)
391
+ const nativeInput = this.#nativeInput(request.operation, input)
392
+ const frame = await this.#transport.request(nativeInput, request.deadlineAt)
393
+ if (!frame.ok) {
394
+ const message = frame.error ?? 'Windows provider operation failed'
395
+ const dispatched = COMPUTER_OPERATIONS[request.operation].mutation
396
+ ? !isPreDispatchFailure(message)
397
+ : false
398
+ return {
399
+ requestId: request.requestId,
400
+ dispatched,
401
+ error: createComputerError(errorCode(message), message).toJSON()
402
+ }
403
+ }
404
+ return {
405
+ requestId: request.requestId,
406
+ dispatched: COMPUTER_OPERATIONS[request.operation].mutation,
407
+ result: this.#normalizeResult(request.operation, frame)
408
+ }
409
+ }
410
+
411
+ async inspect(
412
+ operation: ComputerOperationName,
413
+ input: unknown
414
+ ): Promise<{
415
+ bindings: ReferenceBindings
416
+ appIdentity: StableAppIdentity
417
+ } | null> {
418
+ if (!COMPUTER_OPERATIONS[operation].mutation) return null
419
+ const inputRecord = record(input)
420
+ const reference = targetReference(inputRecord)
421
+ const app = String(inputRecord.app ?? reference.appId ?? '')
422
+ if (app.length === 0) return null
423
+ await this.start()
424
+ const frame = await this.#transport.request({ tool: 'inspect_target', app }, Date.now() + 5_000)
425
+ if (!frame.ok)
426
+ throw createComputerError(errorCode(frame.error ?? ''), frame.error ?? 'Inspect failed')
427
+ const identity = frame.identity as NativeProcessIdentity
428
+ setBounded(this.#identities, app, identity)
429
+ const nativeExecutableId = executableId(identity)
430
+ return {
431
+ bindings: {
432
+ brokerGeneration: String(reference.brokerGeneration ?? 'unbound'),
433
+ providerGeneration: this.generation,
434
+ graphicalSessionId: this.#graphicalSessionId,
435
+ process: {
436
+ pid: identity.pid,
437
+ startedAt: identity.startedAt,
438
+ executableId: nativeExecutableId
439
+ },
440
+ appId: String(frame.appId),
441
+ window: { id: String(frame.windowId), ownerPid: identity.pid },
442
+ snapshotId: String(reference.snapshotId ?? 'inspection'),
443
+ desktopEpoch: Number(reference.desktopEpoch ?? 0)
444
+ },
445
+ appIdentity: { appId: String(frame.appId), executableId: nativeExecutableId }
446
+ }
447
+ }
448
+
449
+ async cancel(_requestId: string): Promise<void> {
450
+ await this.#transport.cancel()
451
+ }
452
+
453
+ async close(): Promise<void> {
454
+ this.#handshake = undefined
455
+ this.#snapshots.clear()
456
+ this.#identities.clear()
457
+ await this.#transport.close()
458
+ }
459
+
460
+ #nativeInput(operation: ComputerOperationName, input: JsonRecord): JsonRecord {
461
+ const toolNames: Partial<Record<ComputerOperationName, string>> = {
462
+ listApps: 'list_apps',
463
+ listWindows: 'list_windows',
464
+ getAppState: 'get_app_state',
465
+ performSecondaryAction: 'perform_secondary_action',
466
+ typeText: 'type_text',
467
+ pressKey: 'press_key',
468
+ pasteText: 'paste_text',
469
+ setValue: 'set_value'
470
+ }
471
+ const tool = toolNames[operation] ?? operation
472
+ const reference = targetReference(input)
473
+ const app = String(input.app ?? reference.appId ?? '')
474
+ const cached = this.#snapshots.get(app)
475
+ const target = record(input.target)
476
+ const from = record(input.from)
477
+ const to = record(input.to)
478
+ const elementFor = (candidate: JsonRecord): unknown => {
479
+ if (candidate.kind !== 'element') return undefined
480
+ const ref = record(candidate.ref)
481
+ const index = Number(String(ref.ref ?? '').replace(/^element:/, ''))
482
+ const elements = Array.isArray(cached?.elements) ? cached.elements : []
483
+ return Number.isInteger(index) ? elements[index] : undefined
484
+ }
485
+ return {
486
+ tool,
487
+ app,
488
+ noScreenshot: input.captureScreenshot === false,
489
+ restoreWindow: input.restoreWindow === true,
490
+ expectedIdentity: this.#identities.get(app),
491
+ windowId: reference.window !== undefined ? record(reference.window).id : cached?.windowId,
492
+ windowBounds: cached?.windowBounds,
493
+ element: elementFor(target),
494
+ fromElement: elementFor(from),
495
+ toElement: elementFor(to),
496
+ x: target.x,
497
+ y: target.y,
498
+ from_x: from.x,
499
+ from_y: from.y,
500
+ to_x: to.x,
501
+ to_y: to.y,
502
+ duration_ms: input.durationMs,
503
+ click_count: input.clickCount,
504
+ mouse_button: input.button,
505
+ modifiers: input.modifiers,
506
+ action: input.action,
507
+ direction: input.direction,
508
+ pages: input.pages,
509
+ text: input.text,
510
+ key: Array.isArray(input.keys) ? input.keys.join('+') : input.key,
511
+ value: input.value,
512
+ ...(operation === 'getAppState'
513
+ ? {
514
+ windowId: record(input.window).id,
515
+ windowIndex: record(input.window).index
516
+ }
517
+ : {})
518
+ }
519
+ }
520
+
521
+ #normalizeResult(operation: ComputerOperationName, frame: NativeFrame): unknown {
522
+ if (operation === 'listApps') {
523
+ return {
524
+ apps: (Array.isArray(frame.apps) ? frame.apps : []).map((value) => {
525
+ const app = record(value)
526
+ return {
527
+ id: String(app.bundleId),
528
+ name: String(app.name),
529
+ bundleId: String(app.bundleId),
530
+ pid: Number(app.pid),
531
+ isRunning: true
532
+ }
533
+ })
534
+ }
535
+ }
536
+ if (operation === 'listWindows') {
537
+ return {
538
+ windows: (Array.isArray(frame.windows) ? frame.windows : []).map((value) => {
539
+ const window = record(value)
540
+ const app = record(window.app)
541
+ return {
542
+ id: String(window.id),
543
+ appId: String(app.bundleId),
544
+ title: String(window.title ?? ''),
545
+ index: Number(window.index),
546
+ bounds: {
547
+ x: Number(window.x ?? 0),
548
+ y: Number(window.y ?? 0),
549
+ width: Math.max(1, Number(window.width ?? 1)),
550
+ height: Math.max(1, Number(window.height ?? 1))
551
+ },
552
+ minimized: Boolean(window.isMinimized)
553
+ }
554
+ })
555
+ }
556
+ }
557
+ if (operation === 'getAppState') return this.#snapshotResult(record(frame.snapshot))
558
+ const action = record(frame.action)
559
+ const verification = record(action.verification)
560
+ return {
561
+ outcome:
562
+ verification.state === 'verified'
563
+ ? { state: 'verified', evidence: verification }
564
+ : {
565
+ state: 'indeterminate',
566
+ reason: String(verification.reason ?? 'native_action_unverified')
567
+ },
568
+ freshState: this.#snapshotResult(record(frame.snapshot))
569
+ }
570
+ }
571
+
572
+ #snapshotResult(native: JsonRecord): unknown {
573
+ const app = record(native.app)
574
+ const identity = native.processIdentity as NativeProcessIdentity
575
+ const bounds = record(native.windowBounds)
576
+ const appId = String(app.bundleId)
577
+ setBounded(this.#snapshots, appId, native)
578
+ setBounded(this.#identities, appId, identity)
579
+ const snapshot = {
580
+ id: String(native.snapshotId),
581
+ app: {
582
+ id: appId,
583
+ name: String(app.name),
584
+ bundleId: appId,
585
+ pid: Number(app.pid),
586
+ isRunning: true
587
+ },
588
+ window: {
589
+ id: String(native.windowId),
590
+ appId,
591
+ title: String(native.windowTitle ?? ''),
592
+ index: 0,
593
+ bounds: {
594
+ x: Number(bounds.x ?? 0),
595
+ y: Number(bounds.y ?? 0),
596
+ width: Math.max(1, Number(bounds.width ?? 1)),
597
+ height: Math.max(1, Number(bounds.height ?? 1))
598
+ },
599
+ minimized: false
600
+ },
601
+ treeText: (Array.isArray(native.treeLines) ? native.treeLines : []).join('\n'),
602
+ elementCount: Array.isArray(native.elements) ? native.elements.length : 0,
603
+ focusedElementRef:
604
+ native.focusedElementId === null || native.focusedElementId === undefined
605
+ ? null
606
+ : String(native.focusedElementId),
607
+ desktopEpoch: 0
608
+ }
609
+ return {
610
+ bindings: {
611
+ brokerGeneration: 'unbound',
612
+ providerGeneration: this.generation,
613
+ graphicalSessionId: this.#graphicalSessionId,
614
+ process: {
615
+ pid: identity.pid,
616
+ startedAt: identity.startedAt,
617
+ executableId: executableId(identity)
618
+ },
619
+ appId,
620
+ window: { id: String(native.windowId), ownerPid: identity.pid },
621
+ snapshotId: String(native.snapshotId),
622
+ desktopEpoch: 0
623
+ },
624
+ snapshot,
625
+ screenshot:
626
+ typeof native.screenshotPngBase64 === 'string'
627
+ ? {
628
+ format: 'png',
629
+ width: Number(native.screenshotWidth),
630
+ height: Number(native.screenshotHeight),
631
+ scale: Number(native.screenshotScale),
632
+ data: native.screenshotPngBase64
633
+ }
634
+ : null,
635
+ issues: normalizeScreenshotIssues(native.screenshotError)
636
+ }
637
+ }
638
+ }
639
+
640
+ export function normalizeScreenshotIssues(value: unknown): JsonRecord[] {
641
+ const error = record(value)
642
+ if (typeof error.message !== 'string' || error.message.length === 0) return []
643
+ return [
644
+ createComputerError('screenshot_failed', error.message, { component: 'screenshots' }).toJSON()
645
+ ]
646
+ }
@@ -0,0 +1,67 @@
1
+ import { createComputerError } from '@crosshands/contract'
2
+
3
+ export type WindowsLogonIdentity = {
4
+ sid: string
5
+ logonSessionId: string
6
+ integrityRid: number
7
+ }
8
+
9
+ export type WindowsPipePeerIdentity = WindowsLogonIdentity & {
10
+ pid: number
11
+ processStartedAt: string
12
+ executablePath: string
13
+ remote: boolean
14
+ }
15
+
16
+ /**
17
+ * This interface is deliberately native-backed. Node's named-pipe API cannot
18
+ * prove the peer token or construct the required logon-SID-only DACL.
19
+ */
20
+ export interface WindowsNamedPipeSecurityBackend {
21
+ currentLogonIdentity(): WindowsLogonIdentity
22
+ createCurrentLogonSidDacl(logonSid: string): Uint8Array
23
+ createServer(pipeName: string, securityDescriptor: Uint8Array): unknown
24
+ inspectClient(serverHandle: unknown): WindowsPipePeerIdentity
25
+ impersonateClient<T>(serverHandle: unknown, work: () => T): T
26
+ }
27
+
28
+ export function verifyWindowsPipePeer(
29
+ backend: WindowsNamedPipeSecurityBackend,
30
+ serverHandle: unknown
31
+ ): WindowsPipePeerIdentity {
32
+ const expected = backend.currentLogonIdentity()
33
+ return backend.impersonateClient(serverHandle, () => {
34
+ const peer = backend.inspectClient(serverHandle)
35
+ if (peer.remote) {
36
+ throw createComputerError('session_unavailable', 'Remote named-pipe clients are rejected')
37
+ }
38
+ if (peer.sid !== expected.sid || peer.logonSessionId !== expected.logonSessionId) {
39
+ throw createComputerError(
40
+ 'session_unavailable',
41
+ 'Named-pipe client belongs to another logon identity or session'
42
+ )
43
+ }
44
+ if (peer.integrityRid !== expected.integrityRid) {
45
+ throw createComputerError(
46
+ 'unsupported_capability',
47
+ 'Named-pipe client integrity differs from the broker'
48
+ )
49
+ }
50
+ if (peer.pid <= 0 || peer.executablePath.length === 0 || peer.processStartedAt.length === 0) {
51
+ throw createComputerError('session_unavailable', 'Named-pipe client identity is incomplete')
52
+ }
53
+ return peer
54
+ })
55
+ }
56
+
57
+ export function requireWindowsPipeSecurityBackend(
58
+ backend: WindowsNamedPipeSecurityBackend | undefined
59
+ ): WindowsNamedPipeSecurityBackend {
60
+ if (backend === undefined) {
61
+ throw createComputerError(
62
+ 'unsupported_capability',
63
+ 'The installed Windows payload has no native named-pipe DACL and peer-token verifier'
64
+ )
65
+ }
66
+ return backend
67
+ }