@crosshands/runtime 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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/broker/broker.d.ts +53 -0
  3. package/dist/broker/broker.d.ts.map +1 -0
  4. package/dist/broker/broker.js +346 -0
  5. package/dist/broker/broker.js.map +1 -0
  6. package/dist/bundle-lifecycle.d.ts +75 -0
  7. package/dist/bundle-lifecycle.d.ts.map +1 -0
  8. package/dist/bundle-lifecycle.js +247 -0
  9. package/dist/bundle-lifecycle.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +10 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/ipc/control-transport.d.ts +59 -0
  15. package/dist/ipc/control-transport.d.ts.map +1 -0
  16. package/dist/ipc/control-transport.js +389 -0
  17. package/dist/ipc/control-transport.js.map +1 -0
  18. package/dist/ipc/endpoint.d.ts +15 -0
  19. package/dist/ipc/endpoint.d.ts.map +1 -0
  20. package/dist/ipc/endpoint.js +16 -0
  21. package/dist/ipc/endpoint.js.map +1 -0
  22. package/dist/ipc/framing.d.ts +4 -0
  23. package/dist/ipc/framing.d.ts.map +1 -0
  24. package/dist/ipc/framing.js +32 -0
  25. package/dist/ipc/framing.js.map +1 -0
  26. package/dist/ipc/unix.d.ts +17 -0
  27. package/dist/ipc/unix.d.ts.map +1 -0
  28. package/dist/ipc/unix.js +106 -0
  29. package/dist/ipc/unix.js.map +1 -0
  30. package/dist/ipc/windows.d.ts +13 -0
  31. package/dist/ipc/windows.d.ts.map +1 -0
  32. package/dist/ipc/windows.js +10 -0
  33. package/dist/ipc/windows.js.map +1 -0
  34. package/dist/policy/policy.d.ts +23 -0
  35. package/dist/policy/policy.d.ts.map +1 -0
  36. package/dist/policy/policy.js +53 -0
  37. package/dist/policy/policy.js.map +1 -0
  38. package/dist/providers/supervisor.d.ts +17 -0
  39. package/dist/providers/supervisor.d.ts.map +1 -0
  40. package/dist/providers/supervisor.js +122 -0
  41. package/dist/providers/supervisor.js.map +1 -0
  42. package/package.json +37 -0
  43. package/src/broker/broker.ts +439 -0
  44. package/src/bundle-lifecycle.ts +385 -0
  45. package/src/index.ts +9 -0
  46. package/src/ipc/control-transport.ts +517 -0
  47. package/src/ipc/endpoint.ts +27 -0
  48. package/src/ipc/framing.ts +31 -0
  49. package/src/ipc/unix.ts +110 -0
  50. package/src/ipc/windows.ts +26 -0
  51. package/src/policy/policy.ts +73 -0
  52. package/src/providers/supervisor.ts +140 -0
@@ -0,0 +1,439 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import {
4
+ COMPUTER_OPERATIONS,
5
+ InteractionContextStore,
6
+ createComputerError,
7
+ negotiateVersionHandshake,
8
+ parseOperationOutput,
9
+ type ComputerOperationName,
10
+ type ContractVersions,
11
+ type InteractionContext,
12
+ type ReferenceBindings,
13
+ type TargetReference
14
+ } from '@crosshands/contract'
15
+
16
+ import { assertAppAllowed, type StableAppIdentity } from '../policy/policy.js'
17
+ import { ProviderSupervisor } from '../providers/supervisor.js'
18
+
19
+ export type BrokerPeer = {
20
+ osIdentity: string
21
+ graphicalSessionId: string
22
+ verified: boolean
23
+ local: boolean
24
+ }
25
+
26
+ export type TargetInspection = {
27
+ bindings: ReferenceBindings
28
+ appIdentity: StableAppIdentity
29
+ }
30
+
31
+ export type BrokerRequest = {
32
+ operation: ComputerOperationName
33
+ input: unknown
34
+ deadlineMs?: number
35
+ }
36
+
37
+ export type BrokerResponse = {
38
+ requestId: string
39
+ result: unknown
40
+ desktopEpoch: number
41
+ providerGeneration: string
42
+ context?: InteractionContext
43
+ }
44
+
45
+ export type LocalBrokerOptions = {
46
+ identity: BrokerPeer
47
+ generation?: string
48
+ providerFactory: ConstructorParameters<typeof ProviderSupervisor>[0]['providerFactory']
49
+ inspectTarget?: (
50
+ operation: ComputerOperationName,
51
+ input: unknown
52
+ ) => Promise<TargetInspection | null>
53
+ publish?: (response: BrokerResponse) => Promise<void>
54
+ contextTtlMs?: number
55
+ now?: () => number
56
+ }
57
+
58
+ class SerialQueue {
59
+ #tail: Promise<void> = Promise.resolve()
60
+
61
+ async run<T>(work: () => Promise<T>): Promise<T> {
62
+ const previous = this.#tail
63
+ let release!: () => void
64
+ this.#tail = new Promise<void>((resolve) => {
65
+ release = resolve
66
+ })
67
+ await previous
68
+ try {
69
+ return await work()
70
+ } finally {
71
+ release()
72
+ }
73
+ }
74
+ }
75
+
76
+ function isMutation(operation: ComputerOperationName): boolean {
77
+ return COMPUTER_OPERATIONS[operation].mutation
78
+ }
79
+
80
+ function assertedInputApp(input: unknown): void {
81
+ if (input === null || typeof input !== 'object') return
82
+ const app = (input as { app?: unknown }).app
83
+ if (typeof app === 'string' && app.length > 0) {
84
+ assertAppAllowed({ appId: app, executableId: app })
85
+ }
86
+ }
87
+
88
+ function targetReference(input: unknown): TargetReference | undefined {
89
+ if (input === null || typeof input !== 'object') return undefined
90
+ const record = input as Record<string, unknown>
91
+ const candidate = record.target ?? record.from
92
+ if (candidate === null || typeof candidate !== 'object') return undefined
93
+ const target = candidate as Record<string, unknown>
94
+ if (target.kind === 'element') return target.ref as TargetReference
95
+ if (target.kind === 'coordinate' && target.window !== undefined)
96
+ return target.window as TargetReference
97
+ if ('contextToken' in target) return target as TargetReference
98
+ return undefined
99
+ }
100
+
101
+ function bindReference(
102
+ context: InteractionContext,
103
+ current: ReferenceBindings,
104
+ kind: TargetReference['kind'],
105
+ ref: string
106
+ ): TargetReference {
107
+ return {
108
+ ...current,
109
+ kind,
110
+ ref,
111
+ contextToken: context.token,
112
+ expiresAt: context.expiresAt
113
+ }
114
+ }
115
+
116
+ function normalizeTarget(
117
+ candidate: unknown,
118
+ context: InteractionContext,
119
+ current: ReferenceBindings
120
+ ): unknown {
121
+ if (candidate === null || typeof candidate !== 'object') return candidate
122
+ const target = candidate as Record<string, unknown>
123
+ if (target.kind === 'element' && typeof target.elementIndex === 'number') {
124
+ return {
125
+ kind: 'element',
126
+ ref: bindReference(context, current, 'element', `element:${target.elementIndex}`)
127
+ }
128
+ }
129
+ if (target.kind === 'coordinate' && target.window === undefined) {
130
+ return {
131
+ ...target,
132
+ window: bindReference(context, current, 'window', current.window.id)
133
+ }
134
+ }
135
+ if (target.kind === 'context-window') {
136
+ return bindReference(context, current, 'window', current.window.id)
137
+ }
138
+ return candidate
139
+ }
140
+
141
+ function normalizeMutationInput(
142
+ input: unknown,
143
+ context: InteractionContext,
144
+ current: ReferenceBindings
145
+ ): unknown {
146
+ if (input === null || typeof input !== 'object') return input
147
+ const record = input as Record<string, unknown>
148
+ return {
149
+ ...record,
150
+ ...(record.target === undefined
151
+ ? {}
152
+ : { target: normalizeTarget(record.target, context, current) }),
153
+ ...(record.from === undefined ? {} : { from: normalizeTarget(record.from, context, current) }),
154
+ ...(record.to === undefined ? {} : { to: normalizeTarget(record.to, context, current) })
155
+ }
156
+ }
157
+
158
+ function mutationReferences(input: unknown): TargetReference[] {
159
+ if (input === null || typeof input !== 'object') return []
160
+ const record = input as Record<string, unknown>
161
+ return [record.target, record.from, record.to]
162
+ .map((candidate) => targetReference({ target: candidate }))
163
+ .filter((candidate): candidate is TargetReference => candidate !== undefined)
164
+ }
165
+
166
+ function contextToken(input: unknown): string | undefined {
167
+ if (input === null || typeof input !== 'object') return undefined
168
+ const token = (input as Record<string, unknown>).contextToken
169
+ return typeof token === 'string' ? token : undefined
170
+ }
171
+
172
+ function computerError(cause: unknown): { code?: string; message: string; toJSON?: () => unknown } {
173
+ if (cause instanceof Error) return cause
174
+ return { message: 'Unknown provider failure' }
175
+ }
176
+
177
+ function assertFreshReference(
178
+ reference: TargetReference,
179
+ context: InteractionContext,
180
+ current: ReferenceBindings,
181
+ now: number
182
+ ): void {
183
+ const mismatches = [
184
+ [reference.contextToken !== context.token, 'context token'],
185
+ [reference.brokerGeneration !== context.brokerGeneration, 'context broker generation'],
186
+ [reference.providerGeneration !== context.providerGeneration, 'context provider generation'],
187
+ [reference.graphicalSessionId !== context.graphicalSessionId, 'context session'],
188
+ [reference.brokerGeneration !== current.brokerGeneration, 'current broker generation'],
189
+ [reference.providerGeneration !== current.providerGeneration, 'current provider generation'],
190
+ [reference.graphicalSessionId !== current.graphicalSessionId, 'current session'],
191
+ [reference.process.pid !== context.process.pid, 'context pid'],
192
+ [reference.process.startedAt !== context.process.startedAt, 'context process start'],
193
+ [reference.process.executableId !== context.process.executableId, 'context executable'],
194
+ [reference.process.pid !== current.process.pid, 'current pid'],
195
+ [reference.process.startedAt !== current.process.startedAt, 'current process start'],
196
+ [reference.process.executableId !== current.process.executableId, 'current executable'],
197
+ [reference.appId !== context.appId, 'context app'],
198
+ [reference.appId !== current.appId, 'current app'],
199
+ [reference.window.id !== context.window.id, 'context window'],
200
+ [reference.window.ownerPid !== context.window.ownerPid, 'context window owner'],
201
+ [reference.window.id !== current.window.id, 'current window'],
202
+ [reference.window.ownerPid !== current.window.ownerPid, 'current window owner'],
203
+ [reference.snapshotId !== context.snapshotId, 'context snapshot'],
204
+ [reference.snapshotId !== current.snapshotId, 'current snapshot'],
205
+ [reference.desktopEpoch !== context.desktopEpoch, 'context desktop epoch'],
206
+ [reference.desktopEpoch !== current.desktopEpoch, 'current desktop epoch'],
207
+ [Date.parse(reference.expiresAt) < now, 'reference expiration'],
208
+ [Date.parse(context.expiresAt) < now, 'context expiration']
209
+ ]
210
+ .filter(([failed]) => failed)
211
+ .map(([, label]) => label)
212
+ if (mismatches.length > 0) {
213
+ throw createComputerError('stale_target', 'Target reference is no longer fresh', { mismatches })
214
+ }
215
+ }
216
+
217
+ export class BrokerClient {
218
+ constructor(private readonly broker: LocalBroker) {}
219
+
220
+ request(request: BrokerRequest): Promise<BrokerResponse> {
221
+ return this.broker.request(request)
222
+ }
223
+ }
224
+
225
+ export class LocalBroker {
226
+ readonly generation: string
227
+ readonly #identity: BrokerPeer
228
+ readonly #supervisor: ProviderSupervisor
229
+ readonly #contexts: InteractionContextStore
230
+ readonly #inspectTarget: LocalBrokerOptions['inspectTarget']
231
+ readonly #publish: LocalBrokerOptions['publish']
232
+ readonly #now: () => number
233
+ readonly #queue = new SerialQueue()
234
+ #desktopEpoch = 0
235
+ #requestSequence = 0
236
+
237
+ constructor(options: LocalBrokerOptions) {
238
+ this.generation = options.generation ?? `broker-${randomUUID()}`
239
+ this.#identity = options.identity
240
+ this.#inspectTarget = options.inspectTarget
241
+ this.#publish = options.publish
242
+ this.#now = options.now ?? Date.now
243
+ this.#contexts = new InteractionContextStore({
244
+ ...(options.contextTtlMs === undefined ? {} : { ttlMs: options.contextTtlMs }),
245
+ now: this.#now
246
+ })
247
+ this.#supervisor = new ProviderSupervisor({
248
+ providerFactory: options.providerFactory,
249
+ graphicalSessionId: options.identity.graphicalSessionId
250
+ })
251
+ }
252
+
253
+ get desktopEpoch(): number {
254
+ return this.#desktopEpoch
255
+ }
256
+
257
+ async connect(handshake: {
258
+ peer: BrokerPeer
259
+ versions: ContractVersions
260
+ }): Promise<BrokerClient> {
261
+ const { peer } = handshake
262
+ if (!peer.verified || !peer.local) {
263
+ throw createComputerError('session_unavailable', 'Broker peer could not be verified locally')
264
+ }
265
+ if (
266
+ peer.osIdentity !== this.#identity.osIdentity ||
267
+ peer.graphicalSessionId !== this.#identity.graphicalSessionId
268
+ ) {
269
+ throw createComputerError(
270
+ 'session_unavailable',
271
+ 'Broker peer belongs to a different OS identity or graphical session'
272
+ )
273
+ }
274
+ const versions = negotiateVersionHandshake(handshake.versions)
275
+ if (!versions.ok) throw versions.error
276
+ await this.#supervisor.start()
277
+ return new BrokerClient(this)
278
+ }
279
+
280
+ issueContext(bindings: ReferenceBindings): InteractionContext {
281
+ return this.#contexts.issue({
282
+ ...bindings,
283
+ brokerGeneration: this.generation,
284
+ providerGeneration: this.#supervisor.generation ?? bindings.providerGeneration,
285
+ graphicalSessionId: this.#identity.graphicalSessionId,
286
+ desktopEpoch: this.#desktopEpoch
287
+ })
288
+ }
289
+
290
+ resolveContext(token: string): InteractionContext {
291
+ return this.#contexts.resolve(token)
292
+ }
293
+
294
+ request(request: BrokerRequest): Promise<BrokerResponse> {
295
+ return this.#queue.run(() => this.#requestLocked(request))
296
+ }
297
+
298
+ async #requestLocked(request: BrokerRequest): Promise<BrokerResponse> {
299
+ const requestId = `broker-${++this.#requestSequence}`
300
+ const deadlineAt = this.#now() + (request.deadlineMs ?? 30_000)
301
+ const mutation = isMutation(request.operation)
302
+ assertedInputApp(request.input)
303
+ let context: InteractionContext | undefined
304
+ let inspectionInput = request.input
305
+ if (mutation) {
306
+ const token = contextToken(request.input)
307
+ if (token === undefined) {
308
+ throw createComputerError(
309
+ 'invalid_argument',
310
+ 'Mutation requires a context token and target'
311
+ )
312
+ }
313
+ context = this.#contexts.resolve(token)
314
+ // Context-window and index shorthand do not carry an identity until the
315
+ // broker binds them. Inspect the bound form so native providers can
316
+ // re-resolve the exact process/window before dispatch.
317
+ inspectionInput = normalizeMutationInput(request.input, context, context)
318
+ }
319
+ const inspection = await this.#inspectTarget?.(request.operation, inspectionInput)
320
+ if (inspection !== undefined && inspection !== null) assertAppAllowed(inspection.appIdentity)
321
+
322
+ let providerInput = request.input
323
+ if (mutation) {
324
+ if (inspection === undefined || inspection === null) {
325
+ throw createComputerError('stale_target', 'Target identity could not be re-resolved')
326
+ }
327
+ providerInput = normalizeMutationInput(request.input, context!, inspection.bindings)
328
+ const references = mutationReferences(providerInput)
329
+ if (references.length === 0) {
330
+ throw createComputerError('invalid_argument', 'Mutation requires a target selector')
331
+ }
332
+ for (const reference of references) {
333
+ assertFreshReference(reference, context!, inspection.bindings, this.#now())
334
+ }
335
+ }
336
+
337
+ try {
338
+ const providerResponse = await this.#supervisor.dispatch({
339
+ requestId,
340
+ operation: request.operation,
341
+ input: providerInput,
342
+ deadlineAt
343
+ })
344
+ if (mutation) {
345
+ const dispatched = providerResponse.dispatched
346
+ if (dispatched) this.#desktopEpoch += 1
347
+ const result =
348
+ 'error' in providerResponse
349
+ ? {
350
+ outcome: dispatched
351
+ ? { state: 'indeterminate', reason: providerResponse.error.message }
352
+ : { state: 'not_attempted', error: providerResponse.error }
353
+ }
354
+ : providerResponse.result
355
+ const response = {
356
+ requestId,
357
+ result: this.#publicResult(request.operation, result),
358
+ desktopEpoch: this.#desktopEpoch,
359
+ providerGeneration: this.#supervisor.generation ?? 'unknown'
360
+ }
361
+ await this.#publish?.(response)
362
+ return response
363
+ }
364
+ return this.#observationResponse(requestId, request.operation, providerResponse.result)
365
+ } catch (cause) {
366
+ const error = computerError(cause)
367
+ if (!mutation && error.code === 'provider_crashed') {
368
+ await this.#supervisor.restart()
369
+ this.#contexts.invalidateAll()
370
+ const retry = await this.#supervisor.dispatch({
371
+ requestId: `${requestId}-retry`,
372
+ operation: request.operation,
373
+ input: request.input,
374
+ deadlineAt
375
+ })
376
+ if ('error' in retry) throw Object.assign(new Error(retry.error.message), retry.error)
377
+ return this.#observationResponse(requestId, request.operation, retry.result)
378
+ }
379
+ if (mutation && (error.code === 'provider_crashed' || error.code === 'timeout')) {
380
+ this.#desktopEpoch += 1
381
+ this.#contexts.invalidateAll()
382
+ const response = {
383
+ requestId,
384
+ result: this.#publicResult(request.operation, {
385
+ outcome: { state: 'indeterminate', reason: error.message }
386
+ }),
387
+ desktopEpoch: this.#desktopEpoch,
388
+ providerGeneration: this.#supervisor.generation ?? 'unknown'
389
+ }
390
+ await this.#publish?.(response)
391
+ return response
392
+ }
393
+ throw cause
394
+ }
395
+ }
396
+
397
+ #observationResponse(
398
+ requestId: string,
399
+ operation: ComputerOperationName,
400
+ result: unknown
401
+ ): BrokerResponse {
402
+ let context: InteractionContext | undefined
403
+ const publicResult = this.#publicResult(operation, result)
404
+ if (publicResult !== null && typeof publicResult === 'object' && 'context' in publicResult)
405
+ context = (publicResult as { context: InteractionContext }).context
406
+ return {
407
+ requestId,
408
+ result: publicResult,
409
+ desktopEpoch: this.#desktopEpoch,
410
+ providerGeneration: this.#supervisor.generation ?? 'unknown',
411
+ ...(context === undefined ? {} : { context })
412
+ }
413
+ }
414
+
415
+ #publicResult(operation: ComputerOperationName, result: unknown): unknown {
416
+ if (result === null || typeof result !== 'object')
417
+ return parseOperationOutput(operation, result)
418
+ const value = result as Record<string, unknown>
419
+ let publicResult: Record<string, unknown> = value
420
+ if ('bindings' in value) {
421
+ const { bindings, ...snapshot } = value
422
+ publicResult = { ...snapshot, context: this.issueContext(bindings as ReferenceBindings) }
423
+ } else if (
424
+ value.freshState !== null &&
425
+ typeof value.freshState === 'object' &&
426
+ 'bindings' in value.freshState
427
+ ) {
428
+ const { bindings, ...freshState } = value.freshState as Record<string, unknown>
429
+ publicResult = {
430
+ ...value,
431
+ freshState: {
432
+ ...freshState,
433
+ context: this.issueContext(bindings as ReferenceBindings)
434
+ }
435
+ }
436
+ }
437
+ return parseOperationOutput(operation, publicResult)
438
+ }
439
+ }