create-openclaw-bot 5.17.0 → 5.17.1
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.md +3 -3
- package/README.vi.md +4 -4
- package/dist/server/local-server.js +461 -905
- package/dist/setup/shared/windows-launcher-gen.js +29 -3
- package/dist/web/app.js +42 -41
- package/package.json +1 -1
|
@@ -727,6 +727,19 @@ function extraBinDirs() {
|
|
|
727
727
|
return dirs;
|
|
728
728
|
}
|
|
729
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Windows refuses to spawn a .cmd/.bat shim without a shell.
|
|
732
|
+
*
|
|
733
|
+
* Node has thrown a bare `spawn EINVAL` for that since the 2024 argument-injection fix, with no
|
|
734
|
+
* hint about which command or why. Every `openclaw` call here goes through `openclaw.cmd`, and
|
|
735
|
+
* ocCapture asks for `shell: false`, so on Windows those calls died on arrival: measured on a
|
|
736
|
+
* customer machine, the node host started and connected fine and then `nodes approve` failed with
|
|
737
|
+
* nothing but "spawn EINVAL" in the log. Force the shell for these, whatever the caller asked.
|
|
738
|
+
*/
|
|
739
|
+
function needsWindowsShell(bin) {
|
|
740
|
+
return process.platform === 'win32' && /\.(cmd|bat)"?$/i.test(String(bin || ''));
|
|
741
|
+
}
|
|
742
|
+
|
|
730
743
|
function resolveBinPath(cmd) {
|
|
731
744
|
if (!cmd || cmd.includes('/') || cmd.includes('\\')) return cmd;
|
|
732
745
|
const names = process.platform === 'win32' ? [`${cmd}.cmd`, `${cmd}.exe`, cmd] : [cmd];
|
|
@@ -1022,8 +1035,8 @@ function runCapture(cmd, args, opts = {}) {
|
|
|
1022
1035
|
return new Promise((resolve) => {
|
|
1023
1036
|
let stdout = '';
|
|
1024
1037
|
let stderr = '';
|
|
1025
|
-
const shell = opts.shell ?? process.platform === 'win32';
|
|
1026
1038
|
const rawBin = resolveBinPath(cmd);
|
|
1039
|
+
const shell = needsWindowsShell(rawBin) || (opts.shell ?? process.platform === 'win32');
|
|
1027
1040
|
const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
|
|
1028
1041
|
const child = spawn(bin, args, {
|
|
1029
1042
|
cwd: opts.cwd,
|
|
@@ -1311,7 +1324,16 @@ async function migrateNativePaths(projectDir) {
|
|
|
1311
1324
|
const abs = join(wsRoot, base);
|
|
1312
1325
|
if (obj.workspace !== abs) { obj.workspace = abs; changed = true; }
|
|
1313
1326
|
};
|
|
1327
|
+
// Walk BOTH shapes. This reads the file with a raw JSON.parse (no ensureConfigShape), so on an
|
|
1328
|
+
// openclaw >=2026.8 config - which keys agents by `agents.entries`, not `agents.list` - `.list`
|
|
1329
|
+
// is simply undefined and the loop used to run over an empty array and fix nothing. A bot added
|
|
1330
|
+
// to such a project kept whatever workspace path it was written with; when that was a container
|
|
1331
|
+
// path the gateway refused to start at all and every bot in the project went down with it.
|
|
1314
1332
|
for (const a of (cfg.agents?.list || [])) fix(a);
|
|
1333
|
+
const entries = cfg.agents?.entries;
|
|
1334
|
+
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
|
|
1335
|
+
for (const a of Object.values(entries)) fix(a);
|
|
1336
|
+
}
|
|
1315
1337
|
fix(cfg.agents?.defaults);
|
|
1316
1338
|
if (changed) {
|
|
1317
1339
|
await fsp.copyFile(cfgPath, `${cfgPath}.bak`).catch(() => {});
|
|
@@ -1741,6 +1763,52 @@ async function appendEnvValue(projectDir, key, value) {
|
|
|
1741
1763
|
await fsp.writeFile(envPath, env, 'utf8');
|
|
1742
1764
|
}
|
|
1743
1765
|
|
|
1766
|
+
/**
|
|
1767
|
+
* Ask openclaw itself whether the config we just wrote is schema-valid, and roll back if not.
|
|
1768
|
+
*
|
|
1769
|
+
* validateOpenclawConfig() below only checks the shapes THIS file cares about. openclaw's own
|
|
1770
|
+
* schema is strict and rejects unknown keys, and it does so at BOOT: one bad key anywhere means
|
|
1771
|
+
* `Gateway failed to start: Invalid config ...` and every bot in the project goes dark at once.
|
|
1772
|
+
* The operator sees bots "not logged in" and reasonably concludes their sessions are gone, when
|
|
1773
|
+
* nothing is wrong with the sessions at all.
|
|
1774
|
+
*
|
|
1775
|
+
* So: write, ask openclaw, and put the backup back the moment it complains. Restoring is always
|
|
1776
|
+
* better than leaving a config in place that we already know will not boot.
|
|
1777
|
+
* Returns null when the config is fine (or when we could not run the check), a message otherwise.
|
|
1778
|
+
*/
|
|
1779
|
+
async function verifyConfigOrRollback(projectDir) {
|
|
1780
|
+
if (!isNativeProject(projectDir)) return null; // Docker is retired; only check what we run.
|
|
1781
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
1782
|
+
const backup = `${cfgPath}.bak`;
|
|
1783
|
+
const r = await ocCapture(projectDir, ['config', 'validate'], { timeout: 30000 }).catch(() => null);
|
|
1784
|
+
if (!r) return null; // Could not run the CLI at all - do not guess.
|
|
1785
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`;
|
|
1786
|
+
if (r.code === 0 || /Config valid/i.test(out)) return null;
|
|
1787
|
+
// Fail SAFE: roll back only when openclaw actually says the config is bad. A non-zero exit can
|
|
1788
|
+
// also mean it looked in the wrong place ("Config file not found") or that the CLI itself broke,
|
|
1789
|
+
// and restoring a backup over a perfectly good config would be worse than the bug this guards.
|
|
1790
|
+
// Wording measured on 2026.9.2: "OpenClaw config is invalid: ..." followed by "× openclaw.json:38
|
|
1791
|
+
// — agents.entries.<id>: Unrecognized key: "role"". Match the shapes openclaw actually prints.
|
|
1792
|
+
if (!/config is invalid|invalid config|unrecognized key|invalid input|invalid option|expected/i.test(out)) {
|
|
1793
|
+
sendLog(`[config] Bỏ qua kiểm tra cấu hình (openclaw không kết luận được): ${out.trim().slice(0, 160)}`);
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
// Keep the rejected file next to the good one: it is the only evidence of what went wrong.
|
|
1797
|
+
const rejected = `${cfgPath}.rejected-${Date.now()}`;
|
|
1798
|
+
await fsp.copyFile(cfgPath, rejected).catch(() => {});
|
|
1799
|
+
let restored = false;
|
|
1800
|
+
if (existsSync(backup)) {
|
|
1801
|
+
await fsp.copyFile(backup, cfgPath).catch(() => {});
|
|
1802
|
+
restored = true;
|
|
1803
|
+
}
|
|
1804
|
+
const detail = out.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 4).join(' · ');
|
|
1805
|
+
sendLog(`[config] openclaw từ chối cấu hình vừa ghi: ${detail}`);
|
|
1806
|
+
sendLog(`[config] Bản bị từ chối giữ ở ${rejected}${restored ? '; đã khôi phục bản trước đó.' : '.'}`);
|
|
1807
|
+
return restored
|
|
1808
|
+
? `Cấu hình vừa ghi bị openclaw từ chối nên đã khôi phục bản cũ (bot vẫn chạy bình thường). Lý do: ${detail}`
|
|
1809
|
+
: `Cấu hình vừa ghi bị openclaw từ chối và không có bản sao lưu để khôi phục. Lý do: ${detail}`;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1744
1812
|
function validateOpenclawConfig(cfg) {
|
|
1745
1813
|
if (!Array.isArray(cfg.agents?.list)) throw httpError(500, 'openclaw.json missing agents.list');
|
|
1746
1814
|
for (const a of cfg.agents.list) {
|
|
@@ -2133,6 +2201,9 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
|
2133
2201
|
// so the generator's ".openclaw/workspace-x" would double. Rewrite to an absolute path now so
|
|
2134
2202
|
// the bot reads its persona on the very first turn (not only after the next runtime sync).
|
|
2135
2203
|
if (isNativeProject(projectDir)) await migrateNativePaths(projectDir).catch(() => {});
|
|
2204
|
+
// Check AFTER the path normalisation above, so we validate exactly what the gateway will read.
|
|
2205
|
+
const rejected = await verifyConfigOrRollback(projectDir).catch(() => null);
|
|
2206
|
+
if (rejected) throw httpError(500, rejected);
|
|
2136
2207
|
await syncExecApprovals(projectDir, cfg);
|
|
2137
2208
|
|
|
2138
2209
|
const hasScheduler = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
|
|
@@ -2244,10 +2315,21 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
|
|
|
2244
2315
|
}
|
|
2245
2316
|
|
|
2246
2317
|
agent.name = botName;
|
|
2247
|
-
agent.
|
|
2318
|
+
// NEVER put `role` (or any free-form field) on the agent entry. openclaw's schema is strict and
|
|
2319
|
+
// rejects unknown keys outright: `agents.entries.<id>: Unrecognized key: "role"` makes the
|
|
2320
|
+
// gateway refuse to boot, which takes down EVERY bot in the project, not just the edited one.
|
|
2321
|
+
// Measured on a customer host: editing one bot silently killed all of them, and the dashboard
|
|
2322
|
+
// then showed "chưa đăng nhập" for sessions that were perfectly intact.
|
|
2323
|
+
// The description already has a home - bot-meta.json, written a few lines below - and that is
|
|
2324
|
+
// what the UI reads back (readBotIdentity prefers meta.role). ensureConfigShape deletes this key
|
|
2325
|
+
// on load precisely because it does not belong here; re-adding it on save just undid that.
|
|
2248
2326
|
validateOpenclawConfig(cfg);
|
|
2249
2327
|
if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
|
|
2250
2328
|
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
2329
|
+
if (isNativeProject(projectDir)) await migrateNativePaths(projectDir).catch(() => {});
|
|
2330
|
+
// Editing one bot must never be able to take the whole project down.
|
|
2331
|
+
const rejectedEdit = await verifyConfigOrRollback(projectDir).catch(() => null);
|
|
2332
|
+
if (rejectedEdit) throw httpError(500, rejectedEdit);
|
|
2251
2333
|
await syncExecApprovals(projectDir, cfg);
|
|
2252
2334
|
|
|
2253
2335
|
// Synchronize the token to .env files for the primary bot to ensure Docker picks it up
|
|
@@ -2939,9 +3021,31 @@ function nativeEnv(projectDir, extra = {}) {
|
|
|
2939
3021
|
};
|
|
2940
3022
|
}
|
|
2941
3023
|
|
|
3024
|
+
/**
|
|
3025
|
+
* Path to openclaw's own entry script, so we can run it with THIS node instead of its .cmd shim.
|
|
3026
|
+
*
|
|
3027
|
+
* The shim is a liability on Windows twice over: Node refuses to spawn a .cmd without a shell
|
|
3028
|
+
* (`spawn EINVAL`), and the shim itself then re-resolves `node` from PATH - which fails with
|
|
3029
|
+
* `'"node"' is not recognized as an internal or external command` whenever the installer's own
|
|
3030
|
+
* environment does not carry node's directory. Both were measured on a customer machine, and both
|
|
3031
|
+
* surfaced as "computer use does not work" rather than as anything to do with PATH. Calling the
|
|
3032
|
+
* script directly with process.execPath sidesteps the shim entirely; the launchers already do it.
|
|
3033
|
+
*/
|
|
3034
|
+
function openclawEntryScript() {
|
|
3035
|
+
for (const dir of globalNodeModulesDirs()) {
|
|
3036
|
+
const entry = join(dir, 'openclaw', 'dist', 'index.js');
|
|
3037
|
+
try { if (existsSync(entry)) return entry; } catch {}
|
|
3038
|
+
}
|
|
3039
|
+
return '';
|
|
3040
|
+
}
|
|
3041
|
+
|
|
2942
3042
|
/** Resolve `openclaw <args>` for whichever runtime this project uses. */
|
|
2943
3043
|
function ocArgv(projectDir, args) {
|
|
2944
3044
|
if (isNativeProject(projectDir)) {
|
|
3045
|
+
const entry = openclawEntryScript();
|
|
3046
|
+
// Prefer the script over the shim; fall back to the shim only when the global install is
|
|
3047
|
+
// somewhere we did not expect, so an unusual layout still works as before.
|
|
3048
|
+
if (entry) return { cmd: process.execPath, args: [entry, ...args], opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
|
|
2945
3049
|
return { cmd: 'openclaw', args, opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
|
|
2946
3050
|
}
|
|
2947
3051
|
return { cmd: 'docker', args: ['exec', getBotContainerName(projectDir), 'openclaw', ...args], opts: { cwd: projectDir } };
|
|
@@ -3094,6 +3198,50 @@ async function runOpenclawDoctorFixIfNeeded(projectDir) {
|
|
|
3094
3198
|
}
|
|
3095
3199
|
}
|
|
3096
3200
|
|
|
3201
|
+
/**
|
|
3202
|
+
* Restart the gateway on Windows the way the double-click launcher starts it.
|
|
3203
|
+
*
|
|
3204
|
+
* Stops the running gateway process, then relaunches `gateway-start.cmd` through `run-hidden.vbs`
|
|
3205
|
+
* - the exact pair "1 - KHOI DONG BOT" uses, so the gateway lands in the operator's own desktop
|
|
3206
|
+
* session with no console window. Returns true only once the port answers again: a restart that
|
|
3207
|
+
* silently left the bot down is the failure this whole function exists to avoid.
|
|
3208
|
+
*/
|
|
3209
|
+
async function restartWindowsGateway(projectDir) {
|
|
3210
|
+
const vbs = join(projectDir, 'run-hidden.vbs');
|
|
3211
|
+
const cmd = join(projectDir, 'gateway-start.cmd');
|
|
3212
|
+
// The launchers are rewritten on every start, but a project from an older build may not have
|
|
3213
|
+
// them yet. Write them now rather than failing - they are the supported way in on Windows.
|
|
3214
|
+
if (!existsSync(vbs) || !existsSync(cmd)) {
|
|
3215
|
+
const meta = readNativeMeta(projectDir) || {};
|
|
3216
|
+
await writeWindowsLaunchers(
|
|
3217
|
+
projectDir,
|
|
3218
|
+
meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT,
|
|
3219
|
+
meta.routerPort || state.routerPort || NATIVE_DEFAULT_ROUTER_PORT,
|
|
3220
|
+
).catch((e) => sendLog(`[native] không tạo được launcher: ${e.message}`));
|
|
3221
|
+
}
|
|
3222
|
+
if (!existsSync(vbs) || !existsSync(cmd)) return false;
|
|
3223
|
+
|
|
3224
|
+
const meta = readNativeMeta(projectDir) || {};
|
|
3225
|
+
const port = meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT;
|
|
3226
|
+
await runCapture('powershell', ['-NoProfile', '-Command',
|
|
3227
|
+
"Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*openclaw*gateway --port*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"],
|
|
3228
|
+
{ shell: false, timeout: 20000 }).catch(() => {});
|
|
3229
|
+
// Give the socket time to be released, or the new gateway loses the port to its own corpse.
|
|
3230
|
+
for (let i = 0; i < 10 && (await portStatus(port)) === 'online'; i++) {
|
|
3231
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
3232
|
+
}
|
|
3233
|
+
startDetached('wscript.exe', [vbs, cmd], { cwd: projectDir });
|
|
3234
|
+
for (let i = 0; i < 45; i++) {
|
|
3235
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
3236
|
+
if ((await portStatus(port)) === 'online') {
|
|
3237
|
+
sendLog(`[native] Bot đã khởi động lại (cổng ${port}).`);
|
|
3238
|
+
return true;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
sendLog(`[native] Bot chưa lên lại sau 90s (cổng ${port}).`);
|
|
3242
|
+
return false;
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3097
3245
|
async function restartNativeRuntime(projectDir) {
|
|
3098
3246
|
// Every restart is a chance to repair a project installed before these fixes existed — the
|
|
3099
3247
|
// calls are no-ops once the service env is complete, stray files are adopted, and the config
|
|
@@ -3109,6 +3257,18 @@ async function restartNativeRuntime(projectDir) {
|
|
|
3109
3257
|
};
|
|
3110
3258
|
let res;
|
|
3111
3259
|
if (process.platform === 'win32') {
|
|
3260
|
+
// `openclaw daemon` cannot touch the gateway on Windows. It manages a Scheduled Task, and it
|
|
3261
|
+
// refuses to create one for our layout at all: "service management skipped: non-default state
|
|
3262
|
+
// dir or config path" (the project keeps its state in <project>\.openclaw, not the account
|
|
3263
|
+
// home). So `daemon stop` + `daemon start` both report success against a service that does not
|
|
3264
|
+
// exist, while the real gateway - started by "1 - KHOI DONG BOT" as a plain hidden process -
|
|
3265
|
+
// keeps running untouched. Measured on a customer machine: `daemon status` said
|
|
3266
|
+
// "Runtime: stopped · Service unit not found" while the port was demonstrably listening, and
|
|
3267
|
+
// the dashboard's Restart button silently did nothing, so a config change never took effect.
|
|
3268
|
+
// Restart it the same way the launcher starts it instead.
|
|
3269
|
+
const restarted = await restartWindowsGateway(projectDir);
|
|
3270
|
+
if (restarted) return;
|
|
3271
|
+
sendLog('[native] Không khởi động lại được bằng launcher, thử qua daemon.');
|
|
3112
3272
|
res = await stopStart();
|
|
3113
3273
|
} else {
|
|
3114
3274
|
res = await ocDaemon(projectDir, 'restart');
|
|
@@ -3588,6 +3748,12 @@ async function prepareNativeStateHome(projectDir) {
|
|
|
3588
3748
|
* from here works because the operator pressing the button is sitting at that desktop.
|
|
3589
3749
|
*/
|
|
3590
3750
|
async function setComputerUse(projectDir, enable) {
|
|
3751
|
+
// Docker is retired, and the node host has to touch a real desktop, so this only makes sense
|
|
3752
|
+
// on a native project. Say so plainly instead of half-applying and leaving the operator to
|
|
3753
|
+
// wonder why the bot still refuses.
|
|
3754
|
+
if (!isNativeProject(projectDir)) {
|
|
3755
|
+
return { ok: false, error: 'Chỉ dùng được với bot chạy native (không phải Docker).' };
|
|
3756
|
+
}
|
|
3591
3757
|
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
3592
3758
|
if (!existsSync(cfgPath)) return { ok: false, error: 'openclaw.json not found' };
|
|
3593
3759
|
const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
|
|
@@ -3601,36 +3767,192 @@ async function setComputerUse(projectDir, enable) {
|
|
|
3601
3767
|
if (enable) {
|
|
3602
3768
|
allow.add('computer');
|
|
3603
3769
|
allow.add('screen');
|
|
3770
|
+
// Without this, `plugins enable` is refused outright with "blocked by allowlist".
|
|
3604
3771
|
if (!cfg.plugins.allow.includes('cua-computer')) cfg.plugins.allow.push('cua-computer');
|
|
3605
3772
|
cfg.plugins.entries['cua-computer'] = { ...(cfg.plugins.entries['cua-computer'] || {}), enabled: true };
|
|
3773
|
+
// A fourth gate nobody sees until they hit it: the gateway keeps a per-platform allowlist of
|
|
3774
|
+
// node commands. `computer.act` counts as a dangerous default and `screen.snapshot` as a
|
|
3775
|
+
// desktop-host command, so BOTH are stripped from the defaults and the invoke is refused with
|
|
3776
|
+
// `"screen.snapshot" is not in the allowlist for platform "windows"` — even though the plugin
|
|
3777
|
+
// is enabled and the node is paired and approved. Only gateway.nodes.commands.allow puts them
|
|
3778
|
+
// back (it is applied after the dangerous-command filter).
|
|
3779
|
+
cfg.gateway = (cfg.gateway && typeof cfg.gateway === 'object') ? cfg.gateway : {};
|
|
3780
|
+
cfg.gateway.nodes = (cfg.gateway.nodes && typeof cfg.gateway.nodes === 'object') ? cfg.gateway.nodes : {};
|
|
3781
|
+
cfg.gateway.nodes.commands = (cfg.gateway.nodes.commands && typeof cfg.gateway.nodes.commands === 'object')
|
|
3782
|
+
? cfg.gateway.nodes.commands : {};
|
|
3783
|
+
const nodeAllow = new Set(Array.isArray(cfg.gateway.nodes.commands.allow) ? cfg.gateway.nodes.commands.allow : []);
|
|
3784
|
+
nodeAllow.add('screen.snapshot');
|
|
3785
|
+
nodeAllow.add('computer.act');
|
|
3786
|
+
cfg.gateway.nodes.commands.allow = [...nodeAllow];
|
|
3606
3787
|
} else {
|
|
3607
3788
|
allow.delete('computer');
|
|
3608
3789
|
allow.delete('screen');
|
|
3609
3790
|
if (cfg.plugins.entries['cua-computer']) cfg.plugins.entries['cua-computer'].enabled = false;
|
|
3791
|
+
const nodeAllow = cfg.gateway?.nodes?.commands?.allow;
|
|
3792
|
+
if (Array.isArray(nodeAllow)) {
|
|
3793
|
+
cfg.gateway.nodes.commands.allow = nodeAllow.filter((c) => c !== 'screen.snapshot' && c !== 'computer.act');
|
|
3794
|
+
}
|
|
3795
|
+
// Leave the allowlist clean too, so a later re-enable is a deliberate act rather than a
|
|
3796
|
+
// leftover permission nobody remembers granting.
|
|
3797
|
+
cfg.plugins.allow = cfg.plugins.allow.filter((x) => x !== 'cua-computer');
|
|
3610
3798
|
}
|
|
3611
3799
|
cfg.tools.alsoAllow = [...allow];
|
|
3612
3800
|
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
3613
3801
|
|
|
3614
3802
|
if (!enable) {
|
|
3615
3803
|
await stopNodeHost().catch(() => {});
|
|
3616
|
-
|
|
3804
|
+
await restartNativeRuntime(projectDir).catch(() => {});
|
|
3805
|
+
sendLog('[computer-use] Đã tắt: gỡ tool computer/screen, tắt plugin và dừng node điều khiển.');
|
|
3617
3806
|
return { ok: true, enabled: false };
|
|
3618
3807
|
}
|
|
3619
3808
|
|
|
3620
|
-
|
|
3809
|
+
// The gateway reads plugins and the tool allowlist at boot. openclaw itself says "Restart the
|
|
3810
|
+
// gateway to apply" when a plugin is enabled — skip this and the switch reports success while
|
|
3811
|
+
// the bot still has no computer tool, which is exactly the kind of silent half-success that
|
|
3812
|
+
// sends the owner back to us.
|
|
3813
|
+
sendLog('[computer-use] Đã bật tool computer + plugin cua-computer. Đang khởi động lại bot để nạp...');
|
|
3814
|
+
// Stop the node host FIRST. Restarting the gateway drops its socket, and on some closes the node
|
|
3815
|
+
// gives up with "reconnect paused ... exiting for supervisor restart" - there is no supervisor
|
|
3816
|
+
// here, so it would sit dead while everything else looked fine. Start it fresh afterwards.
|
|
3817
|
+
await stopNodeHost().catch(() => {});
|
|
3818
|
+
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[computer-use] restart: ${e.message}`));
|
|
3819
|
+
|
|
3621
3820
|
const meta = readNativeMeta(projectDir) || {};
|
|
3622
3821
|
const port = meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT;
|
|
3822
|
+
// The node host cannot connect until the gateway is listening again.
|
|
3823
|
+
let up = false;
|
|
3824
|
+
for (let i = 0; i < 30; i++) {
|
|
3825
|
+
if ((await portStatus(port)) === 'online') { up = true; break; }
|
|
3826
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
3827
|
+
}
|
|
3828
|
+
if (!up) return { ok: false, error: `Bot chưa khởi động lại xong (cổng ${port}). Thử lại sau ít phút.` };
|
|
3829
|
+
|
|
3623
3830
|
await startNodeHost(projectDir, port);
|
|
3624
|
-
|
|
3831
|
+
const running = await nodeHostRunning();
|
|
3832
|
+
if (!running) return { ok: false, error: 'Đã bật quyền nhưng node điều khiển chưa chạy. Xem nhật ký để biết vì sao.' };
|
|
3833
|
+
|
|
3834
|
+
// The node registers its capability surface a moment after the socket opens, so the pending
|
|
3835
|
+
// request is not there instantly. Give it a few rounds rather than approving once and hoping.
|
|
3836
|
+
// The node publishes its capability surface a little after the socket opens, and the CUA driver
|
|
3837
|
+
// is imported asynchronously on top of that, so the first look is expected to come up empty.
|
|
3838
|
+
// Measured on a customer machine: connected and approved within seconds, capabilities visible
|
|
3839
|
+
// roughly half a minute later. Checking a couple of times and giving up reports a working setup
|
|
3840
|
+
// as broken, so wait properly.
|
|
3841
|
+
let caps = await nodeHasComputerCaps(projectDir);
|
|
3842
|
+
for (let i = 0; i < 20 && !caps; i++) {
|
|
3843
|
+
await approvePendingNodes(projectDir).catch(() => {});
|
|
3844
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
3845
|
+
caps = await nodeHasComputerCaps(projectDir);
|
|
3846
|
+
}
|
|
3847
|
+
if (!caps) {
|
|
3848
|
+
// Half-success, reported as such. Mở app và chạy lệnh đi đường `exec` nên vẫn dùng được bình
|
|
3849
|
+
// thường; chỉ phần chụp/điều khiển màn hình là chưa. Trả ok:true để phần đã chạy được không bị
|
|
3850
|
+
// báo thành hỏng — báo hỏng toàn bộ khiến người dùng tưởng mất luôn thứ đang chạy.
|
|
3851
|
+
sendLog(`[computer-use] Node đã chạy nhưng chưa khai báo được khả năng màn hình. ${nodeCapsLastReason}`);
|
|
3852
|
+
return {
|
|
3853
|
+
ok: true,
|
|
3854
|
+
enabled: true,
|
|
3855
|
+
gatewayPort: port,
|
|
3856
|
+
screenControl: false,
|
|
3857
|
+
note: 'Bot mở ứng dụng và chạy lệnh trên máy được. Riêng chụp/điều khiển màn hình thì máy chưa '
|
|
3858
|
+
+ 'khai báo được khả năng này, nên tạm thời chưa dùng được.',
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
sendLog('[computer-use] Máy đã sẵn sàng: bot chụp màn hình, bấm chuột và gõ phím được.');
|
|
3862
|
+
return { ok: true, enabled: true, gatewayPort: port, screenControl: true };
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
/**
|
|
3866
|
+
* Read the gateway auth token out of the project config.
|
|
3867
|
+
*
|
|
3868
|
+
* The node host authenticates to the gateway over the same WebSocket everything else uses, and
|
|
3869
|
+
* with `gateway.auth.mode: "token"` it is rejected before it can advertise anything:
|
|
3870
|
+
* `unauthorized: gateway token missing (provide gateway auth token)` -> exit code 1.
|
|
3871
|
+
* Started detached, that failure is invisible: the process is simply gone a second later and the
|
|
3872
|
+
* switch looks like it worked. Pass the token explicitly.
|
|
3873
|
+
*/
|
|
3874
|
+
function gatewayAuthToken(projectDir) {
|
|
3875
|
+
try {
|
|
3876
|
+
const cfg = JSON.parse(fs.readFileSync(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8'));
|
|
3877
|
+
const t = cfg?.gateway?.auth?.token;
|
|
3878
|
+
return typeof t === 'string' && t ? t : '';
|
|
3879
|
+
} catch { return ''; }
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
/**
|
|
3883
|
+
* Give the node host a state dir of its own, holding nothing but what it needs.
|
|
3884
|
+
*
|
|
3885
|
+
* `openclaw node run` loads the plugins of whatever state dir it is pointed at. Point it at the
|
|
3886
|
+
* bot's and it loads the bot's plugins too - including zalo-mod, which opens its dashboard port.
|
|
3887
|
+
* The gateway already holds that port, so the node host dies on startup with
|
|
3888
|
+
* `listen EADDRINUSE: address already in use 127.0.0.1:18790`, before it ever publishes
|
|
3889
|
+
* `computer.act` / `screen.snapshot`. From the outside that is indistinguishable from "this
|
|
3890
|
+
* machine cannot do computer use": the node appears paired and approved, yet advertises only the
|
|
3891
|
+
* core capabilities. Measured on a customer machine, and it cost most of a day to see.
|
|
3892
|
+
*
|
|
3893
|
+
* A separate dir with only cua-computer enabled has no such plugin to collide with. The node still
|
|
3894
|
+
* reaches the same gateway over loopback with the same token, so nothing else changes.
|
|
3895
|
+
*/
|
|
3896
|
+
async function prepareNodeHostHome(projectDir, gatewayPort) {
|
|
3897
|
+
const home = join(projectDir, '.openclaw-node');
|
|
3898
|
+
await fsp.mkdir(home, { recursive: true });
|
|
3899
|
+
const cfg = {
|
|
3900
|
+
gateway: {
|
|
3901
|
+
port: Number(gatewayPort),
|
|
3902
|
+
mode: 'local',
|
|
3903
|
+
bind: 'loopback',
|
|
3904
|
+
...(gatewayAuthToken(projectDir) ? { auth: { mode: 'token', token: gatewayAuthToken(projectDir) } } : {}),
|
|
3905
|
+
},
|
|
3906
|
+
// Only the driver. Anything else here would be a plugin running twice on one machine.
|
|
3907
|
+
plugins: { allow: ['cua-computer'], entries: { 'cua-computer': { enabled: true } } },
|
|
3908
|
+
};
|
|
3909
|
+
await fsp.writeFile(join(home, 'openclaw.json'), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
3910
|
+
return home;
|
|
3625
3911
|
}
|
|
3626
3912
|
|
|
3627
3913
|
/** Run `openclaw node run` detached so it outlives this request but stays in this desktop session. */
|
|
3628
3914
|
async function startNodeHost(projectDir, gatewayPort) {
|
|
3629
3915
|
if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã chạy sẵn.'); return; }
|
|
3630
|
-
const
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3916
|
+
const token = gatewayAuthToken(projectDir);
|
|
3917
|
+
if (!token) sendLog('[computer-use] Không đọc được gateway token — node có thể bị từ chối kết nối.');
|
|
3918
|
+
// `--no-tls`: the gateway here is plain ws:// on loopback. Without it the node tries TLS and
|
|
3919
|
+
// the handshake never completes.
|
|
3920
|
+
const a = ocArgv(projectDir, ['node', 'run', '--host', '127.0.0.1', '--port', String(gatewayPort), '--no-tls']);
|
|
3921
|
+
// Go through the same bin resolution + env merge as run()/runCapture(). Spawning `a.cmd` raw
|
|
3922
|
+
// with only nativeEnv() drops PATH entirely, so on Windows `openclaw` does not even resolve.
|
|
3923
|
+
const rawBin = resolveBinPath(a.cmd);
|
|
3924
|
+
const shell = process.platform === 'win32';
|
|
3925
|
+
const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
|
|
3926
|
+
const nodeHome = await prepareNodeHostHome(projectDir, gatewayPort).catch((e) => {
|
|
3927
|
+
sendLog(`[computer-use] không tạo được state riêng cho node: ${e.message}`);
|
|
3928
|
+
return null;
|
|
3929
|
+
});
|
|
3930
|
+
if (process.platform === 'win32') {
|
|
3931
|
+
// Node refuses to spawn the `openclaw.cmd` shim detached: `spawn EINVAL`, with nothing else
|
|
3932
|
+
// logged. Go through the generated launcher and wscript, the same pair that starts the gateway
|
|
3933
|
+
// here - it is the one shape proven to work on Windows, and it keeps the process in the
|
|
3934
|
+
// operator's desktop session, which the screen driver requires.
|
|
3935
|
+
await writeWindowsLaunchers(projectDir, gatewayPort,
|
|
3936
|
+
(readNativeMeta(projectDir) || {}).routerPort || state.routerPort || NATIVE_DEFAULT_ROUTER_PORT)
|
|
3937
|
+
.catch((e) => sendLog(`[computer-use] không ghi được launcher: ${e.message}`));
|
|
3938
|
+
const vbs = join(projectDir, 'run-hidden.vbs');
|
|
3939
|
+
const cmd = join(projectDir, 'node-host.cmd');
|
|
3940
|
+
if (!existsSync(vbs) || !existsSync(cmd)) {
|
|
3941
|
+
sendLog('[computer-use] thiếu node-host.cmd — không khởi động được node điều khiển.');
|
|
3942
|
+
return;
|
|
3943
|
+
}
|
|
3944
|
+
startDetached('wscript.exe', [vbs, cmd], { cwd: projectDir });
|
|
3945
|
+
} else {
|
|
3946
|
+
const env = binEnv(rawBin, {
|
|
3947
|
+
...(a.opts.env || {}),
|
|
3948
|
+
// Override the project's state dir: see prepareNodeHostHome for why sharing it kills the node.
|
|
3949
|
+
...(nodeHome ? { OPENCLAW_HOME: nodeHome, OPENCLAW_STATE_DIR: nodeHome } : {}),
|
|
3950
|
+
...(token ? { OPENCLAW_GATEWAY_TOKEN: token } : {}),
|
|
3951
|
+
});
|
|
3952
|
+
const child = spawn(bin, a.args, { cwd: a.opts.cwd, shell, env, detached: true, stdio: 'ignore', windowsHide: true });
|
|
3953
|
+
child.on('error', (err) => sendLog(`[computer-use] không chạy được node host: ${err.message}`));
|
|
3954
|
+
child.unref();
|
|
3955
|
+
}
|
|
3634
3956
|
// Confirm instead of assuming: a node host that failed to start looks exactly like one that
|
|
3635
3957
|
// started, until the bot says it has no permission.
|
|
3636
3958
|
for (let i = 0; i < 15; i++) {
|
|
@@ -3640,6 +3962,72 @@ async function startNodeHost(projectDir, gatewayPort) {
|
|
|
3640
3962
|
sendLog('[computer-use] Node điều khiển chưa lên sau 30s — kiểm tra lại bằng `openclaw node status`.');
|
|
3641
3963
|
}
|
|
3642
3964
|
|
|
3965
|
+
/**
|
|
3966
|
+
* Approve the node's capability surface.
|
|
3967
|
+
*
|
|
3968
|
+
* Connecting is not enough: the gateway parks the node's capability list as a pending pairing
|
|
3969
|
+
* request and the node logs `node capability surface is awaiting operator approval` on a loop.
|
|
3970
|
+
* Until someone approves it the node advertises nothing, so the bot answers "I don't have
|
|
3971
|
+
* permission" even though every config key is right. The operator already consented by pressing
|
|
3972
|
+
* the button, so approve it here instead of making them find a CLI id in a log file.
|
|
3973
|
+
*/
|
|
3974
|
+
async function approvePendingNodes(projectDir) {
|
|
3975
|
+
const pending = await ocCapture(projectDir, ['nodes', 'pending'], { timeout: 20000 });
|
|
3976
|
+
const text = `${pending.stdout || ''}\n${pending.stderr || ''}`;
|
|
3977
|
+
const ids = [...new Set((text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) || []))];
|
|
3978
|
+
if (!ids.length) return 0;
|
|
3979
|
+
let done = 0;
|
|
3980
|
+
for (const id of ids) {
|
|
3981
|
+
const r = await ocCapture(projectDir, ['nodes', 'approve', id], { timeout: 20000 });
|
|
3982
|
+
if (r.code === 0) { done++; sendLog(`[computer-use] Đã duyệt node ${id}.`); }
|
|
3983
|
+
else sendLog(`[computer-use] Duyệt node ${id} không được: ${String(r.stderr || r.stdout || '').trim().slice(0, 200)}`);
|
|
3984
|
+
}
|
|
3985
|
+
return done;
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
/**
|
|
3989
|
+
* Does the connected node actually offer screen control?
|
|
3990
|
+
*
|
|
3991
|
+
* A node can be connected and approved and still advertise only the core caps (file, system,
|
|
3992
|
+
* browser...) when the CUA driver refuses to load - most often because the host was started
|
|
3993
|
+
* outside an interactive desktop session. Checking the caps is the only way to tell a working
|
|
3994
|
+
* setup from one that will fail on the first screenshot the owner asks for.
|
|
3995
|
+
*/
|
|
3996
|
+
let nodeCapsLastReason = '';
|
|
3997
|
+
|
|
3998
|
+
async function nodeHasComputerCaps(projectDir) {
|
|
3999
|
+
// Read JSON, never the table. `nodes status` renders a fixed-width table and wraps the Caps
|
|
4000
|
+
// column, so a node that genuinely advertises "computer" and "screen" prints them as "comp" and
|
|
4001
|
+
// "scre" across two rows - a text match on the table reports a working setup as broken, which is
|
|
4002
|
+
// exactly what it did on a customer machine.
|
|
4003
|
+
const r = await ocCapture(projectDir, ['nodes', 'status', '--json'], { timeout: 30000 });
|
|
4004
|
+
const text = `${r.stdout || ''}`;
|
|
4005
|
+
const start = text.indexOf('{');
|
|
4006
|
+
if (start === -1) {
|
|
4007
|
+
// Say what actually came back. "Not ready yet" and "the command failed" look identical from
|
|
4008
|
+
// the outside, and guessing between them is what turns a five-minute fix into a long day.
|
|
4009
|
+
nodeCapsLastReason = `không đọc được nodes status (exit ${r.code}): ${String(r.stderr || r.stdout || '').trim().slice(0, 160)}`;
|
|
4010
|
+
return false;
|
|
4011
|
+
}
|
|
4012
|
+
let parsed;
|
|
4013
|
+
try { parsed = JSON.parse(text.slice(start)); } catch (e) {
|
|
4014
|
+
nodeCapsLastReason = `nodes status trả về dữ liệu không đọc được: ${e.message}`;
|
|
4015
|
+
return false;
|
|
4016
|
+
}
|
|
4017
|
+
const nodes = Array.isArray(parsed?.nodes) ? parsed.nodes : [];
|
|
4018
|
+
const ok = nodes.some((n) => {
|
|
4019
|
+
const commands = Array.isArray(n?.commands) ? n.commands : [];
|
|
4020
|
+
// Both halves or neither: the gateway only exposes Computer Use when the pair is effective.
|
|
4021
|
+
return commands.includes('computer.act') && commands.includes('screen.snapshot');
|
|
4022
|
+
});
|
|
4023
|
+
if (!ok) {
|
|
4024
|
+
nodeCapsLastReason = nodes.length
|
|
4025
|
+
? `node đã nối nhưng mới khai báo: ${(nodes[0].commands || []).join(', ') || '(chưa có lệnh nào)'}`
|
|
4026
|
+
: 'gateway chưa thấy node nào';
|
|
4027
|
+
}
|
|
4028
|
+
return ok;
|
|
4029
|
+
}
|
|
4030
|
+
|
|
3643
4031
|
async function nodeHostRunning() {
|
|
3644
4032
|
if (process.platform === 'win32') {
|
|
3645
4033
|
const r = await runCapture('powershell', ['-NoProfile', '-Command',
|
|
@@ -3665,7 +4053,13 @@ async function stopNodeHost() {
|
|
|
3665
4053
|
* install or update instead of leaving the customer pressing a stale copy.
|
|
3666
4054
|
*/
|
|
3667
4055
|
async function writeWindowsLaunchers(projectDir, gatewayPort, routerPort) {
|
|
3668
|
-
|
|
4056
|
+
// node-host.cmd needs the gateway token baked in: the node authenticates with it, and without
|
|
4057
|
+
// one it exits within a second with `unauthorized: gateway token missing`.
|
|
4058
|
+
const files = buildWindowsLaunchers({
|
|
4059
|
+
projectDir, gatewayPort, routerPort,
|
|
4060
|
+
setupPort: activeUiPort || 51789,
|
|
4061
|
+
gatewayToken: gatewayAuthToken(projectDir),
|
|
4062
|
+
});
|
|
3669
4063
|
for (const [name, content] of Object.entries(files)) {
|
|
3670
4064
|
await fsp.writeFile(join(projectDir, name), content, 'utf8');
|
|
3671
4065
|
}
|
|
@@ -4436,111 +4830,22 @@ async function getDockerBridgeIp() {
|
|
|
4436
4830
|
} catch {}
|
|
4437
4831
|
return '172.17.0.1';
|
|
4438
4832
|
}
|
|
4439
|
-
// ──
|
|
4440
|
-
//
|
|
4441
|
-
//
|
|
4442
|
-
//
|
|
4443
|
-
//
|
|
4833
|
+
// ── PC control ──────────────────────────────────────────────────────────────────
|
|
4834
|
+
// Letting the bot drive this machine is OpenClaw's `computer` + `screen` tools, nothing else.
|
|
4835
|
+
// Up to 5.17.1 the installer also ran a small HTTP service on 18795 that opened allow-listed apps
|
|
4836
|
+
// for the bot. It was removed: the tools do the job properly, and having a second, weaker path
|
|
4837
|
+
// beside them actively hurt. On a customer machine the bot kept answering "chưa có kết nối Host
|
|
4838
|
+
// Control" and never reached for the tools it already had.
|
|
4444
4839
|
//
|
|
4445
|
-
//
|
|
4446
|
-
// this listens on the Docker bridge address as well — the same approach the Chrome relay
|
|
4447
|
-
// uses, private to this machine and not routable from outside.
|
|
4448
|
-
//
|
|
4449
|
-
// Everything is gated: the service only starts when hostControl.enabled is true, every
|
|
4450
|
-
// request needs the per-project token, and `open` accepts a key from the operator's own app
|
|
4451
|
-
// list rather than an arbitrary command line. Opening apps on the host is a real capability,
|
|
4452
|
-
// so it stays opt-in and enumerable instead of a general shell.
|
|
4453
|
-
const HOST_CONTROL_PORT = 18795;
|
|
4454
|
-
let _hostControlServer = null;
|
|
4455
|
-
// The project the running host-control service serves. Tracked separately from the server
|
|
4456
|
-
// singleton so enabling from a different (connected) project re-points the service without a
|
|
4457
|
-
// restart — the request handler reads config from THIS dir, not a value captured at first-start.
|
|
4458
|
-
let _hostControlProjectDir = null;
|
|
4840
|
+
// What is left is the switch itself, recorded per project in .openclaw/host-control.json.
|
|
4459
4841
|
|
|
4460
4842
|
function hostControlConfigPath(projectDir) {
|
|
4461
4843
|
return join(projectDir, '.openclaw', 'host-control.json');
|
|
4462
4844
|
}
|
|
4463
4845
|
|
|
4464
|
-
/** Common install locations, so the app list is useful before anyone edits it. */
|
|
4465
|
-
function detectHostApps() {
|
|
4466
|
-
const apps = {};
|
|
4467
|
-
const add = (key, candidates) => {
|
|
4468
|
-
for (const candidate of candidates) {
|
|
4469
|
-
if (candidate && existsSync(candidate)) {
|
|
4470
|
-
apps[key] = candidate;
|
|
4471
|
-
return;
|
|
4472
|
-
}
|
|
4473
|
-
}
|
|
4474
|
-
};
|
|
4475
|
-
if (process.platform === 'win32') {
|
|
4476
|
-
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
|
|
4477
|
-
const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
|
4478
|
-
const local = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
|
|
4479
|
-
add('teamviewer', [join(pf, 'TeamViewer', 'TeamViewer.exe'), join(pf86, 'TeamViewer', 'TeamViewer.exe')]);
|
|
4480
|
-
add('chrome', [join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe')]);
|
|
4481
|
-
add('zalo', [join(local, 'Programs', 'Zalo', 'Zalo.exe'), join(local, 'Zalo', 'Zalo.exe')]);
|
|
4482
|
-
add('explorer', ['C:\\Windows\\explorer.exe']);
|
|
4483
|
-
add('notepad', ['C:\\Windows\\System32\\notepad.exe']);
|
|
4484
|
-
// A hand-written shortlist only covers what WE thought of. Every customer machine has its
|
|
4485
|
-
// own software, and the bot is useless the moment it is asked for something not on the list —
|
|
4486
|
-
// "mở TeamViewer" fails not because the app is missing but because nobody enumerated it.
|
|
4487
|
-
// The Start Menu is the one place Windows guarantees an entry per installed app, and a .lnk
|
|
4488
|
-
// launches correctly without knowing where the .exe actually lives. Scanning it turns the
|
|
4489
|
-
// list from "5 apps we guessed" into "everything this machine has", and it stays correct
|
|
4490
|
-
// when the customer installs something new. Measured on win_kha: 5 → 182 apps.
|
|
4491
|
-
Object.assign(apps, scanWindowsStartMenuApps(), apps); // hand-written entries win
|
|
4492
|
-
} else if (process.platform === 'darwin') {
|
|
4493
|
-
add('teamviewer', ['/Applications/TeamViewer.app']);
|
|
4494
|
-
add('chrome', ['/Applications/Google Chrome.app']);
|
|
4495
|
-
add('zalo', ['/Applications/Zalo.app']);
|
|
4496
|
-
add('finder', ['/System/Library/CoreServices/Finder.app']);
|
|
4497
|
-
// Same idea as Windows: enumerate what is really installed instead of guessing.
|
|
4498
|
-
Object.assign(apps, scanMacApplications(), apps);
|
|
4499
|
-
}
|
|
4500
|
-
return apps;
|
|
4501
|
-
}
|
|
4502
4846
|
|
|
4503
|
-
/** Every .lnk under both Start Menu trees, keyed by a slug of its name. */
|
|
4504
|
-
function scanWindowsStartMenuApps() {
|
|
4505
|
-
const apps = {};
|
|
4506
|
-
// Uninstallers and doc links are not apps; opening one by accident is worse than not having it.
|
|
4507
|
-
const SKIP = /(uninstall|gỡ cài đặt|go cai dat|readme|help|documentation|website|release notes|license|repair|modify)/i;
|
|
4508
|
-
const roots = [
|
|
4509
|
-
join(process.env.ProgramData || 'C:\\ProgramData', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
|
|
4510
|
-
join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
|
|
4511
|
-
];
|
|
4512
|
-
const walk = (dir, depth = 0) => {
|
|
4513
|
-
if (depth > 4) return;
|
|
4514
|
-
let entries = [];
|
|
4515
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
4516
|
-
for (const e of entries) {
|
|
4517
|
-
const p = join(dir, e.name);
|
|
4518
|
-
if (e.isDirectory()) { walk(p, depth + 1); continue; }
|
|
4519
|
-
if (!/\.lnk$/i.test(e.name)) continue;
|
|
4520
|
-
const name = e.name.replace(/\.lnk$/i, '');
|
|
4521
|
-
if (SKIP.test(name)) continue;
|
|
4522
|
-
const key = slugify(name, '');
|
|
4523
|
-
if (key && !apps[key]) apps[key] = p;
|
|
4524
|
-
}
|
|
4525
|
-
};
|
|
4526
|
-
for (const r of roots) if (r) walk(r);
|
|
4527
|
-
return apps;
|
|
4528
|
-
}
|
|
4529
4847
|
|
|
4530
|
-
|
|
4531
|
-
function scanMacApplications() {
|
|
4532
|
-
const apps = {};
|
|
4533
|
-
for (const dir of ['/Applications', '/System/Applications', join(os.homedir(), 'Applications')]) {
|
|
4534
|
-
let entries = [];
|
|
4535
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
4536
|
-
for (const e of entries) {
|
|
4537
|
-
if (!e.name.endsWith('.app')) continue;
|
|
4538
|
-
const key = slugify(e.name.replace(/\.app$/, ''), '');
|
|
4539
|
-
if (key && !apps[key]) apps[key] = join(dir, e.name);
|
|
4540
|
-
}
|
|
4541
|
-
}
|
|
4542
|
-
return apps;
|
|
4543
|
-
}
|
|
4848
|
+
|
|
4544
4849
|
|
|
4545
4850
|
/** Resolve an executable on PATH synchronously (returns absolute path or ''). */
|
|
4546
4851
|
function whichSync(name) {
|
|
@@ -4599,221 +4904,13 @@ function resolveHostExecutable(bin) {
|
|
|
4599
4904
|
return { file: target, prefixArgs: [] };
|
|
4600
4905
|
}
|
|
4601
4906
|
|
|
4602
|
-
/**
|
|
4603
|
-
* CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
|
|
4604
|
-
* returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
|
|
4605
|
-
* only allow-listed names run. Auto-detects Claude Code CLI; add more by editing
|
|
4606
|
-
* `.openclaw/host-control.json` → `commands`.
|
|
4607
|
-
*/
|
|
4608
|
-
function detectHostCommands() {
|
|
4609
|
-
const commands = {};
|
|
4610
|
-
const claude = whichSync('claude');
|
|
4611
|
-
if (claude) commands.claude = claude;
|
|
4612
|
-
return commands;
|
|
4613
|
-
}
|
|
4614
4907
|
|
|
4615
|
-
/**
|
|
4616
|
-
* Extra capabilities the operator grants together with PC control: seeing the screen
|
|
4617
|
-
* (screenshot / screen recording) and running scripts through node or the Codex CLI.
|
|
4618
|
-
*
|
|
4619
|
-
* Kept out of detectHostCommands() on purpose. That one is the default list every project gets
|
|
4620
|
-
* as soon as the dashboard reads host-control state; these are only merged in when the operator
|
|
4621
|
-
* actually flips PC control on, so nothing is granted before they ask for it. `node` in
|
|
4622
|
-
* particular runs arbitrary code, which is why it takes an explicit act.
|
|
4623
|
-
*/
|
|
4624
|
-
function detectHostCapabilityCommands() {
|
|
4625
|
-
const commands = {};
|
|
4626
|
-
// The installer is itself node, so this path is guaranteed to exist and to be the same
|
|
4627
|
-
// interpreter the native bot runs under (the one macOS will attach the screen permission to).
|
|
4628
|
-
commands.node = process.execPath;
|
|
4629
|
-
for (const name of ['npx', 'codex', 'claude', 'ffmpeg']) {
|
|
4630
|
-
const bin = whichSync(name);
|
|
4631
|
-
if (bin) commands[name] = bin; // ffmpeg = screen recording on Linux/macOS
|
|
4632
|
-
}
|
|
4633
|
-
// The Codex CLI usually is not on PATH — it ships inside the desktop app. With it allow-listed
|
|
4634
|
-
// the bot can hand a job to Codex headlessly (`codex exec "…"`) and read the answer back.
|
|
4635
|
-
if (!commands.codex) {
|
|
4636
|
-
const bundledCodex = resolveCodexCli(detectCodexApp());
|
|
4637
|
-
if (bundledCodex) commands.codex = bundledCodex;
|
|
4638
|
-
}
|
|
4639
|
-
if (process.platform === 'darwin') {
|
|
4640
|
-
// Both a screenshot (`-x`) and a screen recording (`-v -V <secs>`) tool.
|
|
4641
|
-
if (existsSync('/usr/sbin/screencapture')) commands.screencapture = '/usr/sbin/screencapture';
|
|
4642
|
-
} else if (process.platform === 'linux') {
|
|
4643
|
-
for (const name of ['gnome-screenshot', 'spectacle', 'scrot', 'import']) {
|
|
4644
|
-
const bin = whichSync(name);
|
|
4645
|
-
if (bin) { commands.screenshot = bin; break; }
|
|
4646
|
-
}
|
|
4647
|
-
}
|
|
4648
|
-
return commands;
|
|
4649
|
-
}
|
|
4650
4908
|
|
|
4651
|
-
/**
|
|
4652
|
-
* Merge the capability commands into the project's allow-list, and report what was added so the
|
|
4653
|
-
* dashboard can name it. Existing entries are left alone: an operator who pointed `node` at a
|
|
4654
|
-
* specific interpreter keeps that path.
|
|
4655
|
-
*/
|
|
4656
|
-
function grantHostCapabilities(cfg) {
|
|
4657
|
-
const detected = detectHostCapabilityCommands();
|
|
4658
|
-
const added = [];
|
|
4659
|
-
cfg.commands = cfg.commands || {};
|
|
4660
|
-
for (const [name, bin] of Object.entries(detected)) {
|
|
4661
|
-
if (!cfg.commands[name]) {
|
|
4662
|
-
cfg.commands[name] = bin;
|
|
4663
|
-
added.push(name);
|
|
4664
|
-
}
|
|
4665
|
-
}
|
|
4666
|
-
// Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
|
|
4667
|
-
// clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
|
|
4668
|
-
// on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
|
|
4669
|
-
if (cfg.ui !== true) {
|
|
4670
|
-
cfg.ui = true;
|
|
4671
|
-
added.push('desktop actions (screenshot/click/type)');
|
|
4672
|
-
}
|
|
4673
|
-
return added;
|
|
4674
|
-
}
|
|
4675
|
-
|
|
4676
|
-
// Mouse/keyboard/screen control comes from the Codex desktop app's own `computer-use` plugin.
|
|
4677
|
-
// The bot reaches it by running `codex exec "<task>"`, which is a normal allow-listed command —
|
|
4678
|
-
// no OpenClaw-side harness, no second agent, no gateway restart. All this code has to do is make
|
|
4679
|
-
// sure the desktop app itself has computer-use installed and wired.
|
|
4680
|
-
//
|
|
4681
|
-
/** Where the desktop app that ships the Codex CLI + computer-use bundle lives. */
|
|
4682
|
-
function detectCodexApp() {
|
|
4683
|
-
const candidates = process.platform === 'darwin'
|
|
4684
|
-
? [
|
|
4685
|
-
{ app: '/Applications/Codex.app', bundle: '/Applications/Codex.app/Contents/Resources/plugins/openai-bundled' },
|
|
4686
|
-
{ app: '/Applications/ChatGPT.app', bundle: '/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled' },
|
|
4687
|
-
]
|
|
4688
|
-
: process.platform === 'win32'
|
|
4689
|
-
? [
|
|
4690
|
-
{ app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'Codex'), bundle: '' },
|
|
4691
|
-
{ app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'ChatGPT'), bundle: '' },
|
|
4692
|
-
]
|
|
4693
|
-
: [];
|
|
4694
|
-
for (const candidate of candidates) {
|
|
4695
|
-
if (existsSync(candidate.app)) {
|
|
4696
|
-
return { present: true, app: candidate.app, bundle: candidate.bundle && existsSync(candidate.bundle) ? candidate.bundle : '' };
|
|
4697
|
-
}
|
|
4698
|
-
}
|
|
4699
|
-
return { present: false, app: '', bundle: '' };
|
|
4700
|
-
}
|
|
4701
4909
|
|
|
4702
|
-
/**
|
|
4703
|
-
* Find a marketplace the Codex app-server has ALREADY registered that carries the computer-use
|
|
4704
|
-
* plugin, by reading its own `~/.codex/config.toml`.
|
|
4705
|
-
*
|
|
4706
|
-
* This matters because auto-install refuses to add new sources: pointing the plugin at a
|
|
4707
|
-
* marketplace directory it has not discovered fails with "auto-install only uses marketplaces
|
|
4708
|
-
* Codex app-server has already discovered … run /codex computer-use install". Naming a discovered
|
|
4709
|
-
* marketplace instead keeps provisioning fully automatic.
|
|
4710
|
-
*/
|
|
4711
|
-
function detectCodexMarketplace() {
|
|
4712
|
-
const codexHome = process.env.CODEX_HOME || join(getRealHomedir(), '.codex');
|
|
4713
|
-
const configPath = join(codexHome, 'config.toml');
|
|
4714
|
-
if (!existsSync(configPath)) return null;
|
|
4715
|
-
let toml = '';
|
|
4716
|
-
try {
|
|
4717
|
-
toml = fs.readFileSync(configPath, 'utf8');
|
|
4718
|
-
} catch (_) {
|
|
4719
|
-
return null;
|
|
4720
|
-
}
|
|
4721
|
-
// Minimal line-based TOML read: [marketplaces.<name>] headers and their `source = "..."`. A full
|
|
4722
|
-
// TOML parser is not worth pulling in for two fields of someone else's config.
|
|
4723
|
-
let name = '';
|
|
4724
|
-
for (const rawLine of toml.split(/\r?\n/)) {
|
|
4725
|
-
const line = rawLine.trim();
|
|
4726
|
-
const header = line.match(/^\[([^\]]+)\]$/);
|
|
4727
|
-
if (header) {
|
|
4728
|
-
const section = header[1];
|
|
4729
|
-
name = section.startsWith('marketplaces.') ? section.slice('marketplaces.'.length).replace(/^["']|["']$/g, '') : '';
|
|
4730
|
-
continue;
|
|
4731
|
-
}
|
|
4732
|
-
if (!name) continue;
|
|
4733
|
-
const source = (line.match(/^source\s*=\s*"([^"]+)"$/) || [])[1];
|
|
4734
|
-
if (source && existsSync(join(source, 'plugins', 'computer-use'))) return { name, source };
|
|
4735
|
-
}
|
|
4736
|
-
return null;
|
|
4737
|
-
}
|
|
4738
4910
|
|
|
4739
|
-
/** The Codex CLI that ships inside the desktop app (or one on PATH). */
|
|
4740
|
-
function resolveCodexCli(app) {
|
|
4741
|
-
const bundled = app && app.app ? join(app.app, 'Contents', 'Resources', 'codex') : '';
|
|
4742
|
-
if (bundled && existsSync(bundled)) return bundled;
|
|
4743
|
-
return whichSync('codex');
|
|
4744
|
-
}
|
|
4745
4911
|
|
|
4746
|
-
/**
|
|
4747
|
-
* Last mile on the Codex side: the OpenClaw plugin can only USE computer-use, it cannot install it
|
|
4748
|
-
* into the desktop app. Two things have to be true there, and both are fixable with the app's own
|
|
4749
|
-
* CLI (verified on a real machine):
|
|
4750
|
-
* - the `computer-use` plugin is installed from a discovered marketplace, and
|
|
4751
|
-
* - the `computer-use` MCP server points at that installed plugin. A stale global entry (left by
|
|
4752
|
-
* an earlier manual attempt) shadows the plugin's own and exposes zero tools, which surfaces as
|
|
4753
|
-
* the confusing "Computer Use is ready" with nothing behind it.
|
|
4754
|
-
*/
|
|
4755
|
-
async function ensureCodexComputerUsePlugin(app, marketplace) {
|
|
4756
|
-
const result = { cli: resolveCodexCli(app), pluginInstalled: false, installedNow: false, mcpRepaired: false };
|
|
4757
|
-
if (!result.cli || !marketplace) return result;
|
|
4758
|
-
const list = await runCapture(result.cli, ['plugin', 'list'], { shell: false }).catch(() => null);
|
|
4759
|
-
if (!list) return result;
|
|
4760
|
-
const ref = `computer-use@${marketplace.name}`;
|
|
4761
|
-
const row = `${list.stdout || ''}\n${list.stderr || ''}`.split(/\r?\n/).find((line) => line.trim().startsWith(ref));
|
|
4762
|
-
if (!row) return result;
|
|
4763
|
-
result.pluginInstalled = /\binstalled\b/.test(row) && !/not installed/.test(row);
|
|
4764
|
-
if (!result.pluginInstalled) {
|
|
4765
|
-
sendLog(`[computer-use] Cài plugin ${ref} vào app Codex…`);
|
|
4766
|
-
const add = await runCapture(result.cli, ['plugin', 'add', ref], { shell: false }).catch((err) => ({ code: 1, stderr: err.message }));
|
|
4767
|
-
result.installedNow = add.code === 0;
|
|
4768
|
-
if (!result.installedNow) result.error = (add.stderr || add.stdout || '').trim().split(/\r?\n/).slice(-2).join(' ');
|
|
4769
|
-
else result.pluginInstalled = true;
|
|
4770
|
-
}
|
|
4771
|
-
// Repair the MCP registration only when it clearly is NOT the plugin's own (its cwd lives under
|
|
4772
|
-
// the plugin cache). Removing the global entry lets the plugin-provided server take over.
|
|
4773
|
-
const mcp = await runCapture(result.cli, ['mcp', 'get', 'computer-use'], { shell: false }).catch(() => null);
|
|
4774
|
-
const mcpText = mcp ? `${mcp.stdout || ''}${mcp.stderr || ''}` : '';
|
|
4775
|
-
if (mcpText && !/plugins\/cache\//.test(mcpText)) {
|
|
4776
|
-
sendLog('[computer-use] Gỡ khai báo MCP computer-use cũ (trỏ sai chỗ) để dùng bản của plugin…');
|
|
4777
|
-
const removed = await runCapture(result.cli, ['mcp', 'remove', 'computer-use'], { shell: false }).catch(() => ({ code: 1 }));
|
|
4778
|
-
result.mcpRepaired = removed.code === 0;
|
|
4779
|
-
}
|
|
4780
|
-
return result;
|
|
4781
|
-
}
|
|
4782
4912
|
|
|
4783
|
-
|
|
4784
|
-
* Drop a tiny wrapper next to each workspace so GUI hand-off is one fixed command.
|
|
4785
|
-
*
|
|
4786
|
-
* Relying on the model to remember `--sandbox danger-full-access` does not work: a running session
|
|
4787
|
-
* still holds the TOOLS.md it loaded at session start, so a bot mid-conversation keeps calling
|
|
4788
|
-
* plain `codex exec`, gets "Computer Use was not approved to use <app>", and then invents a reason
|
|
4789
|
-
* (observed twice: it told the operator to grant Screen Recording, which was already granted).
|
|
4790
|
-
* With the wrapper the flags live on disk instead of in the prompt.
|
|
4791
|
-
*/
|
|
4792
|
-
async function writeCodexTaskScript(projectDir, cliPath) {
|
|
4793
|
-
const openclawDir = join(projectDir, '.openclaw');
|
|
4794
|
-
if (!existsSync(openclawDir) || !cliPath) return '';
|
|
4795
|
-
const body = [
|
|
4796
|
-
'#!/bin/sh',
|
|
4797
|
-
'# Managed by create-openclaw-bot — hand a desktop/GUI job to Codex and print its answer.',
|
|
4798
|
-
'# Usage: pc-task.sh "mở TeamViewer và đọc ID trên màn hình"',
|
|
4799
|
-
'# The sandbox flag is REQUIRED: the default read-only sandbox makes Codex refuse computer-use',
|
|
4800
|
-
'# with "Computer Use was not approved to use <app>".',
|
|
4801
|
-
'if [ $# -eq 0 ]; then echo "usage: pc-task.sh \\"việc cần làm\\"" >&2; exit 2; fi',
|
|
4802
|
-
`exec ${JSON.stringify(cliPath)} exec --skip-git-repo-check --sandbox danger-full-access "$@"`,
|
|
4803
|
-
'',
|
|
4804
|
-
].join('\n');
|
|
4805
|
-
let written = '';
|
|
4806
|
-
for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
|
|
4807
|
-
if (!entry.startsWith('workspace')) continue;
|
|
4808
|
-
const binDir = join(openclawDir, entry, 'bin');
|
|
4809
|
-
await fsp.mkdir(binDir, { recursive: true }).catch(() => {});
|
|
4810
|
-
const path = join(binDir, 'pc-task.sh');
|
|
4811
|
-
await fsp.writeFile(path, body, 'utf8').catch(() => {});
|
|
4812
|
-
await fsp.chmod(path, 0o755).catch(() => {});
|
|
4813
|
-
written = path;
|
|
4814
|
-
}
|
|
4815
|
-
return written;
|
|
4816
|
-
}
|
|
4913
|
+
|
|
4817
4914
|
|
|
4818
4915
|
/**
|
|
4819
4916
|
* macOS/Windows privacy panes for the permissions PC control needs. The OS never lets an app
|
|
@@ -4869,6 +4966,13 @@ async function probeScreenPermission() {
|
|
|
4869
4966
|
return { supported: true, granted };
|
|
4870
4967
|
}
|
|
4871
4968
|
|
|
4969
|
+
/**
|
|
4970
|
+
* The on/off record for PC control. That is all it is now.
|
|
4971
|
+
*
|
|
4972
|
+
* It used to carry a token, an app allow-list and a command allow-list for a local HTTP service.
|
|
4973
|
+
* The service is gone (OpenClaw's own `computer`/`screen` tools replaced it), so those fields have
|
|
4974
|
+
* nothing left to gate. Old files keep them harmlessly; nothing reads them.
|
|
4975
|
+
*/
|
|
4872
4976
|
async function readHostControlConfig(projectDir) {
|
|
4873
4977
|
const path = hostControlConfigPath(projectDir);
|
|
4874
4978
|
let cfg = {};
|
|
@@ -4877,24 +4981,8 @@ async function readHostControlConfig(projectDir) {
|
|
|
4877
4981
|
} catch (_) {
|
|
4878
4982
|
cfg = {};
|
|
4879
4983
|
}
|
|
4880
|
-
let changed = false;
|
|
4881
4984
|
if (typeof cfg.enabled !== 'boolean') {
|
|
4882
4985
|
cfg.enabled = false;
|
|
4883
|
-
changed = true;
|
|
4884
|
-
}
|
|
4885
|
-
if (!cfg.token) {
|
|
4886
|
-
cfg.token = _require('crypto').randomBytes(24).toString('hex');
|
|
4887
|
-
changed = true;
|
|
4888
|
-
}
|
|
4889
|
-
if (!cfg.apps || typeof cfg.apps !== 'object') {
|
|
4890
|
-
cfg.apps = detectHostApps();
|
|
4891
|
-
changed = true;
|
|
4892
|
-
}
|
|
4893
|
-
if (!cfg.commands || typeof cfg.commands !== 'object') {
|
|
4894
|
-
cfg.commands = detectHostCommands();
|
|
4895
|
-
changed = true;
|
|
4896
|
-
}
|
|
4897
|
-
if (changed) {
|
|
4898
4986
|
await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
4899
4987
|
await fsp.writeFile(path, JSON.stringify(cfg, null, 2), 'utf8').catch(() => {});
|
|
4900
4988
|
}
|
|
@@ -4908,59 +4996,7 @@ function spawnDetached(command, args) {
|
|
|
4908
4996
|
child.unref();
|
|
4909
4997
|
}
|
|
4910
4998
|
|
|
4911
|
-
function openHostApp(target) {
|
|
4912
|
-
if (process.platform === 'win32') {
|
|
4913
|
-
// `start` needs a shell; the empty "" is the window title cmd expects before the path.
|
|
4914
|
-
spawnDetached('cmd', ['/c', 'start', '', target]);
|
|
4915
|
-
return;
|
|
4916
|
-
}
|
|
4917
|
-
if (process.platform === 'darwin') {
|
|
4918
|
-
spawnDetached('open', [target]);
|
|
4919
|
-
return;
|
|
4920
|
-
}
|
|
4921
|
-
spawnDetached('xdg-open', [target]);
|
|
4922
|
-
}
|
|
4923
4999
|
|
|
4924
|
-
/**
|
|
4925
|
-
* Run an allow-listed CLI (e.g. Claude Code) and return its output. Unlike openHostApp this is
|
|
4926
|
-
* NOT detached: we wait for it, capture stdout/stderr (capped), and enforce a timeout. No shell
|
|
4927
|
-
* (shell:false) so args are literal — no injection; the executable is fixed by the allow-list.
|
|
4928
|
-
*/
|
|
4929
|
-
function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
4930
|
-
const MAX_OUT = 200_000; // ~200 KB cap per stream, so a runaway process can't flood the reply
|
|
4931
|
-
return new Promise((resolveP) => {
|
|
4932
|
-
let out = '';
|
|
4933
|
-
let err = '';
|
|
4934
|
-
let settled = false;
|
|
4935
|
-
const finish = (payload, status) => {
|
|
4936
|
-
if (settled) return;
|
|
4937
|
-
settled = true;
|
|
4938
|
-
clearTimeout(timer);
|
|
4939
|
-
json(res, payload, status);
|
|
4940
|
-
resolveP();
|
|
4941
|
-
};
|
|
4942
|
-
let child;
|
|
4943
|
-
try {
|
|
4944
|
-
const target = resolveHostExecutable(bin);
|
|
4945
|
-
child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
|
|
4946
|
-
} catch (e) {
|
|
4947
|
-
return finish({ ok: false, error: e.message }, 500);
|
|
4948
|
-
}
|
|
4949
|
-
const timer = setTimeout(() => {
|
|
4950
|
-
try { child.kill('SIGKILL'); } catch (_) {}
|
|
4951
|
-
finish({ ok: false, error: `timeout after ${timeoutMs}ms`, timedOut: true, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 504);
|
|
4952
|
-
}, timeoutMs);
|
|
4953
|
-
child.stdout?.on('data', (d) => { if (out.length < MAX_OUT) out += d.toString(); });
|
|
4954
|
-
child.stderr?.on('data', (d) => { if (err.length < MAX_OUT) err += d.toString(); });
|
|
4955
|
-
child.on('error', (e) => finish({ ok: false, error: e.message }, 500));
|
|
4956
|
-
child.on('close', (code) => {
|
|
4957
|
-
sendLog(`[host-control] Đã chạy "${name}" (exit ${code}).`);
|
|
4958
|
-
finish({ ok: code === 0, command: name, code, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 200);
|
|
4959
|
-
});
|
|
4960
|
-
if (input != null) { try { child.stdin.write(input); } catch (_) {} }
|
|
4961
|
-
try { child.stdin.end(); } catch (_) {}
|
|
4962
|
-
});
|
|
4963
|
-
}
|
|
4964
5000
|
|
|
4965
5001
|
/**
|
|
4966
5002
|
* Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
|
|
@@ -5021,461 +5057,56 @@ async function hostUiScreenshotTarget(projectDir) {
|
|
|
5021
5057
|
return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
|
|
5022
5058
|
}
|
|
5023
5059
|
|
|
5024
|
-
async function runHostUiWindows(projectDir, action, body, shot) {
|
|
5025
|
-
const script = await ensureHostUiScript(projectDir);
|
|
5026
|
-
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
|
|
5027
|
-
const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
|
|
5028
|
-
push('-X', body.x);
|
|
5029
|
-
push('-Y', body.y);
|
|
5030
|
-
push('-ToX', body.toX);
|
|
5031
|
-
push('-ToY', body.toY);
|
|
5032
|
-
push('-Amount', body.amount);
|
|
5033
|
-
push('-Text', body.text);
|
|
5034
|
-
push('-Button', body.button);
|
|
5035
|
-
push('-Clicks', body.clicks);
|
|
5036
|
-
push('-Title', body.title);
|
|
5037
|
-
if (shot) push('-Path', shot.hostPath);
|
|
5038
|
-
const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
|
|
5039
|
-
const parsed = parseJsonText(String(r.stdout || '').trim(), null);
|
|
5040
|
-
if (parsed) return parsed;
|
|
5041
|
-
const err = String(r.stderr || r.stdout || '').trim();
|
|
5042
|
-
if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
|
|
5043
|
-
return { ok: false, error: 'no desktop session available. The installer must run in the logged-in desktop session (not over SSH) for screen capture and input to work.' };
|
|
5044
|
-
}
|
|
5045
|
-
return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
|
|
5046
|
-
}
|
|
5047
|
-
|
|
5048
|
-
async function runHostUiMac(action, body, shot) {
|
|
5049
|
-
const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
|
|
5050
|
-
const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
|
|
5051
|
-
switch (action) {
|
|
5052
|
-
case 'screenshot': {
|
|
5053
|
-
const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
|
|
5054
|
-
return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
|
|
5055
|
-
}
|
|
5056
|
-
case 'screen_size': {
|
|
5057
|
-
const r = await osa('tell application "Finder" to get bounds of window of desktop');
|
|
5058
|
-
const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
|
|
5059
|
-
return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
|
|
5060
|
-
}
|
|
5061
|
-
case 'mouse_move':
|
|
5062
|
-
case 'click': {
|
|
5063
|
-
// System Events can click at a point; a plain move has no equivalent, so a move is a click
|
|
5064
|
-
// target set-up only. Accessibility permission is required (System Settings → Privacy).
|
|
5065
|
-
const clicks = Math.max(1, Number(body.clicks) || 1);
|
|
5066
|
-
if (action === 'mouse_move') return { ok: true, note: 'macOS has no pointer-move without a click; pass x/y to click instead', x: body.x, y: body.y };
|
|
5067
|
-
for (let i = 0; i < clicks; i++) {
|
|
5068
|
-
const r = await osa(`tell application "System Events" to click at ${point()}`);
|
|
5069
|
-
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
|
|
5070
|
-
}
|
|
5071
|
-
return { ok: true, button: 'left', clicks };
|
|
5072
|
-
}
|
|
5073
|
-
case 'type': {
|
|
5074
|
-
const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
5075
|
-
const r = await osa(`tell application "System Events" to keystroke "${text}"`);
|
|
5076
|
-
return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
|
|
5077
|
-
}
|
|
5078
|
-
case 'key': {
|
|
5079
|
-
const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
|
|
5080
|
-
for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
|
|
5081
|
-
const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
|
|
5082
|
-
const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
|
|
5083
|
-
const mods = parts.slice(0, -1).map((m) => ({ ctrl: 'control down', control: 'control down', cmd: 'command down', meta: 'command down', alt: 'option down', option: 'option down', shift: 'shift down' })[m]).filter(Boolean);
|
|
5084
|
-
const using = mods.length ? ` using {${mods.join(', ')}}` : '';
|
|
5085
|
-
const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
|
|
5086
|
-
const script = named.includes(key)
|
|
5087
|
-
? `tell application "System Events" to key code ${{ return: 36, escape: 53, tab: 48, space: 49, delete: 51, up: 126, down: 125, left: 123, right: 124, home: 115, end: 119, 'page up': 116, 'page down': 121 }[key]}${using}`
|
|
5088
|
-
: `tell application "System Events" to keystroke "${key}"${using}`;
|
|
5089
|
-
const r = await osa(script);
|
|
5090
|
-
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
|
|
5091
|
-
}
|
|
5092
|
-
return { ok: true, keys: body.text };
|
|
5093
|
-
}
|
|
5094
|
-
case 'scroll': {
|
|
5095
|
-
const amount = Number(body.amount) || 3;
|
|
5096
|
-
const dir = amount < 0 ? 121 : 116; // page down / page up
|
|
5097
|
-
for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
|
|
5098
|
-
return { ok: true, amount };
|
|
5099
|
-
}
|
|
5100
|
-
case 'clipboard_get': {
|
|
5101
|
-
const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
|
|
5102
|
-
return { ok: true, text: String(r.stdout || '') };
|
|
5103
|
-
}
|
|
5104
|
-
case 'clipboard_set': {
|
|
5105
|
-
const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
5106
|
-
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
|
|
5107
|
-
}
|
|
5108
|
-
case 'windows': {
|
|
5109
|
-
const r = await osa('tell application "System Events" to get name of every process whose background only is false');
|
|
5110
|
-
const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
|
|
5111
|
-
return { ok: true, windows: list };
|
|
5112
|
-
}
|
|
5113
|
-
case 'focus': {
|
|
5114
|
-
const title = String(body.title || '').replace(/"/g, '');
|
|
5115
|
-
if (!title) return { ok: false, error: 'focus needs a title' };
|
|
5116
|
-
const r = await osa(`tell application "${title}" to activate`);
|
|
5117
|
-
return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
|
|
5118
|
-
}
|
|
5119
|
-
default:
|
|
5120
|
-
return { ok: false, error: `unsupported on macOS: ${action}` };
|
|
5121
|
-
}
|
|
5122
|
-
}
|
|
5123
|
-
|
|
5124
|
-
async function runHostUiLinux(action, body, shot) {
|
|
5125
|
-
const xdo = whichSync('xdotool');
|
|
5126
|
-
const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
|
|
5127
|
-
switch (action) {
|
|
5128
|
-
case 'screenshot': {
|
|
5129
|
-
const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
|
|
5130
|
-
if (!tool) return need('scrot', 'screenshot');
|
|
5131
|
-
const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
|
|
5132
|
-
: tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
|
|
5133
|
-
: tool.name === 'import' ? ['-window', 'root', shot.hostPath]
|
|
5134
|
-
: [shot.hostPath];
|
|
5135
|
-
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
|
|
5136
|
-
return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
|
|
5137
|
-
}
|
|
5138
|
-
case 'screen_size': {
|
|
5139
|
-
if (!xdo) return need('xdotool', 'screen_size');
|
|
5140
|
-
const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
|
|
5141
|
-
const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
|
|
5142
|
-
return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
|
|
5143
|
-
}
|
|
5144
|
-
case 'mouse_move':
|
|
5145
|
-
case 'click':
|
|
5146
|
-
case 'drag':
|
|
5147
|
-
case 'scroll':
|
|
5148
|
-
case 'type':
|
|
5149
|
-
case 'key':
|
|
5150
|
-
case 'windows':
|
|
5151
|
-
case 'focus': {
|
|
5152
|
-
if (!xdo) return need('xdotool', action);
|
|
5153
|
-
const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
|
|
5154
|
-
const argvFor = {
|
|
5155
|
-
mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
|
|
5156
|
-
click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
|
|
5157
|
-
drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
|
|
5158
|
-
scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
|
|
5159
|
-
type: ['type', '--delay', '12', '--', String(body.text || '')],
|
|
5160
|
-
key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
|
|
5161
|
-
windows: ['search', '--onlyvisible', '--name', '.'],
|
|
5162
|
-
focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
|
|
5163
|
-
}[action];
|
|
5164
|
-
const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
|
|
5165
|
-
if (action === 'windows') {
|
|
5166
|
-
const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
|
|
5167
|
-
const titles = [];
|
|
5168
|
-
for (const id of ids) {
|
|
5169
|
-
const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
|
|
5170
|
-
const title = String(t.stdout || '').trim();
|
|
5171
|
-
if (title) titles.push({ title, id });
|
|
5172
|
-
}
|
|
5173
|
-
return { ok: true, windows: titles };
|
|
5174
|
-
}
|
|
5175
|
-
return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
|
|
5176
|
-
}
|
|
5177
|
-
case 'clipboard_get': {
|
|
5178
|
-
const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
|
|
5179
|
-
if (!tool) return need('xclip', 'clipboard_get');
|
|
5180
|
-
const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
|
|
5181
|
-
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
|
|
5182
|
-
return { ok: true, text: String(r.stdout || '') };
|
|
5183
|
-
}
|
|
5184
|
-
case 'clipboard_set': {
|
|
5185
|
-
const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
|
|
5186
|
-
if (!tool) return need('xclip', 'clipboard_set');
|
|
5187
|
-
const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
|
|
5188
|
-
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
5189
|
-
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
|
|
5190
|
-
}
|
|
5191
|
-
default:
|
|
5192
|
-
return { ok: false, error: `unsupported on Linux: ${action}` };
|
|
5193
|
-
}
|
|
5194
|
-
}
|
|
5195
5060
|
|
|
5196
|
-
async function runHostUi(projectDir, body = {}) {
|
|
5197
|
-
const action = String(body.action || '').trim();
|
|
5198
|
-
if (!HOST_UI_ACTIONS.has(action)) {
|
|
5199
|
-
return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
|
|
5200
|
-
}
|
|
5201
|
-
const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
|
|
5202
|
-
let result;
|
|
5203
|
-
try {
|
|
5204
|
-
if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
|
|
5205
|
-
else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
|
|
5206
|
-
else result = await runHostUiLinux(action, body, shot);
|
|
5207
|
-
} catch (err) {
|
|
5208
|
-
result = { ok: false, error: err.message };
|
|
5209
|
-
}
|
|
5210
|
-
if (shot && result?.ok) {
|
|
5211
|
-
// The project folder is bind-mounted into the container, so hand back the path the bot can
|
|
5212
|
-
// actually open — otherwise it gets a Windows path it cannot read and reports failure.
|
|
5213
|
-
result.path = shot.hostPath;
|
|
5214
|
-
result.containerPath = shot.containerPath;
|
|
5215
|
-
result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
|
|
5216
|
-
}
|
|
5217
|
-
sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
|
|
5218
|
-
return { status: result?.ok ? 200 : 500, payload: result };
|
|
5219
|
-
}
|
|
5220
5061
|
|
|
5221
|
-
async function handleHostControl(req, res, projectDir) {
|
|
5222
|
-
const cfg = await readHostControlConfig(projectDir);
|
|
5223
|
-
const url = new URL(req.url, 'http://localhost');
|
|
5224
|
-
const presented = req.headers['x-openclaw-token'] || url.searchParams.get('token') || '';
|
|
5225
|
-
if (!cfg.enabled) return json(res, { ok: false, error: 'host control is disabled' }, 403);
|
|
5226
|
-
if (presented !== cfg.token) return json(res, { ok: false, error: 'invalid token' }, 401);
|
|
5227
5062
|
|
|
5228
|
-
|
|
5229
|
-
try {
|
|
5230
|
-
return json(res, await startChromeDebug());
|
|
5231
|
-
} catch (err) {
|
|
5232
|
-
return json(res, { ok: false, error: err.message }, err.status || 500);
|
|
5233
|
-
}
|
|
5234
|
-
}
|
|
5235
|
-
if (url.pathname === '/api/host/apps' && req.method === 'GET') {
|
|
5236
|
-
return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
|
|
5237
|
-
}
|
|
5238
|
-
if (url.pathname === '/api/host/ui' && req.method === 'POST') {
|
|
5239
|
-
// Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
|
|
5240
|
-
// step than opening an app, so it only answers once the operator has granted capabilities.
|
|
5241
|
-
if (cfg.ui !== true) {
|
|
5242
|
-
return json(res, { ok: false, error: 'desktop actions are not granted. Ask the operator to press "Điều khiển máy" again in the dashboard (that writes ui:true).' }, 403);
|
|
5243
|
-
}
|
|
5244
|
-
const body = await readJson(req).catch(() => ({}));
|
|
5245
|
-
const { status, payload } = await runHostUi(projectDir, body || {});
|
|
5246
|
-
return json(res, payload, status);
|
|
5247
|
-
}
|
|
5248
|
-
if (url.pathname === '/api/host/exec' && req.method === 'POST') {
|
|
5249
|
-
const body = await readJson(req).catch(() => ({}));
|
|
5250
|
-
const name = String(body.command || '').trim().toLowerCase();
|
|
5251
|
-
if (!name) return json(res, { ok: false, error: 'missing "command"' }, 400);
|
|
5252
|
-
const bin = (cfg.commands || {})[name];
|
|
5253
|
-
if (!bin) {
|
|
5254
|
-
return json(res, {
|
|
5255
|
-
ok: false,
|
|
5256
|
-
error: `"${name}" is not in this machine's command list`,
|
|
5257
|
-
commands: Object.keys(cfg.commands || {}),
|
|
5258
|
-
}, 404);
|
|
5259
|
-
}
|
|
5260
|
-
// Args are passed literally (spawn with shell:false) so nothing in them is re-interpreted
|
|
5261
|
-
// by a shell — the executable is fixed to the allow-listed path, callers cannot pick a
|
|
5262
|
-
// different binary or inject a second command.
|
|
5263
|
-
const args = Array.isArray(body.args) ? body.args.map((a) => String(a)) : [];
|
|
5264
|
-
const input = body.input != null ? String(body.input) : null;
|
|
5265
|
-
const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || 180000, 1000), 600000);
|
|
5266
|
-
return runHostCommand(res, name, bin, args, input, timeoutMs);
|
|
5267
|
-
}
|
|
5268
|
-
if (url.pathname === '/api/host/open' && req.method === 'POST') {
|
|
5269
|
-
const body = await readJson(req).catch(() => ({}));
|
|
5270
|
-
const key = String(body.app || body.target || '').trim();
|
|
5271
|
-
if (!key) return json(res, { ok: false, error: 'missing "app"' }, 400);
|
|
5272
|
-
const path = (cfg.apps || {})[key.toLowerCase()];
|
|
5273
|
-
if (!path) {
|
|
5274
|
-
return json(res, {
|
|
5275
|
-
ok: false,
|
|
5276
|
-
error: `"${key}" is not in this machine's app list`,
|
|
5277
|
-
apps: Object.keys(cfg.apps || {}),
|
|
5278
|
-
}, 404);
|
|
5279
|
-
}
|
|
5280
|
-
openHostApp(path);
|
|
5281
|
-
sendLog(`[host-control] Đã mở "${key}" trên máy (${path}).`);
|
|
5282
|
-
return json(res, { ok: true, app: key, path });
|
|
5283
|
-
}
|
|
5284
|
-
return json(res, { ok: false, error: 'unknown endpoint' }, 404);
|
|
5285
|
-
}
|
|
5063
|
+
|
|
5286
5064
|
|
|
5287
5065
|
/**
|
|
5288
|
-
* Teach every bot in the project how to
|
|
5289
|
-
*
|
|
5290
|
-
*
|
|
5291
|
-
*
|
|
5292
|
-
*
|
|
5066
|
+
* Teach every bot in the project how to drive the machine with OpenClaw's own `computer` tool.
|
|
5067
|
+
*
|
|
5068
|
+
* This used to describe a local HTTP service on port 18795 that the bot called with curl. That
|
|
5069
|
+
* service is gone: OpenClaw ships the real thing, and a second half-capable path next to it only
|
|
5070
|
+
* gave the bot a way to fail. `computer` sees the screen and moves the pointer; `screen` takes the
|
|
5071
|
+
* snapshot. Both arrive once the operator presses "Điều khiển máy", which allows the tools, enables
|
|
5072
|
+
* cua-computer and starts the node host.
|
|
5073
|
+
*
|
|
5074
|
+
* Written as a managed block so switching PC control off removes the instructions again - a bot
|
|
5075
|
+
* still holding them would keep reaching for a tool it no longer has.
|
|
5293
5076
|
*/
|
|
5294
5077
|
async function writeHostControlAccess(projectDir, cfg) {
|
|
5295
5078
|
const openclawDir = join(projectDir, '.openclaw');
|
|
5296
5079
|
if (!existsSync(openclawDir)) return;
|
|
5297
|
-
const native = isNativeProject(projectDir);
|
|
5298
|
-
// Native bots run on the host itself; host.docker.internal only resolves from inside a container,
|
|
5299
|
-
// so a native bot curling it fails ("could not connect"). Use loopback there instead.
|
|
5300
|
-
const base = native ? `http://127.0.0.1:${HOST_CONTROL_PORT}` : `http://host.docker.internal:${HOST_CONTROL_PORT}`;
|
|
5301
|
-
const apps = Object.keys(cfg.apps || {});
|
|
5302
|
-
const commands = Object.keys(cfg.commands || {});
|
|
5303
|
-
const execBlock = commands.length ? [
|
|
5304
|
-
'',
|
|
5305
|
-
'Chạy một CLI trên máy chủ và LẤY KẾT QUẢ về (chỉ lệnh trong danh sách; trả `{ok,code,stdout,stderr}`).',
|
|
5306
|
-
'Dùng để giao việc cho công cụ dòng lệnh, ví dụ Claude Code:',
|
|
5307
|
-
'',
|
|
5308
|
-
'```sh',
|
|
5309
|
-
`curl -s -X POST ${base}/api/host/exec -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
5310
|
-
' -H "content-type: application/json" -d \'{"command":"claude","args":["-p","tóm tắt repo hiện tại"]}\'',
|
|
5311
|
-
'```',
|
|
5312
|
-
'',
|
|
5313
|
-
`Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
|
|
5314
|
-
] : [];
|
|
5315
|
-
// Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
|
|
5316
|
-
// instructions. Screenshots land in the project folder, which the container already sees.
|
|
5317
|
-
const uiBlock = cfg.ui === true ? [
|
|
5318
|
-
'',
|
|
5319
|
-
'### Thao tác trên màn hình chủ',
|
|
5320
|
-
'',
|
|
5321
|
-
'Một endpoint duy nhất cho mọi hệ điều hành. Cách làm đúng: **chụp màn hình trước, xem toạ độ, rồi mới click** —',
|
|
5322
|
-
'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
|
|
5323
|
-
'',
|
|
5324
|
-
'```sh',
|
|
5325
|
-
`curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
5326
|
-
' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
|
|
5327
|
-
'```',
|
|
5328
|
-
'',
|
|
5329
|
-
'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
|
|
5330
|
-
'kèm `width`/`height` để biết màn hình bao lớn.',
|
|
5331
|
-
'',
|
|
5332
|
-
'Các action khác (cùng dạng `{"action":...}`):',
|
|
5333
|
-
'',
|
|
5334
|
-
'- `screen_size` — kích thước màn hình',
|
|
5335
|
-
'- `mouse_move` + `x`,`y` — di chuột',
|
|
5336
|
-
'- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
|
|
5337
|
-
'- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
|
|
5338
|
-
'- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
|
|
5339
|
-
'- `type` + `text` — gõ chữ vào cửa sổ đang focus',
|
|
5340
|
-
'- `key` + `text` — nhấn tổ hợp, ví dụ `"ctrl+c"`, `"enter"`, `"alt+tab"`; nhiều tổ hợp thì cách nhau bằng space',
|
|
5341
|
-
'- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
|
|
5342
|
-
'- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
|
|
5343
|
-
'',
|
|
5344
|
-
'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
|
|
5345
|
-
'nói chủ mở lại installer trong máy, đừng thử cách khác.',
|
|
5346
|
-
'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
|
|
5347
|
-
] : [];
|
|
5348
|
-
// Screen capture / recording — only advertised when the operator granted the matching tool, so
|
|
5349
|
-
// the bot never tries a binary that is not on this machine's allow-list.
|
|
5350
|
-
// Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
|
|
5351
|
-
// up there too — a native bot runs the command itself, the allow-list only gates the bridge.
|
|
5352
|
-
const hasCapture = commands.includes('screencapture') || commands.includes('screenshot') || commands.includes('ffmpeg') || (native && process.platform === 'win32');
|
|
5353
|
-
const captureBlock = hasCapture ? [
|
|
5354
|
-
'',
|
|
5355
|
-
'### Chụp / quay màn hình',
|
|
5356
|
-
'',
|
|
5357
|
-
...(commands.includes('screencapture') ? [
|
|
5358
|
-
'- Chụp: `screencapture -x /tmp/shot.png` (thêm `-R x,y,w,h` để chụp một vùng, `-l <windowid>` chụp 1 cửa sổ).',
|
|
5359
|
-
'- Quay: `screencapture -v -V 10 /tmp/rec.mov` (quay 10 giây rồi tự dừng).',
|
|
5360
|
-
] : []),
|
|
5361
|
-
...(commands.includes('screenshot') ? ['- Chụp: dùng lệnh `screenshot` (công cụ chụp của desktop này) với đường dẫn file đầu ra.'] : []),
|
|
5362
|
-
...(native && process.platform === 'win32' ? [
|
|
5363
|
-
'- Chụp (Windows): `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bm=New-Object Drawing.Bitmap $b.Width,$b.Height; [Drawing.Graphics]::FromImage($bm).CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $bm.Save(\'C:\\Temp\\shot.png\')"` (tạo sẵn thư mục đích).',
|
|
5364
|
-
] : []),
|
|
5365
|
-
...(commands.includes('ffmpeg') ? ['- Quay bằng `ffmpeg` khi cần định dạng khác (macOS: `-f avfoundation`, Linux: `-f x11grab`, Windows: `-f gdigrab -i desktop`).'] : []),
|
|
5366
|
-
'',
|
|
5367
|
-
'Chụp xong thì ĐỌC file ảnh bằng tool đọc ảnh để phân tích, rồi xoá file tạm. Lần đầu macOS sẽ hỏi quyền **Screen Recording** cho `node`: nếu ảnh ra đen/rỗng hoặc lệnh lỗi quyền thì nhờ chủ bấm "Cấp quyền chụp/quay màn hình" trong dashboard, đừng thử vòng khác.',
|
|
5368
|
-
] : [];
|
|
5369
|
-
const scriptCommands = commands.filter((c) => c === 'node' || c === 'npx' || c === 'codex' || c === 'claude');
|
|
5370
|
-
const scriptBlock = scriptCommands.length ? [
|
|
5371
|
-
'',
|
|
5372
|
-
'### Chạy script & giao việc cho CLI khác',
|
|
5373
|
-
'',
|
|
5374
|
-
`Chủ đã cho phép: ${scriptCommands.map((c) => `\`${c}\``).join(', ')} — dùng cho việc tự động hoá nhỏ (ví dụ \`node -e "..."\`, \`node script.js\`).`,
|
|
5375
|
-
...(commands.includes('codex') ? [
|
|
5376
|
-
'- Giao việc cho **Codex** (chạy ngầm, lấy kết quả text): `codex exec --skip-git-repo-check "việc cần làm"`. Việc cần nhìn/điều khiển màn hình thì thêm `--sandbox danger-full-access` (xem mục dưới). Lượt này tiêu quota gói ChatGPT của chủ, nên chỉ dùng khi chủ yêu cầu và mô tả việc gọn.',
|
|
5377
|
-
] : []),
|
|
5378
|
-
...(commands.includes('claude') ? [
|
|
5379
|
-
'- Giao việc cho **Claude Code**: `claude -p "việc cần làm"` (một lượt, trả stdout).',
|
|
5380
|
-
] : []),
|
|
5381
|
-
'Đây là quyền chạy mã tuỳ ý trên máy chủ: chỉ chạy khi chủ yêu cầu rõ, không cài thêm gì, không sửa file ngoài phạm vi được yêu cầu.',
|
|
5382
|
-
] : [];
|
|
5383
5080
|
const startTag = '<!-- OPENCLAW:HOST_CONTROL:START -->';
|
|
5384
5081
|
const endTag = '<!-- OPENCLAW:HOST_CONTROL:END -->';
|
|
5385
|
-
|
|
5386
|
-
// no bridge, no host.docker.internal (which doesn't resolve off-container anyway). DOCKER: the
|
|
5387
|
-
// bot is in a container and can't see the desktop, so it must call the installer's host service.
|
|
5388
|
-
const nativeBlock = [
|
|
5389
|
-
startTag,
|
|
5390
|
-
'',
|
|
5391
|
-
'## 🖥️ Điều khiển máy của chủ (host control — chế độ native)',
|
|
5392
|
-
'',
|
|
5393
|
-
'Bạn chạy TRỰC TIẾP trên máy của chủ và có quyền `exec`, nên mở ứng dụng bằng lệnh hệ điều hành — KHÔNG cần service/bridge nào (đừng dùng host.docker.internal hay curl cổng 18795):',
|
|
5394
|
-
'',
|
|
5395
|
-
'- macOS: `open -a "<Tên app>"` — ví dụ `open -a "TeamViewer"`',
|
|
5396
|
-
'- Linux: `xdg-open <app|url>` hoặc chạy binary trực tiếp',
|
|
5397
|
-
...(process.platform === 'win32' ? [
|
|
5398
|
-
'- Windows: **đừng** gọi `Start-Process <TênApp>` — tên app KHÔNG phải lệnh trên PATH, bạn sẽ nhận',
|
|
5399
|
-
' `The system cannot find the file specified`. Hãy tra shortcut trong Start Menu rồi mở nó:',
|
|
5400
|
-
'',
|
|
5401
|
-
'```sh',
|
|
5402
|
-
'powershell -NoProfile -Command "$n=\'TeamViewer\'; $r=@(\"$env:ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\",\"$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs\"); $l=Get-ChildItem $r -Recurse -Filter \"*$n*.lnk\" -EA SilentlyContinue | Select -First 1; if($l){ Start-Process $l.FullName } else { \"khong tim thay $n\" }"',
|
|
5403
|
-
'```',
|
|
5404
|
-
'',
|
|
5405
|
-
'Đổi `TeamViewer` thành tên app cần mở. Cách này đúng với MỌI app đã cài, kể cả app chủ mới cài hôm qua,',
|
|
5406
|
-
'vì Windows luôn đặt shortcut vào Start Menu và `.lnk` tự biết đường dẫn thật.',
|
|
5407
|
-
] : ['- Windows: `cmd /c start "" "<app>"`']),
|
|
5408
|
-
'',
|
|
5409
|
-
apps.length ? `App có trên máy này: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Chủ chưa liệt kê app — mở theo tên app có trên máy.',
|
|
5410
|
-
...captureBlock,
|
|
5411
|
-
...scriptBlock,
|
|
5412
|
-
'',
|
|
5413
|
-
...(commands.includes('codex') ? [
|
|
5414
|
-
'### Điều khiển chuột/bàn phím & nhìn màn hình',
|
|
5415
|
-
'',
|
|
5416
|
-
'Bạn KHÔNG có tool điều khiển GUI. Việc đó giao cho **Codex** — nó có sẵn Computer Use. Dùng ĐÚNG script này (đã cài sẵn cờ cần thiết):',
|
|
5417
|
-
'',
|
|
5418
|
-
'```sh',
|
|
5419
|
-
'bin/pc-task.sh "mở TeamViewer, đọc mật khẩu đang hiện trên màn hình rồi in ra"',
|
|
5420
|
-
'```',
|
|
5421
|
-
'',
|
|
5422
|
-
'⚠️ **Đừng gọi `codex exec` trần.** Mặc định nó chạy sandbox read-only và tự từ chối computer-use với lỗi `Computer Use was not approved to use <app>` — KHÔNG phải do thiếu quyền Screen Recording, đừng bảo chủ đi cấp lại quyền. Script trên đã kèm `--sandbox danger-full-access` (mức `workspace-write` không đủ).',
|
|
5423
|
-
'',
|
|
5424
|
-
'Codex tự nhìn màn hình, click, gõ phím rồi trả kết quả text về cho bạn; bạn thuật lại cho chủ. Lưu ý:',
|
|
5425
|
-
'',
|
|
5426
|
-
'- Mô tả việc gọn nhưng đủ (mục tiêu + kết quả cần trả). Mỗi lượt tiêu quota gói ChatGPT của chủ, đừng gọi lung tung.',
|
|
5427
|
-
'- Việc GUI có thể chạy lâu: đặt timeout rộng, đừng kết luận thất bại sớm.',
|
|
5428
|
-
'- Computer Use từ chối vài app vì an toàn (Terminal, chính app ChatGPT/Codex): lỗi ghi rõ `not allowed to use the app ... for safety reasons` — báo chủ tự làm, đừng lách.',
|
|
5429
|
-
'- Điều khiển chuột/bàn phím hiện chỉ chạy trên macOS. Trên Windows/Linux bạn vẫn mở app, chụp màn hình và chạy script được.',
|
|
5430
|
-
'- Lỗi thật sự do thiếu quyền hệ điều hành sẽ nói về Screen Recording/Accessibility; chỉ khi đó mới nhờ chủ bấm nút cấp quyền trong dashboard. Luôn trích **nguyên văn** lỗi cho chủ thay vì đoán nguyên nhân.',
|
|
5431
|
-
'',
|
|
5432
|
-
] : []),
|
|
5433
|
-
'Chỉ mở app, chụp/quay màn hình hoặc điều khiển máy khi chủ yêu cầu rõ. Không tự ý chụp màn hình để "xem thử".',
|
|
5434
|
-
'',
|
|
5435
|
-
endTag,
|
|
5436
|
-
'',
|
|
5437
|
-
].join('\n');
|
|
5438
|
-
const dockerBlock = [
|
|
5082
|
+
const block = [
|
|
5439
5083
|
startTag,
|
|
5440
5084
|
'',
|
|
5441
|
-
'## 🖥️ Điều khiển máy của chủ
|
|
5442
|
-
'',
|
|
5443
|
-
'Bạn chạy trong container nên không thấy desktop của chủ. Muốn mở Chrome hay một ứng dụng trên máy thật thì gọi service của installer (chạy trên máy chủ) bằng `exec`:',
|
|
5085
|
+
'## 🖥️ Điều khiển máy của chủ',
|
|
5444
5086
|
'',
|
|
5445
|
-
'
|
|
5446
|
-
|
|
5447
|
-
'```',
|
|
5087
|
+
'Chủ đã cho phép bạn dùng máy này. Bạn có hai công cụ dưới đây và hãy dùng THẲNG chúng - đừng',
|
|
5088
|
+
'gọi HTTP, đừng tự dựng script PowerShell, đừng đi tìm dịch vụ phụ nào khác:',
|
|
5448
5089
|
'',
|
|
5449
|
-
'
|
|
5090
|
+
'- `screen` — chụp màn hình. Luôn chụp TRƯỚC khi định bấm hay gõ, để biết đang nhìn thấy gì.',
|
|
5091
|
+
'- `computer` — rê chuột, bấm, gõ phím, kéo thả, cuộn, nhấn tổ hợp phím.',
|
|
5450
5092
|
'',
|
|
5451
|
-
'
|
|
5452
|
-
|
|
5453
|
-
'
|
|
5454
|
-
'```',
|
|
5093
|
+
'Cách mở một ứng dụng, ví dụ TeamViewer: chụp màn hình → bấm nút Start → gõ `TeamViewer` →',
|
|
5094
|
+
'nhấn Enter → chụp lại để xác nhận nó đã mở. Cách này dùng được với MỌI app đã cài trên máy,',
|
|
5095
|
+
'kể cả app vừa cài hôm qua, vì bạn thao tác đúng như người ngồi trước máy.',
|
|
5455
5096
|
'',
|
|
5456
|
-
'
|
|
5097
|
+
'**Luôn kiểm chứng bằng mắt.** Sau mỗi bước quan trọng hãy chụp lại màn hình rồi mới nói đã xong.',
|
|
5098
|
+
'Đừng báo "đã mở" khi chưa nhìn thấy cửa sổ của nó.',
|
|
5457
5099
|
'',
|
|
5458
|
-
'
|
|
5459
|
-
|
|
5460
|
-
'
|
|
5100
|
+
'**Khi không dùng được:** nếu công cụ báo lỗi, hãy trích **nguyên văn** câu lỗi cho chủ và nói rõ',
|
|
5101
|
+
'bạn đang định làm gì. Đừng đoán nguyên nhân, và đừng đi tìm đường vòng khác - không có đường',
|
|
5102
|
+
'nào khác. Thường chỉ cần chủ bấm lại nút "Điều khiển máy" trong bảng điều khiển.',
|
|
5461
5103
|
'',
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
...uiBlock,
|
|
5465
|
-
// Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
|
|
5466
|
-
// container cannot read — say so instead of letting the bot hunt for a missing file.
|
|
5467
|
-
...(hasCapture ? [
|
|
5468
|
-
'',
|
|
5469
|
-
'Chụp/quay màn hình chạy trên MÁY CHỦ nên file ảnh nằm ở ổ đĩa của chủ, container này KHÔNG đọc được. Chụp vào một thư mục đã mount cho bot (nếu có) hoặc nhờ chủ gửi ảnh; đừng đoán nội dung màn hình.',
|
|
5470
|
-
] : []),
|
|
5471
|
-
'',
|
|
5472
|
-
'Nếu trả về `host control is disabled` thì chủ chưa bật quyền này — nói chủ bật trong dashboard,',
|
|
5473
|
-
'đừng cố tìm đường khác. Chỉ mở app hoặc chạy lệnh khi chủ yêu cầu rõ.',
|
|
5104
|
+
'Chỉ dùng khi chủ yêu cầu rõ. Không tự chụp màn hình để "xem thử", không tự bấm vào thứ chủ',
|
|
5105
|
+
'không nhắc tới, và không gõ mật khẩu hay thông tin thanh toán vào bất cứ đâu.',
|
|
5474
5106
|
'',
|
|
5475
5107
|
endTag,
|
|
5476
5108
|
'',
|
|
5477
5109
|
].join('\n');
|
|
5478
|
-
const block = native ? nativeBlock : dockerBlock;
|
|
5479
5110
|
for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
|
|
5480
5111
|
if (!entry.startsWith('workspace')) continue;
|
|
5481
5112
|
const toolsMd = join(openclawDir, entry, 'TOOLS.md');
|
|
@@ -5499,46 +5130,6 @@ function removeManagedBlockFrom(content, blockId) {
|
|
|
5499
5130
|
return `${content.substring(0, startIdx).trimEnd()}\n${content.substring(endIdx + endTag.length).trimStart()}`.trim() + '\n';
|
|
5500
5131
|
}
|
|
5501
5132
|
|
|
5502
|
-
async function ensureHostControl(projectDir) {
|
|
5503
|
-
// Point the service at the project being enabled (re-points a service already running for
|
|
5504
|
-
// another project — the handler reads _hostControlProjectDir per request).
|
|
5505
|
-
_hostControlProjectDir = projectDir;
|
|
5506
|
-
const cfg = await readHostControlConfig(projectDir);
|
|
5507
|
-
if (!cfg.enabled) return { ok: false, reason: 'disabled' };
|
|
5508
|
-
// Desktop only. Opening TeamViewer or an app needs a GUI, so a headless server has nothing
|
|
5509
|
-
// to control — and, more importantly, it is where 0.0.0.0 would be a real exposure (a VPS
|
|
5510
|
-
// has a public IP). Refusing here means the service never binds on a headless box, so the
|
|
5511
|
-
// public-exposure question does not arise. A rare VPS-with-desktop can override with
|
|
5512
|
-
// OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS=1.
|
|
5513
|
-
if (isHeadlessServer() && process.env.OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS !== '1') {
|
|
5514
|
-
return { ok: false, reason: 'headless server — no desktop to control' };
|
|
5515
|
-
}
|
|
5516
|
-
if (_hostControlServer) return { ok: true, port: HOST_CONTROL_PORT };
|
|
5517
|
-
const bridgeIp = await getDockerBridgeIp().catch(() => null);
|
|
5518
|
-
const server = http.createServer((req, res) => {
|
|
5519
|
-
// Read the CURRENTLY active project each request, so re-pointing takes effect live.
|
|
5520
|
-
handleHostControl(req, res, _hostControlProjectDir || projectDir).catch((err) => json(res, { ok: false, error: err.message }, 500));
|
|
5521
|
-
});
|
|
5522
|
-
// Bind all interfaces: the container reaches the host by different addresses per platform —
|
|
5523
|
-
// docker0 (172.17.0.1) on native Linux, the Docker Desktop gateway (host.docker.internal,
|
|
5524
|
-
// e.g. 192.168.65.254) on macOS/Windows — and binding one misses the others. The token is
|
|
5525
|
-
// the guard here, not the interface: every request needs it, and the service only exists
|
|
5526
|
-
// while the operator has host control switched on.
|
|
5527
|
-
const bindOk = await new Promise((resolveP) => {
|
|
5528
|
-
server.once('error', () => resolveP(false));
|
|
5529
|
-
server.listen(HOST_CONTROL_PORT, '0.0.0.0', () => resolveP(true));
|
|
5530
|
-
});
|
|
5531
|
-
if (!bindOk) return { ok: false, reason: `port ${HOST_CONTROL_PORT} in use` };
|
|
5532
|
-
_hostControlServer = server;
|
|
5533
|
-
sendLog(`[host-control] Nghe ở 0.0.0.0:${HOST_CONTROL_PORT} (cần token) — bot có thể mở Chrome/app trên máy này.`);
|
|
5534
|
-
if (bridgeIp && process.platform === 'linux') {
|
|
5535
|
-
// ufw's default-deny drops container→host traffic silently. Scope the allow rule to the
|
|
5536
|
-
// private bridge address only, so opening the port here does not expose it to the LAN.
|
|
5537
|
-
run('sh', ['-c', `command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active" && ufw allow in to ${bridgeIp} port ${HOST_CONTROL_PORT} proto tcp comment "openclaw host-control (docker bridge only)" || true`])
|
|
5538
|
-
.catch(() => {});
|
|
5539
|
-
}
|
|
5540
|
-
return { ok: true, port: HOST_CONTROL_PORT, host: '0.0.0.0' };
|
|
5541
|
-
}
|
|
5542
5133
|
|
|
5543
5134
|
async function ensureChromeRelay() {
|
|
5544
5135
|
if (_chromeRelayServer) return true;
|
|
@@ -7418,25 +7009,18 @@ async function handler(req, res, rootProjectDir) {
|
|
|
7418
7009
|
// start-chrome-debug is the old path; kept so an already-open dashboard keeps working.
|
|
7419
7010
|
return json(res, await startChromeDebug());
|
|
7420
7011
|
}
|
|
7421
|
-
//
|
|
7422
|
-
//
|
|
7423
|
-
//
|
|
7012
|
+
// PC control: read or flip the switch. There is no service behind it any more - enabling
|
|
7013
|
+
// configures OpenClaw's own `computer`/`screen` tools and starts the node host that drives
|
|
7014
|
+
// the screen.
|
|
7424
7015
|
if (url.pathname === '/api/host/control' && req.method === 'GET') {
|
|
7425
|
-
// Target the SELECTED project (not the launch root), so
|
|
7426
|
-
//
|
|
7016
|
+
// Target the SELECTED project (not the launch root), so this provisions the bot the
|
|
7017
|
+
// operator is actually looking at - a connected project can differ from rootProjectDir.
|
|
7427
7018
|
const projectDir = await resolveProjectDir(rootProjectDir, {});
|
|
7428
7019
|
const cfg = await readHostControlConfig(projectDir);
|
|
7429
7020
|
return json(res, {
|
|
7430
7021
|
ok: true,
|
|
7431
7022
|
enabled: cfg.enabled,
|
|
7432
|
-
port: HOST_CONTROL_PORT,
|
|
7433
|
-
apps: Object.keys(cfg.apps || {}),
|
|
7434
|
-
commands: Object.keys(cfg.commands || {}),
|
|
7435
|
-
running: Boolean(_hostControlServer),
|
|
7436
7023
|
native: isNativeProject(projectDir),
|
|
7437
|
-
// What enabling will additionally grant, so the confirm dialog can spell it out.
|
|
7438
|
-
grants: Object.keys(detectHostCapabilityCommands()),
|
|
7439
|
-
codexApp: detectCodexApp(),
|
|
7440
7024
|
});
|
|
7441
7025
|
}
|
|
7442
7026
|
if (url.pathname === '/api/host/control' && req.method === 'POST') {
|
|
@@ -7444,18 +7028,11 @@ async function handler(req, res, rootProjectDir) {
|
|
|
7444
7028
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
7445
7029
|
const cfg = await readHostControlConfig(projectDir);
|
|
7446
7030
|
if (typeof body.enabled === 'boolean') cfg.enabled = body.enabled;
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
//
|
|
7450
|
-
//
|
|
7451
|
-
|
|
7452
|
-
if (granted.length) sendLog(`[host-control] Đã cấp thêm quyền chạy: ${granted.join(', ')}.`);
|
|
7453
|
-
// Pressing this button means "let the bot drive this machine", so it must deliver the real
|
|
7454
|
-
// thing — OpenClaw's own `computer` tool (screenshot → click → type → drag), not just the
|
|
7455
|
-
// ability to launch an app. That tool needs three separate pieces switched on together, and
|
|
7456
|
-
// any one missing leaves the bot insisting it has no permission:
|
|
7457
|
-
// • the tool allowed for agents, • the cua-computer plugin (mandatory on Windows),
|
|
7458
|
-
// • a running node host advertising computer.act + screen.snapshot.
|
|
7031
|
+
// Pressing this button means "let the bot drive this machine", and it delivers exactly that:
|
|
7032
|
+
// OpenClaw's own `computer` tool (screenshot -> click -> type -> drag). That needs four
|
|
7033
|
+
// things switched on together, and any one missing leaves the bot insisting it has no
|
|
7034
|
+
// permission: the tools allowed for agents, the cua-computer plugin, those two node commands
|
|
7035
|
+
// on the gateway's per-platform allowlist, and a node host advertising them.
|
|
7459
7036
|
// Doing it here rather than in a side script matters: the node host must live in a real
|
|
7460
7037
|
// interactive desktop session, and the operator pressing this button IS in one.
|
|
7461
7038
|
const computerUse = await setComputerUse(projectDir, cfg.enabled).catch((e) => {
|
|
@@ -7463,34 +7040,16 @@ async function handler(req, res, rootProjectDir) {
|
|
|
7463
7040
|
return { ok: false, error: e.message };
|
|
7464
7041
|
});
|
|
7465
7042
|
await fsp.writeFile(hostControlConfigPath(projectDir), JSON.stringify(cfg, null, 2), 'utf8');
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
// Always rewrite the workspace guidance: enabling adds the block (with the token),
|
|
7469
|
-
// disabling strips it so a bot never keeps instructions for an endpoint now refusing.
|
|
7043
|
+
// Always rewrite the workspace guidance: enabling adds the block, disabling strips it so a
|
|
7044
|
+
// bot never keeps instructions for a capability it no longer has.
|
|
7470
7045
|
await writeHostControlAccess(projectDir, cfg).catch(() => {});
|
|
7471
7046
|
sendLog(`[host-control] ${cfg.enabled ? 'Đã BẬT' : 'Đã TẮT'} quyền điều khiển máy cho bot.`);
|
|
7472
7047
|
if (cfg.enabled && computerUse?.ok) sendLog('[host-control] Bot có thể chụp màn hình, click chuột, gõ phím trên máy này.');
|
|
7473
|
-
// Make sure the Codex desktop app can actually do GUI work, so `codex exec` is enough for
|
|
7474
|
-
// the bot: install computer-use into the app and repair its MCP registration. Nothing is
|
|
7475
|
-
// installed into the OpenClaw project and the gateway never restarts.
|
|
7476
|
-
let codex = null;
|
|
7477
|
-
if (cfg.enabled && body.codex !== false && (cfg.commands || {}).codex) {
|
|
7478
|
-
const app = detectCodexApp();
|
|
7479
|
-
codex = await ensureCodexComputerUsePlugin(app, detectCodexMarketplace())
|
|
7480
|
-
.then((r) => ({ ...r, app }))
|
|
7481
|
-
.catch((err) => ({ error: err.message, app }));
|
|
7482
|
-
// The wrapper carries the sandbox flag, so a bot cannot get the invocation wrong.
|
|
7483
|
-
codex.taskScript = await writeCodexTaskScript(projectDir, (cfg.commands || {}).codex).catch(() => '');
|
|
7484
|
-
}
|
|
7485
7048
|
return json(res, {
|
|
7486
7049
|
ok: true,
|
|
7487
7050
|
enabled: cfg.enabled,
|
|
7488
|
-
started,
|
|
7489
|
-
apps: Object.keys(cfg.apps || {}),
|
|
7490
|
-
commands: Object.keys(cfg.commands || {}),
|
|
7491
|
-
granted,
|
|
7492
7051
|
native: isNativeProject(projectDir),
|
|
7493
|
-
|
|
7052
|
+
computerUse,
|
|
7494
7053
|
});
|
|
7495
7054
|
}
|
|
7496
7055
|
// Take the operator to the OS privacy pane PC control needs (screen recording, accessibility).
|
|
@@ -7947,9 +7506,6 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
7947
7506
|
ensureReopenShortcut();
|
|
7948
7507
|
if (openBrowser) openUrl(url);
|
|
7949
7508
|
printRemoteAccessHint(port).catch(() => {});
|
|
7950
|
-
// Bring the host-control service back up when the operator left it enabled, so the bot's
|
|
7951
|
-
// saved instructions keep working across installer restarts.
|
|
7952
|
-
ensureHostControl(projectDir).catch(() => {});
|
|
7953
7509
|
// Warm the probes the first page load would otherwise wait on (project list, runtime versions,
|
|
7954
7510
|
// public IP, Zalo status). They run while the browser is still starting, so the dashboard opens
|
|
7955
7511
|
// against a warm cache instead of paying for docker and CLI round-trips on first paint.
|
|
@@ -7961,4 +7517,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
7961
7517
|
]).catch(() => {});
|
|
7962
7518
|
}
|
|
7963
7519
|
|
|
7964
|
-
export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations,
|
|
7520
|
+
export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome, runNativeConfigMigrations, detectExistingSetupUi, nodeVersionSupported, migrateDockerProjectToNative, dedupeProjectsByRealState, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };
|