@anionex/dsh-computer-use 0.1.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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +307 -0
  4. package/README.zh.md +307 -0
  5. package/assets/computer-use-fixture.png +0 -0
  6. package/assets/cursor.png +0 -0
  7. package/cordis.patch.yml +13 -0
  8. package/docs/interaction-policy.i18n.yaml +4 -0
  9. package/docs/interaction-policy.md +140 -0
  10. package/docs/interaction-policy.zh.md +140 -0
  11. package/lib/approval-policy.js +12 -0
  12. package/lib/approval-policy.js.map +1 -0
  13. package/lib/artifacts.js +79 -0
  14. package/lib/artifacts.js.map +1 -0
  15. package/lib/backend.js +3 -0
  16. package/lib/backend.js.map +1 -0
  17. package/lib/client.js +349 -0
  18. package/lib/client.js.map +1 -0
  19. package/lib/config.js +120 -0
  20. package/lib/config.js.map +1 -0
  21. package/lib/confirmations.js +99 -0
  22. package/lib/confirmations.js.map +1 -0
  23. package/lib/diff.js +58 -0
  24. package/lib/diff.js.map +1 -0
  25. package/lib/errors.js +22 -0
  26. package/lib/errors.js.map +1 -0
  27. package/lib/exposure.js +231 -0
  28. package/lib/exposure.js.map +1 -0
  29. package/lib/index.js +56 -0
  30. package/lib/index.js.map +1 -0
  31. package/lib/leases.js +259 -0
  32. package/lib/leases.js.map +1 -0
  33. package/lib/providers/macos.js +148 -0
  34. package/lib/providers/macos.js.map +1 -0
  35. package/lib/providers/native-helper.js +356 -0
  36. package/lib/providers/native-helper.js.map +1 -0
  37. package/lib/service.js +480 -0
  38. package/lib/service.js.map +1 -0
  39. package/lib/skill.js +119 -0
  40. package/lib/skill.js.map +1 -0
  41. package/lib/target-resolver.js +180 -0
  42. package/lib/target-resolver.js.map +1 -0
  43. package/lib/tools.js +493 -0
  44. package/lib/tools.js.map +1 -0
  45. package/lib/types/approval-policy.d.ts +13 -0
  46. package/lib/types/approval-policy.d.ts.map +1 -0
  47. package/lib/types/artifacts.d.ts +10 -0
  48. package/lib/types/artifacts.d.ts.map +1 -0
  49. package/lib/types/backend.d.ts +94 -0
  50. package/lib/types/backend.d.ts.map +1 -0
  51. package/lib/types/client/index.d.ts +78 -0
  52. package/lib/types/client/index.d.ts.map +1 -0
  53. package/lib/types/config.d.ts +75 -0
  54. package/lib/types/config.d.ts.map +1 -0
  55. package/lib/types/confirmations.d.ts +23 -0
  56. package/lib/types/confirmations.d.ts.map +1 -0
  57. package/lib/types/diff.d.ts +5 -0
  58. package/lib/types/diff.d.ts.map +1 -0
  59. package/lib/types/errors.d.ts +15 -0
  60. package/lib/types/errors.d.ts.map +1 -0
  61. package/lib/types/exposure.d.ts +30 -0
  62. package/lib/types/exposure.d.ts.map +1 -0
  63. package/lib/types/index.d.ts +21 -0
  64. package/lib/types/index.d.ts.map +1 -0
  65. package/lib/types/leases.d.ts +80 -0
  66. package/lib/types/leases.d.ts.map +1 -0
  67. package/lib/types/providers/macos.d.ts +15 -0
  68. package/lib/types/providers/macos.d.ts.map +1 -0
  69. package/lib/types/providers/native-helper.d.ts +36 -0
  70. package/lib/types/providers/native-helper.d.ts.map +1 -0
  71. package/lib/types/service.d.ts +52 -0
  72. package/lib/types/service.d.ts.map +1 -0
  73. package/lib/types/skill.d.ts +9 -0
  74. package/lib/types/skill.d.ts.map +1 -0
  75. package/lib/types/target-resolver.d.ts +38 -0
  76. package/lib/types/target-resolver.d.ts.map +1 -0
  77. package/lib/types/tools.d.ts +6 -0
  78. package/lib/types/tools.d.ts.map +1 -0
  79. package/lib/types/types.d.ts +246 -0
  80. package/lib/types/types.d.ts.map +1 -0
  81. package/lib/types/web.d.ts +32 -0
  82. package/lib/types/web.d.ts.map +1 -0
  83. package/lib/types.js +9 -0
  84. package/lib/types.js.map +1 -0
  85. package/lib/web.js +163 -0
  86. package/lib/web.js.map +1 -0
  87. package/native/macos/Sources/Fixture/main.swift +366 -0
  88. package/native/macos/Sources/Helper/CursorImage.swift +11 -0
  89. package/native/macos/Sources/Helper/CursorOverlay.swift +488 -0
  90. package/native/macos/Sources/Helper/TargetedPointer.swift +314 -0
  91. package/native/macos/Sources/Helper/main.swift +1140 -0
  92. package/native/macos/Sources/Monitor/main.swift +139 -0
  93. package/native/macos/bin/dsh-computer-use-helper +0 -0
  94. package/native/macos/manifest.json +14 -0
  95. package/package.json +128 -0
  96. package/scripts/build-client.mjs +24 -0
  97. package/scripts/build-native.mjs +158 -0
  98. package/scripts/check-native.mjs +92 -0
  99. package/scripts/model-e2e.mjs +291 -0
  100. package/scripts/session-transcript.mjs +117 -0
  101. package/scripts/validate.mjs +256 -0
  102. package/src/approval-policy.ts +16 -0
  103. package/src/artifacts.ts +93 -0
  104. package/src/backend.ts +103 -0
  105. package/src/client/index.tsx +539 -0
  106. package/src/config.ts +196 -0
  107. package/src/confirmations.ts +128 -0
  108. package/src/diff.ts +52 -0
  109. package/src/errors.ts +39 -0
  110. package/src/exposure.ts +236 -0
  111. package/src/index.ts +66 -0
  112. package/src/leases.ts +337 -0
  113. package/src/providers/macos.ts +185 -0
  114. package/src/providers/native-helper.ts +393 -0
  115. package/src/service.ts +583 -0
  116. package/src/skill.ts +123 -0
  117. package/src/target-resolver.ts +237 -0
  118. package/src/tools.ts +528 -0
  119. package/src/types.ts +299 -0
  120. package/src/web.ts +180 -0
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
5
+ import { constants } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { dirname, join, resolve } from 'node:path'
8
+ import { setTimeout as delay } from 'node:timers/promises'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { findComputerClickEvidence } from './session-transcript.mjs'
11
+
12
+ const ROOT = dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
13
+ const HELPER = join(ROOT, 'native', 'macos', 'bin', 'dsh-computer-use-helper')
14
+ const FIXTURE_APP = join(ROOT, 'native', 'macos', 'fixture', 'DSHComputerUseFixture.app')
15
+ const BUNDLE_ID = 'io.anionex.dsh-computer-use-fixture'
16
+ const RESULTS = []
17
+ const TEMPORARY = []
18
+
19
+ function parseArgs(argv) {
20
+ const options = { keepTemp: false, output: undefined }
21
+ const values = [...argv]
22
+ while (values.length > 0) {
23
+ const option = values.shift()
24
+ if (option === '--keep-temp') options.keepTemp = true
25
+ else if (option === '--output') {
26
+ const value = values.shift()
27
+ if (value === undefined) throw new Error('--output needs a value')
28
+ options.output = resolve(value)
29
+ } else throw new Error(`unknown option: ${option}`)
30
+ }
31
+ return options
32
+ }
33
+
34
+ function requiredEnvironment(name) {
35
+ const value = process.env[name]?.trim()
36
+ if (!value) throw new Error(`${name} is required for the real-model lane`)
37
+ return value
38
+ }
39
+
40
+ function tail(value, length = 8000) {
41
+ return value.length <= length ? value : value.slice(-length)
42
+ }
43
+
44
+ async function runCommand(name, command, args, options = {}) {
45
+ const startedAt = Date.now()
46
+ const result = await new Promise((resolveResult) => {
47
+ const child = spawn(command, args, {
48
+ cwd: options.cwd ?? ROOT,
49
+ env: { ...process.env, ...options.env },
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ })
52
+ let stdout = ''
53
+ let stderr = ''
54
+ let timedOut = false
55
+ child.stdout.setEncoding('utf8').on('data', value => { stdout += value })
56
+ child.stderr.setEncoding('utf8').on('data', value => { stderr += value })
57
+ const timer = setTimeout(() => {
58
+ timedOut = true
59
+ child.kill('SIGKILL')
60
+ }, options.timeoutMs ?? 600_000)
61
+ child.once('error', error => {
62
+ clearTimeout(timer)
63
+ resolveResult({ code: -1, stdout, stderr: `${stderr}${error.message}`, timedOut })
64
+ })
65
+ child.once('close', code => {
66
+ clearTimeout(timer)
67
+ resolveResult({ code: code ?? -1, stdout, stderr, timedOut })
68
+ })
69
+ })
70
+ const evidence = {
71
+ name,
72
+ status: result.code === 0 && !result.timedOut ? 'pass' : result.timedOut ? 'timeout' : 'fail',
73
+ command: [command, ...args],
74
+ durationMs: Date.now() - startedAt,
75
+ code: result.code,
76
+ stdout: tail(result.stdout),
77
+ stderr: tail(result.stderr),
78
+ }
79
+ RESULTS.push(evidence)
80
+ if (evidence.status !== 'pass') {
81
+ throw new Error(`${name} ${evidence.status} (exit ${result.code})\n${evidence.stderr || evidence.stdout}`)
82
+ }
83
+ return result
84
+ }
85
+
86
+ async function helper(request) {
87
+ return await new Promise((resolveResult, reject) => {
88
+ const child = spawn(HELPER, [], { detached: true, stdio: ['pipe', 'pipe', 'pipe'] })
89
+ let stdout = ''
90
+ let stderr = ''
91
+ const timer = setTimeout(() => {
92
+ child.kill('SIGKILL')
93
+ reject(new Error('native helper timed out'))
94
+ }, 15_000)
95
+ child.stdout.setEncoding('utf8').on('data', value => { stdout += value })
96
+ child.stderr.setEncoding('utf8').on('data', value => { stderr += value })
97
+ child.once('error', error => { clearTimeout(timer); reject(error) })
98
+ child.once('close', () => {
99
+ clearTimeout(timer)
100
+ try {
101
+ const envelope = JSON.parse(stdout)
102
+ if (!envelope.ok || envelope.value === undefined) {
103
+ reject(new Error(`${envelope.error?.code}: ${envelope.error?.message}`))
104
+ } else resolveResult(envelope.value)
105
+ } catch (error) {
106
+ reject(new Error(`invalid helper response: ${stdout || stderr}`, { cause: error }))
107
+ }
108
+ })
109
+ child.stdin.end(`${JSON.stringify({ protocolVersion: 1, ...request })}\n`)
110
+ })
111
+ }
112
+
113
+ async function fixtureApps() {
114
+ const apps = await helper({ command: 'list-apps' })
115
+ return apps.filter(app => app.bundleId === BUNDLE_ID)
116
+ }
117
+
118
+ async function terminateFixtures() {
119
+ for (const app of await fixtureApps()) {
120
+ try { process.kill(app.pid, 'SIGTERM') } catch {}
121
+ }
122
+ const deadline = Date.now() + 5000
123
+ while ((await fixtureApps()).length > 0 && Date.now() < deadline) await delay(50)
124
+ if ((await fixtureApps()).length > 0) throw new Error('fixture processes did not terminate')
125
+ }
126
+
127
+ async function launchFixture(transcript) {
128
+ await runCommand(
129
+ 'launch deterministic fixture without foreground activation',
130
+ 'open',
131
+ ['-g', '-n', FIXTURE_APP, '--args', '--background', '--transcript', transcript],
132
+ { timeoutMs: 10_000 },
133
+ )
134
+ const deadline = Date.now() + 10_000
135
+ while (Date.now() < deadline) {
136
+ const [app] = await fixtureApps()
137
+ if (app !== undefined) return app
138
+ await delay(50)
139
+ }
140
+ throw new Error('fixture did not launch')
141
+ }
142
+
143
+ async function screenshotFiles(directory) {
144
+ try {
145
+ const files = await readdir(directory, { recursive: true })
146
+ return files.filter(file => file.endsWith('.png'))
147
+ } catch {
148
+ return []
149
+ }
150
+ }
151
+
152
+ async function findTarball(directory) {
153
+ const files = (await readdir(directory)).filter(file => file.endsWith('.tgz'))
154
+ if (files.length !== 1) throw new Error(`expected one tarball in ${directory}, found ${files.length}`)
155
+ return join(directory, files[0])
156
+ }
157
+
158
+ async function realModelWorkflow() {
159
+ const packing = await mkdtemp(join(tmpdir(), 'dsh-computer-model-pack-'))
160
+ const home = await mkdtemp(join(tmpdir(), 'dsh-computer-model-home-'))
161
+ const workspace = await mkdtemp(join(tmpdir(), 'dsh-computer-model-workspace-'))
162
+ const privateState = await mkdtemp(join(tmpdir(), 'dsh-computer-model-state-'))
163
+ TEMPORARY.push(packing, home, workspace, privateState)
164
+
165
+ await runCommand('pack dsh-computer-use', 'pnpm', ['pack', '--pack-destination', packing], { timeoutMs: 300_000 })
166
+ await access(HELPER, constants.X_OK)
167
+ await access(join(FIXTURE_APP, 'Contents', 'MacOS', 'DSHComputerUseFixture'), constants.X_OK)
168
+ const health = await helper({ command: 'health' })
169
+ if (health.accessibility !== 'granted' || health.screenRecording !== 'granted') {
170
+ throw new Error(`real-model lane requires macOS TCC grants: ${JSON.stringify(health)}`)
171
+ }
172
+
173
+ const tarball = await findTarball(packing)
174
+ await runCommand(
175
+ 'install dsh-computer-use tarball',
176
+ 'dsh',
177
+ ['plugin', '--profile', 'headless', 'add', tarball],
178
+ { env: { DSH_HOME: home }, timeoutMs: 180_000 },
179
+ )
180
+ const dump = await runCommand(
181
+ 'dump real-model Profile',
182
+ 'dsh',
183
+ ['--profile', 'headless', '--dump-config'],
184
+ { env: { DSH_HOME: home }, timeoutMs: 60_000 },
185
+ )
186
+ if (!dump.stdout.includes("name: '@anionex/dsh-computer-use'")) {
187
+ throw new Error('real-model Profile does not mount dsh-computer-use')
188
+ }
189
+
190
+ await terminateFixtures()
191
+ const transcript = join(privateState, 'fixture-transcript.json')
192
+ const app = await launchFixture(transcript)
193
+ const patch = join(home, 'computer-use-model.patch.yml')
194
+ await writeFile(patch, [
195
+ '- id: computer-use',
196
+ ' config:',
197
+ ' settleMs: 50',
198
+ ' maxSettleMs: 3000',
199
+ ' grants:',
200
+ ` - bundleId: ${BUNDLE_ID}`,
201
+ ' read: true',
202
+ ' control: true',
203
+ '- id: session-title-llm',
204
+ ' disabled: true',
205
+ '',
206
+ ].join('\n'))
207
+
208
+ const prompt = `/computer-use\n\nUse the dsh-computer-use capability to operate the running deterministic macOS fixture whose bundle id is ${BUNDLE_ID}. Load the Skill, list running applications, select the exact fixture process, observe it with a required screenshot and full Accessibility state, locate the element labelled "Targeted pointer probe", and click that current element using its observation id and element index with allowCoordinateFallback=true. Confirm from the fresh post-action observation that the status reads "Status: pointer click". Use only the focused computer-use Tools for UI observation and input; do not use shell, AppleScript, JXA, direct file edits, or coordinate guessing. Finish immediately after confirming the pointer-click state.`
209
+ const result = await runCommand(
210
+ 'DeepSeek V4 computer-use fixture workflow',
211
+ 'dsh',
212
+ ['run', '--profile', 'headless', '--patch', patch, prompt],
213
+ {
214
+ cwd: workspace,
215
+ env: {
216
+ DSH_HOME: home,
217
+ DSH_TELEMETRY_DISABLED: '1',
218
+ DSH_PERMISSION_MODE: 'workspace-write',
219
+ DEEPSEEK_API_KEY: requiredEnvironment('DEEPSEEK_API_KEY'),
220
+ DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL?.trim() || 'https://api.deepseek.com/v1',
221
+ },
222
+ timeoutMs: 600_000,
223
+ },
224
+ )
225
+
226
+ const state = JSON.parse(await readFile(transcript, 'utf8'))
227
+ if (state.activationCount !== 0 || state.pointerClickCount !== 1 || state.status !== 'Status: pointer click') {
228
+ throw new Error(`fixture state did not prove the requested action: ${JSON.stringify(state)}`)
229
+ }
230
+ const screenshots = await screenshotFiles(join(workspace, '.dsh-computer-use', 'artifacts'))
231
+ if (screenshots.length === 0) throw new Error('computer_observe produced no screenshot Artifact')
232
+ const evidence = await findComputerClickEvidence(join(home, 'sessions'))
233
+ if (!evidence.allowCoordinateFallback
234
+ || evidence.activation !== 'not-requested'
235
+ || evidence.pointerInput !== true
236
+ || evidence.pointerRouting !== 'target-process') {
237
+ throw new Error(`real-model transcript did not expose the target-process action evidence: ${JSON.stringify(evidence)}`)
238
+ }
239
+ RESULTS.push({
240
+ name: 'DeepSeek V4 computer-use assertions',
241
+ status: 'pass',
242
+ bundleId: app.bundleId,
243
+ pid: app.pid,
244
+ fixtureState: state,
245
+ toolEvidence: evidence,
246
+ screenshotArtifacts: screenshots,
247
+ finalResponse: tail(result.stdout.trim(), 2000),
248
+ })
249
+ }
250
+
251
+ async function writeOutput(options, value) {
252
+ const text = `${JSON.stringify(value, null, 2)}\n`
253
+ if (options.output !== undefined) {
254
+ await mkdir(dirname(options.output), { recursive: true })
255
+ await writeFile(options.output, text)
256
+ }
257
+ process.stdout.write(text)
258
+ }
259
+
260
+ let options
261
+ let failure
262
+ let cleanupFailure
263
+ try {
264
+ options = parseArgs(process.argv.slice(2))
265
+ requiredEnvironment('DEEPSEEK_API_KEY')
266
+ await realModelWorkflow()
267
+ } catch (error) {
268
+ failure = error instanceof Error ? error.message : String(error)
269
+ } finally {
270
+ try {
271
+ if (await access(HELPER, constants.X_OK).then(() => true, () => false)) await terminateFixtures()
272
+ if (options?.keepTemp !== true) {
273
+ for (const path of TEMPORARY.reverse()) await rm(path, { recursive: true, force: true })
274
+ }
275
+ } catch (error) {
276
+ cleanupFailure = error instanceof Error ? error.message : String(error)
277
+ }
278
+ }
279
+
280
+ const report = {
281
+ schemaVersion: 1,
282
+ project: '@anionex/dsh-computer-use',
283
+ lane: 'real-model',
284
+ ok: failure === undefined && cleanupFailure === undefined,
285
+ failure: failure ?? null,
286
+ cleanup: cleanupFailure === undefined ? options?.keepTemp === true ? 'kept' : 'complete' : 'failed',
287
+ cleanupFailure: cleanupFailure ?? null,
288
+ results: RESULTS,
289
+ }
290
+ await writeOutput(options ?? {}, report)
291
+ if (!report.ok) process.exitCode = 1
@@ -0,0 +1,117 @@
1
+ import { readFile, readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { promisify } from 'node:util'
4
+ import { zstdDecompress } from 'node:zlib'
5
+
6
+ const ZSTD_MAGIC = 0xFD2FB528
7
+ const decompressZstd = promisify(zstdDecompress)
8
+
9
+ function zstdFrames(buffer) {
10
+ const frames = []
11
+ let offset = 0
12
+ while (offset < buffer.length) {
13
+ const start = offset
14
+ if (buffer.length - offset < 5 || buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
15
+ throw new Error(`invalid Zstandard session frame at byte ${offset}`)
16
+ }
17
+ offset += 4
18
+ const descriptor = buffer.readUInt8(offset++)
19
+ if ((descriptor & 0x18) !== 0) throw new Error(`invalid Zstandard frame descriptor at byte ${offset - 1}`)
20
+ const contentSizeFlag = descriptor >>> 6
21
+ const singleSegment = (descriptor & 0x20) !== 0
22
+ const checksum = (descriptor & 0x04) !== 0
23
+ const dictionaryFlag = descriptor & 0x03
24
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
25
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
26
+ const headerBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
27
+ if (buffer.length - offset < headerBytes) throw new Error(`truncated Zstandard session frame at byte ${start}`)
28
+ offset += headerBytes
29
+ for (;;) {
30
+ if (buffer.length - offset < 3) throw new Error(`truncated Zstandard session frame at byte ${start}`)
31
+ const blockHeader = buffer.readUIntLE(offset, 3)
32
+ offset += 3
33
+ const lastBlock = (blockHeader & 1) !== 0
34
+ const blockType = (blockHeader >>> 1) & 0x03
35
+ const blockSize = blockHeader >>> 3
36
+ if (blockType === 0x03) throw new Error(`invalid Zstandard block at byte ${offset - 3}`)
37
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize
38
+ if (buffer.length - offset < payloadBytes) throw new Error(`truncated Zstandard session frame at byte ${start}`)
39
+ offset += payloadBytes
40
+ if (lastBlock) break
41
+ }
42
+ if (checksum) {
43
+ if (buffer.length - offset < 4) throw new Error(`truncated Zstandard checksum at byte ${start}`)
44
+ offset += 4
45
+ }
46
+ frames.push(buffer.subarray(start, offset))
47
+ }
48
+ return frames
49
+ }
50
+
51
+ async function sessionLogs(directory) {
52
+ const result = []
53
+ for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
54
+ const path = join(directory, entry.name)
55
+ if (entry.isDirectory()) result.push(...await sessionLogs(path))
56
+ else if (entry.isFile() && (entry.name === 'session.jsonl' || entry.name === 'session.jsonl.zstd')) result.push(path)
57
+ }
58
+ return result
59
+ }
60
+
61
+ /** Read every JSON record from one plain or concatenated-frame DSH Session log. */
62
+ export async function readSessionRecords(path) {
63
+ const encoded = await readFile(path)
64
+ const decoded = path.endsWith('.zstd')
65
+ ? Buffer.concat(await Promise.all(zstdFrames(encoded).map(frame => decompressZstd(frame))))
66
+ : encoded
67
+ return decoded.toString('utf8').split('\n').filter(Boolean).map(line => JSON.parse(line))
68
+ }
69
+
70
+ function resultValue(event) {
71
+ const outer = event?.data?.message?.content
72
+ if (!Array.isArray(outer)) return undefined
73
+ for (const block of outer) {
74
+ if (block?.type !== 'tool-result' || !Array.isArray(block.content)) continue
75
+ for (const content of block.content) {
76
+ if (content?.type !== 'text' || typeof content.text !== 'string') continue
77
+ try { return JSON.parse(content.text) }
78
+ catch { return undefined }
79
+ }
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ /** Locate the durable model-visible result for the target-process pointer probe. */
85
+ export async function findComputerClickEvidence(sessionRoot) {
86
+ const candidates = []
87
+ for (const path of await sessionLogs(sessionRoot)) {
88
+ const records = await readSessionRecords(path)
89
+ const header = records.find(record => record?.type === 'session')
90
+ const calls = new Map(records
91
+ .filter(record => record?.type === 'tool/call' && record?.data?.name === 'computer_click')
92
+ .map(record => [record.data.callId, record]))
93
+ for (const record of records) {
94
+ if (record?.type !== 'tool/result') continue
95
+ const callId = record?.data?.message?.source?.callId
96
+ const call = calls.get(callId)
97
+ if (call === undefined) continue
98
+ let args
99
+ try { args = JSON.parse(call.data.arguments) }
100
+ catch { continue }
101
+ const result = resultValue(record)
102
+ if (result?.action !== 'click') continue
103
+ candidates.push({
104
+ time: record.time ?? 0,
105
+ sessionId: header?.id,
106
+ callId,
107
+ allowCoordinateFallback: args.allowCoordinateFallback === true,
108
+ activation: result.activation,
109
+ pointerInput: result.pointerInput,
110
+ pointerRouting: result.pointerRouting,
111
+ })
112
+ }
113
+ }
114
+ const evidence = candidates.sort((left, right) => right.time - left.time)[0]
115
+ if (evidence === undefined) throw new Error('durable Session transcript contains no completed computer_click result')
116
+ return evidence
117
+ }
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { spawn } from 'node:child_process'
5
+ import { constants } from 'node:fs'
6
+ import { access, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { fileURLToPath, pathToFileURL } from 'node:url'
10
+
11
+ const ROOT = dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
12
+ const results = []
13
+ const temporaryPaths = []
14
+
15
+ function parseArgs(argv) {
16
+ const options = {
17
+ lane: 'all',
18
+ allowHubWarning: false,
19
+ keepTemp: false,
20
+ output: undefined,
21
+ }
22
+ const values = [...argv]
23
+ while (values.length > 0) {
24
+ const option = values.shift()
25
+ if (option === '--allow-hub-warning') options.allowHubWarning = true
26
+ else if (option === '--keep-temp') options.keepTemp = true
27
+ else if (option === '--lane' || option === '--output') {
28
+ const value = values.shift()
29
+ if (value === undefined) throw new Error(`${option} needs a value`)
30
+ if (option === '--lane') options.lane = value
31
+ else options.output = resolve(value)
32
+ } else throw new Error(`unknown option: ${option}`)
33
+ }
34
+ if (!['local', 'profile', 'all'].includes(options.lane)) {
35
+ throw new Error(`--lane must be local, profile, or all; got ${options.lane}`)
36
+ }
37
+ return options
38
+ }
39
+
40
+ function tail(value, length = 6000) {
41
+ return value.length <= length ? value : value.slice(-length)
42
+ }
43
+
44
+ async function runCommand(name, command, args, options = {}) {
45
+ const startedAt = Date.now()
46
+ let final
47
+ for (let attempt = 1; attempt <= (options.retries ?? 0) + 1; attempt += 1) {
48
+ final = await new Promise((resolveResult) => {
49
+ const child = spawn(command, args, {
50
+ cwd: options.cwd ?? ROOT,
51
+ env: { ...process.env, ...options.env },
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ })
54
+ let stdout = ''
55
+ let stderr = ''
56
+ let timedOut = false
57
+ child.stdout.setEncoding('utf8').on('data', value => { stdout += value })
58
+ child.stderr.setEncoding('utf8').on('data', value => { stderr += value })
59
+ const timer = setTimeout(() => {
60
+ timedOut = true
61
+ child.kill('SIGKILL')
62
+ }, options.timeoutMs ?? 120_000)
63
+ child.once('error', (error) => {
64
+ clearTimeout(timer)
65
+ resolveResult({ code: -1, stdout, stderr: `${stderr}${error.message}`, timedOut, attempt })
66
+ })
67
+ child.once('close', (code) => {
68
+ clearTimeout(timer)
69
+ resolveResult({ code: code ?? -1, stdout, stderr, timedOut, attempt })
70
+ })
71
+ })
72
+ if (final.code === 0 && !final.timedOut) break
73
+ }
74
+ const result = {
75
+ name,
76
+ status: final.code === 0 && !final.timedOut ? 'pass' : final.timedOut ? 'timeout' : 'fail',
77
+ command: [command, ...args],
78
+ attempts: final.attempt,
79
+ durationMs: Date.now() - startedAt,
80
+ code: final.code,
81
+ stdout: tail(final.stdout),
82
+ stderr: tail(final.stderr),
83
+ }
84
+ results.push(result)
85
+ if (result.status !== 'pass') {
86
+ throw new Error(`${name} ${result.status} (exit ${result.code})\n${result.stderr || result.stdout}`)
87
+ }
88
+ return final
89
+ }
90
+
91
+ async function executable(name) {
92
+ for (const directory of (process.env.PATH ?? '').split(':').filter(Boolean)) {
93
+ const candidate = join(directory, name)
94
+ try {
95
+ await access(candidate, constants.X_OK)
96
+ return candidate
97
+ } catch {
98
+ // Continue PATH lookup.
99
+ }
100
+ }
101
+ throw new Error(`required executable is unavailable: ${name}`)
102
+ }
103
+
104
+ async function dshSourceRoot() {
105
+ const dsh = await realpath(await executable('dsh'))
106
+ let candidate = dirname(dsh)
107
+ for (let depth = 0; depth < 6; depth += 1) {
108
+ try {
109
+ await access(join(candidate, 'apps', 'cli', 'config', 'agent-presets', 'standard'))
110
+ return await realpath(candidate)
111
+ } catch {
112
+ const parent = dirname(candidate)
113
+ if (parent === candidate) break
114
+ candidate = parent
115
+ }
116
+ }
117
+ throw new Error(`cannot infer DSH source root from ${dsh}`)
118
+ }
119
+
120
+ async function pluginCheck(options) {
121
+ const sourceRoot = await dshSourceRoot()
122
+ const checkout = await mkdtemp(join(tmpdir(), 'dsh-computer-plugin-check-'))
123
+ temporaryPaths.push(checkout)
124
+ await runCommand(
125
+ 'clone dsh-plugin-check',
126
+ 'gh',
127
+ ['repo', 'clone', 'dsh-external/dsh-plugin-check', checkout, '--', '--depth=1'],
128
+ { timeoutMs: 60_000, retries: 1 },
129
+ )
130
+ const code = [
131
+ `const { checkRepo } = await import(${JSON.stringify(pathToFileURL(join(checkout, 'src', 'index.ts')).href)});`,
132
+ `const report = await checkRepo(${JSON.stringify(ROOT)}, false);`,
133
+ 'console.log(JSON.stringify(report));',
134
+ ].join(' ')
135
+ const checked = await runCommand(
136
+ 'dsh-plugin-check',
137
+ process.execPath,
138
+ ['--import', join(sourceRoot, 'node_modules', 'tsx', 'dist', 'esm', 'index.mjs'), '--input-type=module', '-e', code],
139
+ { env: { TSX_TSCONFIG_PATH: join(sourceRoot, 'tsconfig.json') }, timeoutMs: 30_000 },
140
+ )
141
+ const report = JSON.parse(checked.stdout.trim())
142
+ const warnings = report.warnings ?? []
143
+ const allowed = options.allowHubWarning && warnings.every(warning => warning.code === 'not-in-hub')
144
+ if (report.errors?.length > 0 || (warnings.length > 0 && !allowed)) {
145
+ throw new Error(`dsh-plugin-check did not pass cleanly: ${JSON.stringify(report, null, 2)}`)
146
+ }
147
+ results.push({
148
+ name: 'dsh-plugin-check assertion',
149
+ status: 'pass',
150
+ verdict: report.verdict,
151
+ allowedWarnings: allowed ? warnings : [],
152
+ checks: report.checks,
153
+ })
154
+ }
155
+
156
+ async function javascriptFiles(directory) {
157
+ const paths = []
158
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
159
+ const path = join(directory, entry.name)
160
+ if (entry.isDirectory()) paths.push(...await javascriptFiles(path))
161
+ else if (entry.isFile() && entry.name.endsWith('.js')) paths.push(path)
162
+ }
163
+ return paths
164
+ }
165
+
166
+ async function portableAssertions() {
167
+ const startedAt = Date.now()
168
+ const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
169
+ if (pkg.dsh?.bundle?.patch !== './cordis.patch.yml') throw new Error('package is missing dsh.bundle.patch')
170
+ if (pkg.dsh?.client?.platform !== 'web' || pkg.dshClient !== undefined) throw new Error('package must use dsh.client and omit dshClient')
171
+ for (const group of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
172
+ for (const [name, spec] of Object.entries(pkg[group] ?? {})) {
173
+ if (typeof spec !== 'string' || /^(?:file:|link:|workspace:|\/|[A-Za-z]:[\\/])/u.test(spec) || spec.includes('/Users/')) {
174
+ throw new Error(`${group}.${name} is machine-local: ${String(spec)}`)
175
+ }
176
+ }
177
+ }
178
+ for (const path of await javascriptFiles(join(ROOT, 'lib'))) {
179
+ const source = await readFile(path, 'utf8')
180
+ if (/from ['"]\.\.?\/[^'"]+\.ts['"]/u.test(source)) throw new Error(`built JavaScript imports TypeScript: ${path}`)
181
+ }
182
+ const client = await readFile(join(ROOT, 'lib', 'client.js'), 'utf8')
183
+ if (!client.includes('window.__ModuleLoader__.load')) throw new Error('lib/client.js is not loader-compatible')
184
+ const manifest = JSON.parse(await readFile(join(ROOT, 'native', 'macos', 'manifest.json'), 'utf8'))
185
+ const helper = await readFile(join(ROOT, 'native', 'macos', manifest.binary.path))
186
+ const digest = createHash('sha256').update(helper).digest('hex')
187
+ if (digest !== manifest.binary.sha256) throw new Error('native helper hash does not match manifest')
188
+ if (JSON.stringify(manifest.binary.architectures) !== JSON.stringify(['arm64', 'x86_64'])) {
189
+ throw new Error(`native helper architectures are not universal: ${JSON.stringify(manifest.binary.architectures)}`)
190
+ }
191
+ results.push({
192
+ name: 'portable artifact assertions',
193
+ status: 'pass',
194
+ durationMs: Date.now() - startedAt,
195
+ helperSha256: digest,
196
+ architectures: manifest.binary.architectures,
197
+ minimumMacOS: manifest.binary.minimumMacOS,
198
+ })
199
+ }
200
+
201
+ async function localLane(options) {
202
+ await runCommand('build', 'pnpm', ['run', 'build'], { timeoutMs: 300_000 })
203
+ await runCommand(
204
+ 'unit and native fixture tests',
205
+ 'pnpm',
206
+ ['exec', 'vitest', 'run', 'tests', '--exclude', 'tests/profile-install.e2e.spec.ts'],
207
+ { env: { DSH_COMPUTER_USE_REQUIRE_TCC: '1' }, timeoutMs: 600_000 },
208
+ )
209
+ await portableAssertions()
210
+ await runCommand('pack dry run', 'pnpm', ['pack', '--dry-run'], { timeoutMs: 300_000 })
211
+ await pluginCheck(options)
212
+ }
213
+
214
+ async function profileLane() {
215
+ await runCommand(
216
+ 'clean Web/Headless Profile fixture E2E',
217
+ 'pnpm',
218
+ ['exec', 'vitest', 'run', 'tests/profile-install.e2e.spec.ts'],
219
+ { env: { DSH_COMPUTER_USE_REQUIRE_TCC: '1' }, timeoutMs: 420_000 },
220
+ )
221
+ }
222
+
223
+ async function writeOutput(options, value) {
224
+ const text = `${JSON.stringify(value, null, 2)}\n`
225
+ if (options.output !== undefined) {
226
+ await mkdir(dirname(options.output), { recursive: true })
227
+ await writeFile(options.output, text)
228
+ }
229
+ process.stdout.write(text)
230
+ }
231
+
232
+ let options
233
+ let failure
234
+ try {
235
+ options = parseArgs(process.argv.slice(2))
236
+ if (options.lane === 'local' || options.lane === 'all') await localLane(options)
237
+ if (options.lane === 'profile' || options.lane === 'all') await profileLane()
238
+ } catch (error) {
239
+ failure = error instanceof Error ? error.message : String(error)
240
+ } finally {
241
+ if (options?.keepTemp !== true) {
242
+ for (const path of temporaryPaths.reverse()) await rm(path, { recursive: true, force: true })
243
+ }
244
+ }
245
+
246
+ const report = {
247
+ schemaVersion: 1,
248
+ project: '@anionex/dsh-computer-use',
249
+ lane: options?.lane ?? null,
250
+ ok: failure === undefined,
251
+ failure: failure ?? null,
252
+ cleanup: options?.keepTemp === true ? 'kept' : 'complete',
253
+ results,
254
+ }
255
+ await writeOutput(options ?? {}, report)
256
+ if (failure !== undefined) process.exitCode = 1
@@ -0,0 +1,16 @@
1
+ /** Shared DSH approval-policy read for Computer Use permission paths. */
2
+
3
+ import type { Agent } from '@deepseek-ai/dsh-agent'
4
+ import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
5
+ import type { Context } from '@deepseek-ai/cordis'
6
+
7
+ /**
8
+ * The session's effective DSH approval policy: a logged override, else the
9
+ * approval plugin's configured default.
10
+ * @param ctx - context with the approval service injected.
11
+ * @param agent - the agent whose session policy applies.
12
+ * @returns `'never'` when every approval ask would auto-reject without a prompt.
13
+ */
14
+ export function approvalPolicy(ctx: Context, agent: Agent): ApprovalPolicy {
15
+ return ctx.approval.overrideOf(agent.session) ?? ctx.approval.config.policy ?? 'ask'
16
+ }