@crosshands/cli 0.1.6 → 0.2.1

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 (54) hide show
  1. package/dist/bin.js +3 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/broker-host.d.ts +2 -1
  4. package/dist/broker-host.d.ts.map +1 -1
  5. package/dist/broker-host.js +65 -40
  6. package/dist/broker-host.js.map +1 -1
  7. package/dist/broker-result.d.ts +7 -0
  8. package/dist/broker-result.d.ts.map +1 -0
  9. package/dist/broker-result.js +10 -0
  10. package/dist/broker-result.js.map +1 -0
  11. package/dist/index.d.ts +6 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +60 -32
  14. package/dist/index.js.map +1 -1
  15. package/dist/intent/dispatch.d.ts +13 -0
  16. package/dist/intent/dispatch.d.ts.map +1 -0
  17. package/dist/intent/dispatch.js +277 -0
  18. package/dist/intent/dispatch.js.map +1 -0
  19. package/dist/intent/env.d.ts +11 -0
  20. package/dist/intent/env.d.ts.map +1 -0
  21. package/dist/intent/env.js +10 -0
  22. package/dist/intent/env.js.map +1 -0
  23. package/dist/intent/evaluate.d.ts +31 -0
  24. package/dist/intent/evaluate.d.ts.map +1 -0
  25. package/dist/intent/evaluate.js +156 -0
  26. package/dist/intent/evaluate.js.map +1 -0
  27. package/dist/intent/http.d.ts +8 -0
  28. package/dist/intent/http.d.ts.map +1 -0
  29. package/dist/intent/http.js +4 -0
  30. package/dist/intent/http.js.map +1 -0
  31. package/dist/intent/log.d.ts +82 -0
  32. package/dist/intent/log.d.ts.map +1 -0
  33. package/dist/intent/log.js +124 -0
  34. package/dist/intent/log.js.map +1 -0
  35. package/dist/intent/tree.d.ts +13 -0
  36. package/dist/intent/tree.d.ts.map +1 -0
  37. package/dist/intent/tree.js +99 -0
  38. package/dist/intent/tree.js.map +1 -0
  39. package/dist/local-client.d.ts +1 -0
  40. package/dist/local-client.d.ts.map +1 -1
  41. package/dist/local-client.js +23 -15
  42. package/dist/local-client.js.map +1 -1
  43. package/package.json +6 -6
  44. package/src/bin.ts +4 -3
  45. package/src/broker-host.ts +68 -38
  46. package/src/broker-result.ts +14 -0
  47. package/src/index.ts +71 -35
  48. package/src/intent/dispatch.ts +373 -0
  49. package/src/intent/env.ts +13 -0
  50. package/src/intent/evaluate.ts +211 -0
  51. package/src/intent/http.ts +10 -0
  52. package/src/intent/log.ts +219 -0
  53. package/src/intent/tree.ts +108 -0
  54. package/src/local-client.ts +31 -15
@@ -0,0 +1,211 @@
1
+ import type { Suggestion } from '@crosshands/contract'
2
+
3
+ import { elapsedMs, type JevHttpStats } from './http.js'
4
+ import type { TreeMove } from './tree.js'
5
+
6
+ export type { JevHttpStats }
7
+
8
+ export class JevEvaluateError extends Error {
9
+ readonly http: JevHttpStats
10
+ constructor(message: string, http: JevHttpStats) {
11
+ super(message)
12
+ this.name = 'JevEvaluateError'
13
+ this.http = http
14
+ }
15
+ }
16
+
17
+ export type JevAnswers = {
18
+ move: 'click' | 'setValue' | 'wait' | 'done' | 'blocked'
19
+ clickWhich?: string
20
+ setValueWhich?: string
21
+ confidence?: number
22
+ goalMet?: number
23
+ blockedReason?: string
24
+ }
25
+
26
+ export type EvaluateResult = {
27
+ answers: JevAnswers
28
+ http?: JevHttpStats
29
+ }
30
+
31
+ export type EvaluateInput = {
32
+ goal: string
33
+ app?: string
34
+ moves: readonly TreeMove[]
35
+ }
36
+
37
+ export type EvaluateFn = (input: EvaluateInput) => Promise<EvaluateResult>
38
+
39
+ export const GATE_REFUSE_BELOW = 0.35
40
+
41
+ export function namedTargetAllowed(confidence: number | undefined): boolean {
42
+ if (confidence === undefined) return true
43
+ return confidence >= GATE_REFUSE_BELOW
44
+ }
45
+
46
+ export function suggestionFromAnswers(
47
+ snapshotId: string,
48
+ moves: readonly TreeMove[],
49
+ answers: JevAnswers
50
+ ): Suggestion {
51
+ const byIndex = new Map(moves.map((move) => [String(move.elementIndex), move]))
52
+ if (answers.move === 'wait') {
53
+ return { untrusted: true, snapshotId, move: { kind: 'wait' }, confidence: answers.confidence }
54
+ }
55
+ if (answers.move === 'done') {
56
+ return { untrusted: true, snapshotId, move: { kind: 'done' }, confidence: answers.confidence }
57
+ }
58
+ if (answers.move === 'blocked') {
59
+ return {
60
+ untrusted: true,
61
+ snapshotId,
62
+ move: { kind: 'blocked', reason: answers.blockedReason ?? 'blocked' },
63
+ confidence: answers.confidence
64
+ }
65
+ }
66
+ const raw = answers.move === 'setValue' ? answers.setValueWhich : answers.clickWhich
67
+ const selected = raw === undefined ? undefined : byIndex.get(raw)
68
+ if (selected === undefined) {
69
+ return {
70
+ untrusted: true,
71
+ snapshotId,
72
+ move: { kind: 'blocked', reason: 'no_candidate' },
73
+ confidence: answers.confidence
74
+ }
75
+ }
76
+ return {
77
+ untrusted: true,
78
+ snapshotId,
79
+ move: { kind: answers.move, elementIndex: selected.elementIndex },
80
+ confidence: answers.confidence,
81
+ label: `${selected.role} ${selected.label}`.trim()
82
+ }
83
+ }
84
+
85
+ function criteria(moves: readonly TreeMove[]): Record<string, string> {
86
+ return Object.fromEntries(
87
+ moves.map((move) => [String(move.elementIndex), `${move.role} ${move.label}`.trim()])
88
+ )
89
+ }
90
+
91
+ type SystemOneAnswer = {
92
+ type?: string
93
+ choice?: string
94
+ noul?: number
95
+ confidence?: number
96
+ }
97
+
98
+ const JEV_MODEL = 'jev-latest'
99
+
100
+ function httpStats(
101
+ started: number,
102
+ requestBytes: number,
103
+ status: number,
104
+ responseBytes: number
105
+ ): JevHttpStats {
106
+ return {
107
+ status,
108
+ durationMs: elapsedMs(started),
109
+ requestBytes,
110
+ responseBytes
111
+ }
112
+ }
113
+
114
+ function answersOf(raw: Record<string, SystemOneAnswer>): JevAnswers {
115
+ const move = raw.move?.choice
116
+ if (
117
+ move !== 'click' &&
118
+ move !== 'setValue' &&
119
+ move !== 'wait' &&
120
+ move !== 'done' &&
121
+ move !== 'blocked'
122
+ ) {
123
+ return { move: 'blocked', blockedReason: 'no_candidate' }
124
+ }
125
+ const clickWhich = raw.click_which?.choice
126
+ const setValueWhich = raw.setvalue_which?.choice
127
+ const confidence = raw.move?.confidence ?? raw.click_which?.confidence
128
+ const goalMet = raw.goal_met?.noul
129
+ return {
130
+ move,
131
+ ...(clickWhich === undefined ? {} : { clickWhich }),
132
+ ...(setValueWhich === undefined ? {} : { setValueWhich }),
133
+ ...(confidence === undefined ? {} : { confidence }),
134
+ ...(goalMet === undefined ? {} : { goalMet })
135
+ }
136
+ }
137
+
138
+ export function liveEvaluator(apiKey: string): EvaluateFn {
139
+ return async (input) => {
140
+ const clickable = criteria(input.moves)
141
+ const payload = JSON.stringify({
142
+ model: JEV_MODEL,
143
+ state: {
144
+ goal: input.goal,
145
+ ...(input.app === undefined ? {} : { app: input.app }),
146
+ clickable
147
+ },
148
+ questions: {
149
+ move: {
150
+ type: 'choice',
151
+ instructions: 'What should we do next for the goal?',
152
+ criteria: {
153
+ click: 'Press one clickable control',
154
+ setValue: 'Pick a field for the caller to fill',
155
+ wait: 'The window is still loading',
156
+ done: 'The goal is already met',
157
+ blocked: 'Stop'
158
+ }
159
+ },
160
+ click_which: {
161
+ type: 'choice',
162
+ instructions: 'If the move is click, which control?',
163
+ criteria: clickable
164
+ },
165
+ setvalue_which: {
166
+ type: 'choice',
167
+ instructions: 'If the move is setValue, which field?',
168
+ criteria: clickable
169
+ },
170
+ goal_met: {
171
+ type: 'noul',
172
+ instructions: 'Is the goal already met?'
173
+ }
174
+ }
175
+ })
176
+ const requestBytes = Buffer.byteLength(payload)
177
+ const started = Date.now()
178
+ let status = 0
179
+ let responseBytes = 0
180
+ let raw = ''
181
+ try {
182
+ const response = await fetch('https://api.typesafe.ai/v1/systemone', {
183
+ method: 'POST',
184
+ headers: {
185
+ authorization: `Bearer ${apiKey}`,
186
+ 'content-type': 'application/json'
187
+ },
188
+ body: payload
189
+ })
190
+ status = response.status
191
+ raw = await response.text()
192
+ responseBytes = Buffer.byteLength(raw)
193
+ } catch (cause) {
194
+ throw new JevEvaluateError(
195
+ cause instanceof Error ? cause.message : 'TypeSafe HTTP failed',
196
+ httpStats(started, requestBytes, 0, 0)
197
+ )
198
+ }
199
+ const http = httpStats(started, requestBytes, status, responseBytes)
200
+ if (status < 200 || status >= 300) {
201
+ throw new JevEvaluateError(`TypeSafe HTTP ${status}`, http)
202
+ }
203
+ let parsed: { answers?: Record<string, SystemOneAnswer> }
204
+ try {
205
+ parsed = JSON.parse(raw) as { answers?: Record<string, SystemOneAnswer> }
206
+ } catch {
207
+ throw new JevEvaluateError('TypeSafe HTTP response was not JSON', http)
208
+ }
209
+ return { answers: answersOf(parsed.answers ?? {}), http }
210
+ }
211
+ }
@@ -0,0 +1,10 @@
1
+ export type JevHttpStats = {
2
+ status: number
3
+ durationMs: number
4
+ requestBytes: number
5
+ responseBytes: number
6
+ }
7
+
8
+ export function elapsedMs(started: number): number {
9
+ return Math.max(0, Date.now() - started)
10
+ }
@@ -0,0 +1,219 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+
3
+ import type { Suggestion } from '@crosshands/contract'
4
+ import { JsonlFileWriter, diagnosticsDirectory } from '@crosshands/runtime'
5
+
6
+ import { localClientPaths } from '../local-client.js'
7
+ import { parseJevEnv } from './env.js'
8
+ import { elapsedMs, type JevHttpStats } from './http.js'
9
+
10
+ export type JevPhase = 'observe' | 'bind' | 'named'
11
+
12
+ type JevShared = {
13
+ callId: string
14
+ operation: string
15
+ snapshotId: string
16
+ phase: JevPhase
17
+ goal?: string
18
+ }
19
+
20
+ export type JevCallRecord = {
21
+ kind: 'jev.call'
22
+ callId: string
23
+ operation: string
24
+ durationMs: number
25
+ httpMs: number
26
+ brokerCalls: number
27
+ ranks: number
28
+ phase?: JevPhase
29
+ bound?: boolean
30
+ error?: string
31
+ goal?: string
32
+ }
33
+
34
+ export type JevHttpRecord = JevShared & {
35
+ kind: 'jev.http'
36
+ status: number
37
+ durationMs: number
38
+ requestBytes: number
39
+ responseBytes: number
40
+ candidateCount: number
41
+ }
42
+
43
+ export type JevDecisionRecord = JevShared & {
44
+ kind: 'jev.decision'
45
+ app?: string
46
+ move: Suggestion['move']['kind']
47
+ elementIndex?: number
48
+ reason?: string
49
+ confidence?: number
50
+ goalChars: number
51
+ treeChars: number
52
+ elementCount: number
53
+ candidateCount: number
54
+ parseMs: number
55
+ evaluateMs?: number
56
+ }
57
+
58
+ export type JevRecord = JevCallRecord | JevHttpRecord | JevDecisionRecord
59
+
60
+ export type JevLogger = {
61
+ emit(record: JevRecord): void
62
+ close(): Promise<void>
63
+ }
64
+
65
+ export type JevCall = {
66
+ callId: string
67
+ operation: string
68
+ goal?: string
69
+ ranks: number
70
+ httpMs: number
71
+ brokerCalls: number
72
+ phase?: JevPhase
73
+ }
74
+
75
+ export type JevRankStats = {
76
+ snapshotId: string
77
+ treeChars: number
78
+ elementCount: number
79
+ candidateCount: number
80
+ parseMs: number
81
+ goalChars: number
82
+ evaluateMs?: number
83
+ app?: string
84
+ http?: JevHttpStats
85
+ }
86
+
87
+ export type RankRecorder = (phase: JevPhase, stats: JevRankStats, suggestion?: Suggestion) => void
88
+
89
+ export function hashGoal(goal: string): string {
90
+ return createHash('sha256').update(goal).digest('hex')
91
+ }
92
+
93
+ const OMIT = new Set(['goal', 'apiKey', 'treeText', 'text', 'value', 'TYPESAFE_API_KEY'])
94
+
95
+ export function createJevCall(operation: string, goal?: string): JevCall {
96
+ return {
97
+ callId: randomUUID(),
98
+ operation,
99
+ ...(goal === undefined ? {} : { goal }),
100
+ ranks: 0,
101
+ httpMs: 0,
102
+ brokerCalls: 0
103
+ }
104
+ }
105
+
106
+ export function recordRank(
107
+ log: JevLogger | undefined,
108
+ call: JevCall | undefined,
109
+ phase: JevPhase,
110
+ stats: JevRankStats,
111
+ suggestion?: Suggestion
112
+ ): void {
113
+ if (call !== undefined) {
114
+ call.phase = phase
115
+ call.ranks += 1
116
+ if (stats.http !== undefined) call.httpMs += stats.http.durationMs
117
+ }
118
+ if (log === undefined || call === undefined) return
119
+ const shared: JevShared = {
120
+ callId: call.callId,
121
+ operation: call.operation,
122
+ snapshotId: stats.snapshotId,
123
+ phase,
124
+ ...(call.goal === undefined ? {} : { goal: call.goal })
125
+ }
126
+ if (stats.http !== undefined) {
127
+ log.emit({
128
+ kind: 'jev.http',
129
+ ...shared,
130
+ status: stats.http.status,
131
+ durationMs: stats.http.durationMs,
132
+ requestBytes: stats.http.requestBytes,
133
+ responseBytes: stats.http.responseBytes,
134
+ candidateCount: stats.candidateCount
135
+ })
136
+ }
137
+ if (suggestion === undefined) return
138
+ const move = suggestion.move
139
+ log.emit({
140
+ kind: 'jev.decision',
141
+ ...shared,
142
+ ...(stats.app === undefined ? {} : { app: stats.app }),
143
+ move: move.kind,
144
+ ...(move.kind === 'click' ||
145
+ move.kind === 'setValue' ||
146
+ move.kind === 'secondary' ||
147
+ move.kind === 'scroll'
148
+ ? { elementIndex: move.elementIndex }
149
+ : {}),
150
+ ...(move.kind === 'blocked' ? { reason: move.reason } : {}),
151
+ ...(suggestion.confidence === undefined ? {} : { confidence: suggestion.confidence }),
152
+ goalChars: stats.goalChars,
153
+ treeChars: stats.treeChars,
154
+ elementCount: stats.elementCount,
155
+ candidateCount: stats.candidateCount,
156
+ parseMs: stats.parseMs,
157
+ ...(stats.evaluateMs === undefined ? {} : { evaluateMs: stats.evaluateMs })
158
+ })
159
+ }
160
+
161
+ export function emitCall(
162
+ log: JevLogger | undefined,
163
+ call: JevCall | undefined,
164
+ started: number,
165
+ extra: { error?: string; bound?: boolean } = {}
166
+ ): void {
167
+ if (log === undefined || call === undefined) return
168
+ log.emit({
169
+ kind: 'jev.call',
170
+ callId: call.callId,
171
+ operation: call.operation,
172
+ durationMs: elapsedMs(started),
173
+ httpMs: call.httpMs,
174
+ brokerCalls: call.brokerCalls,
175
+ ranks: call.ranks,
176
+ ...(call.phase === undefined ? {} : { phase: call.phase }),
177
+ ...(extra.bound === undefined ? {} : { bound: extra.bound }),
178
+ ...(extra.error === undefined ? {} : { error: extra.error }),
179
+ ...(call.goal === undefined ? {} : { goal: call.goal })
180
+ })
181
+ }
182
+
183
+ export function createJevLogger(
184
+ graphicalSessionId: string,
185
+ env: NodeJS.ProcessEnv = process.env
186
+ ): JevLogger {
187
+ const writer = new JsonlFileWriter({
188
+ directory: diagnosticsDirectory(graphicalSessionId, { env }),
189
+ generation: `jev-${randomUUID()}`
190
+ })
191
+ writer.start()
192
+ return {
193
+ emit(record) {
194
+ try {
195
+ const raw = record as JevRecord & Record<string, unknown>
196
+ const goal = raw.goal
197
+ const rest: Record<string, unknown> = {
198
+ ...raw,
199
+ kind: record.kind,
200
+ v: 1,
201
+ ts: new Date().toISOString()
202
+ }
203
+ for (const key of OMIT) delete rest[key]
204
+ writer.emit({
205
+ ...rest,
206
+ ...(typeof goal === 'string' ? { goalSha256: hashGoal(goal) } : {})
207
+ })
208
+ } catch {
209
+ // Jev logs must not fail computer-use.
210
+ }
211
+ },
212
+ close: () => writer.close()
213
+ }
214
+ }
215
+
216
+ export function createCliJevLogger(env: NodeJS.ProcessEnv = process.env): JevLogger | undefined {
217
+ if (parseJevEnv(env).kind === 'off') return undefined
218
+ return createJevLogger(localClientPaths().identity.graphicalSessionId, env)
219
+ }
@@ -0,0 +1,108 @@
1
+ export type TreeMove = {
2
+ elementIndex: number
3
+ role: string
4
+ label: string
5
+ line: string
6
+ disabled: boolean
7
+ clickable: boolean
8
+ settable: boolean
9
+ }
10
+
11
+ const ROW_START = /^(\t*)(\d+) (.+)$/
12
+ const MULTI_WORD_ROLES = [
13
+ 'search text field',
14
+ 'text field',
15
+ 'text area',
16
+ 'menu button',
17
+ 'pop up button',
18
+ 'popup button',
19
+ 'radio button',
20
+ 'check box',
21
+ 'combo box',
22
+ 'disclosure triangle',
23
+ 'standard window',
24
+ 'scroll area',
25
+ 'split group',
26
+ 'outline row',
27
+ 'table row'
28
+ ] as const
29
+ const CLICKABLE_ROLE = new Set([
30
+ 'button',
31
+ 'link',
32
+ 'checkbox',
33
+ 'check box',
34
+ 'radio button',
35
+ 'radio',
36
+ 'tab',
37
+ 'menu button',
38
+ 'pop up button',
39
+ 'popup button',
40
+ 'combo box',
41
+ 'disclosure triangle'
42
+ ])
43
+ const SETTABLE_ROLE = new Set(['text field', 'search text field', 'text area', 'combo box'])
44
+
45
+ function isHeaderOrFooter(line: string): boolean {
46
+ if (line.length === 0) return true
47
+ if (line.startsWith('App=')) return true
48
+ if (line.startsWith('Window:')) return true
49
+ if (line.startsWith('The focused UI element')) return true
50
+ if (line.startsWith('No UI element')) return true
51
+ return false
52
+ }
53
+
54
+ function roleAndLabel(body: string): { role: string; label: string } {
55
+ const lower = body.toLowerCase()
56
+ const multi = MULTI_WORD_ROLES.find(
57
+ (role) =>
58
+ lower.startsWith(role) && (body.length === role.length || /[\s,(]/.test(body[role.length]!))
59
+ )
60
+ if (multi !== undefined) {
61
+ return {
62
+ role: body.slice(0, multi.length),
63
+ label: body.slice(multi.length).replace(/^[\s,]+/, '')
64
+ }
65
+ }
66
+ const comma = body.indexOf(',')
67
+ const head = (comma === -1 ? body : body.slice(0, comma)).trim()
68
+ const space = head.indexOf(' ')
69
+ if (space === -1) return { role: head, label: '' }
70
+ return { role: head.slice(0, space), label: head.slice(space + 1).trim() }
71
+ }
72
+
73
+ export function parseTreeMoves(treeText: string): TreeMove[] {
74
+ const rows: { elementIndex: number; body: string }[] = []
75
+ for (const line of treeText.split('\n')) {
76
+ const match = ROW_START.exec(line)
77
+ if (match !== null && match[2] !== undefined && match[3] !== undefined) {
78
+ rows.push({ elementIndex: Number(match[2]), body: match[3] })
79
+ continue
80
+ }
81
+ const previous = rows[rows.length - 1]
82
+ if (previous !== undefined && !isHeaderOrFooter(line)) previous.body += `\n${line}`
83
+ }
84
+ return rows.map((row) => {
85
+ const { role, label } = roleAndLabel(row.body)
86
+ const disabled = /\(disabled\)/.test(row.body)
87
+ const roleKey = role.toLowerCase()
88
+ const settable = /\(settable\)/.test(row.body) || SETTABLE_ROLE.has(roleKey)
89
+ const clickable = !disabled && (CLICKABLE_ROLE.has(roleKey) || settable)
90
+ return {
91
+ elementIndex: row.elementIndex,
92
+ role,
93
+ label,
94
+ line: `${row.elementIndex} ${row.body}`,
95
+ disabled,
96
+ clickable,
97
+ settable
98
+ }
99
+ })
100
+ }
101
+
102
+ export const JEV_CHOICE_CAP = 255
103
+
104
+ export function clickableMoves(moves: readonly TreeMove[]): TreeMove[] {
105
+ const selected = moves.filter((move) => move.clickable)
106
+ if (selected.length <= JEV_CHOICE_CAP) return selected
107
+ return selected.slice(0, JEV_CHOICE_CAP)
108
+ }
@@ -1,12 +1,18 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { createHash } from 'node:crypto'
3
- import { chmod, lstat, mkdir, readFile } from 'node:fs/promises'
2
+ import { lstat, readFile } from 'node:fs/promises'
4
3
  import { homedir } from 'node:os'
5
4
  import { isAbsolute, join, resolve } from 'node:path'
6
5
 
7
6
  import { CONTRACT_VERSIONS, createComputerError } from '@crosshands/contract'
8
- import { LocalControlClient, brokerEndpoint, type LocalControlIdentity } from '@crosshands/runtime'
7
+ import {
8
+ LocalControlClient,
9
+ brokerEndpoint,
10
+ ensurePrivateDirectory,
11
+ graphicalSessionKey,
12
+ type LocalControlIdentity
13
+ } from '@crosshands/runtime'
9
14
 
15
+ import { unwrapBrokerResult } from './broker-result.js'
10
16
  import type { CliBrokerClient } from './index.js'
11
17
 
12
18
  export type LocalClientPaths = {
@@ -29,7 +35,7 @@ export function localClientPaths(): LocalClientPaths {
29
35
  process.env.SECURITYSESSIONID ??
30
36
  process.env.SESSIONNAME ??
31
37
  `interactive:${osIdentity}`
32
- const sessionKey = createHash('sha256').update(graphicalSessionId).digest('hex').slice(0, 12)
38
+ const sessionKey = graphicalSessionKey(graphicalSessionId)
33
39
  const runtimeDirectory = process.env.CROSSHANDS_RUNTIME_DIR ?? defaultRuntimeDirectory(sessionKey)
34
40
  const identity = { osIdentity, graphicalSessionId }
35
41
  return {
@@ -69,14 +75,15 @@ function defaultRuntimeDirectory(sessionKey: string): string {
69
75
  return join(localAppData, 'CrossHands', 'runtime', sessionKey)
70
76
  }
71
77
 
72
- async function prepareRuntimeDirectory(path: string): Promise<void> {
73
- await mkdir(path, { recursive: true, mode: 0o700 })
74
- const info = await lstat(path)
75
- if (!info.isDirectory() || info.isSymbolicLink())
76
- throw createComputerError('provider_unavailable', 'CrossHands runtime path is unsafe')
77
- if (process.getuid !== undefined && info.uid !== process.getuid())
78
- throw createComputerError('provider_unavailable', 'CrossHands runtime path has another owner')
79
- await chmod(path, 0o700)
78
+ function prepareRuntimeDirectory(path: string): void {
79
+ try {
80
+ ensurePrivateDirectory(path)
81
+ } catch (cause) {
82
+ throw createComputerError(
83
+ 'provider_unavailable',
84
+ cause instanceof Error ? cause.message : 'CrossHands directory is unsafe'
85
+ )
86
+ }
80
87
  }
81
88
 
82
89
  async function readSecureToken(path: string): Promise<string> {
@@ -105,12 +112,21 @@ export type ProductionClientOptions = {
105
112
  paths?: LocalClientPaths
106
113
  }
107
114
 
115
+ export function brokerSpawnEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
116
+ const next: NodeJS.ProcessEnv = { ...env }
117
+ delete next.CROSSHANDS_JEV
118
+ for (const key of Object.keys(next)) {
119
+ if (key === 'TYPESAFE_API_KEY' || key.startsWith('TYPESAFE_')) delete next[key]
120
+ }
121
+ return next
122
+ }
123
+
108
124
  function defaultSpawnBroker(entrypoint: string): void {
109
125
  const child = spawn(process.execPath, [entrypoint, 'broker'], {
110
126
  detached: true,
111
127
  stdio: 'ignore',
112
128
  windowsHide: true,
113
- env: process.env
129
+ env: brokerSpawnEnv(process.env)
114
130
  })
115
131
  child.unref()
116
132
  }
@@ -125,7 +141,7 @@ export async function createProductionBrokerClient(
125
141
  options: ProductionClientOptions = {}
126
142
  ): Promise<CliBrokerClient> {
127
143
  const paths = options.paths ?? localClientPaths()
128
- await prepareRuntimeDirectory(paths.runtimeDirectory)
144
+ prepareRuntimeDirectory(paths.runtimeDirectory)
129
145
  try {
130
146
  const control = await connect(paths)
131
147
  return controlAdapter(control)
@@ -165,7 +181,7 @@ export async function createProductionBrokerClient(
165
181
  function controlAdapter(control: LocalControlClient): CliBrokerClient {
166
182
  return {
167
183
  request: async (operation, input) =>
168
- control.request({ operation, input }, { deadlineMs: 30_000 }),
184
+ unwrapBrokerResult(await control.request({ operation, input }, { deadlineMs: 30_000 })),
169
185
  close: () => control.close()
170
186
  }
171
187
  }