@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.
- package/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/assets/crosshands-pipe-relay.exe +0 -0
- package/assets/payload.json +13 -0
- package/assets/runtime.ps1 +1426 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +51 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +547 -0
- package/dist/provider.js.map +1 -0
- package/dist/security.d.ts +25 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +27 -0
- package/dist/security.js.map +1 -0
- package/dist/windows-control-security.d.ts +95 -0
- package/dist/windows-control-security.d.ts.map +1 -0
- package/dist/windows-control-security.js +432 -0
- package/dist/windows-control-security.js.map +1 -0
- package/package.json +46 -0
- package/src/index.ts +76 -0
- package/src/provider.ts +646 -0
- package/src/security.ts +67 -0
- package/src/windows-control-security.ts +562 -0
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|
package/src/security.ts
ADDED
|
@@ -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
|
+
}
|