deepseek-computer-use 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/cordis.patch.yml +28 -0
- package/lib/bridge.js +224 -0
- package/lib/build-helper.mjs +106 -0
- package/lib/build-native.mjs +52 -0
- package/lib/build.mjs +22 -0
- package/lib/config.js +95 -0
- package/lib/index.js +156 -0
- package/lib/safety.js +247 -0
- package/lib/tools.js +534 -0
- package/native/Driver.cs +1803 -0
- package/native/Json.cs +274 -0
- package/native/Overlay.cs +669 -0
- package/native/Program.cs +158 -0
- package/native/audio/Audio.csproj +17 -0
- package/native/audio/Program.cs +95 -0
- package/native/wgcfw/Capture.csproj +23 -0
- package/native/wgcfw/Program.cs +249 -0
- package/package.json +66 -4
- package/skills/computer-use/SKILL.md +90 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the Windows helper with the system C# compiler.
|
|
3
|
+
*
|
|
4
|
+
* Why compile instead of shipping a binary: Windows 10/11 always carry
|
|
5
|
+
* csc.exe (part of .NET Framework 4.x), so this needs no SDK, no npm native
|
|
6
|
+
* module, and no build server. The exe is cached and rebuilt only when a
|
|
7
|
+
* source file is newer.
|
|
8
|
+
*
|
|
9
|
+
* @module deepseek-computer-use/build-helper
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
export const PLUGIN_ROOT = path.resolve(here, '..');
|
|
18
|
+
export const NATIVE_DIR = path.join(PLUGIN_ROOT, 'native');
|
|
19
|
+
export const BIN_DIR = path.join(NATIVE_DIR, 'bin');
|
|
20
|
+
export const HELPER_EXE = path.join(BIN_DIR, 'dsh-computer-use-helper.exe');
|
|
21
|
+
export const OVERLAY_EXE = path.join(BIN_DIR, 'dsh-cu-overlay.exe');
|
|
22
|
+
|
|
23
|
+
const SOURCES = ['Json.cs', 'Driver.cs', 'Program.cs'].map(name => path.join(NATIVE_DIR, name));
|
|
24
|
+
const OVERLAY_SOURCES = ['Overlay.cs'].map(name => path.join(NATIVE_DIR, name));
|
|
25
|
+
|
|
26
|
+
/** GAC assembly paths the compiler needs (UIAutomation + WindowsBase are not default refs). */
|
|
27
|
+
const GAC_ROOT = 'C:\\Windows\\Microsoft.NET\\assembly\\GAC_MSIL';
|
|
28
|
+
const GAC_REFS = ['WindowsBase', 'UIAutomationClient', 'UIAutomationTypes'];
|
|
29
|
+
|
|
30
|
+
function cscCandidates() {
|
|
31
|
+
return [
|
|
32
|
+
'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe',
|
|
33
|
+
'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe',
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findGacAssembly(name) {
|
|
38
|
+
const dir = path.join(GAC_ROOT, name);
|
|
39
|
+
if (!fs.existsSync(dir)) return null;
|
|
40
|
+
for (const version of fs.readdirSync(dir)) {
|
|
41
|
+
const candidate = path.join(dir, version, name + '.dll');
|
|
42
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findCompiler() {
|
|
48
|
+
for (const candidate of cscCandidates()) {
|
|
49
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isHelperFresh(exe = HELPER_EXE) {
|
|
55
|
+
if (!fs.existsSync(exe)) return false;
|
|
56
|
+
const exeTime = fs.statSync(exe).mtimeMs;
|
|
57
|
+
return SOURCES.every(source => fs.existsSync(source) && fs.statSync(source).mtimeMs <= exeTime);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compile the helper if it is missing or stale.
|
|
62
|
+
* @returns {{ exe: string, built: boolean, reason?: string }}
|
|
63
|
+
*/
|
|
64
|
+
export function ensureHelper({ force = false, exe = HELPER_EXE, log } = {}) {
|
|
65
|
+
if (!force && isHelperFresh(exe)) return { exe, built: false };
|
|
66
|
+
if (process.platform !== 'win32') return { exe, built: false, reason: 'not-windows' };
|
|
67
|
+
|
|
68
|
+
const compiler = findCompiler();
|
|
69
|
+
if (compiler === null) throw new Error('cannot build the Computer Use helper: csc.exe (.NET Framework 4.x) was not found');
|
|
70
|
+
|
|
71
|
+
const refs = [];
|
|
72
|
+
for (const name of GAC_REFS) {
|
|
73
|
+
const found = findGacAssembly(name);
|
|
74
|
+
if (found === null) throw new Error('cannot build the Computer Use helper: reference assembly ' + name + ' is missing from the GAC');
|
|
75
|
+
refs.push('/r:' + found);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fs.mkdirSync(path.dirname(exe), { recursive: true });
|
|
79
|
+
const args = ['/nologo', '/target:exe', '/platform:anycpu', '/out:' + exe, ...SOURCES, ...refs];
|
|
80
|
+
log?.('[dsh-computer-use] compiling helper with ' + compiler);
|
|
81
|
+
const output = execFileSync(compiler, args, { encoding: 'utf8', windowsHide: true });
|
|
82
|
+
if (!fs.existsSync(exe)) throw new Error('helper compilation produced no executable: ' + output);
|
|
83
|
+
return { exe, built: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The in-use indicator (breathing frame + virtual cursor) is a separate win-exe. */
|
|
87
|
+
export function isOverlayFresh(exe = OVERLAY_EXE) {
|
|
88
|
+
if (!fs.existsSync(exe)) return false;
|
|
89
|
+
const exeTime = fs.statSync(exe).mtimeMs;
|
|
90
|
+
return OVERLAY_SOURCES.every(source => fs.existsSync(source) && fs.statSync(source).mtimeMs <= exeTime);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ensureOverlay({ force = false, exe = OVERLAY_EXE, log } = {}) {
|
|
94
|
+
if (!force && isOverlayFresh(exe)) return { exe, built: false };
|
|
95
|
+
if (process.platform !== 'win32') return { exe, built: false, reason: 'not-windows' };
|
|
96
|
+
const compiler = findCompiler();
|
|
97
|
+
if (compiler === null) throw new Error('cannot build the Computer Use overlay: csc.exe (.NET Framework 4.x) was not found');
|
|
98
|
+
|
|
99
|
+
fs.mkdirSync(path.dirname(exe), { recursive: true });
|
|
100
|
+
const args = ['/nologo', '/target:winexe', '/platform:anycpu', '/out:' + exe, ...OVERLAY_SOURCES,
|
|
101
|
+
'/r:System.Windows.Forms.dll', '/r:System.Drawing.dll'];
|
|
102
|
+
log?.('[dsh-computer-use] compiling overlay with ' + compiler);
|
|
103
|
+
const output = execFileSync(compiler, args, { encoding: 'utf8', windowsHide: true });
|
|
104
|
+
if (!fs.existsSync(exe)) throw new Error('overlay compilation produced no executable: ' + output);
|
|
105
|
+
return { exe, built: true };
|
|
106
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the optional native helpers:
|
|
3
|
+
* capture/ Windows.Graphics.Capture tool (net48 + SharpDX)
|
|
4
|
+
* audio/ WASAPI loopback recorder (net48 + NAudio)
|
|
5
|
+
*
|
|
6
|
+
* Both target .NET Framework 4.8 - no runtime for the user to install - but they
|
|
7
|
+
* do need the .NET SDK to compile (NuGet restore). When dotnet is missing the
|
|
8
|
+
* plugin still works: capture falls back to PrintWindow and audio reports
|
|
9
|
+
* `audio_missing`.
|
|
10
|
+
*
|
|
11
|
+
* @module deepseek-computer-use/build-native
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { BIN_DIR, PLUGIN_ROOT } from './build-helper.mjs';
|
|
17
|
+
|
|
18
|
+
const PROJECTS = [
|
|
19
|
+
{ name: 'capture', csproj: path.join(PLUGIN_ROOT, 'native', 'wgcfw', 'Capture.csproj'), output: path.join(BIN_DIR, 'capture'), exe: 'dsh-cu-capture.exe' },
|
|
20
|
+
{ name: 'audio', csproj: path.join(PLUGIN_ROOT, 'native', 'audio', 'Audio.csproj'), output: path.join(BIN_DIR, 'audio'), exe: 'dsh-cu-audio.exe' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function hasDotnet() {
|
|
24
|
+
try { execFileSync('dotnet', ['--version'], { stdio: 'ignore', windowsHide: true }); return true; } catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildNative({ log } = {}) {
|
|
28
|
+
if (!hasDotnet()) {
|
|
29
|
+
log?.('[dsh-computer-use] .NET SDK not found: skipping capture/audio (fallbacks stay active)');
|
|
30
|
+
return { built: [], skipped: PROJECTS.map(p => p.name) };
|
|
31
|
+
}
|
|
32
|
+
const built = [];
|
|
33
|
+
const skipped = [];
|
|
34
|
+
for (const project of PROJECTS) {
|
|
35
|
+
if (!fs.existsSync(project.csproj)) { skipped.push(project.name); continue; }
|
|
36
|
+
try {
|
|
37
|
+
log?.('[dsh-computer-use] building native/' + project.name);
|
|
38
|
+
execFileSync('dotnet', ['build', project.csproj, '-c', 'Release', '-v', 'q', '--nologo'], { stdio: 'inherit', windowsHide: true });
|
|
39
|
+
const produced = path.join(path.dirname(project.csproj), 'bin', 'Release', 'net48');
|
|
40
|
+
fs.mkdirSync(project.output, { recursive: true });
|
|
41
|
+
for (const file of fs.readdirSync(produced)) {
|
|
42
|
+
if (!file.endsWith('.pdb')) fs.copyFileSync(path.join(produced, file), path.join(project.output, file));
|
|
43
|
+
}
|
|
44
|
+
if (fs.existsSync(path.join(project.output, project.exe))) built.push(project.name);
|
|
45
|
+
else skipped.push(project.name);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
log?.('[dsh-computer-use] ' + project.name + ' build failed: ' + error.message);
|
|
48
|
+
skipped.push(project.name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { built, skipped };
|
|
52
|
+
}
|
package/lib/build.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build every native piece this plugin uses:
|
|
4
|
+
* helper UIA / SendInput / capture dispatch (csc, always available)
|
|
5
|
+
* overlay in-use indicator (csc, always available)
|
|
6
|
+
* capture Windows.Graphics.Capture (needs .NET SDK)
|
|
7
|
+
* audio WASAPI loopback recorder (needs .NET SDK)
|
|
8
|
+
*/
|
|
9
|
+
import { ensureHelper, ensureOverlay, HELPER_EXE, OVERLAY_EXE } from './build-helper.mjs';
|
|
10
|
+
import { buildNative } from './build-native.mjs';
|
|
11
|
+
|
|
12
|
+
const log = message => console.log(message);
|
|
13
|
+
|
|
14
|
+
const helper = ensureHelper({ force: process.argv.includes('--force'), log });
|
|
15
|
+
log('[dsh-computer-use] helper ' + (helper.built ? 'built' : 'up to date') + ' -> ' + HELPER_EXE);
|
|
16
|
+
|
|
17
|
+
const overlay = ensureOverlay({ force: process.argv.includes('--force'), log });
|
|
18
|
+
log('[dsh-computer-use] overlay ' + (overlay.built ? 'built' : 'up to date') + ' -> ' + OVERLAY_EXE);
|
|
19
|
+
|
|
20
|
+
const native = buildNative({ log });
|
|
21
|
+
log('[dsh-computer-use] capture/audio built: ' + (native.built.join(', ') || 'none') +
|
|
22
|
+
(native.skipped.length > 0 ? ' | skipped: ' + native.skipped.join(', ') : ''));
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for deepseek-computer-use.
|
|
3
|
+
*
|
|
4
|
+
* Every field has a safe default: an empty config block must boot without
|
|
5
|
+
* touching a mouse, a keyboard, or the network.
|
|
6
|
+
*
|
|
7
|
+
* @module deepseek-computer-use/config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Accessible names that almost always carry an irreversible side effect. */
|
|
11
|
+
export const DEFAULT_RISKY_PATTERN =
|
|
12
|
+
'(\\bdelete\\b|\\bremove\\b|\\buninstall\\b|\\bformat\\b|\\berase\\b|\\bwipe\\b|\\breset\\b|' +
|
|
13
|
+
'\\bpay\\b|\\bpurchase\\b|\\bbuy\\b|\\btransfer\\b|\\bsubmit\\b|\\bsend\\b|\\bpublish\\b|\\bpost\\b|' +
|
|
14
|
+
'\\binstall\\b|删除|移除|卸载|格式化|清空|重置|支付|付款|转账|购买|下单|提交|发送|发布|安装|运行)';
|
|
15
|
+
|
|
16
|
+
const DEFAULTS = {
|
|
17
|
+
/** 'never' | 'sensitive' | 'always' — when to ask the DSH approval seam. */
|
|
18
|
+
approval: 'sensitive',
|
|
19
|
+
/** Empty array means "every app is reachable". Substrings of exe path or process name. */
|
|
20
|
+
allowApps: [],
|
|
21
|
+
denyApps: [],
|
|
22
|
+
/** Codex parity: get_window_state captures a screenshot unless told otherwise. */
|
|
23
|
+
defaultIncludeScreenshot: true,
|
|
24
|
+
maxElements: 260,
|
|
25
|
+
snapshotTtlMs: 120000,
|
|
26
|
+
/** Codex waits for the UI to settle before the next capture; same idea here. */
|
|
27
|
+
settleMs: 1000,
|
|
28
|
+
/** Ask once per app before Computer Use touches it ('session') or never ('never'). */
|
|
29
|
+
appConsent: 'session',
|
|
30
|
+
/** Apps whose screenshots must never be captured (substring match on path/name). */
|
|
31
|
+
redactApps: [],
|
|
32
|
+
/** WASAPI loopback recording tools (Codex gates the same feature behind SKY_ENABLE_AUDIO). */
|
|
33
|
+
audio: false,
|
|
34
|
+
/** Record actions in the result instead of injecting them (integration rehearsals). */
|
|
35
|
+
dryRun: false,
|
|
36
|
+
/** Prefix for every tool name, e.g. 'cu_' when another plugin already owns click/type_text. */
|
|
37
|
+
toolPrefix: '',
|
|
38
|
+
/** Request budget. Codex uses 10s for ordinary calls and 15s for launch_app. */
|
|
39
|
+
timeoutMs: 10000,
|
|
40
|
+
launchTimeoutMs: 15000,
|
|
41
|
+
helperPath: null,
|
|
42
|
+
riskyPattern: DEFAULT_RISKY_PATTERN,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const APPROVAL_MODES = new Set(['never', 'sensitive', 'always']);
|
|
46
|
+
|
|
47
|
+
function clampInt(value, min, max, fallback) {
|
|
48
|
+
const n = Number(value);
|
|
49
|
+
if (!Number.isFinite(n)) return fallback;
|
|
50
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stringList(value, fallback) {
|
|
54
|
+
if (!Array.isArray(value)) return fallback;
|
|
55
|
+
return value.map(item => String(item).trim()).filter(item => item.length > 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalize an untrusted config block into the resolved shape the plugin uses.
|
|
60
|
+
* Unknown keys are dropped; invalid values fall back instead of throwing so a
|
|
61
|
+
* bad config can never keep the host from starting.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveConfig(raw = {}) {
|
|
64
|
+
const input = raw && typeof raw === 'object' ? raw : {};
|
|
65
|
+
const approval = APPROVAL_MODES.has(input.approval) ? input.approval : DEFAULTS.approval;
|
|
66
|
+
let riskyPattern = DEFAULTS.riskyPattern;
|
|
67
|
+
if (typeof input.riskyPattern === 'string' && input.riskyPattern.length > 0) {
|
|
68
|
+
try {
|
|
69
|
+
new RegExp(input.riskyPattern);
|
|
70
|
+
riskyPattern = input.riskyPattern;
|
|
71
|
+
} catch {
|
|
72
|
+
riskyPattern = DEFAULTS.riskyPattern;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
approval,
|
|
77
|
+
allowApps: stringList(input.allowApps, DEFAULTS.allowApps),
|
|
78
|
+
denyApps: stringList(input.denyApps, DEFAULTS.denyApps),
|
|
79
|
+
defaultIncludeScreenshot: input.defaultIncludeScreenshot !== false,
|
|
80
|
+
maxElements: clampInt(input.maxElements, 20, 2000, DEFAULTS.maxElements),
|
|
81
|
+
snapshotTtlMs: clampInt(input.snapshotTtlMs, 1000, 3600000, DEFAULTS.snapshotTtlMs),
|
|
82
|
+
settleMs: clampInt(input.settleMs, 0, 10000, DEFAULTS.settleMs),
|
|
83
|
+
appConsent: input.appConsent === 'never' ? 'never' : DEFAULTS.appConsent,
|
|
84
|
+
redactApps: stringList(input.redactApps, DEFAULTS.redactApps),
|
|
85
|
+
audio: input.audio === true,
|
|
86
|
+
dryRun: input.dryRun === true,
|
|
87
|
+
toolPrefix: typeof input.toolPrefix === 'string' ? input.toolPrefix : DEFAULTS.toolPrefix,
|
|
88
|
+
timeoutMs: clampInt(input.timeoutMs, 1000, 120000, DEFAULTS.timeoutMs),
|
|
89
|
+
launchTimeoutMs: clampInt(input.launchTimeoutMs, 1000, 120000, DEFAULTS.launchTimeoutMs),
|
|
90
|
+
helperPath: typeof input.helperPath === 'string' && input.helperPath.length > 0 ? input.helperPath : null,
|
|
91
|
+
riskyPattern,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { DEFAULTS };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deepseek-computer-use - Windows Computer Use for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Registers the Codex window2 tool surface on top of a native Windows driver
|
|
5
|
+
* (UI Automation + SendInput + PrintWindow). The heavy lifting lives in a
|
|
6
|
+
* long-lived C# helper so element indexes stay valid across calls; this module
|
|
7
|
+
* owns the DSH seams: tool registration, image attachment, approval gating and
|
|
8
|
+
* config.
|
|
9
|
+
*
|
|
10
|
+
* @module deepseek-computer-use
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { HelperBridge } from './bridge.js';
|
|
16
|
+
import { resolveConfig } from './config.js';
|
|
17
|
+
import { ensureHelper, ensureOverlay, findCompiler, HELPER_EXE, isHelperFresh, isOverlayFresh } from './build-helper.mjs';
|
|
18
|
+
import { buildTools } from './tools.js';
|
|
19
|
+
|
|
20
|
+
export const name = 'deepseek-computer-use';
|
|
21
|
+
/** Only the tool registry is mandatory; skills/attachments/approval are optional. */
|
|
22
|
+
export const inject = ['tools'];
|
|
23
|
+
|
|
24
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const SKILL_DIR = path.resolve(here, '..', 'skills', 'computer-use');
|
|
26
|
+
|
|
27
|
+
function readSkill() {
|
|
28
|
+
try {
|
|
29
|
+
const text = fs.readFileSync(path.join(SKILL_DIR, 'SKILL.md'), 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
30
|
+
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*\n?/.exec(text);
|
|
31
|
+
if (match === null) return null;
|
|
32
|
+
let skillName = '';
|
|
33
|
+
let description = '';
|
|
34
|
+
for (const rawLine of match[1].split('\n')) {
|
|
35
|
+
const keyMatch = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$/.exec(rawLine.trim());
|
|
36
|
+
if (keyMatch === null) continue;
|
|
37
|
+
if (keyMatch[1] === 'name') skillName = keyMatch[2].replace(/^["']|["']$/g, '').trim();
|
|
38
|
+
if (keyMatch[1] === 'description') description = keyMatch[2].replace(/^["']|["']$/g, '').trim();
|
|
39
|
+
}
|
|
40
|
+
const content = text.slice(match[0].length).trimStart();
|
|
41
|
+
if (skillName === '' || description === '' || content.length === 0) return null;
|
|
42
|
+
return { name: skillName, description, content };
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function apply(ctx, rawConfig = {}) {
|
|
49
|
+
const config = resolveConfig(rawConfig);
|
|
50
|
+
const warn = message => {
|
|
51
|
+
try { ctx.logger?.warn?.(message); } catch { /* logger is optional */ }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const states = new Map();
|
|
55
|
+
let bridge = null;
|
|
56
|
+
let starting = null;
|
|
57
|
+
const exePath = config.helperPath ?? HELPER_EXE;
|
|
58
|
+
|
|
59
|
+
async function startBridge() {
|
|
60
|
+
const built = ensureHelper({ exe: exePath, log: warn });
|
|
61
|
+
// The in-use indicator is a second exe; build it eagerly so the very first
|
|
62
|
+
// action already shows the breathing frame instead of a silent no-op.
|
|
63
|
+
try { ensureOverlay({ log: warn }); } catch (error) { warn('overlay build skipped: ' + error.message); }
|
|
64
|
+
return new HelperBridge({ exe: built.exe, timeoutMs: config.timeoutMs, log: warn });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function getBridge() {
|
|
68
|
+
if (bridge !== null) return bridge;
|
|
69
|
+
if (starting === null) starting = startBridge();
|
|
70
|
+
try {
|
|
71
|
+
bridge = await starting;
|
|
72
|
+
return bridge;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
starting = null;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let configured = false;
|
|
80
|
+
let lastToken = null;
|
|
81
|
+
|
|
82
|
+
async function request(method, params = {}, opts = {}) {
|
|
83
|
+
const active = await getBridge();
|
|
84
|
+
|
|
85
|
+
if (!configured) {
|
|
86
|
+
// The helper refuses windows whose app was never approved; keep that switch in
|
|
87
|
+
// sync with the plugin policy.
|
|
88
|
+
try {
|
|
89
|
+
await active.request('configure', { requireAppApproval: config.appConsent !== 'never' }, { timeoutMs: config.timeoutMs });
|
|
90
|
+
} catch { /* older helper */ }
|
|
91
|
+
configured = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const token = opts.turnToken;
|
|
95
|
+
if (token !== undefined && token !== lastToken) {
|
|
96
|
+
if (lastToken !== null) {
|
|
97
|
+
// Turn boundary: drop element snapshots, screenshot regions and the Esc block.
|
|
98
|
+
try { await active.request('end_turn', {}, { timeoutMs: 5000 }); } catch { /* best effort */ }
|
|
99
|
+
}
|
|
100
|
+
lastToken = token;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const timeoutMs = opts.timeoutMs ?? (method === 'launch_app' ? config.launchTimeoutMs : config.timeoutMs);
|
|
104
|
+
return active.request(method, params, { ...opts, timeoutMs });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const describeHelper = () => {
|
|
108
|
+
try {
|
|
109
|
+
return {
|
|
110
|
+
path: exePath,
|
|
111
|
+
exists: fs.existsSync(exePath),
|
|
112
|
+
fresh: isHelperFresh(exePath),
|
|
113
|
+
compiler: findCompiler(),
|
|
114
|
+
overlay: isOverlayFresh(),
|
|
115
|
+
platform: process.platform,
|
|
116
|
+
};
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return { path: exePath, error: error.message };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const disposers = [];
|
|
123
|
+
for (const definition of buildTools(config, { request, ctx, log: warn, states, describeHelper })) {
|
|
124
|
+
disposers.push(ctx.tools.register(definition));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Optional: publish the bundled operator guide as a DSH skill.
|
|
128
|
+
try {
|
|
129
|
+
const skills = ctx.get?.('skills') ?? ctx.skills;
|
|
130
|
+
const skill = readSkill();
|
|
131
|
+
if (skill !== null && skills !== undefined && typeof skills.register === 'function') {
|
|
132
|
+
disposers.push(skills.register({
|
|
133
|
+
name: skill.name,
|
|
134
|
+
description: skill.description,
|
|
135
|
+
content: skill.content,
|
|
136
|
+
resourceBase: { kind: 'directory', path: SKILL_DIR },
|
|
137
|
+
source: 'runtime',
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
warn('[dsh-computer-use] skill registration skipped: ' + error.message);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof ctx.on === 'function') {
|
|
145
|
+
ctx.on('dispose', () => {
|
|
146
|
+
try { bridge?.dispose(); } catch { /* already gone */ }
|
|
147
|
+
bridge = null;
|
|
148
|
+
for (const dispose of disposers) {
|
|
149
|
+
try { dispose(); } catch { /* registry may be tearing down */ }
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { resolveConfig, buildTools, HelperBridge };
|
|
156
|
+
export default { name, inject, apply };
|
package/lib/safety.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety layers for deepseek-computer-use.
|
|
3
|
+
*
|
|
4
|
+
* Three independent gates, in the order the action hits them:
|
|
5
|
+
* 1. hard deny - never automatable, not even with user approval
|
|
6
|
+
* (terminals, Run dialog, password managers, security
|
|
7
|
+
* software, auth dialogs, the agent's own app, Win key)
|
|
8
|
+
* 2. reachability - is this app inside allowApps and outside denyApps?
|
|
9
|
+
* 3. confirmation - should the user approve this specific action?
|
|
10
|
+
*
|
|
11
|
+
* Ported from Codex's computer-use guidance.md ("Non-negotiable Windows
|
|
12
|
+
* Automation Safety") and confirmations.md.
|
|
13
|
+
*
|
|
14
|
+
* @module deepseek-computer-use/safety
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class PolicyError extends Error {
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'PolicyError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Processes that must never be driven by UI automation.
|
|
27
|
+
* Matched as lowercase substrings against the exe path, process name and title.
|
|
28
|
+
*/
|
|
29
|
+
export const HARD_DENY_APPS = [
|
|
30
|
+
// terminals and shells
|
|
31
|
+
'windowsterminal', 'windows terminal', 'wt.exe', 'cmd.exe', 'conhost', 'consolewindowclass',
|
|
32
|
+
'powershell.exe', 'pwsh.exe', 'powershell ise', 'mintty', 'git-bash', 'bash.exe', 'wsl.exe', 'ubuntu.exe',
|
|
33
|
+
// launch surfaces that can run arbitrary commands
|
|
34
|
+
'run dialog', '运行', 'shellexperiencehost',
|
|
35
|
+
// credential stores
|
|
36
|
+
'1password', 'keepass', 'bitwarden', 'lastpass', 'dashlane', 'keeper', 'enpass', 'nordpass', 'roboform',
|
|
37
|
+
// security and anti-malware
|
|
38
|
+
'securityhealth', 'windows security', 'windows 安全中心', 'msmpeng', 'defender', 'malwarebytes',
|
|
39
|
+
'avast', 'avg', 'kaspersky', 'bitdefender', 'eset', 'norton', 'mcafee', 'sophos', 'crowdstrike', 'sentinelone',
|
|
40
|
+
// authentication and elevation surfaces
|
|
41
|
+
'consent.exe', 'credentialuibroker', 'logonui', 'useraccountcontrol',
|
|
42
|
+
// the agent's own host and other assistants: automating them invites prompt loops
|
|
43
|
+
'deepseek harness', 'chatgpt', 'codex',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Window classes that are terminal-like regardless of the owning exe name. */
|
|
47
|
+
const HARD_DENY_CLASSES = [
|
|
48
|
+
'consolewindowclass', 'cascadia_hosting_window_class', 'mintty', '#32770',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Key tokens that are banned outright (Windows key family). */
|
|
52
|
+
const DENY_KEY_TOKENS = new Set([
|
|
53
|
+
'win', 'win_l', 'win_r', 'lwin', 'rwin', 'super', 'super_l', 'super_r', 'meta', 'meta_l', 'meta_r',
|
|
54
|
+
'cmd', 'command', 'os', 'os_l', 'os_r', 'windows', 'windows_l', 'windows_r', 'lwindows', 'rwindows',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
function lower(value) {
|
|
58
|
+
return String(value ?? '').toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function matchesAny(haystack, needles) {
|
|
62
|
+
const value = lower(haystack);
|
|
63
|
+
return needles.some(needle => value.includes(needle.toLowerCase()));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Gate 1: refuse windows that must never be automated.
|
|
68
|
+
* Runs before allowApps/denyApps so a user cannot opt into it by accident.
|
|
69
|
+
*/
|
|
70
|
+
export function assertNotHardDenied(window, extraDeny = HARD_DENY_APPS) {
|
|
71
|
+
const app = window?.app ?? {};
|
|
72
|
+
const fields = {
|
|
73
|
+
appId: String(app.id ?? ''),
|
|
74
|
+
appName: String(app.name ?? ''),
|
|
75
|
+
displayName: String(app.displayName ?? ''),
|
|
76
|
+
title: String(window?.title ?? ''),
|
|
77
|
+
className: String(window?.className ?? ''),
|
|
78
|
+
};
|
|
79
|
+
const hit = Object.values(fields).find(value => matchesAny(value, extraDeny));
|
|
80
|
+
if (hit !== undefined) {
|
|
81
|
+
throw new PolicyError(
|
|
82
|
+
'hard_denied',
|
|
83
|
+
'Computer Use refuses to automate "' + (fields.title || fields.appName || fields.className) +
|
|
84
|
+
'": terminals, Run dialogs, credential stores, security software, authentication dialogs and the agent host are hard-denied and cannot be approved.',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const deniedClass = HARD_DENY_CLASSES.find(name => fields.className.toLowerCase().startsWith(name));
|
|
88
|
+
if (deniedClass !== undefined) {
|
|
89
|
+
throw new PolicyError(
|
|
90
|
+
'hard_denied',
|
|
91
|
+
'Computer Use refuses to automate window class "' + fields.className + '": console/dialog classes are hard-denied (Run dialog, terminals, generic system dialogs).',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Gate 1 for keyboard: the Windows key family is banned, alone or in a chord.
|
|
98
|
+
* Windows+anything can open Run, Settings, lock the desktop or trigger shell jumps.
|
|
99
|
+
*/
|
|
100
|
+
export function assertKeyAllowed(key) {
|
|
101
|
+
const parts = String(key ?? '').split('+').map(part => lower(part).trim()).filter(part => part.length > 0);
|
|
102
|
+
const banned = parts.find(part => DENY_KEY_TOKENS.has(part));
|
|
103
|
+
if (banned !== undefined) {
|
|
104
|
+
throw new PolicyError(
|
|
105
|
+
'hard_denied',
|
|
106
|
+
'the Windows/Meta/Super key ("' + banned + '") is hard-denied: it opens Run, Settings and shell surfaces that Computer Use must never drive.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Gate 1 for launch targets. */
|
|
112
|
+
export function assertLaunchAllowed(app) {
|
|
113
|
+
const value = lower(app);
|
|
114
|
+
const banned = HARD_DENY_APPS.find(needle => value.includes(needle.toLowerCase()));
|
|
115
|
+
if (banned !== undefined) {
|
|
116
|
+
throw new PolicyError('hard_denied', 'Computer Use refuses to launch "' + app + '": that surface is hard-denied.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Gate 2: allowApps / denyApps. */
|
|
121
|
+
export function assertAppAllowed(config, app, window) {
|
|
122
|
+
const id = String(app?.id ?? '');
|
|
123
|
+
const name = String(app?.name ?? app?.displayName ?? '');
|
|
124
|
+
const title = String(window?.title ?? '');
|
|
125
|
+
if (matchesAny(id, config.denyApps) || matchesAny(name, config.denyApps) || matchesAny(title, config.denyApps)) {
|
|
126
|
+
throw new PolicyError('app_denied', 'the Computer Use policy denies app "' + (name || id) + '" (denyApps)');
|
|
127
|
+
}
|
|
128
|
+
if (config.allowApps.length > 0 && !matchesAny(id, config.allowApps) && !matchesAny(name, config.allowApps)) {
|
|
129
|
+
throw new PolicyError('app_not_allowed', 'app "' + (name || id) + '" is outside allowApps; add it to the plugin config to permit this action');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The accessible label an action would hit, from the cached observation. */
|
|
134
|
+
export function labelOfElement(state, elementIndex) {
|
|
135
|
+
if (state === undefined || elementIndex === undefined || elementIndex === null) return '';
|
|
136
|
+
const elements = state.elements;
|
|
137
|
+
if (!Array.isArray(elements)) return '';
|
|
138
|
+
const hit = elements.find(element => Number(element.index) === Number(elementIndex));
|
|
139
|
+
if (hit === undefined) return '';
|
|
140
|
+
return String(hit.name ?? '') + ' ' + String(hit.role ?? '');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Gate 3: decide whether an action needs a human answer.
|
|
145
|
+
* 'sensitive' asks when the target label looks irreversible; launch_app always asks;
|
|
146
|
+
* 'always' asks for everything.
|
|
147
|
+
*/
|
|
148
|
+
export function needsApproval(config, { toolName, label = '' }) {
|
|
149
|
+
if (config.approval === 'never') return false;
|
|
150
|
+
if (config.approval === 'always') return true;
|
|
151
|
+
if (toolName === 'launch_app') return true;
|
|
152
|
+
if (label.length === 0) return false;
|
|
153
|
+
try {
|
|
154
|
+
return new RegExp(config.riskyPattern, 'i').test(label);
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ask the DSH approval seam and fail closed.
|
|
162
|
+
* A missing answerer rejects the action; it never silently proceeds.
|
|
163
|
+
*/
|
|
164
|
+
export async function requestApproval(ctx, exec, { toolName, reason }) {
|
|
165
|
+
const approval = ctx?.get?.('approval');
|
|
166
|
+
// The user's session preset may disable prompting entirely ("approval: never",
|
|
167
|
+
// e.g. the full-access preset). Honour that instead of failing every action:
|
|
168
|
+
// the hard denies above still apply, and asking would only return "rejected".
|
|
169
|
+
try {
|
|
170
|
+
if (typeof approval?.effectivePolicy === 'function') {
|
|
171
|
+
const policy = approval.effectivePolicy(exec?.agent?.session);
|
|
172
|
+
if (policy === 'never') return;
|
|
173
|
+
}
|
|
174
|
+
} catch { /* policy lookup is best effort */ }
|
|
175
|
+
if (approval === undefined || approval === null || typeof approval.request !== 'function') {
|
|
176
|
+
throw new PolicyError(
|
|
177
|
+
'approval_unavailable',
|
|
178
|
+
'this action needs user confirmation but no approval service is mounted; switch the session approval policy or change approval mode in the plugin config',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const outcome = await approval.request({
|
|
182
|
+
agent: exec?.agent,
|
|
183
|
+
toolName,
|
|
184
|
+
callId: exec?.callId,
|
|
185
|
+
reason,
|
|
186
|
+
signal: exec?.signal,
|
|
187
|
+
});
|
|
188
|
+
if (outcome !== 'allowed-once') {
|
|
189
|
+
throw new PolicyError('approval_denied', 'the user did not approve this action (' + outcome + ')');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Codex stops issuing input once a turn ends. The DSH session log is the only
|
|
195
|
+
* place that knows, so read it defensively: an unknown session shape never
|
|
196
|
+
* blocks an action, but a provably closed turn does.
|
|
197
|
+
*/
|
|
198
|
+
export function assertTurnOpen(exec) {
|
|
199
|
+
const session = exec?.agent?.session;
|
|
200
|
+
if (session === undefined || session === null) return;
|
|
201
|
+
if (typeof session.seq !== 'number' || typeof session.eventAt !== 'function') return;
|
|
202
|
+
const floor = Math.max(0, session.seq - 400);
|
|
203
|
+
for (let index = session.seq - 1; index >= floor; index -= 1) {
|
|
204
|
+
let type;
|
|
205
|
+
try { type = session.eventAt(index)?.type; } catch { return; }
|
|
206
|
+
if (type === 'turn/start') return;
|
|
207
|
+
if (type === 'turn/end') {
|
|
208
|
+
throw new PolicyError('turn_ended', 'the turn that requested this action has ended, so Computer Use stops issuing input. Ask the user to continue in a new turn.');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A token that identifies the current turn. Codex blocks further Computer Use
|
|
215
|
+
* calls for the rest of a turn once the user hits Esc; the helper cannot know
|
|
216
|
+
* about turns, so the plugin sends this token with every call.
|
|
217
|
+
*/
|
|
218
|
+
export function turnTokenOf(exec) {
|
|
219
|
+
const session = exec?.agent?.session;
|
|
220
|
+
if (session === undefined || session === null) return 'none';
|
|
221
|
+
if (typeof session.seq !== 'number' || typeof session.eventAt !== 'function') return 'none';
|
|
222
|
+
const floor = Math.max(0, session.seq - 400);
|
|
223
|
+
for (let index = session.seq - 1; index >= floor; index -= 1) {
|
|
224
|
+
let type;
|
|
225
|
+
try { type = session.eventAt(index)?.type; } catch { return 'none'; }
|
|
226
|
+
if (type === 'turn/start') {
|
|
227
|
+
// Include the session so a persisted Esc marker can never block a later session
|
|
228
|
+
// that happens to reuse the same turn index (Codex scopes markers the same way).
|
|
229
|
+
const id = session.id ?? session.sessionId ?? session.key;
|
|
230
|
+
const scope = typeof id === 'string' && id.length > 0 ? id : 'session';
|
|
231
|
+
return scope + ':' + 'turn-' + index;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return 'none';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Windows whose pixels must never leave the machine ("appshot" guard). Codex mounts a
|
|
239
|
+
* prepared overlay around the capture; here the same intent is enforced by refusing the
|
|
240
|
+
* screenshot for the listed apps and reporting it.
|
|
241
|
+
*/
|
|
242
|
+
export function isRedacted(config, app) {
|
|
243
|
+
const list = Array.isArray(config?.redactApps) ? config.redactApps : [];
|
|
244
|
+
if (list.length === 0) return false;
|
|
245
|
+
const haystack = (String(app?.id ?? '') + ' ' + String(app?.name ?? '') + ' ' + String(app?.displayName ?? '')).toLowerCase();
|
|
246
|
+
return list.some(needle => haystack.includes(String(needle).toLowerCase()));
|
|
247
|
+
}
|