@crosshands/runtime 0.1.6 → 0.2.0

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 (41) hide show
  1. package/dist/broker/broker.d.ts +4 -0
  2. package/dist/broker/broker.d.ts.map +1 -1
  3. package/dist/broker/broker.js +194 -47
  4. package/dist/broker/broker.js.map +1 -1
  5. package/dist/diagnostics/paths.d.ts +5 -0
  6. package/dist/diagnostics/paths.d.ts.map +1 -0
  7. package/dist/diagnostics/paths.js +25 -0
  8. package/dist/diagnostics/paths.js.map +1 -0
  9. package/dist/diagnostics/record.d.ts +99 -0
  10. package/dist/diagnostics/record.d.ts.map +1 -0
  11. package/dist/diagnostics/record.js +154 -0
  12. package/dist/diagnostics/record.js.map +1 -0
  13. package/dist/diagnostics/writer.d.ts +27 -0
  14. package/dist/diagnostics/writer.d.ts.map +1 -0
  15. package/dist/diagnostics/writer.js +95 -0
  16. package/dist/diagnostics/writer.js.map +1 -0
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/ipc/control-transport.d.ts +6 -0
  22. package/dist/ipc/control-transport.d.ts.map +1 -1
  23. package/dist/ipc/control-transport.js +13 -1
  24. package/dist/ipc/control-transport.js.map +1 -1
  25. package/dist/ipc/endpoint.d.ts +1 -0
  26. package/dist/ipc/endpoint.d.ts.map +1 -1
  27. package/dist/ipc/endpoint.js +3 -0
  28. package/dist/ipc/endpoint.js.map +1 -1
  29. package/dist/private-directory.d.ts +2 -0
  30. package/dist/private-directory.d.ts.map +1 -0
  31. package/dist/private-directory.js +15 -0
  32. package/dist/private-directory.js.map +1 -0
  33. package/package.json +2 -2
  34. package/src/broker/broker.ts +233 -50
  35. package/src/diagnostics/paths.ts +30 -0
  36. package/src/diagnostics/record.ts +262 -0
  37. package/src/diagnostics/writer.ts +112 -0
  38. package/src/index.ts +4 -0
  39. package/src/ipc/control-transport.ts +20 -1
  40. package/src/ipc/endpoint.ts +4 -0
  41. package/src/private-directory.ts +14 -0
@@ -0,0 +1,262 @@
1
+ import {
2
+ COMPUTER_OPERATIONS,
3
+ ComputerError,
4
+ type ComputerOperationName,
5
+ type MutationResult,
6
+ type ReferenceBindings,
7
+ type SnapshotResult
8
+ } from '@crosshands/contract'
9
+
10
+ export const DIAGNOSTIC_RECORD_VERSION = 1 as const
11
+
12
+ const ERROR_DETAIL_KEYS = new Set([
13
+ 'mismatches',
14
+ 'cause',
15
+ 'expectedProviderProtocol',
16
+ 'receivedProviderProtocol',
17
+ 'expectedPublicContract',
18
+ 'receivedPublicContract'
19
+ ])
20
+
21
+ export type DiagnosticPlatform = 'darwin' | 'win32' | 'linux'
22
+
23
+ export type DiagnosticError = {
24
+ code: string
25
+ message: string
26
+ retry: boolean
27
+ remediation: string
28
+ details?: Record<string, string | number | boolean | string[]>
29
+ }
30
+
31
+ export type DiagnosticTarget = {
32
+ appId: string
33
+ pid?: number
34
+ windowId?: string
35
+ snapshotId?: string
36
+ kind?: string
37
+ ref?: string
38
+ }
39
+
40
+ export type DiagnosticTimings = {
41
+ queue: number
42
+ inspect: number
43
+ dispatch: number
44
+ total: number
45
+ }
46
+
47
+ export type DiagnosticRequestResult =
48
+ | {
49
+ type: 'mutation'
50
+ dispatched: boolean
51
+ outcome: { state: string; reason?: string }
52
+ }
53
+ | {
54
+ type: 'observation'
55
+ snapshotId?: string
56
+ elementCount?: number
57
+ focusedElementRef?: string | null
58
+ screenshot: boolean
59
+ issues: Array<{ code: string; retry: boolean; remediation: string; message?: string }>
60
+ }
61
+ | {
62
+ type: 'lookup'
63
+ appCount?: number
64
+ windowCount?: number
65
+ permissions?: Record<string, string>
66
+ }
67
+ | ({ type: 'error' } & DiagnosticError)
68
+
69
+ export type DiagnosticEnvelope = {
70
+ v: typeof DIAGNOSTIC_RECORD_VERSION
71
+ ts: string
72
+ session: string
73
+ brokerGeneration: string
74
+ providerGeneration?: string
75
+ desktopEpoch: number
76
+ platform: DiagnosticPlatform
77
+ }
78
+
79
+ export type DiagnosticRecord =
80
+ | (DiagnosticEnvelope & { kind: 'broker.start' })
81
+ | (DiagnosticEnvelope & { kind: 'broker.stop'; reason: string })
82
+ | (DiagnosticEnvelope & { kind: 'helper.start' })
83
+ | (DiagnosticEnvelope & {
84
+ kind: 'helper.crash'
85
+ requestId?: string
86
+ operation?: string
87
+ error: DiagnosticError
88
+ })
89
+ | (DiagnosticEnvelope & { kind: 'helper.restart'; previousGeneration?: string })
90
+ | (DiagnosticEnvelope & {
91
+ kind: 'client.handshake'
92
+ accepted: boolean
93
+ requestId: string
94
+ code?: string
95
+ })
96
+ | (DiagnosticEnvelope & {
97
+ kind: 'request'
98
+ requestId: string
99
+ operation: string
100
+ mutation: boolean
101
+ ms: DiagnosticTimings
102
+ target?: DiagnosticTarget
103
+ result: DiagnosticRequestResult
104
+ })
105
+
106
+ export type DiagnosticsSink = {
107
+ emit(record: DiagnosticRecord): void
108
+ close(): Promise<void>
109
+ }
110
+
111
+ export function diagnosticPlatform(
112
+ platform: NodeJS.Platform = process.platform
113
+ ): DiagnosticPlatform {
114
+ if (platform === 'darwin' || platform === 'win32' || platform === 'linux') return platform
115
+ return 'linux'
116
+ }
117
+
118
+ export function emitDiagnostic(sink: DiagnosticsSink | undefined, record: DiagnosticRecord): void {
119
+ try {
120
+ sink?.emit(record)
121
+ } catch {
122
+ // Diagnostics must not fail computer-use.
123
+ }
124
+ }
125
+
126
+ export function diagnosticError(cause: unknown): DiagnosticError {
127
+ if (cause instanceof ComputerError) {
128
+ const json = cause.toJSON()
129
+ return {
130
+ code: json.code,
131
+ message: json.message,
132
+ retry: json.retry,
133
+ remediation: json.remediation,
134
+ ...diagnosticErrorDetails(json.details)
135
+ }
136
+ }
137
+ return {
138
+ code: 'provider_unavailable',
139
+ message: cause instanceof Error ? cause.message : 'Request failed',
140
+ retry: true,
141
+ remediation: 'run_doctor'
142
+ }
143
+ }
144
+
145
+ export function diagnosticTarget(
146
+ input: unknown,
147
+ bindings: ReferenceBindings | undefined
148
+ ): DiagnosticTarget | undefined {
149
+ const appId = bindings?.appId ?? inputAppId(input)
150
+ if (appId === undefined) return undefined
151
+ const selector = inputSelector(input)
152
+ return {
153
+ appId,
154
+ ...(bindings?.process.pid === undefined ? {} : { pid: bindings.process.pid }),
155
+ ...(bindings?.window.id === undefined ? {} : { windowId: bindings.window.id }),
156
+ ...(bindings?.snapshotId === undefined ? {} : { snapshotId: bindings.snapshotId }),
157
+ ...(selector.kind === undefined ? {} : { kind: selector.kind }),
158
+ ...(selector.ref === undefined ? {} : { ref: selector.ref })
159
+ }
160
+ }
161
+
162
+ export function diagnosticRequestResult(
163
+ operation: ComputerOperationName,
164
+ result: unknown,
165
+ dispatched: boolean | undefined
166
+ ): DiagnosticRequestResult {
167
+ if (COMPUTER_OPERATIONS[operation].mutation) {
168
+ const { outcome } = result as MutationResult
169
+ return {
170
+ type: 'mutation',
171
+ dispatched: dispatched ?? true,
172
+ outcome: {
173
+ state: outcome.state,
174
+ ...('reason' in outcome && typeof outcome.reason === 'string'
175
+ ? { reason: outcome.reason }
176
+ : {})
177
+ }
178
+ }
179
+ }
180
+ if (operation === 'getAppState') {
181
+ const observation = result as SnapshotResult
182
+ return {
183
+ type: 'observation',
184
+ snapshotId: observation.snapshot.id,
185
+ elementCount: observation.snapshot.elementCount,
186
+ focusedElementRef: observation.snapshot.focusedElementRef,
187
+ screenshot: observation.screenshot !== null,
188
+ issues: observation.issues.map((issue) => {
189
+ const recorded: {
190
+ code: string
191
+ retry: boolean
192
+ remediation: string
193
+ message?: string
194
+ } = {
195
+ code: issue.code,
196
+ retry: issue.retry,
197
+ remediation: issue.remediation
198
+ }
199
+ if (issue.message !== undefined) recorded.message = issue.message
200
+ return recorded
201
+ })
202
+ }
203
+ }
204
+ if (operation === 'listApps') {
205
+ return { type: 'lookup', appCount: (result as { apps: readonly unknown[] }).apps.length }
206
+ }
207
+ if (operation === 'listWindows') {
208
+ return {
209
+ type: 'lookup',
210
+ windowCount: (result as { windows: readonly unknown[] }).windows.length
211
+ }
212
+ }
213
+ if (operation === 'capabilities' || operation === 'permissions') {
214
+ const permissions = (result as { permissions: Record<string, string> }).permissions
215
+ return { type: 'lookup', permissions }
216
+ }
217
+ return { type: 'lookup' }
218
+ }
219
+
220
+ function diagnosticErrorDetails(
221
+ details: unknown
222
+ ): { details: NonNullable<DiagnosticError['details']> } | Record<string, never> {
223
+ if (details === null || typeof details !== 'object' || Array.isArray(details)) return {}
224
+ const result: NonNullable<DiagnosticError['details']> = {}
225
+ for (const [key, value] of Object.entries(details as Record<string, unknown>)) {
226
+ if (!ERROR_DETAIL_KEYS.has(key)) continue
227
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
228
+ result[key] = value
229
+ } else if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) {
230
+ result[key] = value
231
+ }
232
+ }
233
+ return Object.keys(result).length > 0 ? { details: result } : {}
234
+ }
235
+
236
+ function inputAppId(input: unknown): string | undefined {
237
+ if (input === null || typeof input !== 'object') return undefined
238
+ const app = (input as { app?: unknown }).app
239
+ return typeof app === 'string' && app.length > 0 ? app : undefined
240
+ }
241
+
242
+ function inputSelector(input: unknown): { kind?: string; ref?: string } {
243
+ if (input === null || typeof input !== 'object') return {}
244
+ const record = input as { target?: unknown; from?: unknown }
245
+ const selector = record.target ?? record.from
246
+ if (selector === null || typeof selector !== 'object') return {}
247
+ const target = selector as { kind?: unknown; elementIndex?: unknown; ref?: unknown }
248
+ let kind = typeof target.kind === 'string' ? target.kind : undefined
249
+ let ref: string | undefined
250
+ if (typeof target.elementIndex === 'number') ref = `element:${target.elementIndex}`
251
+ if (typeof target.ref === 'string') {
252
+ ref = target.ref
253
+ } else if (target.ref !== null && typeof target.ref === 'object') {
254
+ const bound = target.ref as { kind?: unknown; ref?: unknown }
255
+ if (typeof bound.kind === 'string') kind = bound.kind
256
+ if (typeof bound.ref === 'string') ref = bound.ref
257
+ }
258
+ return {
259
+ ...(kind === undefined ? {} : { kind }),
260
+ ...(ref === undefined ? {} : { ref })
261
+ }
262
+ }
@@ -0,0 +1,112 @@
1
+ import { closeSync, fstatSync, fsyncSync, openSync, writeSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { ensurePrivateDirectory } from '../private-directory.js'
5
+ import type { DiagnosticRecord, DiagnosticsSink } from './record.js'
6
+
7
+ export const DEFAULT_DIAGNOSTICS_ROTATE_BYTES = 5 * 1024 * 1024
8
+
9
+ export type JsonlFileWriterOptions = {
10
+ directory: string
11
+ generation: string
12
+ maxBytes?: number
13
+ }
14
+
15
+ export class JsonlFileWriter {
16
+ readonly directory: string
17
+ readonly generation: string
18
+ readonly #maxBytes: number
19
+ #part = 1
20
+ #started = false
21
+ #closed = false
22
+ #fd: number | undefined
23
+ #bytes = 0
24
+
25
+ constructor(options: JsonlFileWriterOptions) {
26
+ this.directory = options.directory
27
+ this.generation = options.generation
28
+ this.#maxBytes = options.maxBytes ?? DEFAULT_DIAGNOSTICS_ROTATE_BYTES
29
+ }
30
+
31
+ start(): void {
32
+ if (this.#started) return
33
+ ensurePrivateDirectory(this.directory)
34
+ this.#started = true
35
+ }
36
+
37
+ emit(record: Record<string, unknown>): void {
38
+ if (this.#closed) return
39
+ if (!this.#started) this.start()
40
+ const line = `${JSON.stringify(record)}\n`
41
+ const size = Buffer.byteLength(line)
42
+ if (this.#bytes > 0 && this.#bytes + size > this.#maxBytes) this.#rotate()
43
+ if (this.#fd === undefined) this.#open()
44
+ const fd = this.#fd
45
+ if (fd === undefined) return
46
+ writeSync(fd, line)
47
+ this.#bytes += size
48
+ }
49
+
50
+ async close(): Promise<void> {
51
+ if (this.#closed) return
52
+ this.#closed = true
53
+ this.#closeFile()
54
+ }
55
+
56
+ #filePath(): string {
57
+ const suffix = this.#part === 1 ? '' : `.${this.#part}`
58
+ return join(this.directory, `${this.generation}${suffix}.jsonl`)
59
+ }
60
+
61
+ #open(): void {
62
+ const fd = openSync(this.#filePath(), 'a', 0o600)
63
+ this.#fd = fd
64
+ this.#bytes = fstatSync(fd).size
65
+ }
66
+
67
+ #closeFile(): void {
68
+ const fd = this.#fd
69
+ this.#fd = undefined
70
+ if (fd === undefined) return
71
+ try {
72
+ fsyncSync(fd)
73
+ } finally {
74
+ closeSync(fd)
75
+ }
76
+ }
77
+
78
+ #rotate(): void {
79
+ this.#closeFile()
80
+ this.#part += 1
81
+ }
82
+ }
83
+
84
+ export type JsonlDiagnosticsWriterOptions = JsonlFileWriterOptions
85
+
86
+ export class JsonlDiagnosticsWriter implements DiagnosticsSink {
87
+ readonly #file: JsonlFileWriter
88
+
89
+ constructor(options: JsonlDiagnosticsWriterOptions) {
90
+ this.#file = new JsonlFileWriter(options)
91
+ }
92
+
93
+ get directory(): string {
94
+ return this.#file.directory
95
+ }
96
+
97
+ get generation(): string {
98
+ return this.#file.generation
99
+ }
100
+
101
+ start(): void {
102
+ this.#file.start()
103
+ }
104
+
105
+ emit(record: DiagnosticRecord): void {
106
+ this.#file.emit(record)
107
+ }
108
+
109
+ close(): Promise<void> {
110
+ return this.#file.close()
111
+ }
112
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export * from './broker/broker.js'
2
2
  export * from './bundle-lifecycle.js'
3
+ export * from './diagnostics/paths.js'
4
+ export * from './diagnostics/record.js'
5
+ export * from './diagnostics/writer.js'
3
6
  export * from './ipc/endpoint.js'
7
+ export * from './private-directory.js'
4
8
  export * from './ipc/control-transport.js'
5
9
  export * from './ipc/framing.js'
6
10
  export * from './ipc/unix.js'
@@ -42,6 +42,12 @@ export type LocalControlRequest = {
42
42
  deadlineAt: number
43
43
  }
44
44
 
45
+ export type ControlHandshakeEvent = {
46
+ accepted: boolean
47
+ requestId: string
48
+ code?: string
49
+ }
50
+
45
51
  export type LocalControlServerOptions = {
46
52
  endpoint: BrokerEndpoint
47
53
  runtimeDirectory?: string
@@ -53,6 +59,7 @@ export type LocalControlServerOptions = {
53
59
  maxFrameBytes?: number
54
60
  maxFramesPerConnection?: number
55
61
  now?: () => number
62
+ onHandshake?: (event: ControlHandshakeEvent) => void
56
63
  }
57
64
 
58
65
  type ControlMessage = Record<string, unknown> & { type: string; requestId?: string }
@@ -254,6 +261,13 @@ export class LocalControlServer {
254
261
 
255
262
  async #authenticate(message: ControlMessage, socket: Socket): Promise<boolean> {
256
263
  const handshake = message as unknown as LocalControlHandshake
264
+ const note = (accepted: boolean, code?: string): void => {
265
+ this.#options.onHandshake?.({
266
+ accepted,
267
+ requestId: typeof handshake.requestId === 'string' ? handshake.requestId : '',
268
+ ...(code === undefined ? {} : { code })
269
+ })
270
+ }
257
271
  if (
258
272
  typeof handshake.requestId !== 'string' ||
259
273
  typeof handshake.token !== 'string' ||
@@ -265,6 +279,7 @@ export class LocalControlServer {
265
279
  socket,
266
280
  errorMessage(handshake.requestId, 'peer_rejected', 'Peer identity was rejected')
267
281
  )
282
+ note(false, 'peer_rejected')
268
283
  return false
269
284
  }
270
285
  const versions = negotiateVersionHandshake(handshake.versions)
@@ -278,6 +293,7 @@ export class LocalControlServer {
278
293
  versions.error
279
294
  )
280
295
  )
296
+ note(false, versions.error.code)
281
297
  return false
282
298
  }
283
299
  let accepted: boolean
@@ -297,8 +313,11 @@ export class LocalControlServer {
297
313
  socket,
298
314
  errorMessage(handshake.requestId, 'peer_rejected', 'Peer authentication failed')
299
315
  )
316
+ note(false, 'peer_rejected')
317
+ return false
300
318
  }
301
- return accepted
319
+ note(true)
320
+ return true
302
321
  }
303
322
 
304
323
  async #handleRequest(socket: Socket, message: ControlMessage): Promise<void> {
@@ -12,6 +12,10 @@ export type BrokerEndpointOptions = {
12
12
  runtimeDirectory?: string
13
13
  }
14
14
 
15
+ export function graphicalSessionKey(graphicalSessionId: string): string {
16
+ return createHash('sha256').update(graphicalSessionId).digest('hex').slice(0, 12)
17
+ }
18
+
15
19
  export function brokerEndpoint(options: BrokerEndpointOptions): BrokerEndpoint {
16
20
  const key = createHash('sha256')
17
21
  .update(`${options.osIdentity}\0${options.graphicalSessionId}`)
@@ -0,0 +1,14 @@
1
+ import { chmodSync, lstatSync, mkdirSync } from 'node:fs'
2
+
3
+ export function ensurePrivateDirectory(directory: string): void {
4
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
5
+ const info = lstatSync(directory)
6
+ if (!info.isDirectory() || info.isSymbolicLink()) {
7
+ throw new Error('CrossHands directory is unsafe')
8
+ }
9
+ const uid = process.getuid?.()
10
+ if (uid !== undefined && info.uid !== uid) {
11
+ throw new Error('CrossHands directory has another owner')
12
+ }
13
+ if (process.platform !== 'win32') chmodSync(directory, 0o700)
14
+ }