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.
- package/README.ar.md +234 -0
- package/README.de.md +234 -0
- package/README.es.md +264 -0
- package/README.fa.md +234 -0
- package/README.fr.md +243 -0
- package/README.md +72 -17
- package/README.pt.md +234 -0
- package/README.ru.md +259 -0
- package/README.zh.md +66 -16
- package/icon.svg +20 -4
- package/lib/audit.js +7 -0
- package/lib/batch.js +14 -19
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +1 -1
- package/lib/compact-feedback.js +175 -0
- package/lib/continuation.js +1 -1
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
- package/lib/core/index.js +403 -136
- package/lib/core/native-runtime.js +17 -0
- package/lib/core/worker.cs +2160 -229
- package/lib/feedback-routes.json +11 -0
- package/lib/feedback.js +49 -16
- package/lib/image-output.js +87 -17
- package/lib/index.js +25 -7
- package/lib/key-command.js +37 -0
- package/lib/launch.js +67 -0
- package/lib/media-contract.js +43 -0
- package/lib/observations.js +267 -13
- package/lib/policy.js +29 -9
- package/lib/tool-contract.js +56 -0
- package/lib/tools.js +203 -52
- package/lib/verification-region.js +17 -0
- package/mcp/package.json +2 -1
- package/mcp/src/execution.js +8 -5
- package/mcp/src/index.js +16 -267
- package/mcp/src/tools.js +271 -0
- package/package.json +6 -1
- package/scripts/check-behavior.mjs +83 -0
- package/scripts/measure-tool-tokens.mjs +21 -0
- package/scripts/verify-package.mjs +9 -0
- package/skills/computer-use/SKILL.md +55 -43
- package/skills/computer-use/references/lifecycle.md +11 -7
- package/skills/computer-use/references/maintenance.md +9 -5
- package/skills/computer-use/references/precision.md +39 -14
- 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
|
-
|
|
2
|
-
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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([
|
package/lib/image-output.js
CHANGED
|
@@ -1,19 +1,61 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
-
import {
|
|
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
|
-
:
|
|
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
|
-
|
|
33
|
-
if (
|
|
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
|
-
|
|
92
|
+
value.visionEvidence = evidence
|
|
93
|
+
if (value.visionEvidence.grounding === 'failed') {
|
|
43
94
|
value.visionStatus = 'unverified-route'
|
|
44
|
-
value.view = 'Image transport succeeded, but
|
|
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
|
-
|
|
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 =
|
|
81
|
-
?
|
|
82
|
-
:
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|