dsh-codex-style-computer-use 1.2.0-rc.3
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/LICENSE +21 -0
- package/README.md +144 -0
- package/README.zh.md +132 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifacts.js +35 -0
- package/lib/attention-native.js +93 -0
- package/lib/attention-worker.cs +194 -0
- package/lib/attention.js +140 -0
- package/lib/audit.js +30 -0
- package/lib/batch.js +52 -0
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +7 -0
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +7 -0
- package/lib/core/compile-worker.ps1 +58 -0
- package/lib/core/index.js +844 -0
- package/lib/core/native-runtime.js +97 -0
- package/lib/core/worker.cs +6375 -0
- package/lib/cycle.js +314 -0
- package/lib/exposure.js +91 -0
- package/lib/feedback.js +35 -0
- package/lib/image-output.js +92 -0
- package/lib/index.js +1715 -0
- package/lib/observations.js +78 -0
- package/lib/policy.js +109 -0
- package/lib/recovery.js +8 -0
- package/lib/skill-gate.js +68 -0
- package/lib/tools.js +717 -0
- package/mcp/package.json +12 -0
- package/mcp/src/index.js +335 -0
- package/package.json +88 -0
- package/scripts/build-native.mjs +7 -0
- package/scripts/smoke-native.mjs +38 -0
- package/scripts/verify-package.mjs +28 -0
- package/skills/computer-use/SKILL.md +74 -0
- package/skills/computer-use/references/lifecycle.md +180 -0
- package/skills/computer-use/references/maintenance.md +56 -0
- package/skills/computer-use/references/precision.md +297 -0
- package/skills/computer-use/references/progressive-control.md +30 -0
- package/skills/computer-use/references/shared-input.md +27 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const clone = value => JSON.parse(JSON.stringify(value))
|
|
4
|
+
const encode = value => JSON.stringify(value)
|
|
5
|
+
const elementKey = e => e.target || (e.runtimeId ? `uia:${e.pid ?? ''}:${e.runtimeId}` : null)
|
|
6
|
+
const windowKey = w => w.hwnd ? `window:${w.pid ?? ''}:${w.hwnd}` : null
|
|
7
|
+
|
|
8
|
+
// An explicit base prevents implicit deltas after compaction or in an unrelated Agent.
|
|
9
|
+
// Full immutable observations are retained, including every provider field; only the wire view differs.
|
|
10
|
+
export class ObservationStore {
|
|
11
|
+
constructor ({ capacity = 64 } = {}) {
|
|
12
|
+
this.capacity = capacity
|
|
13
|
+
this.scopes = new WeakMap()
|
|
14
|
+
this.unscoped = {}
|
|
15
|
+
this.sequence = 0
|
|
16
|
+
this.prefix = randomBytes(4).toString('hex')
|
|
17
|
+
}
|
|
18
|
+
scope (exec) {
|
|
19
|
+
const owner = exec?.agent || this.unscoped
|
|
20
|
+
let cache = this.scopes.get(owner)
|
|
21
|
+
if (!cache) { cache = new Map(); this.scopes.set(owner, cache) }
|
|
22
|
+
return cache
|
|
23
|
+
}
|
|
24
|
+
get (exec, id, kind) {
|
|
25
|
+
const entry = this.scope(exec).get(id)
|
|
26
|
+
if (!entry || entry.kind !== kind) throw new Error('OBSERVATION_UNAVAILABLE: snapshot expired or belongs to another Agent/tool; request a fresh full observation')
|
|
27
|
+
return clone(entry.full)
|
|
28
|
+
}
|
|
29
|
+
ownsTarget (exec, target) {
|
|
30
|
+
return [...this.scope(exec).values()].some(entry => (entry.full.elements || []).some(e => e.target === target))
|
|
31
|
+
}
|
|
32
|
+
publish (exec, kind, query, value, since) {
|
|
33
|
+
const cache = this.scope(exec)
|
|
34
|
+
const fields = kind === 'uia' ? { elements: elementKey } : { windows: windowKey, uiaFlat: elementKey }
|
|
35
|
+
const full = clone({ ...value, observation: `O${this.prefix}-${++this.sequence}`, mode: 'full' })
|
|
36
|
+
// The scope includes the exact query and observed window/process. A change forces a full view.
|
|
37
|
+
const scope = encode([query, value.hwnd ?? value.active?.hwnd ?? null, value.pid ?? value.active?.pid ?? null])
|
|
38
|
+
const previous = since && cache.get(since)
|
|
39
|
+
let wire = full
|
|
40
|
+
if (since) {
|
|
41
|
+
let reason = !previous ? 'base-unavailable' : previous.kind !== kind || previous.scope !== scope ? 'scope-changed'
|
|
42
|
+
: previous.full.truncated || previous.full.uiaTruncated || full.truncated || full.uiaTruncated ? 'incomplete-observation' : null
|
|
43
|
+
if (!reason) {
|
|
44
|
+
const delta = {}
|
|
45
|
+
for (const [field, keyOf] of Object.entries(fields)) {
|
|
46
|
+
const before = previous.full[field] || [], after = full[field] || []
|
|
47
|
+
const keyMap = rows => {
|
|
48
|
+
const map = new Map()
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
const key = keyOf(row)
|
|
51
|
+
if (!key || map.has(key)) return null
|
|
52
|
+
map.set(key, row)
|
|
53
|
+
}
|
|
54
|
+
return map
|
|
55
|
+
}
|
|
56
|
+
const oldMap = keyMap(before), newMap = keyMap(after)
|
|
57
|
+
if (!oldMap || !newMap) { reason = 'ambiguous-identity'; break }
|
|
58
|
+
delta[field] = { added: [], changed: [], removed: [] }
|
|
59
|
+
for (const [key, row] of newMap) {
|
|
60
|
+
if (!oldMap.has(key)) delta[field].added.push({ key, element: row })
|
|
61
|
+
else if (encode(oldMap.get(key)) !== encode(row)) delta[field].changed.push({ key, element: row })
|
|
62
|
+
}
|
|
63
|
+
for (const key of oldMap.keys()) if (!newMap.has(key)) delta[field].removed.push(key)
|
|
64
|
+
if (encode([...oldMap.keys()]) !== encode([...newMap.keys()])) delta[field].order = [...newMap.keys()]
|
|
65
|
+
}
|
|
66
|
+
if (!reason) {
|
|
67
|
+
wire = { ...full, mode: 'diff', base: since, delta }
|
|
68
|
+
for (const field of Object.keys(fields)) delete wire[field]
|
|
69
|
+
if (encode(wire).length >= encode(full).length) reason = 'delta-not-smaller'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (reason) wire = { ...full, resetReason: reason }
|
|
73
|
+
}
|
|
74
|
+
cache.set(full.observation, { full, kind, scope })
|
|
75
|
+
while (cache.size > this.capacity) cache.delete(cache.keys().next().value)
|
|
76
|
+
return wire
|
|
77
|
+
}
|
|
78
|
+
}
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// policy.js — pure approval decision for dsh-computer-use.
|
|
2
|
+
// Modeled on @anweat/dsh-browser's approval-policy.js: a pure function mapping
|
|
3
|
+
// (toolName, args, automationMode) -> {kind:'allow'|'deny'|'ask', reason}.
|
|
4
|
+
// Fail-closed by construction: unknown tool names are denied.
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ KEEP IN SYNC WITH THE TOOL SURFACE. A fail-closed list is only safe if it is also COMPLETE.
|
|
7
|
+
// 2026-09-13: the tool set was rewritten from 28 names to 17 (merged + renamed) and this
|
|
8
|
+
// allow-list was NOT updated, so the plugin's own gate would have denied most of its own tools
|
|
9
|
+
// with "unknown computer-use tool" — before they ever ran. `build/test-policy.mjs` now fails if
|
|
10
|
+
// any registered tool falls through to that branch, so the next rename cannot silently do this.
|
|
11
|
+
//
|
|
12
|
+
// Two tools cannot be classified by name alone and are decided by their ARGUMENTS:
|
|
13
|
+
// computer_clip — no text = read the clipboard; text = write it
|
|
14
|
+
// computer_ctrl — selftest = health; stop = the brake; exit = end the session;
|
|
15
|
+
// calibrate / indicator = control
|
|
16
|
+
// (`resume` is NOT among them any more: it was removed from the tool surface on 2026-09-13 because
|
|
17
|
+
// it un-latched Ctrl+Alt+Q, i.e. a TOOL CALL could re-open a cycle the human had ended. Only the
|
|
18
|
+
// plugin's session/event turn/start opens a cycle, and only the human's Ctrl+Alt+R releases a pause.)
|
|
19
|
+
// computer_ask is argument-independent and BRAKE-LIKE — see its branch below.
|
|
20
|
+
|
|
21
|
+
export const OBSERVATION_TOOLS = new Set([
|
|
22
|
+
'computer_shot', 'computer_state', 'computer_marks', 'computer_uia', 'computer_wait',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export const ACTUATION_TOOLS = new Set([
|
|
26
|
+
'computer_click', 'computer_move', 'computer_drag', 'computer_scroll', 'computer_select',
|
|
27
|
+
'computer_key', 'computer_type', 'computer_uia_act', 'computer_window', 'computer_batch',
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
// combos that escalate: run dialog, window close, signout/lock/taskmgr, delete-key
|
|
31
|
+
const HIGH_RISK_KEY = /(^|\+)(win\+r|alt\+f4|ctrl\+shift\+esc|win\+l|win\+d)(\+|$)/i
|
|
32
|
+
const DESTRUCTIVE_KEY = /(^|\+)(del|delete|bksp|backspace)(\+|$)/i
|
|
33
|
+
|
|
34
|
+
export const HIGH_RISK_NOTE = 'high-risk action (destructive key / closing a window / password field) — always requires human approval'
|
|
35
|
+
|
|
36
|
+
/** @returns {{kind:'allow'|'deny'|'ask', reason?: string}} */
|
|
37
|
+
export function policyDecision (name, args, mode) {
|
|
38
|
+
const a = args || {}
|
|
39
|
+
|
|
40
|
+
// ---- the two argument-dependent tools -------------------------------------------------
|
|
41
|
+
if (name === 'computer_ctrl') {
|
|
42
|
+
const act = String(a.action || 'selftest')
|
|
43
|
+
// The brake is available in EVERY mode, including read-only, and it drives nothing.
|
|
44
|
+
if (act === 'stop') return { kind: 'allow' }
|
|
45
|
+
if (act === 'recover') return mode === 'read-only'
|
|
46
|
+
? { kind: 'deny', reason: 'read-only mode cannot recover control' } : { kind: 'allow' }
|
|
47
|
+
if (act === 'selftest') return { kind: 'allow' } // input-path health, never moves the pointer
|
|
48
|
+
// ends the session: it drives nothing, and the human's own Ctrl+Alt+Q is unconditional. It is
|
|
49
|
+
// the second answer of the ask protocol ("I cannot proceed") and the ask notice promises the
|
|
50
|
+
// human the agent can give it — denying it made that answer dead in all four modes.
|
|
51
|
+
if (act === 'exit') return { kind: 'allow' }
|
|
52
|
+
if (act === 'calibrate' || act === 'indicator') return decideActuation(name, a, mode)
|
|
53
|
+
return { kind: 'deny', reason: `unknown computer_ctrl action: ${act}` }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- BRAKE-LIKE, and therefore NEVER gated -------------------------------------------------
|
|
57
|
+
// computer_ask actuates NOTHING: it stops the machine, raises the host window and asks the human
|
|
58
|
+
// a question. That is the SAFETY DIRECTION — it is the one call that must never sit behind an
|
|
59
|
+
// approval prompt, because what it is asking for IS human attention. Gating it is exactly
|
|
60
|
+
// backwards. Until 2026-09-13 it fell through to the fail-closed "unknown computer-use tool"
|
|
61
|
+
// branch and was DENIED in all four modes, which silently killed the human's own "ask + 5 秒"
|
|
62
|
+
// rule (their spec: "一旦出现 computer ask, 这时候返回到 DSH desktop 窗口. 若5秒规则内有回应,
|
|
63
|
+
// 则进入暂停态").
|
|
64
|
+
if (name === 'computer_ask') return { kind: 'allow' }
|
|
65
|
+
if (name === 'computer_clip') {
|
|
66
|
+
if (a.text === undefined) return { kind: 'allow' } // read
|
|
67
|
+
return decideActuation(name, a, mode) // write
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (OBSERVATION_TOOLS.has(name)) return { kind: 'allow' }
|
|
71
|
+
if (ACTUATION_TOOLS.has(name)) return decideActuation(name, a, mode)
|
|
72
|
+
return { kind: 'deny', reason: `unknown computer-use tool: ${name}` }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decideActuation (name, a, mode) {
|
|
76
|
+
const hr = highRisk(name, a)
|
|
77
|
+
switch (mode) {
|
|
78
|
+
case 'read-only':
|
|
79
|
+
return { kind: 'deny', reason: `automationMode=read-only: ${name} drives the machine` }
|
|
80
|
+
case 'standard':
|
|
81
|
+
return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE }
|
|
82
|
+
: { kind: 'ask', reason: `automationMode=standard: actuation ${name}` }
|
|
83
|
+
case 'autonomous':
|
|
84
|
+
return hr ? { kind: 'ask', reason: HIGH_RISK_NOTE } : { kind: 'allow' }
|
|
85
|
+
case 'unrestricted':
|
|
86
|
+
return { kind: 'allow' }
|
|
87
|
+
default:
|
|
88
|
+
return { kind: 'deny', reason: `unknown automationMode: ${mode}` }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function highRisk (name, args) {
|
|
93
|
+
const a = args || {}
|
|
94
|
+
if (name === 'computer_key') {
|
|
95
|
+
const combo = String(a.combo || '')
|
|
96
|
+
if (HIGH_RISK_KEY.test(combo) || DESTRUCTIVE_KEY.test(combo)) return true
|
|
97
|
+
}
|
|
98
|
+
if (name === 'computer_window' && String(a.op || 'activate') === 'close') return true
|
|
99
|
+
if (name === 'computer_type' && a.allowPassword) return true
|
|
100
|
+
if (name === 'computer_batch') {
|
|
101
|
+
const actions = Array.isArray(a.actions) ? a.actions : []
|
|
102
|
+
for (const step of actions) {
|
|
103
|
+
if (!step) continue
|
|
104
|
+
const t = String(step.tool || '')
|
|
105
|
+
if ((ACTUATION_TOOLS.has(t) || t === 'computer_clip' || t === 'computer_ctrl') && highRisk(t, step.args || {})) return true
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false
|
|
109
|
+
}
|
package/lib/recovery.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Host facts checked independently of the native pause credential.
|
|
2
|
+
export function recoveryProblem (permit, claim, cycle, pendingApprovals, exited) {
|
|
3
|
+
if (!permit || !claim.sessionKey || !claim.agentKey) return 'no temporary pause owned by this execution'
|
|
4
|
+
if (pendingApprovals) return 'human approval is pending'
|
|
5
|
+
if (exited || cycle.state !== 'paused' || cycle.id !== permit.cycleId) return 'the original paused cycle changed'
|
|
6
|
+
if (cycle.ownerIdent !== claim.ident || permit.ownerIdent !== claim.ident) return 'the pause belongs to another owner'
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// skill-gate.js — "READ THE PROCEDURE BEFORE DRIVING".
|
|
2
|
+
//
|
|
3
|
+
// The human's rule (2026-09-13, verbatim): "未先读 skill 时拒绝驱动类 op". It exists because an
|
|
4
|
+
// agent that skipped the manual improvised its way through a two-call job: it never looked at the
|
|
5
|
+
// window list, spent three rounds attacking an occlusion that did not exist (the windows were
|
|
6
|
+
// MINIMIZED), guessed taskbar coordinates from a scaled screenshot, and then reported all of it to
|
|
7
|
+
// the human as "the environment blocked it".
|
|
8
|
+
//
|
|
9
|
+
// No tool layer can SEE whether a model read a document. So the receipt is a PHRASE THAT ONLY EXISTS
|
|
10
|
+
// INSIDE SKILL.md — the model has to open the file to produce it. That is the strongest honest
|
|
11
|
+
// version of "force it to read": not guessable by accident, verifiable by exact string equality, and
|
|
12
|
+
// free. build/test-skill-gate.mjs asserts this constant against SKILL.md itself, so the manual and
|
|
13
|
+
// the gate can never drift apart (a phrase that lived in only one of the two would either lock the
|
|
14
|
+
// agent out forever or let it in without reading anything).
|
|
15
|
+
//
|
|
16
|
+
// Deliberately NOT in here: any way for the gate to block the safety direction. `computer_ask`, and
|
|
17
|
+
// `computer_ctrl {action:"stop"|"exit"}`, are decided by the caller and never gated — a gate that can
|
|
18
|
+
// block a brake is worse than no gate.
|
|
19
|
+
|
|
20
|
+
import { ACTUATION_TOOLS } from './policy.js'
|
|
21
|
+
|
|
22
|
+
export const REQUIRED_PHRASE = 'A MINIMIZED window is not an occluded window'
|
|
23
|
+
|
|
24
|
+
export const ACK_HINT =
|
|
25
|
+
'Before the first DRIVING call of a session, read the computer-use skill ' +
|
|
26
|
+
'(skills/computer-use/SKILL.md) and acknowledge it once: ' +
|
|
27
|
+
'computer_ctrl {action:"acknowledge", text:"' + REQUIRED_PHRASE + '"}. ' +
|
|
28
|
+
'Observation tools are never gated, and neither is the safety direction (computer_ask, ' +
|
|
29
|
+
'computer_ctrl stop/exit).'
|
|
30
|
+
|
|
31
|
+
const acked = new Set()
|
|
32
|
+
|
|
33
|
+
/** @returns {boolean} true when `text` is the exact phrase from the skill. */
|
|
34
|
+
export function acknowledge (text, key) {
|
|
35
|
+
if (String(text == null ? '' : text).trim() !== REQUIRED_PHRASE) return false
|
|
36
|
+
acked.add(String(key == null ? 'default' : key))
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isAcked (key) {
|
|
41
|
+
return acked.has(String(key == null ? 'default' : key))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Tests only. */
|
|
45
|
+
export function resetAcks () { acked.clear() }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Does this call DRIVE the machine — i.e. does it need the receipt?
|
|
49
|
+
* It mirrors policy.js's own classification instead of keeping a second list: a drifted list either
|
|
50
|
+
* locks the agent out of its own tools or lets it through without reading anything.
|
|
51
|
+
* Never driving: observation, `computer_ask` (the safety direction), and computer_ctrl
|
|
52
|
+
* stop/selftest/exit/indicator.
|
|
53
|
+
* Driving: everything in ACTUATION_TOOLS, a clipboard WRITE, calibrate (it moves the pointer) — and a
|
|
54
|
+
* BATCH only when something INSIDE it drives. Classifying the wrapper alone blocked batches built
|
|
55
|
+
* from pure observation steps: a false positive that buys no safety and costs a round trip. Recursion
|
|
56
|
+
* means a nested batch is judged by its innermost steps.
|
|
57
|
+
*/
|
|
58
|
+
export function isDrivingCall (name, args) {
|
|
59
|
+
const a = args || {}
|
|
60
|
+
if (name === 'computer_ask') return false
|
|
61
|
+
if (name === 'computer_ctrl') return String(a.action || 'selftest') === 'calibrate'
|
|
62
|
+
if (name === 'computer_clip') return a.text !== undefined
|
|
63
|
+
if (name === 'computer_batch') {
|
|
64
|
+
const steps = Array.isArray(a.actions) ? a.actions : []
|
|
65
|
+
return steps.some((step) => step && isDrivingCall(String(step.tool || ''), step.args || {}))
|
|
66
|
+
}
|
|
67
|
+
return ACTUATION_TOOLS.has(name)
|
|
68
|
+
}
|