dsh-codex-style-computer-use 1.3.1 → 2.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 (46) hide show
  1. package/README.ar.md +234 -0
  2. package/README.de.md +234 -0
  3. package/README.es.md +264 -0
  4. package/README.fa.md +234 -0
  5. package/README.fr.md +243 -0
  6. package/README.md +72 -17
  7. package/README.pt.md +234 -0
  8. package/README.ru.md +259 -0
  9. package/README.zh.md +66 -16
  10. package/icon.svg +20 -4
  11. package/lib/audit.js +7 -0
  12. package/lib/batch.js +14 -19
  13. package/lib/bin/attention-worker.exe +0 -0
  14. package/lib/bin/attention-worker.exe.json +1 -1
  15. package/lib/compact-feedback.js +175 -0
  16. package/lib/continuation.js +1 -1
  17. package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
  18. package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
  19. package/lib/core/index.js +403 -136
  20. package/lib/core/native-runtime.js +17 -0
  21. package/lib/core/worker.cs +2160 -229
  22. package/lib/feedback-routes.json +11 -0
  23. package/lib/feedback.js +49 -16
  24. package/lib/image-output.js +87 -17
  25. package/lib/index.js +25 -7
  26. package/lib/key-command.js +37 -0
  27. package/lib/launch.js +67 -0
  28. package/lib/media-contract.js +43 -0
  29. package/lib/observations.js +267 -13
  30. package/lib/policy.js +29 -9
  31. package/lib/tool-contract.js +56 -0
  32. package/lib/tools.js +203 -52
  33. package/lib/verification-region.js +17 -0
  34. package/mcp/package.json +2 -1
  35. package/mcp/src/execution.js +8 -5
  36. package/mcp/src/index.js +16 -267
  37. package/mcp/src/tools.js +271 -0
  38. package/package.json +6 -1
  39. package/scripts/check-behavior.mjs +83 -0
  40. package/scripts/measure-tool-tokens.mjs +21 -0
  41. package/scripts/verify-package.mjs +9 -0
  42. package/skills/computer-use/SKILL.md +55 -43
  43. package/skills/computer-use/references/lifecycle.md +11 -7
  44. package/skills/computer-use/references/maintenance.md +9 -5
  45. package/skills/computer-use/references/precision.md +39 -14
  46. package/skills/computer-use/references/progressive-control.md +10 -4
@@ -0,0 +1,11 @@
1
+ [
2
+ {
3
+ "provider": "bigmodel-anthropic",
4
+ "model": "glm-5.3",
5
+ "adapterVersion": "unversioned",
6
+ "grounding": "failed",
7
+ "testedAt": "2026-10-02T00:00:00Z",
8
+ "expiresAt": "2026-12-01T00:00:00Z",
9
+ "reason": "Historical Desktop grounding test failed; adapter version was not recorded. Conservative text fallback expires after 60 days and must be revalidated."
10
+ }
11
+ ]
package/lib/feedback.js CHANGED
@@ -1,5 +1,8 @@
1
- // Route-specific observations, verified against the Desktop adapters on 2026-10-02.
2
- // A model catalog's image flag is transport permission, not a grounding test.
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ // Transport capability and grounding are separate evidence. Route exceptions
4
+ // are dated data, can be replaced in settings, and expire.
5
+ export const DEFAULT_FEEDBACK_RULES = Object.freeze(JSON.parse(readFileSync(new URL('./feedback-routes.json', import.meta.url), 'utf8')))
3
6
  export function feedbackRoute (exec) {
4
7
  const routed = exec?.agent?.session?.requestHeader?.()?.config
5
8
  return {
@@ -8,23 +11,53 @@ export function feedbackRoute (exec) {
8
11
  }
9
12
  }
10
13
 
11
- export function unverifiedVisionRoute (exec) {
12
- const { provider, model } = feedbackRoute(exec)
13
- return provider === 'bigmodel-anthropic' && model === 'glm-5.3'
14
+ export function visionEvidence (exec, { rules = DEFAULT_FEEDBACK_RULES, adapterVersion = 'unversioned', now = Date.now() } = {}) {
15
+ const route = feedbackRoute(exec)
16
+ for (const rule of [...(Array.isArray(rules) ? rules : [])].reverse()) {
17
+ const tested = Date.parse(rule.testedAt), expires = Date.parse(rule.expiresAt)
18
+ if (rule.provider !== route.provider || rule.model !== route.model ||
19
+ rule.adapterVersion !== adapterVersion || !Number.isFinite(tested) || !Number.isFinite(expires) ||
20
+ tested > now || expires <= now || expires <= tested || !['failed', 'verified'].includes(rule.grounding)) continue
21
+ // Unversioned historical tests may select a conservative text fallback,
22
+ // but cannot certify the current adapter's visual grounding.
23
+ if (rule.grounding === 'verified' && adapterVersion === 'unversioned') continue
24
+ return { grounding: rule.grounding, adapterVersion, testedAt: rule.testedAt, expiresAt: rule.expiresAt,
25
+ reason: rule.reason || 'configured route evidence' }
26
+ }
27
+ return { grounding: 'unverified', adapterVersion, reason: 'no current matching grounding record' }
28
+ }
29
+
30
+ export function unverifiedVisionRoute (exec, options) {
31
+ return visionEvidence(exec, options).grounding === 'failed'
14
32
  }
15
33
 
16
- export async function feedbackMode (ctx, exec, enabled = true) {
17
- if (!enabled || exec?.signal?.aborted) return null
18
- if (unverifiedVisionRoute(exec)) return 'state'
19
- const { provider, model } = feedbackRoute(exec)
20
- const tested = (provider === 'bigmodel-anthropic' && model === 'glm-5.3-flash') ||
21
- (provider === 'deepseek-official' && model === 'deepseek-flash')
22
- if (!tested) return null
34
+ export function selectFeedbackDetail (requested, fallback = 'compact') {
35
+ const detail = requested ?? fallback
36
+ if (!['compact', 'full', 'none'].includes(detail)) throw Object.assign(new Error('feedback must be compact, full or none'),
37
+ { code: 'INVALID_FEEDBACK', outcome: 'not-dispatched' })
38
+ return detail
39
+ }
40
+
41
+ export async function feedbackPolicy (ctx, exec, enabled = true, options = {}) {
42
+ const route = feedbackRoute(exec)
43
+ const detail = selectFeedbackDetail(options.detail, 'full')
44
+ if (!enabled || detail === 'none' || exec?.signal?.aborted) return { kind: null, detail, reason: exec?.signal?.aborted ? 'cancelled' : !enabled ? 'disabled' : 'feedback-none' }
45
+ let active
23
46
  try {
24
- if (!ctx.get?.('attachments')?.saveImage) return null
25
- const active = await ctx.get?.('llm')?.resolveModelInfo?.(provider, model, exec?.signal)
26
- return !exec?.signal?.aborted && active?.inputModalities?.includes('image') ? 'image' : null
27
- } catch { return null }
47
+ if (route.provider && route.model) active = await ctx.get?.('llm')?.resolveModelInfo?.(route.provider, route.model, exec?.signal)
48
+ } catch { /* missing capability evidence selects text, not silence */ }
49
+ if (exec?.signal?.aborted) return { kind: null, reason: 'cancelled' }
50
+ const evidence = visionEvidence(exec, { ...options, adapterVersion: active?.adapterVersion || 'unversioned' })
51
+ const base = { route, detail, ...evidence }
52
+ if (evidence.grounding === 'failed') return { ...base, kind: 'state', reason: 'current grounding record requires UIA/text readback' }
53
+ if (!active) return { ...base, kind: 'state', reason: 'model-capabilities-unavailable' }
54
+ if (!active.inputModalities?.includes('image')) return { ...base, kind: 'state', reason: 'model-text-only' }
55
+ if (!ctx.get?.('attachments')?.saveImage) return { ...base, kind: 'state', reason: 'attachments-unavailable' }
56
+ return { ...base, kind: 'image', reason: 'image transport declared; inspect returned evidence to verify effect' }
57
+ }
58
+
59
+ export async function feedbackMode (ctx, exec, enabled = true, options) {
60
+ return (await feedbackPolicy(ctx, exec, enabled, options)).kind
28
61
  }
29
62
 
30
63
  export const feedbackActions = new Set([
@@ -1,19 +1,61 @@
1
1
  import { basename } from 'node:path'
2
- import { unverifiedVisionRoute } from './feedback.js'
2
+ import { visionEvidence } from './feedback.js'
3
+ import { captureCoordinates } from './media-contract.js'
4
+
5
+ export function screenshotMetadata (shot) {
6
+ const value = {}
7
+ for (const key of ['capturedAt', 'ts', 'imageSize', 'settled', 'settleMs', 'settleDiffPct', 'settleNote',
8
+ 'cursor', 'cursorRequested', 'cursorDrawn', 'cursorScreenPos', 'cursorInImage', 'cursorNote']) {
9
+ if (shot[key] !== undefined) value[key] = shot[key]
10
+ }
11
+ return value
12
+ }
13
+
14
+ function setCoordinates (value, size) {
15
+ const coordinates = captureCoordinates(value.region, size)
16
+ if (!coordinates) { delete value.coordinates; delete value.coordinateRule; return }
17
+ value.coordinates = coordinates
18
+ value.coordinateRule = 'screen = screenOrigin + imagePixel * screenPerImagePixel (per axis). Do not apply DPI or browser zoom again. After resize/move/zoom, obtain fresh evidence.'
19
+ }
20
+
21
+ const validImageSize = size => Array.isArray(size) && size.length === 2 && size.every(n => Number.isFinite(n) && n > 0)
22
+ const validPoint = point => Number.isFinite(point?.x) && Number.isFinite(point?.y)
23
+
24
+ function restoreFileGeometry (value) {
25
+ if (!value.sourceImageSize) return
26
+ value.imageSize = value.sourceImageSize
27
+ if (value.sourceCursorInImage) value.cursorInImage = value.sourceCursorInImage
28
+ else delete value.cursorInImage
29
+ delete value.sourceImageSize
30
+ delete value.sourceCursorInImage
31
+ setCoordinates(value, value.imageSize)
32
+ }
3
33
 
4
34
  // DSH image blocks reference the host's durable attachment store. Paths and raw
5
35
  // base64 are not image blocks. Keep a usable file when a route cannot accept one.
6
- export async function attachScreenshot (ctx, exec, value, data) {
36
+ export async function attachScreenshot (ctx, exec, value, data, options = {}) {
37
+ // The local file has geometry even when the model route cannot accept images.
38
+ setCoordinates(value, value.imageSize)
7
39
  const fallback = (reason) => {
40
+ delete value.image
41
+ restoreFileGeometry(value)
8
42
  value.imageStatus = 'path-only'
9
43
  value.imageReason = reason
10
44
  value.view = reason === 'model-text-only'
11
45
  ? 'Image not attached: this model does not declare image input. Use UIA/text or switch to an image-capable model.'
12
- : 'Image not attached. Use read_image on path if visual inspection is needed.'
46
+ : reason === 'delivery-path'
47
+ ? 'Path delivery requested: no image block was attached. The file is on path; call read_image only if visual inspection is actually needed.'
48
+ : 'Image not attached. Use read_image on path if visual inspection is needed.'
13
49
  if (value.settled === false) value.view = 'NOT settled — re-shoot. ' + value.view
14
50
  return value
15
51
  }
52
+ // delivery:"path" — the caller has another way to verify (UIA/OCR/window text), so the pixels
53
+ // are not evidence it will read. The file is still written and the path still returned; only
54
+ // the image block is withheld, which is what keeps it out of the re-sent conversation history.
55
+ if (options.delivery === 'path') return fallback('delivery-path')
16
56
  try {
57
+ if (!validImageSize(value.imageSize)) return fallback('invalid-source-image-size')
58
+ const sourceSize = [...value.imageSize]
17
59
  const attachments = ctx.get?.('attachments')
18
60
  const llm = ctx.get?.('llm')
19
61
  if (!attachments?.saveImage) return fallback('attachments-unavailable')
@@ -27,21 +69,32 @@ export async function attachScreenshot (ctx, exec, value, data) {
27
69
  if (!attachments.imageLimits?.mediaTypes?.includes(value.mime)) return fallback('media-type-unavailable')
28
70
  const image = await attachments.saveImage({ data, mediaType: value.mime, name: basename(value.path) })
29
71
  if (exec?.signal?.aborted) return fallback('cancelled')
72
+ const attachedSize = [image?.width, image?.height]
73
+ if (!validImageSize(attachedSize)) return fallback('invalid-attachment-image-size')
74
+ const evidence = visionEvidence(exec, { ...options, adapterVersion: active.adapterVersion || 'unversioned' })
75
+ const resized = attachedSize.some((n, axis) => n !== sourceSize[axis])
76
+ if (resized) {
77
+ value.sourceImageSize = sourceSize
78
+ if (validPoint(value.cursorInImage)) {
79
+ value.sourceCursorInImage = { ...value.cursorInImage }
80
+ value.cursorInImage = { x: value.cursorInImage.x * attachedSize[0] / sourceSize[0],
81
+ y: value.cursorInImage.y * attachedSize[1] / sourceSize[1] }
82
+ } else delete value.cursorInImage
83
+ value.imageSize = attachedSize
84
+ }
30
85
  value.image = image
31
86
  value.imageStatus = 'attached'
32
- const r = value.region
33
- if (r && [r.x, r.y, r.width, r.height].every(Number.isFinite) && image.width > 0 && image.height > 0) {
34
- value.coordinates = {
35
- screenOrigin: [r.x, r.y], imageSize: [image.width, image.height],
36
- screenPerImagePixel: [r.width / image.width, r.height / image.height],
37
- }
38
- }
87
+ setCoordinates(value, attachedSize)
88
+ if (resized && value.coordinateRule) value.coordinateRule += ' imageSize, cursorInImage and coordinates describe the attached image. path retains the original capture: use sourceImageSize and sourceCursorInImage for that file; map its pixels using region.width/sourceImageSize[0] and region.height/sourceImageSize[1].'
39
89
  value.view = value.settled === false
40
90
  ? 'NOT settled — may be a partial render; re-shoot.'
41
91
  : 'Image attached. Inspect it directly; no read_image needed. Coordinates for clicks are screen pixels.'
42
- if (unverifiedVisionRoute(exec)) {
92
+ value.visionEvidence = evidence
93
+ if (value.visionEvidence.grounding === 'failed') {
43
94
  value.visionStatus = 'unverified-route'
44
- value.view = 'Image transport succeeded, but this GLM 5.3 route failed grounding tests. Use UIA/text readback; do not infer coordinates or success from unseen pixels. The image and path are retained for inspection.'
95
+ value.view = 'Image transport succeeded, but a current route record reports failed grounding. Use UIA/text readback; the image and path remain available. See visionEvidence for the scope and expiry.'
96
+ } else {
97
+ value.visionStatus = value.visionEvidence.grounding === 'verified' ? 'grounding-verified' : 'grounding-unverified'
45
98
  }
46
99
  return value
47
100
  } catch (error) {
@@ -60,7 +113,12 @@ export function renderScreenshot (value) {
60
113
 
61
114
  // A batch exposes one latest image as its checkpoint. Earlier explicit captures
62
115
  // keep their paths for deliberate later reads, without multiplying vision input.
63
- export function selectBatchScreenshot (result, automatic, kind = 'image') {
116
+ //
117
+ // `delivery` is the batch-level request, and it has to reach further than the checkpoint: when
118
+ // no checkpoint happens (shot:"never", a readback tail, or a later failure) the observation
119
+ // falls back to the last explicit step image. A batch that asked for paths must not hand one
120
+ // back as a picture just because the checkpoint was skipped — same pixels, quieter route.
121
+ export function selectBatchScreenshot (result, automatic, kind = 'image', delivery = 'auto') {
64
122
  let latest = automatic
65
123
  let index
66
124
  if (!latest) {
@@ -71,18 +129,30 @@ export function selectBatchScreenshot (result, automatic, kind = 'image') {
71
129
  }
72
130
  }
73
131
  }
132
+ const pathDelivery = delivery === 'path'
133
+ const pathNote = 'Path delivery requested: no image block was attached. Use read_image on this path only if these pixels are the evidence.'
74
134
  for (const action of result.actions) {
75
135
  const shot = action.result
76
136
  if (shot?.image && shot !== latest) {
77
137
  delete shot.image
138
+ restoreFileGeometry(shot)
78
139
  shot.imageStatus = 'path-only'
79
- shot.imageReason = 'earlier-batch-image'
80
- shot.view = kind === 'state'
81
- ? 'A newer UIA state is returned. This earlier image remains at its path.'
82
- : 'Only the latest batch screenshot is attached. Use read_image on this path if this earlier frame is needed.'
140
+ shot.imageReason = pathDelivery ? 'delivery-path' : 'earlier-batch-image'
141
+ shot.view = pathDelivery
142
+ ? pathNote
143
+ : kind === 'state'
144
+ ? 'A newer UIA state is returned. This earlier image remains at its path.'
145
+ : 'Only the latest batch screenshot is attached. Use read_image on this path if this earlier frame is needed.'
83
146
  }
84
147
  }
85
148
  if (latest) {
149
+ if (pathDelivery && latest.image) {
150
+ delete latest.image
151
+ restoreFileGeometry(latest)
152
+ latest.imageStatus = 'path-only'
153
+ latest.imageReason = 'delivery-path'
154
+ latest.view = pathNote
155
+ }
86
156
  result.observation = latest
87
157
  if (kind === 'state') result.observationSource = 'automatic final UIA state'
88
158
  else result.imageSource = automatic ? 'automatic final screenshot' : `action ${index + 1}; captured before any later actions`
package/lib/index.js CHANGED
@@ -31,8 +31,8 @@ import Schema from '@deepseek-ai/schemastery'
31
31
  import { ComputerUse } from './core/index.js'
32
32
  import { policyDecision, HIGH_RISK_NOTE } from './policy.js'
33
33
  import { acknowledge, isAcked, ACK_HINT, isDrivingCall } from './skill-gate.js'
34
- import { mkDataDir, snapshotPath, appendAudit } from './audit.js'
35
- import { registerTools } from './tools.js'
34
+ import { mkDataDir, snapshotPath, installAudit } from './audit.js'
35
+ import { registerTools, isCycleStateOnly } from './tools.js'
36
36
  import { installToolExposure, ACTIVATION_TOOL } from './exposure.js'
37
37
  import { scopeOf } from '@deepseek-ai/dsh-scope'
38
38
  import { createCycleGate, agentKeyOf } from './cycle.js'
@@ -382,7 +382,14 @@ export const inject = ['tools', 'userQuestions']
382
382
  export const Config = Schema.object({
383
383
  enabled: Schema.boolean().default(true).description('enable the computer-use toolset'),
384
384
  progressiveTools: Schema.boolean().default(true).description('expose desktop execution tools only to Agents that load/activate computer-use; brakes remain available'),
385
- actionFeedback: Schema.boolean().default(true).description('post-input feedback for tested GLM 5.3 Flash / DeepSeek Flash routes; GLM 5.3 uses UIA state until its vision is verified'),
385
+ actionFeedback: Schema.boolean().default(true).description('automatic post-input observation; per-call feedback can explicitly select compact, full or none'),
386
+ actionFeedbackDetail: Schema.union([Schema.const('compact'), Schema.const('full'), Schema.const('none')]).default('compact').description('compact bounds local text readback to 6 KiB; vision keeps its image and crops only an explicitly requested verificationRegion. full retains complete route-specific feedback'),
387
+ feedbackRules: Schema.array(Schema.object({
388
+ provider: Schema.string().required(), model: Schema.string().required(),
389
+ adapterVersion: Schema.string().required().description('exact adapter version, or unversioned for a conservative failed-test fallback'),
390
+ grounding: Schema.union([Schema.const('failed'), Schema.const('verified')]).required(),
391
+ testedAt: Schema.string().required(), expiresAt: Schema.string().required(), reason: Schema.string(),
392
+ })).default(undefined).description('optional dated route grounding evidence; defaults to bundled expiring records; [] removes historical overrides'),
386
393
  automationMode: Schema.union([
387
394
  Schema.const('read-only').description('observation only'),
388
395
  Schema.const('standard').description('actuations require approval'),
@@ -436,7 +443,7 @@ export function apply (ctx, config) {
436
443
  rateLimit: { max: config.maxActionsPerMinute, windowMs: 60_000 },
437
444
  log: (m) => log.info(m),
438
445
  })
439
- cu.on('action', (rec) => appendAudit(dataDir, rec))
446
+ const detachAudit = installAudit(cu, dataDir)
440
447
 
441
448
  // ---- the live agent of the session that drives this machine ------------------------------
442
449
  // Captured from the tool execution itself (`exec.agent` is the live Agent object); a session
@@ -560,7 +567,14 @@ export function apply (ctx, config) {
560
567
  const expect = exitRecordText()
561
568
  // Rejections ride the SHARED qualification promise: every later call in this turn awaits the
562
569
  // same outcome instead of retrying it into a resurrection.
563
- await cu.resume(expect)
570
+ const cycleId = gate.snapshot().id
571
+ const openingContext = { signal: exec?.signal, cycleId, sessionKey: cl.sessionKey, agentKey: cl.agentKey,
572
+ assertOwnership: () => gate.assertOwnership(cl, cycleId) }
573
+ const resume = async () => {
574
+ await cu.resume(expect)
575
+ }
576
+ if (cu.withInputContext) await cu.withInputContext(openingContext, resume)
577
+ else await resume()
564
578
  if (!cu.exited && !exitedLatch()) {
565
579
  log.info('opened a new computer-use cycle for this turn (cleared the Ctrl+Alt+Q record left by an earlier cycle)')
566
580
  return { opened: true }
@@ -596,6 +610,10 @@ export function apply (ctx, config) {
596
610
  paused: pausedOrAsking, report: error => log.warn(`computer-use terminal cleanup failed: ${error.message}`),
597
611
  })
598
612
  const hooks = {
613
+ contextOf: (exec) => {
614
+ const owner = exec && typeof exec === 'object' ? callOwners.get(exec) : null
615
+ return owner ? { cycleId: owner.cycleId, sessionKey: owner.claim.sessionKey, agentKey: owner.claim.agentKey } : {}
616
+ },
599
617
  assertOwnership: (exec) => {
600
618
  const owner = exec && typeof exec === 'object' ? callOwners.get(exec) : null
601
619
  if (!owner) {
@@ -608,7 +626,7 @@ export function apply (ctx, config) {
608
626
  releaseCycle: (exec, why) => lifecycle.release(exec, why),
609
627
  onStart: (exec) => {
610
628
  inFlight++
611
- if (exec?.name === 'computer_ctrl' && exec.arguments?.action === 'release') {
629
+ if (isCycleStateOnly(exec?.name, exec?.arguments)) {
612
630
  callOwners.set(exec, { claim: claimOf(exec), cycleId: gate.snapshot().id })
613
631
  }
614
632
  },
@@ -1154,7 +1172,7 @@ export function apply (ctx, config) {
1154
1172
  })
1155
1173
 
1156
1174
  ctx.effect(() => {
1157
- const disposer = () => { cu.kill().catch(() => {}) }
1175
+ const disposer = () => { return cu.kill().catch(() => {}).finally(detachAudit) }
1158
1176
  return disposer
1159
1177
  })
1160
1178
 
@@ -0,0 +1,37 @@
1
+ // One command grammar for approval and dispatch. Native parsing implements the
2
+ // same contract for direct worker clients; cross-language fixtures guard parity.
3
+ export const KEY_ALIASES = Object.freeze({
4
+ control: 'ctrl', lwin: 'win', windows: 'win', meta: 'win',
5
+ return: 'enter', escape: 'esc', bksp: 'backspace', del: 'delete',
6
+ ins: 'insert', pageup: 'pgup', pagedown: 'pgdn', capslock: 'caps', printscreen: 'prtsc',
7
+ })
8
+ const modifierOrder = ['ctrl', 'alt', 'shift', 'win', 'rwin']
9
+ const named = new Set([...modifierOrder, 'enter', 'esc', 'tab', 'space', 'backspace', 'delete',
10
+ 'insert', 'home', 'end', 'pgup', 'pgdn', 'up', 'down', 'left', 'right', 'caps', 'apps',
11
+ 'prtsc', 'numlock', 'plus', 'minus', 'comma', 'period'])
12
+
13
+ export function parseKeyCommand (combo) {
14
+ if (typeof combo !== 'string' || !combo.trim()) throw new Error('KEY_COMMAND_INVALID: nonempty key combo required')
15
+ const unique = []
16
+ for (const part of combo.split('+')) {
17
+ const raw = part.trim().toLowerCase()
18
+ const key = KEY_ALIASES[raw] || raw
19
+ if (!key || !(named.has(key) || /^[a-z0-9]$/.test(key) || /^f(?:[1-9]|1\d|2[0-4])$/.test(key))) {
20
+ throw new Error(`KEY_COMMAND_INVALID: unknown or empty key ${JSON.stringify(raw)}`)
21
+ }
22
+ if (!unique.includes(key)) unique.push(key)
23
+ }
24
+ const modifiers = modifierOrder.filter(key => unique.includes(key))
25
+ const keys = unique.filter(key => !modifierOrder.includes(key))
26
+ const tokens = [...modifiers, ...keys]
27
+ return { combo: tokens.join('+'), tokens, modifiers, keys }
28
+ }
29
+
30
+ export function keyCommandRisk (combo) {
31
+ const { tokens } = parseKeyCommand(combo)
32
+ const has = key => tokens.includes(key)
33
+ const win = has('win') || has('rwin')
34
+ return has('delete') || has('backspace') ||
35
+ (win && ['r', 'l', 'd'].some(has)) || (has('alt') && has('f4')) ||
36
+ (has('ctrl') && has('shift') && has('esc'))
37
+ }
package/lib/launch.js ADDED
@@ -0,0 +1,67 @@
1
+ // launch.js — validation for computer_launch. EXECUTION lives in the worker (lib/core/worker.cs
2
+ // `launch` op) on purpose: a launch must go through the SAME funnel as every other actuation —
3
+ // brake/pause checks, the status halo, the audit JSONL and the rate window — and the human has
4
+ // to SEE that the machine was just acted on. A Node-side spawn skipped all of that (measured
5
+ // 2026-11-07: the halo never lit, so nothing told the human an app was opening).
6
+ //
7
+ // Validation still lives here so an impossible target is refused BEFORE dispatch: the receipt
8
+ // says outcome:"not-dispatched", and that has to be true. The policy layer also needs the string.
9
+ //
10
+ // RESOLUTION IS ShellExecute's (worker: ProcessStartInfo.UseShellExecute). Measured on this
11
+ // machine: `explorer.exe calc` opens a FILE EXPLORER window (a bare word is a location to
12
+ // explorer), while ShellExecute("calc") starts Calculator — so the launcher, not a shell
13
+ // wrapper, is the correct resolver for App Paths aliases, shell: verbs, URLs and associations.
14
+ //
15
+ // The target crosses as ONE value: no command line is composed here or in the worker, and no
16
+ // shell interpreter is involved. The deny-list below is defence in depth for anything
17
+ // downstream that might interpolate the string — the boundary is the single-value call.
18
+ const MAX_LEN = 2000
19
+
20
+ // Control characters: C0 (includes \r \n \t), DEL, C1, and the Unicode line/paragraph
21
+ // separators (U+2028/U+2029 still break line discipline in several parsers). None has an
22
+ // honest use in a path, URL or alias.
23
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/
24
+
25
+ // Metacharacters for a shell or a string interpolator. Percent is deliberately NOT here:
26
+ // "%20"-style percent-encoding is a legitimate, unavoidable part of URLs, and no shell is
27
+ // involved, so there is no %VAR% expansion to fear.
28
+ const INJECTIVE = /["'&|;<>$`^]/
29
+
30
+ const badTarget = (why) => {
31
+ throw Object.assign(new Error(`bad launch target: ${why}`), { code: 'LAUNCH_BAD_TARGET', outcome: 'not-dispatched' })
32
+ }
33
+
34
+ /** Trim and validate a launch target; throws LAUNCH_BAD_TARGET (not-dispatched) when impossible.
35
+ * Everything is checked on the TRIMMED string, i.e. on exactly what the worker will receive. */
36
+ export function validateLaunchTarget (target) {
37
+ if (typeof target !== 'string') badTarget('must be a string')
38
+ const t = target.trim()
39
+ if (!t) badTarget('empty (or only whitespace)')
40
+ if (t.length > MAX_LEN) badTarget(`longer than ${MAX_LEN} characters`)
41
+ const c = CONTROL.exec(t)
42
+ if (c) badTarget(`control character U+${c[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`)
43
+ const m = INJECTIVE.exec(t)
44
+ if (m) badTarget(`forbidden character ${JSON.stringify(m[0])}`)
45
+ return t
46
+ }
47
+
48
+ const badArgs = (why) => {
49
+ throw Object.assign(new Error(`bad launch arguments: ${why}`), { code: 'LAUNCH_BAD_ARGS', outcome: 'not-dispatched' })
50
+ }
51
+
52
+ /** Validate the optional argument array. Each entry is ONE argument VALUE — never a command
53
+ * line, never shell syntax: the worker quotes each entry exactly once. Spaces, backslashes and
54
+ * `--flag=value` are ordinary; only NUL, control characters and empties are impossible. */
55
+ export function validateLaunchArgs (args) {
56
+ if (args === undefined || args === null) return undefined
57
+ if (!Array.isArray(args)) badArgs('must be an array of strings')
58
+ if (args.length > 64) badArgs('more than 64 arguments')
59
+ return args.map((value, index) => {
60
+ if (typeof value !== 'string') badArgs(`argument ${index} must be a string`)
61
+ if (value.length === 0) badArgs(`argument ${index} is empty`)
62
+ if (value.length > MAX_LEN) badArgs(`argument ${index} longer than ${MAX_LEN} characters`)
63
+ const c = CONTROL.exec(value)
64
+ if (c) badArgs(`argument ${index} contains control character U+${c[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`)
65
+ return value
66
+ })
67
+ }
@@ -0,0 +1,43 @@
1
+ // Coordinate metadata is transport-independent; attachments and MCP content
2
+ // must describe the same captured pixels without serializing binary bytes as text.
3
+ export function captureCoordinates (region, imageSize) {
4
+ if (!region || ![region.x, region.y, region.width, region.height].every(Number.isFinite) ||
5
+ region.width <= 0 || region.height <= 0 || !Array.isArray(imageSize) || imageSize.length !== 2 ||
6
+ !imageSize.every(n => Number.isFinite(n) && n > 0)) return null
7
+ return { screenOrigin: [region.x, region.y], imageSize,
8
+ screenPerImagePixel: [region.width / imageSize[0], region.height / imageSize[1]] }
9
+ }
10
+
11
+ export function imageMime (bytes, declared) {
12
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png'
13
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg'
14
+ if (declared === 'image/png' || declared === 'image/jpeg') return declared
15
+ throw new Error('IMAGE_FORMAT_UNKNOWN: missing supported image signature or MIME')
16
+ }
17
+
18
+ export function splitMediaResult (value) {
19
+ const images = []
20
+ const clean = (input, location) => {
21
+ if (Buffer.isBuffer(input)) return undefined
22
+ if (Array.isArray(input)) return input.map((child, i) => clean(child, `${location}[${i}]`))
23
+ if (!input || typeof input !== 'object') return input
24
+ const output = {}
25
+ for (const [key, child] of Object.entries(input)) {
26
+ if (!Buffer.isBuffer(child)) output[key] = clean(child, `${location}.${key}`)
27
+ }
28
+ const bytes = Buffer.isBuffer(input.annotated) ? input.annotated
29
+ : Buffer.isBuffer(input.image) ? input.image : Buffer.isBuffer(input.raw) ? input.raw : null
30
+ if (bytes) {
31
+ const mime = imageMime(bytes, input.mime)
32
+ output.mime = mime
33
+ const coordinates = captureCoordinates(input.region, input.imageSize)
34
+ if (coordinates) output.coordinates = coordinates
35
+ images.push({ bytes, mime, location, metadata: output })
36
+ }
37
+ return output
38
+ }
39
+ const text = clean(value, 'result')
40
+ const latest = images.at(-1)
41
+ for (const entry of images) entry.metadata.imageStatus = entry === latest ? 'attached' : 'omitted-earlier-batch-image'
42
+ return { value: text, ...(latest ? { image: latest.bytes, mime: latest.mime, imageSource: latest.location } : {}) }
43
+ }