create-openclaw-bot 5.16.9 → 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 +4 -4
- package/README.vi.md +9 -9
- package/dist/server/local-server.js +925 -841
- package/dist/setup/shared/windows-launcher-gen.js +225 -0
- package/dist/web/app.js +53 -47
- package/dist/web/styles.css +1 -0
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSki
|
|
|
17
17
|
const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
|
|
18
18
|
const { buildDockerArtifacts, contextDefaultsScript } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
|
|
19
19
|
const { HOST_UI_PS1, HOST_UI_PS1_VERSION } = loadSharedModule('../setup/shared/host-ui-ps1.js', '__openclawHostUiPs1');
|
|
20
|
+
const { buildWindowsLaunchers, WINDOWS_DESKTOP_LAUNCHERS } = loadSharedModule('../setup/shared/windows-launcher-gen.js', '__openclawWindowsLaunchers');
|
|
20
21
|
const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, ZALO_CHANNEL_ID, ZALO_PLUGIN_ID, ZALO_CONNECT_VERSION, ZALO_CONNECT_PLUGIN_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
|
|
21
22
|
const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
|
|
22
23
|
|
|
@@ -726,6 +727,19 @@ function extraBinDirs() {
|
|
|
726
727
|
return dirs;
|
|
727
728
|
}
|
|
728
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
|
+
|
|
729
743
|
function resolveBinPath(cmd) {
|
|
730
744
|
if (!cmd || cmd.includes('/') || cmd.includes('\\')) return cmd;
|
|
731
745
|
const names = process.platform === 'win32' ? [`${cmd}.cmd`, `${cmd}.exe`, cmd] : [cmd];
|
|
@@ -1021,8 +1035,8 @@ function runCapture(cmd, args, opts = {}) {
|
|
|
1021
1035
|
return new Promise((resolve) => {
|
|
1022
1036
|
let stdout = '';
|
|
1023
1037
|
let stderr = '';
|
|
1024
|
-
const shell = opts.shell ?? process.platform === 'win32';
|
|
1025
1038
|
const rawBin = resolveBinPath(cmd);
|
|
1039
|
+
const shell = needsWindowsShell(rawBin) || (opts.shell ?? process.platform === 'win32');
|
|
1026
1040
|
const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
|
|
1027
1041
|
const child = spawn(bin, args, {
|
|
1028
1042
|
cwd: opts.cwd,
|
|
@@ -1213,7 +1227,12 @@ async function detectRuntime(projectDir) {
|
|
|
1213
1227
|
routerPort,
|
|
1214
1228
|
gatewayUrl: `http://127.0.0.1:${gatewayPort}`,
|
|
1215
1229
|
routerUrl: `http://127.0.0.1:${routerPort}`,
|
|
1216
|
-
|
|
1230
|
+
// Ask projectDeployMode, don't re-derive from the compose file: a project migrated to native
|
|
1231
|
+
// KEEPS its docker/ folder on purpose (that is the way back), so "compose file exists" stops
|
|
1232
|
+
// meaning "runs on docker" the moment migration lands. Getting this wrong made the dashboard
|
|
1233
|
+
// report OpenClaw and 9Router OFFLINE on a machine whose native gateway was answering 200 —
|
|
1234
|
+
// it was probing the stopped containers. Seen on win_kha, 10/09/2026.
|
|
1235
|
+
mode: projectDeployMode(projectDir),
|
|
1217
1236
|
cliGatewayStatus,
|
|
1218
1237
|
syncSource,
|
|
1219
1238
|
};
|
|
@@ -1305,7 +1324,16 @@ async function migrateNativePaths(projectDir) {
|
|
|
1305
1324
|
const abs = join(wsRoot, base);
|
|
1306
1325
|
if (obj.workspace !== abs) { obj.workspace = abs; changed = true; }
|
|
1307
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.
|
|
1308
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
|
+
}
|
|
1309
1337
|
fix(cfg.agents?.defaults);
|
|
1310
1338
|
if (changed) {
|
|
1311
1339
|
await fsp.copyFile(cfgPath, `${cfgPath}.bak`).catch(() => {});
|
|
@@ -1735,6 +1763,52 @@ async function appendEnvValue(projectDir, key, value) {
|
|
|
1735
1763
|
await fsp.writeFile(envPath, env, 'utf8');
|
|
1736
1764
|
}
|
|
1737
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
|
+
|
|
1738
1812
|
function validateOpenclawConfig(cfg) {
|
|
1739
1813
|
if (!Array.isArray(cfg.agents?.list)) throw httpError(500, 'openclaw.json missing agents.list');
|
|
1740
1814
|
for (const a of cfg.agents.list) {
|
|
@@ -2127,6 +2201,9 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
|
2127
2201
|
// so the generator's ".openclaw/workspace-x" would double. Rewrite to an absolute path now so
|
|
2128
2202
|
// the bot reads its persona on the very first turn (not only after the next runtime sync).
|
|
2129
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);
|
|
2130
2207
|
await syncExecApprovals(projectDir, cfg);
|
|
2131
2208
|
|
|
2132
2209
|
const hasScheduler = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
|
|
@@ -2238,10 +2315,21 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
|
|
|
2238
2315
|
}
|
|
2239
2316
|
|
|
2240
2317
|
agent.name = botName;
|
|
2241
|
-
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.
|
|
2242
2326
|
validateOpenclawConfig(cfg);
|
|
2243
2327
|
if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
|
|
2244
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);
|
|
2245
2333
|
await syncExecApprovals(projectDir, cfg);
|
|
2246
2334
|
|
|
2247
2335
|
// Synchronize the token to .env files for the primary bot to ensure Docker picks it up
|
|
@@ -2770,15 +2858,34 @@ async function computeZaloHealth(projectDir) {
|
|
|
2770
2858
|
textStatus = `${r.stdout || ''}\n${r.stderr || ''}`;
|
|
2771
2859
|
} catch {}
|
|
2772
2860
|
}
|
|
2773
|
-
//
|
|
2774
|
-
//
|
|
2861
|
+
// Where the Zalo session file lives depends on the plugin version, and getting this wrong
|
|
2862
|
+
// makes a perfectly logged-in bot show as "chưa đăng nhập":
|
|
2863
|
+
// <=3.1.4 <home>/.openclaw/zalo-connect-credentials[-<account>].json
|
|
2864
|
+
// >=3.1.5 <state>/credentials/zalo-connect/credentials[-<account>].json
|
|
2865
|
+
// 3.1.5 MOVES the old file on first run and leaves a *.migrated stub behind, so a scan that
|
|
2866
|
+
// only knows the old shape finds nothing at all. Look in the new layout first, then fall
|
|
2867
|
+
// back, and normalise the new names to the old ones so the caller's lookup keeps working.
|
|
2868
|
+
// Measured on win_kha after the zalo-connect upgrade, 10/09/2026.
|
|
2775
2869
|
const credRe = /^zalo-connect-credentials(?:-[^.]+)?\.json$/i;
|
|
2776
|
-
|
|
2870
|
+
const newCredRe = /^credentials(?:-[^.]+)?\.json$/i;
|
|
2871
|
+
const toLegacyName = (n) => n.replace(/^credentials/, 'zalo-connect-credentials');
|
|
2872
|
+
for (const dir of [
|
|
2873
|
+
join(projectDir, '.openclaw', 'credentials', 'zalo-connect'),
|
|
2874
|
+
join(os.homedir(), '.openclaw', 'credentials', 'zalo-connect'),
|
|
2875
|
+
]) {
|
|
2777
2876
|
try {
|
|
2778
|
-
const names = (await fsp.readdir(dir)).filter((n) =>
|
|
2779
|
-
if (names.length) { credentialNames = names; break; }
|
|
2877
|
+
const names = (await fsp.readdir(dir)).filter((n) => newCredRe.test(n));
|
|
2878
|
+
if (names.length) { credentialNames = names.map(toLegacyName); break; }
|
|
2780
2879
|
} catch {}
|
|
2781
2880
|
}
|
|
2881
|
+
if (!credentialNames || !credentialNames.length) {
|
|
2882
|
+
for (const dir of [join(projectDir, '.openclaw'), join(os.homedir(), '.openclaw')]) {
|
|
2883
|
+
try {
|
|
2884
|
+
const names = (await fsp.readdir(dir)).filter((n) => credRe.test(n));
|
|
2885
|
+
if (names.length) { credentialNames = names; break; }
|
|
2886
|
+
} catch {}
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2782
2889
|
}
|
|
2783
2890
|
} else {
|
|
2784
2891
|
try {
|
|
@@ -2914,9 +3021,31 @@ function nativeEnv(projectDir, extra = {}) {
|
|
|
2914
3021
|
};
|
|
2915
3022
|
}
|
|
2916
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
|
+
|
|
2917
3042
|
/** Resolve `openclaw <args>` for whichever runtime this project uses. */
|
|
2918
3043
|
function ocArgv(projectDir, args) {
|
|
2919
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) } };
|
|
2920
3049
|
return { cmd: 'openclaw', args, opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
|
|
2921
3050
|
}
|
|
2922
3051
|
return { cmd: 'docker', args: ['exec', getBotContainerName(projectDir), 'openclaw', ...args], opts: { cwd: projectDir } };
|
|
@@ -3069,6 +3198,50 @@ async function runOpenclawDoctorFixIfNeeded(projectDir) {
|
|
|
3069
3198
|
}
|
|
3070
3199
|
}
|
|
3071
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
|
+
|
|
3072
3245
|
async function restartNativeRuntime(projectDir) {
|
|
3073
3246
|
// Every restart is a chance to repair a project installed before these fixes existed — the
|
|
3074
3247
|
// calls are no-ops once the service env is complete, stray files are adopted, and the config
|
|
@@ -3084,6 +3257,18 @@ async function restartNativeRuntime(projectDir) {
|
|
|
3084
3257
|
};
|
|
3085
3258
|
let res;
|
|
3086
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.');
|
|
3087
3272
|
res = await stopStart();
|
|
3088
3273
|
} else {
|
|
3089
3274
|
res = await ocDaemon(projectDir, 'restart');
|
|
@@ -3545,6 +3730,520 @@ async function prepareNativeStateHome(projectDir) {
|
|
|
3545
3730
|
return installEnv;
|
|
3546
3731
|
}
|
|
3547
3732
|
|
|
3733
|
+
/**
|
|
3734
|
+
* Turn OpenClaw's real computer-use on or off for a project.
|
|
3735
|
+
*
|
|
3736
|
+
* Three pieces must line up, and a bot with only two of them reports "I don't have permission"
|
|
3737
|
+
* without saying which one is missing — that ambiguity cost a whole debugging session:
|
|
3738
|
+
* 1. `computer` (+ `screen`) allowed in tools.alsoAllow — otherwise the agent never receives
|
|
3739
|
+
* the tool at all, no matter what else is configured;
|
|
3740
|
+
* 2. the `cua-computer` plugin, which is MANDATORY on Windows and ships disabled. It also has
|
|
3741
|
+
* to be on plugins.allow, or enabling is refused with "blocked by allowlist";
|
|
3742
|
+
* 3. a node host process advertising `computer.act` + `screen.snapshot`. The tool is only the
|
|
3743
|
+
* model-facing surface; the node is what actually touches the screen, which is why a
|
|
3744
|
+
* correctly configured tool still does nothing on its own.
|
|
3745
|
+
*
|
|
3746
|
+
* The node host must run inside a real interactive desktop session — started from a service or
|
|
3747
|
+
* an SSH session it either dies with the session or cannot see a desktop at all. Launching it
|
|
3748
|
+
* from here works because the operator pressing the button is sitting at that desktop.
|
|
3749
|
+
*/
|
|
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
|
+
}
|
|
3757
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
3758
|
+
if (!existsSync(cfgPath)) return { ok: false, error: 'openclaw.json not found' };
|
|
3759
|
+
const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
|
|
3760
|
+
|
|
3761
|
+
cfg.tools = (cfg.tools && typeof cfg.tools === 'object') ? cfg.tools : {};
|
|
3762
|
+
const allow = new Set(Array.isArray(cfg.tools.alsoAllow) ? cfg.tools.alsoAllow : []);
|
|
3763
|
+
cfg.plugins = (cfg.plugins && typeof cfg.plugins === 'object') ? cfg.plugins : {};
|
|
3764
|
+
cfg.plugins.allow = Array.isArray(cfg.plugins.allow) ? cfg.plugins.allow : [];
|
|
3765
|
+
cfg.plugins.entries = (cfg.plugins.entries && typeof cfg.plugins.entries === 'object') ? cfg.plugins.entries : {};
|
|
3766
|
+
|
|
3767
|
+
if (enable) {
|
|
3768
|
+
allow.add('computer');
|
|
3769
|
+
allow.add('screen');
|
|
3770
|
+
// Without this, `plugins enable` is refused outright with "blocked by allowlist".
|
|
3771
|
+
if (!cfg.plugins.allow.includes('cua-computer')) cfg.plugins.allow.push('cua-computer');
|
|
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];
|
|
3787
|
+
} else {
|
|
3788
|
+
allow.delete('computer');
|
|
3789
|
+
allow.delete('screen');
|
|
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');
|
|
3798
|
+
}
|
|
3799
|
+
cfg.tools.alsoAllow = [...allow];
|
|
3800
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
3801
|
+
|
|
3802
|
+
if (!enable) {
|
|
3803
|
+
await stopNodeHost().catch(() => {});
|
|
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.');
|
|
3806
|
+
return { ok: true, enabled: false };
|
|
3807
|
+
}
|
|
3808
|
+
|
|
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
|
+
|
|
3820
|
+
const meta = readNativeMeta(projectDir) || {};
|
|
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
|
+
|
|
3830
|
+
await startNodeHost(projectDir, port);
|
|
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;
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
/** Run `openclaw node run` detached so it outlives this request but stays in this desktop session. */
|
|
3914
|
+
async function startNodeHost(projectDir, gatewayPort) {
|
|
3915
|
+
if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã chạy sẵn.'); return; }
|
|
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
|
+
}
|
|
3956
|
+
// Confirm instead of assuming: a node host that failed to start looks exactly like one that
|
|
3957
|
+
// started, until the bot says it has no permission.
|
|
3958
|
+
for (let i = 0; i < 15; i++) {
|
|
3959
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
3960
|
+
if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã kết nối.'); return; }
|
|
3961
|
+
}
|
|
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`.');
|
|
3963
|
+
}
|
|
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
|
+
|
|
4031
|
+
async function nodeHostRunning() {
|
|
4032
|
+
if (process.platform === 'win32') {
|
|
4033
|
+
const r = await runCapture('powershell', ['-NoProfile', '-Command',
|
|
4034
|
+
"(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*node*run*--host*' } | Measure-Object).Count"], { shell: false, timeout: 8000 });
|
|
4035
|
+
return Number(String(r.stdout || '0').trim()) > 0;
|
|
4036
|
+
}
|
|
4037
|
+
const r = await runCapture('sh', ['-c', "ps ax | grep -c '[n]ode run --host'"], { shell: false, timeout: 8000 });
|
|
4038
|
+
return Number(String(r.stdout || '0').trim()) > 0;
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
async function stopNodeHost() {
|
|
4042
|
+
if (process.platform === 'win32') {
|
|
4043
|
+
await runCapture('powershell', ['-NoProfile', '-Command',
|
|
4044
|
+
"Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*node*run*--host*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"], { shell: false, timeout: 10000 });
|
|
4045
|
+
return;
|
|
4046
|
+
}
|
|
4047
|
+
await runCapture('sh', ['-c', "pkill -f 'node run --host' || true"], { shell: false, timeout: 10000 });
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
/**
|
|
4051
|
+
* Write the double-click launchers into the project and copy the operator-facing ones to the
|
|
4052
|
+
* Desktop. Safe to re-run: it overwrites, so a fixed launcher reaches machines on the next
|
|
4053
|
+
* install or update instead of leaving the customer pressing a stale copy.
|
|
4054
|
+
*/
|
|
4055
|
+
async function writeWindowsLaunchers(projectDir, gatewayPort, routerPort) {
|
|
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
|
+
});
|
|
4063
|
+
for (const [name, content] of Object.entries(files)) {
|
|
4064
|
+
await fsp.writeFile(join(projectDir, name), content, 'utf8');
|
|
4065
|
+
}
|
|
4066
|
+
const desktop = join(os.homedir(), 'Desktop');
|
|
4067
|
+
if (existsSync(desktop)) {
|
|
4068
|
+
for (const name of WINDOWS_DESKTOP_LAUNCHERS) {
|
|
4069
|
+
await fsp.writeFile(join(desktop, name), files[name], 'utf8').catch(() => {});
|
|
4070
|
+
}
|
|
4071
|
+
sendLog(`[native] Đã đặt ${WINDOWS_DESKTOP_LAUNCHERS.length} tệp bấm-đúp lên Desktop.`);
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
/**
|
|
4076
|
+
* Move a running docker project onto native, keeping every bot and every workspace file.
|
|
4077
|
+
*
|
|
4078
|
+
* Why this exists: openclaw >=2026.9 writes files through @openclaw/fs-safe, which fstat()s the
|
|
4079
|
+
* file after an atomic rename. That check cannot pass through a Docker Desktop bind mount on
|
|
4080
|
+
* Windows, so the moment a docker image is rebuilt onto 2026.9.x the gateway can no longer write
|
|
4081
|
+
* its own config: every boot is unclean, the restart-loop breaker trips, and the bot is down with
|
|
4082
|
+
* a misleading "ENOENT: fstat". Measured end to end on win_kha, 09/09/2026 — native on the very
|
|
4083
|
+
* same NTFS path works, so the fault is the bind-mount layer, not the disk.
|
|
4084
|
+
*
|
|
4085
|
+
* The data lives in two different places and only one of them is visible on the host:
|
|
4086
|
+
* bind-mounted paths are already on disk, but named volumes are not — state (the sqlite DB with
|
|
4087
|
+
* every session), the plugin tree, and 9router's login live inside volumes. Copy those out FIRST;
|
|
4088
|
+
* everything after this point is reversible, the containers and image are left untouched.
|
|
4089
|
+
*/
|
|
4090
|
+
async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {}) {
|
|
4091
|
+
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
4092
|
+
if (!existsSync(composeFile)) return false;
|
|
4093
|
+
sendLog('[migrate] Chuyển project từ Docker sang native...');
|
|
4094
|
+
|
|
4095
|
+
// 1. Stop the containers so nothing writes while we copy. Never `down -v` — that deletes the
|
|
4096
|
+
// very volumes holding the customer's sessions.
|
|
4097
|
+
await run('docker', ['compose', '-f', composeFile, 'stop']).catch(() => {});
|
|
4098
|
+
// Stopping is not enough: the containers carry a restart policy, so the next reboot brings them
|
|
4099
|
+
// back and they grab 18789/18790 before the native gateway can bind — the native process then
|
|
4100
|
+
// dies with EADDRINUSE seconds after reporting "ready", which reads like a random crash.
|
|
4101
|
+
// Measured on win_kha after its first reboot, 10/09/2026. Clear the policy, keep the containers.
|
|
4102
|
+
const psAll = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
|
|
4103
|
+
const names = String(psAll.stdout || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
4104
|
+
if (names.length) await run('docker', ['update', '--restart=no', ...names]).catch(() => {});
|
|
4105
|
+
|
|
4106
|
+
// 2. Copy every named volume out to the host path it maps to. Read the mapping from the
|
|
4107
|
+
// containers themselves rather than guessing volume names: compose prefixes them with the
|
|
4108
|
+
// project name, which differs per machine.
|
|
4109
|
+
const psOut = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
|
|
4110
|
+
const containers = String(psOut.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
4111
|
+
const CONTAINER_HOME = '/home/node/project';
|
|
4112
|
+
let copied = 0;
|
|
4113
|
+
for (const name of containers) {
|
|
4114
|
+
const insp = await runCapture('docker', ['inspect', name, '--format', '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Destination}}\n{{end}}'], { shell: false });
|
|
4115
|
+
for (const line of String(insp.stdout || '').split(/\r?\n/)) {
|
|
4116
|
+
const [type, volName, dest] = line.split('|').map((s) => (s || '').trim());
|
|
4117
|
+
if (type !== 'volume' || !volName || !dest) continue;
|
|
4118
|
+
// Map the in-container path back onto the project on disk.
|
|
4119
|
+
let hostDir = null;
|
|
4120
|
+
if (dest.startsWith(`${CONTAINER_HOME}/`)) hostDir = join(projectDir, dest.slice(CONTAINER_HOME.length + 1));
|
|
4121
|
+
else if (dest === '/root/.9router' || dest.endsWith('/.9router')) hostDir = join(projectDir, '.9router');
|
|
4122
|
+
if (!hostDir) { sendLog(`[migrate] bỏ qua volume ${volName} (${dest}) — không map được về project`); continue; }
|
|
4123
|
+
await fsp.mkdir(hostDir, { recursive: true });
|
|
4124
|
+
// Copy with a throwaway container: the volume has no host path we can read directly.
|
|
4125
|
+
const r = await runCapture('docker', [
|
|
4126
|
+
'run', '--rm', '-v', `${volName}:/__src:ro`, '-v', `${hostDir}:/__dst`,
|
|
4127
|
+
'node:22-slim', 'sh', '-c', 'cp -a /__src/. /__dst/ 2>/dev/null; ls -A /__dst | head -1',
|
|
4128
|
+
], { shell: false });
|
|
4129
|
+
if (r.stdout && r.stdout.trim()) { copied++; sendLog(`[migrate] ${volName} → ${hostDir}`); }
|
|
4130
|
+
else sendLog(`[migrate] ${volName} rỗng hoặc không chép được — kiểm tra tay: ${hostDir}`);
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
sendLog(`[migrate] đã đưa ${copied} volume xuống đĩa.`);
|
|
4134
|
+
|
|
4135
|
+
// 3. Rewrite what only made sense inside the container.
|
|
4136
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
4137
|
+
if (existsSync(cfgPath)) {
|
|
4138
|
+
const before = await fsp.readFile(cfgPath, 'utf8');
|
|
4139
|
+
await fsp.writeFile(`${cfgPath}.bak-truoc-native`, before, 'utf8');
|
|
4140
|
+
let cfg = null;
|
|
4141
|
+
try { cfg = JSON.parse(before); } catch { cfg = null; }
|
|
4142
|
+
if (cfg) {
|
|
4143
|
+
// 3a. Container-absolute workspace paths. On Windows these become "C:\home\node\..." and
|
|
4144
|
+
// every plugin fails its install-dir check.
|
|
4145
|
+
const projPosix = join(projectDir, '.openclaw').replace(/\\/g, '/');
|
|
4146
|
+
let text = JSON.stringify(cfg, null, 2).split(`${CONTAINER_HOME}/.openclaw`).join(projPosix);
|
|
4147
|
+
cfg = JSON.parse(text);
|
|
4148
|
+
// 3a-ter. RELATIVE workspace paths. Inside the container the process always ran from
|
|
4149
|
+
// /home/node/project so ".openclaw/workspace-x" resolved correctly. Native has no such
|
|
4150
|
+
// guarantee: the customer double-clicks a launcher from wherever they filed it, and the
|
|
4151
|
+
// agent then looks for its workspace under THAT folder and dies with WORKSPACE_VANISHED
|
|
4152
|
+
// — while agents that happen to carry an absolute path keep working, so only some bots
|
|
4153
|
+
// break. Exactly what happened when the launchers were moved into a Desktop subfolder
|
|
4154
|
+
// (win_kha 10/09/2026). Pin every workspace to the project.
|
|
4155
|
+
{
|
|
4156
|
+
const ag = cfg.agents || {};
|
|
4157
|
+
const list = ag.entries && typeof ag.entries === 'object' ? Object.entries(ag.entries)
|
|
4158
|
+
: Array.isArray(ag.list) ? ag.list.map((a) => [a && a.id, a]) : [];
|
|
4159
|
+
for (const [aid, meta] of list) {
|
|
4160
|
+
if (!meta || typeof meta.workspace !== 'string') continue;
|
|
4161
|
+
const w = meta.workspace;
|
|
4162
|
+
if (/^[a-zA-Z]:[\\/]/.test(w) || w.startsWith('/')) continue; // already absolute
|
|
4163
|
+
meta.workspace = join(projectDir, w.replace(/^\.[\\/]/, '')).replace(/\\/g, '/');
|
|
4164
|
+
sendLog(`[migrate] workspace ${aid}: "${w}" → "${meta.workspace}" (đường tương đối phụ thuộc chỗ bấm tệp)`);
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
text = JSON.stringify(cfg, null, 2);
|
|
4168
|
+
|
|
4169
|
+
// 3a-bis. Docker-only HOSTNAMES. `http://9router:20128` resolves through compose's internal
|
|
4170
|
+
// DNS and nowhere else, so on native every model call dies with
|
|
4171
|
+
// `getaddrinfo ENOTFOUND 9router` — the bot still reacts (that is the channel, no model
|
|
4172
|
+
// needed) but can never reply, which reads as "bot ignores me". Same story for
|
|
4173
|
+
// host.docker.internal and the 192.168.65.x Desktop bridge. Measured win_kha 10/09/2026.
|
|
4174
|
+
text = text
|
|
4175
|
+
.split('http://9router:20128').join('http://127.0.0.1:20128')
|
|
4176
|
+
.split('http://host.docker.internal:20128').join('http://127.0.0.1:20128')
|
|
4177
|
+
.replace(/http:\/\/192\.168\.65\.\d+:(\d+)/g, 'http://127.0.0.1:$1');
|
|
4178
|
+
cfg = JSON.parse(text);
|
|
4179
|
+
// 3b. bind. Inside docker, 0.0.0.0 was safe because the port was published to loopback only.
|
|
4180
|
+
// Native binds the host directly, so the same value would expose the gateway to the LAN.
|
|
4181
|
+
cfg.gateway = (cfg.gateway && typeof cfg.gateway === 'object') ? cfg.gateway : {};
|
|
4182
|
+
if (cfg.gateway.bind !== 'loopback') {
|
|
4183
|
+
cfg.gateway.bind = 'loopback';
|
|
4184
|
+
delete cfg.gateway.customBindHost;
|
|
4185
|
+
if (cfg.gateway.controlUi) {
|
|
4186
|
+
cfg.gateway.controlUi.allowedOrigins = ['http://localhost:' + (cfg.gateway.port || NATIVE_DEFAULT_GATEWAY_PORT), 'http://127.0.0.1:' + (cfg.gateway.port || NATIVE_DEFAULT_GATEWAY_PORT)];
|
|
4187
|
+
}
|
|
4188
|
+
sendLog('[migrate] gateway.bind → loopback (native bind thẳng lên host, 0.0.0.0 là mở ra mạng)');
|
|
4189
|
+
}
|
|
4190
|
+
// 3c. The browser-automation plugin rewrites the browser block on every boot, so simply
|
|
4191
|
+
// deleting the bad key below is not enough — it grows back and the config is invalid
|
|
4192
|
+
// again by the next restart (same shape as the old toolResultMaxChars reinfection).
|
|
4193
|
+
// Take the pen away from it; patchDocker is pointless here now too.
|
|
4194
|
+
const baEntry = cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['browser-automation'];
|
|
4195
|
+
if (baEntry && typeof baEntry === 'object') {
|
|
4196
|
+
baEntry.config = (baEntry.config && typeof baEntry.config === 'object') ? baEntry.config : {};
|
|
4197
|
+
baEntry.config.manageBrowserConfig = false;
|
|
4198
|
+
baEntry.config.patchDocker = false;
|
|
4199
|
+
sendLog('[migrate] tắt browser-automation.manageBrowserConfig (nó ghi lại khoá 2026.9 từ chối mỗi lần boot)');
|
|
4200
|
+
}
|
|
4201
|
+
// 3d. browser profile colour: required up to 2026.8, rejected outright by 2026.9.
|
|
4202
|
+
const profiles = (cfg.browser && cfg.browser.profiles) || null;
|
|
4203
|
+
if (profiles && typeof profiles === 'object') {
|
|
4204
|
+
for (const key of Object.keys(profiles)) {
|
|
4205
|
+
if (profiles[key] && typeof profiles[key] === 'object' && profiles[key].color !== undefined) {
|
|
4206
|
+
delete profiles[key].color;
|
|
4207
|
+
sendLog(`[migrate] gỡ browser.profiles.${key}.color (2026.9 không nhận)`);
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
|
|
4215
|
+
// 4. Backup folders sitting inside extensions/ are still scanned as real plugins — that is where
|
|
4216
|
+
// the long-standing "duplicate plugin id" warnings came from. A leading dot does NOT hide them
|
|
4217
|
+
// on Windows, so move them out of the tree entirely.
|
|
4218
|
+
const extDir = join(projectDir, '.openclaw', 'extensions');
|
|
4219
|
+
if (existsSync(extDir)) {
|
|
4220
|
+
const parked = join(projectDir, '_plugin-backups');
|
|
4221
|
+
for (const entry of await fsp.readdir(extDir).catch(() => [])) {
|
|
4222
|
+
if (!/\.(bak|broken|old)[-.]|^\.openclaw-install-backups$/i.test(entry)) continue;
|
|
4223
|
+
await fsp.mkdir(parked, { recursive: true });
|
|
4224
|
+
await fsp.rename(join(extDir, entry), join(parked, entry.replace(/^\./, ''))).catch(() => {});
|
|
4225
|
+
sendLog(`[migrate] dời bản sao lưu plugin ra khỏi extensions/: ${entry}`);
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
// 5. zalo-connect wrote its Zalo sessions next to the container's HOME, which was itself
|
|
4230
|
+
// <project>/.openclaw — leaving them one level too deep. Lift them so 3.1.5's one-time
|
|
4231
|
+
// migration finds them; without this every account comes back "not authenticated".
|
|
4232
|
+
const nested = join(projectDir, '.openclaw', '.openclaw');
|
|
4233
|
+
if (existsSync(nested)) {
|
|
4234
|
+
for (const entry of await fsp.readdir(nested).catch(() => [])) {
|
|
4235
|
+
if (!/^zalo-connect-credentials.*\.json$/.test(entry)) continue;
|
|
4236
|
+
const target = join(projectDir, '.openclaw', entry);
|
|
4237
|
+
if (existsSync(target)) continue;
|
|
4238
|
+
await fsp.copyFile(join(nested, entry), target).catch(() => {});
|
|
4239
|
+
sendLog(`[migrate] nâng phiên Zalo lên đúng chỗ: ${entry}`);
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
sendLog('[migrate] Xong phần dữ liệu. Container và image giữ nguyên để còn quay lui.');
|
|
4244
|
+
return true;
|
|
4245
|
+
}
|
|
4246
|
+
|
|
3548
4247
|
/**
|
|
3549
4248
|
* Bring a native project up end to end: 9router → smart-route sync → resolve its API key into
|
|
3550
4249
|
* openclaw.json → install the gateway as a managed service. Order matters: the gateway must boot
|
|
@@ -3562,6 +4261,15 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
|
|
|
3562
4261
|
'utf8',
|
|
3563
4262
|
);
|
|
3564
4263
|
|
|
4264
|
+
// Windows has no usable service story for this (see windows-launcher-gen.js): the Scheduled
|
|
4265
|
+
// Task openclaw installs is bound to a login session, so it dies with the SSH session and is
|
|
4266
|
+
// refused outright when set to run at boot. Give the owner double-click launchers instead —
|
|
4267
|
+
// the same shape as the .command files on macOS — and put the three they actually press on
|
|
4268
|
+
// the Desktop where they can find them.
|
|
4269
|
+
if (process.platform === 'win32') {
|
|
4270
|
+
await writeWindowsLaunchers(projectDir, gwPort, rtPort).catch((e) => sendLog(`[native] launcher: ${e.message}`));
|
|
4271
|
+
}
|
|
4272
|
+
|
|
3565
4273
|
await startNative9Router(projectDir);
|
|
3566
4274
|
|
|
3567
4275
|
// Same smart-route sync the docker sidecar runs, pointed at the native DB path. Without it
|
|
@@ -3830,6 +4538,30 @@ async function updateRuntime(target, projectDir) {
|
|
|
3830
4538
|
probeCacheClear();
|
|
3831
4539
|
return { ok: true, target, spec, mode: 'native' };
|
|
3832
4540
|
}
|
|
4541
|
+
// A docker project asked to update is a docker project about to rebuild its image — which is
|
|
4542
|
+
// exactly the step that lands it on openclaw >=2026.9 and breaks writing through the bind mount
|
|
4543
|
+
// (see migrateDockerProjectToNative). So the update IS the migration: move the data onto native
|
|
4544
|
+
// first, then update the native way. Doing it here rather than on every page load keeps it a
|
|
4545
|
+
// deliberate act by the customer, not something that reshapes their machine behind their back.
|
|
4546
|
+
if (!isNativeProject(projectDir) && projectDir && existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
|
|
4547
|
+
sendLog('[update] Project đang chạy Docker — chuyển sang native trước khi cập nhật.');
|
|
4548
|
+
await ensureNodeInstalled();
|
|
4549
|
+
await migrateDockerProjectToNative(projectDir, { osChoice: state.os || '' });
|
|
4550
|
+
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
4551
|
+
invalidateHostOpenclawVersion();
|
|
4552
|
+
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
4553
|
+
const meta = readNativeMeta(projectDir) || {};
|
|
4554
|
+
await startNativeRuntime({
|
|
4555
|
+
projectDir,
|
|
4556
|
+
osChoice: state.os || '',
|
|
4557
|
+
gatewayPort: meta.gatewayPort || state.gatewayPort,
|
|
4558
|
+
routerPort: meta.routerPort || state.routerPort,
|
|
4559
|
+
});
|
|
4560
|
+
await syncRuntimeState(projectDir, { full: true }).catch(() => {});
|
|
4561
|
+
probeCacheClear();
|
|
4562
|
+
sendLog('[update] Đã chuyển sang native. Container Docker được giữ nguyên (đã dừng) để còn quay lui.');
|
|
4563
|
+
return { ok: true, target, spec, mode: 'native', migratedFromDocker: true };
|
|
4564
|
+
}
|
|
3833
4565
|
if (state.mode === 'docker' && projectDir) {
|
|
3834
4566
|
const dockerDir = join(projectDir, 'docker', 'openclaw');
|
|
3835
4567
|
if (isRouter) {
|
|
@@ -4098,59 +4830,22 @@ async function getDockerBridgeIp() {
|
|
|
4098
4830
|
} catch {}
|
|
4099
4831
|
return '172.17.0.1';
|
|
4100
4832
|
}
|
|
4101
|
-
// ──
|
|
4102
|
-
//
|
|
4103
|
-
//
|
|
4104
|
-
//
|
|
4105
|
-
//
|
|
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.
|
|
4106
4839
|
//
|
|
4107
|
-
//
|
|
4108
|
-
// this listens on the Docker bridge address as well — the same approach the Chrome relay
|
|
4109
|
-
// uses, private to this machine and not routable from outside.
|
|
4110
|
-
//
|
|
4111
|
-
// Everything is gated: the service only starts when hostControl.enabled is true, every
|
|
4112
|
-
// request needs the per-project token, and `open` accepts a key from the operator's own app
|
|
4113
|
-
// list rather than an arbitrary command line. Opening apps on the host is a real capability,
|
|
4114
|
-
// so it stays opt-in and enumerable instead of a general shell.
|
|
4115
|
-
const HOST_CONTROL_PORT = 18795;
|
|
4116
|
-
let _hostControlServer = null;
|
|
4117
|
-
// The project the running host-control service serves. Tracked separately from the server
|
|
4118
|
-
// singleton so enabling from a different (connected) project re-points the service without a
|
|
4119
|
-
// restart — the request handler reads config from THIS dir, not a value captured at first-start.
|
|
4120
|
-
let _hostControlProjectDir = null;
|
|
4840
|
+
// What is left is the switch itself, recorded per project in .openclaw/host-control.json.
|
|
4121
4841
|
|
|
4122
4842
|
function hostControlConfigPath(projectDir) {
|
|
4123
4843
|
return join(projectDir, '.openclaw', 'host-control.json');
|
|
4124
4844
|
}
|
|
4125
4845
|
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
const add = (key, candidates) => {
|
|
4130
|
-
for (const candidate of candidates) {
|
|
4131
|
-
if (candidate && existsSync(candidate)) {
|
|
4132
|
-
apps[key] = candidate;
|
|
4133
|
-
return;
|
|
4134
|
-
}
|
|
4135
|
-
}
|
|
4136
|
-
};
|
|
4137
|
-
if (process.platform === 'win32') {
|
|
4138
|
-
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
|
|
4139
|
-
const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
|
4140
|
-
const local = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
|
|
4141
|
-
add('teamviewer', [join(pf, 'TeamViewer', 'TeamViewer.exe'), join(pf86, 'TeamViewer', 'TeamViewer.exe')]);
|
|
4142
|
-
add('chrome', [join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe')]);
|
|
4143
|
-
add('zalo', [join(local, 'Programs', 'Zalo', 'Zalo.exe'), join(local, 'Zalo', 'Zalo.exe')]);
|
|
4144
|
-
add('explorer', ['C:\\Windows\\explorer.exe']);
|
|
4145
|
-
add('notepad', ['C:\\Windows\\System32\\notepad.exe']);
|
|
4146
|
-
} else if (process.platform === 'darwin') {
|
|
4147
|
-
add('teamviewer', ['/Applications/TeamViewer.app']);
|
|
4148
|
-
add('chrome', ['/Applications/Google Chrome.app']);
|
|
4149
|
-
add('zalo', ['/Applications/Zalo.app']);
|
|
4150
|
-
add('finder', ['/System/Library/CoreServices/Finder.app']);
|
|
4151
|
-
}
|
|
4152
|
-
return apps;
|
|
4153
|
-
}
|
|
4846
|
+
|
|
4847
|
+
|
|
4848
|
+
|
|
4154
4849
|
|
|
4155
4850
|
/** Resolve an executable on PATH synchronously (returns absolute path or ''). */
|
|
4156
4851
|
function whichSync(name) {
|
|
@@ -4209,221 +4904,13 @@ function resolveHostExecutable(bin) {
|
|
|
4209
4904
|
return { file: target, prefixArgs: [] };
|
|
4210
4905
|
}
|
|
4211
4906
|
|
|
4212
|
-
/**
|
|
4213
|
-
* CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
|
|
4214
|
-
* returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
|
|
4215
|
-
* only allow-listed names run. Auto-detects Claude Code CLI; add more by editing
|
|
4216
|
-
* `.openclaw/host-control.json` → `commands`.
|
|
4217
|
-
*/
|
|
4218
|
-
function detectHostCommands() {
|
|
4219
|
-
const commands = {};
|
|
4220
|
-
const claude = whichSync('claude');
|
|
4221
|
-
if (claude) commands.claude = claude;
|
|
4222
|
-
return commands;
|
|
4223
|
-
}
|
|
4224
4907
|
|
|
4225
|
-
/**
|
|
4226
|
-
* Extra capabilities the operator grants together with PC control: seeing the screen
|
|
4227
|
-
* (screenshot / screen recording) and running scripts through node or the Codex CLI.
|
|
4228
|
-
*
|
|
4229
|
-
* Kept out of detectHostCommands() on purpose. That one is the default list every project gets
|
|
4230
|
-
* as soon as the dashboard reads host-control state; these are only merged in when the operator
|
|
4231
|
-
* actually flips PC control on, so nothing is granted before they ask for it. `node` in
|
|
4232
|
-
* particular runs arbitrary code, which is why it takes an explicit act.
|
|
4233
|
-
*/
|
|
4234
|
-
function detectHostCapabilityCommands() {
|
|
4235
|
-
const commands = {};
|
|
4236
|
-
// The installer is itself node, so this path is guaranteed to exist and to be the same
|
|
4237
|
-
// interpreter the native bot runs under (the one macOS will attach the screen permission to).
|
|
4238
|
-
commands.node = process.execPath;
|
|
4239
|
-
for (const name of ['npx', 'codex', 'claude', 'ffmpeg']) {
|
|
4240
|
-
const bin = whichSync(name);
|
|
4241
|
-
if (bin) commands[name] = bin; // ffmpeg = screen recording on Linux/macOS
|
|
4242
|
-
}
|
|
4243
|
-
// The Codex CLI usually is not on PATH — it ships inside the desktop app. With it allow-listed
|
|
4244
|
-
// the bot can hand a job to Codex headlessly (`codex exec "…"`) and read the answer back.
|
|
4245
|
-
if (!commands.codex) {
|
|
4246
|
-
const bundledCodex = resolveCodexCli(detectCodexApp());
|
|
4247
|
-
if (bundledCodex) commands.codex = bundledCodex;
|
|
4248
|
-
}
|
|
4249
|
-
if (process.platform === 'darwin') {
|
|
4250
|
-
// Both a screenshot (`-x`) and a screen recording (`-v -V <secs>`) tool.
|
|
4251
|
-
if (existsSync('/usr/sbin/screencapture')) commands.screencapture = '/usr/sbin/screencapture';
|
|
4252
|
-
} else if (process.platform === 'linux') {
|
|
4253
|
-
for (const name of ['gnome-screenshot', 'spectacle', 'scrot', 'import']) {
|
|
4254
|
-
const bin = whichSync(name);
|
|
4255
|
-
if (bin) { commands.screenshot = bin; break; }
|
|
4256
|
-
}
|
|
4257
|
-
}
|
|
4258
|
-
return commands;
|
|
4259
|
-
}
|
|
4260
4908
|
|
|
4261
|
-
/**
|
|
4262
|
-
* Merge the capability commands into the project's allow-list, and report what was added so the
|
|
4263
|
-
* dashboard can name it. Existing entries are left alone: an operator who pointed `node` at a
|
|
4264
|
-
* specific interpreter keeps that path.
|
|
4265
|
-
*/
|
|
4266
|
-
function grantHostCapabilities(cfg) {
|
|
4267
|
-
const detected = detectHostCapabilityCommands();
|
|
4268
|
-
const added = [];
|
|
4269
|
-
cfg.commands = cfg.commands || {};
|
|
4270
|
-
for (const [name, bin] of Object.entries(detected)) {
|
|
4271
|
-
if (!cfg.commands[name]) {
|
|
4272
|
-
cfg.commands[name] = bin;
|
|
4273
|
-
added.push(name);
|
|
4274
|
-
}
|
|
4275
|
-
}
|
|
4276
|
-
// Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
|
|
4277
|
-
// clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
|
|
4278
|
-
// on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
|
|
4279
|
-
if (cfg.ui !== true) {
|
|
4280
|
-
cfg.ui = true;
|
|
4281
|
-
added.push('desktop actions (screenshot/click/type)');
|
|
4282
|
-
}
|
|
4283
|
-
return added;
|
|
4284
|
-
}
|
|
4285
|
-
|
|
4286
|
-
// Mouse/keyboard/screen control comes from the Codex desktop app's own `computer-use` plugin.
|
|
4287
|
-
// The bot reaches it by running `codex exec "<task>"`, which is a normal allow-listed command —
|
|
4288
|
-
// no OpenClaw-side harness, no second agent, no gateway restart. All this code has to do is make
|
|
4289
|
-
// sure the desktop app itself has computer-use installed and wired.
|
|
4290
|
-
//
|
|
4291
|
-
/** Where the desktop app that ships the Codex CLI + computer-use bundle lives. */
|
|
4292
|
-
function detectCodexApp() {
|
|
4293
|
-
const candidates = process.platform === 'darwin'
|
|
4294
|
-
? [
|
|
4295
|
-
{ app: '/Applications/Codex.app', bundle: '/Applications/Codex.app/Contents/Resources/plugins/openai-bundled' },
|
|
4296
|
-
{ app: '/Applications/ChatGPT.app', bundle: '/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled' },
|
|
4297
|
-
]
|
|
4298
|
-
: process.platform === 'win32'
|
|
4299
|
-
? [
|
|
4300
|
-
{ app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'Codex'), bundle: '' },
|
|
4301
|
-
{ app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'ChatGPT'), bundle: '' },
|
|
4302
|
-
]
|
|
4303
|
-
: [];
|
|
4304
|
-
for (const candidate of candidates) {
|
|
4305
|
-
if (existsSync(candidate.app)) {
|
|
4306
|
-
return { present: true, app: candidate.app, bundle: candidate.bundle && existsSync(candidate.bundle) ? candidate.bundle : '' };
|
|
4307
|
-
}
|
|
4308
|
-
}
|
|
4309
|
-
return { present: false, app: '', bundle: '' };
|
|
4310
|
-
}
|
|
4311
4909
|
|
|
4312
|
-
/**
|
|
4313
|
-
* Find a marketplace the Codex app-server has ALREADY registered that carries the computer-use
|
|
4314
|
-
* plugin, by reading its own `~/.codex/config.toml`.
|
|
4315
|
-
*
|
|
4316
|
-
* This matters because auto-install refuses to add new sources: pointing the plugin at a
|
|
4317
|
-
* marketplace directory it has not discovered fails with "auto-install only uses marketplaces
|
|
4318
|
-
* Codex app-server has already discovered … run /codex computer-use install". Naming a discovered
|
|
4319
|
-
* marketplace instead keeps provisioning fully automatic.
|
|
4320
|
-
*/
|
|
4321
|
-
function detectCodexMarketplace() {
|
|
4322
|
-
const codexHome = process.env.CODEX_HOME || join(getRealHomedir(), '.codex');
|
|
4323
|
-
const configPath = join(codexHome, 'config.toml');
|
|
4324
|
-
if (!existsSync(configPath)) return null;
|
|
4325
|
-
let toml = '';
|
|
4326
|
-
try {
|
|
4327
|
-
toml = fs.readFileSync(configPath, 'utf8');
|
|
4328
|
-
} catch (_) {
|
|
4329
|
-
return null;
|
|
4330
|
-
}
|
|
4331
|
-
// Minimal line-based TOML read: [marketplaces.<name>] headers and their `source = "..."`. A full
|
|
4332
|
-
// TOML parser is not worth pulling in for two fields of someone else's config.
|
|
4333
|
-
let name = '';
|
|
4334
|
-
for (const rawLine of toml.split(/\r?\n/)) {
|
|
4335
|
-
const line = rawLine.trim();
|
|
4336
|
-
const header = line.match(/^\[([^\]]+)\]$/);
|
|
4337
|
-
if (header) {
|
|
4338
|
-
const section = header[1];
|
|
4339
|
-
name = section.startsWith('marketplaces.') ? section.slice('marketplaces.'.length).replace(/^["']|["']$/g, '') : '';
|
|
4340
|
-
continue;
|
|
4341
|
-
}
|
|
4342
|
-
if (!name) continue;
|
|
4343
|
-
const source = (line.match(/^source\s*=\s*"([^"]+)"$/) || [])[1];
|
|
4344
|
-
if (source && existsSync(join(source, 'plugins', 'computer-use'))) return { name, source };
|
|
4345
|
-
}
|
|
4346
|
-
return null;
|
|
4347
|
-
}
|
|
4348
4910
|
|
|
4349
|
-
/** The Codex CLI that ships inside the desktop app (or one on PATH). */
|
|
4350
|
-
function resolveCodexCli(app) {
|
|
4351
|
-
const bundled = app && app.app ? join(app.app, 'Contents', 'Resources', 'codex') : '';
|
|
4352
|
-
if (bundled && existsSync(bundled)) return bundled;
|
|
4353
|
-
return whichSync('codex');
|
|
4354
|
-
}
|
|
4355
4911
|
|
|
4356
|
-
/**
|
|
4357
|
-
* Last mile on the Codex side: the OpenClaw plugin can only USE computer-use, it cannot install it
|
|
4358
|
-
* into the desktop app. Two things have to be true there, and both are fixable with the app's own
|
|
4359
|
-
* CLI (verified on a real machine):
|
|
4360
|
-
* - the `computer-use` plugin is installed from a discovered marketplace, and
|
|
4361
|
-
* - the `computer-use` MCP server points at that installed plugin. A stale global entry (left by
|
|
4362
|
-
* an earlier manual attempt) shadows the plugin's own and exposes zero tools, which surfaces as
|
|
4363
|
-
* the confusing "Computer Use is ready" with nothing behind it.
|
|
4364
|
-
*/
|
|
4365
|
-
async function ensureCodexComputerUsePlugin(app, marketplace) {
|
|
4366
|
-
const result = { cli: resolveCodexCli(app), pluginInstalled: false, installedNow: false, mcpRepaired: false };
|
|
4367
|
-
if (!result.cli || !marketplace) return result;
|
|
4368
|
-
const list = await runCapture(result.cli, ['plugin', 'list'], { shell: false }).catch(() => null);
|
|
4369
|
-
if (!list) return result;
|
|
4370
|
-
const ref = `computer-use@${marketplace.name}`;
|
|
4371
|
-
const row = `${list.stdout || ''}\n${list.stderr || ''}`.split(/\r?\n/).find((line) => line.trim().startsWith(ref));
|
|
4372
|
-
if (!row) return result;
|
|
4373
|
-
result.pluginInstalled = /\binstalled\b/.test(row) && !/not installed/.test(row);
|
|
4374
|
-
if (!result.pluginInstalled) {
|
|
4375
|
-
sendLog(`[computer-use] Cài plugin ${ref} vào app Codex…`);
|
|
4376
|
-
const add = await runCapture(result.cli, ['plugin', 'add', ref], { shell: false }).catch((err) => ({ code: 1, stderr: err.message }));
|
|
4377
|
-
result.installedNow = add.code === 0;
|
|
4378
|
-
if (!result.installedNow) result.error = (add.stderr || add.stdout || '').trim().split(/\r?\n/).slice(-2).join(' ');
|
|
4379
|
-
else result.pluginInstalled = true;
|
|
4380
|
-
}
|
|
4381
|
-
// Repair the MCP registration only when it clearly is NOT the plugin's own (its cwd lives under
|
|
4382
|
-
// the plugin cache). Removing the global entry lets the plugin-provided server take over.
|
|
4383
|
-
const mcp = await runCapture(result.cli, ['mcp', 'get', 'computer-use'], { shell: false }).catch(() => null);
|
|
4384
|
-
const mcpText = mcp ? `${mcp.stdout || ''}${mcp.stderr || ''}` : '';
|
|
4385
|
-
if (mcpText && !/plugins\/cache\//.test(mcpText)) {
|
|
4386
|
-
sendLog('[computer-use] Gỡ khai báo MCP computer-use cũ (trỏ sai chỗ) để dùng bản của plugin…');
|
|
4387
|
-
const removed = await runCapture(result.cli, ['mcp', 'remove', 'computer-use'], { shell: false }).catch(() => ({ code: 1 }));
|
|
4388
|
-
result.mcpRepaired = removed.code === 0;
|
|
4389
|
-
}
|
|
4390
|
-
return result;
|
|
4391
|
-
}
|
|
4392
4912
|
|
|
4393
|
-
|
|
4394
|
-
* Drop a tiny wrapper next to each workspace so GUI hand-off is one fixed command.
|
|
4395
|
-
*
|
|
4396
|
-
* Relying on the model to remember `--sandbox danger-full-access` does not work: a running session
|
|
4397
|
-
* still holds the TOOLS.md it loaded at session start, so a bot mid-conversation keeps calling
|
|
4398
|
-
* plain `codex exec`, gets "Computer Use was not approved to use <app>", and then invents a reason
|
|
4399
|
-
* (observed twice: it told the operator to grant Screen Recording, which was already granted).
|
|
4400
|
-
* With the wrapper the flags live on disk instead of in the prompt.
|
|
4401
|
-
*/
|
|
4402
|
-
async function writeCodexTaskScript(projectDir, cliPath) {
|
|
4403
|
-
const openclawDir = join(projectDir, '.openclaw');
|
|
4404
|
-
if (!existsSync(openclawDir) || !cliPath) return '';
|
|
4405
|
-
const body = [
|
|
4406
|
-
'#!/bin/sh',
|
|
4407
|
-
'# Managed by create-openclaw-bot — hand a desktop/GUI job to Codex and print its answer.',
|
|
4408
|
-
'# Usage: pc-task.sh "mở TeamViewer và đọc ID trên màn hình"',
|
|
4409
|
-
'# The sandbox flag is REQUIRED: the default read-only sandbox makes Codex refuse computer-use',
|
|
4410
|
-
'# with "Computer Use was not approved to use <app>".',
|
|
4411
|
-
'if [ $# -eq 0 ]; then echo "usage: pc-task.sh \\"việc cần làm\\"" >&2; exit 2; fi',
|
|
4412
|
-
`exec ${JSON.stringify(cliPath)} exec --skip-git-repo-check --sandbox danger-full-access "$@"`,
|
|
4413
|
-
'',
|
|
4414
|
-
].join('\n');
|
|
4415
|
-
let written = '';
|
|
4416
|
-
for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
|
|
4417
|
-
if (!entry.startsWith('workspace')) continue;
|
|
4418
|
-
const binDir = join(openclawDir, entry, 'bin');
|
|
4419
|
-
await fsp.mkdir(binDir, { recursive: true }).catch(() => {});
|
|
4420
|
-
const path = join(binDir, 'pc-task.sh');
|
|
4421
|
-
await fsp.writeFile(path, body, 'utf8').catch(() => {});
|
|
4422
|
-
await fsp.chmod(path, 0o755).catch(() => {});
|
|
4423
|
-
written = path;
|
|
4424
|
-
}
|
|
4425
|
-
return written;
|
|
4426
|
-
}
|
|
4913
|
+
|
|
4427
4914
|
|
|
4428
4915
|
/**
|
|
4429
4916
|
* macOS/Windows privacy panes for the permissions PC control needs. The OS never lets an app
|
|
@@ -4479,6 +4966,13 @@ async function probeScreenPermission() {
|
|
|
4479
4966
|
return { supported: true, granted };
|
|
4480
4967
|
}
|
|
4481
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
|
+
*/
|
|
4482
4976
|
async function readHostControlConfig(projectDir) {
|
|
4483
4977
|
const path = hostControlConfigPath(projectDir);
|
|
4484
4978
|
let cfg = {};
|
|
@@ -4487,24 +4981,8 @@ async function readHostControlConfig(projectDir) {
|
|
|
4487
4981
|
} catch (_) {
|
|
4488
4982
|
cfg = {};
|
|
4489
4983
|
}
|
|
4490
|
-
let changed = false;
|
|
4491
4984
|
if (typeof cfg.enabled !== 'boolean') {
|
|
4492
4985
|
cfg.enabled = false;
|
|
4493
|
-
changed = true;
|
|
4494
|
-
}
|
|
4495
|
-
if (!cfg.token) {
|
|
4496
|
-
cfg.token = _require('crypto').randomBytes(24).toString('hex');
|
|
4497
|
-
changed = true;
|
|
4498
|
-
}
|
|
4499
|
-
if (!cfg.apps || typeof cfg.apps !== 'object') {
|
|
4500
|
-
cfg.apps = detectHostApps();
|
|
4501
|
-
changed = true;
|
|
4502
|
-
}
|
|
4503
|
-
if (!cfg.commands || typeof cfg.commands !== 'object') {
|
|
4504
|
-
cfg.commands = detectHostCommands();
|
|
4505
|
-
changed = true;
|
|
4506
|
-
}
|
|
4507
|
-
if (changed) {
|
|
4508
4986
|
await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
4509
4987
|
await fsp.writeFile(path, JSON.stringify(cfg, null, 2), 'utf8').catch(() => {});
|
|
4510
4988
|
}
|
|
@@ -4518,59 +4996,7 @@ function spawnDetached(command, args) {
|
|
|
4518
4996
|
child.unref();
|
|
4519
4997
|
}
|
|
4520
4998
|
|
|
4521
|
-
function openHostApp(target) {
|
|
4522
|
-
if (process.platform === 'win32') {
|
|
4523
|
-
// `start` needs a shell; the empty "" is the window title cmd expects before the path.
|
|
4524
|
-
spawnDetached('cmd', ['/c', 'start', '', target]);
|
|
4525
|
-
return;
|
|
4526
|
-
}
|
|
4527
|
-
if (process.platform === 'darwin') {
|
|
4528
|
-
spawnDetached('open', [target]);
|
|
4529
|
-
return;
|
|
4530
|
-
}
|
|
4531
|
-
spawnDetached('xdg-open', [target]);
|
|
4532
|
-
}
|
|
4533
4999
|
|
|
4534
|
-
/**
|
|
4535
|
-
* Run an allow-listed CLI (e.g. Claude Code) and return its output. Unlike openHostApp this is
|
|
4536
|
-
* NOT detached: we wait for it, capture stdout/stderr (capped), and enforce a timeout. No shell
|
|
4537
|
-
* (shell:false) so args are literal — no injection; the executable is fixed by the allow-list.
|
|
4538
|
-
*/
|
|
4539
|
-
function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
4540
|
-
const MAX_OUT = 200_000; // ~200 KB cap per stream, so a runaway process can't flood the reply
|
|
4541
|
-
return new Promise((resolveP) => {
|
|
4542
|
-
let out = '';
|
|
4543
|
-
let err = '';
|
|
4544
|
-
let settled = false;
|
|
4545
|
-
const finish = (payload, status) => {
|
|
4546
|
-
if (settled) return;
|
|
4547
|
-
settled = true;
|
|
4548
|
-
clearTimeout(timer);
|
|
4549
|
-
json(res, payload, status);
|
|
4550
|
-
resolveP();
|
|
4551
|
-
};
|
|
4552
|
-
let child;
|
|
4553
|
-
try {
|
|
4554
|
-
const target = resolveHostExecutable(bin);
|
|
4555
|
-
child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
|
|
4556
|
-
} catch (e) {
|
|
4557
|
-
return finish({ ok: false, error: e.message }, 500);
|
|
4558
|
-
}
|
|
4559
|
-
const timer = setTimeout(() => {
|
|
4560
|
-
try { child.kill('SIGKILL'); } catch (_) {}
|
|
4561
|
-
finish({ ok: false, error: `timeout after ${timeoutMs}ms`, timedOut: true, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 504);
|
|
4562
|
-
}, timeoutMs);
|
|
4563
|
-
child.stdout?.on('data', (d) => { if (out.length < MAX_OUT) out += d.toString(); });
|
|
4564
|
-
child.stderr?.on('data', (d) => { if (err.length < MAX_OUT) err += d.toString(); });
|
|
4565
|
-
child.on('error', (e) => finish({ ok: false, error: e.message }, 500));
|
|
4566
|
-
child.on('close', (code) => {
|
|
4567
|
-
sendLog(`[host-control] Đã chạy "${name}" (exit ${code}).`);
|
|
4568
|
-
finish({ ok: code === 0, command: name, code, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 200);
|
|
4569
|
-
});
|
|
4570
|
-
if (input != null) { try { child.stdin.write(input); } catch (_) {} }
|
|
4571
|
-
try { child.stdin.end(); } catch (_) {}
|
|
4572
|
-
});
|
|
4573
|
-
}
|
|
4574
5000
|
|
|
4575
5001
|
/**
|
|
4576
5002
|
* Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
|
|
@@ -4631,451 +5057,56 @@ async function hostUiScreenshotTarget(projectDir) {
|
|
|
4631
5057
|
return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
|
|
4632
5058
|
}
|
|
4633
5059
|
|
|
4634
|
-
async function runHostUiWindows(projectDir, action, body, shot) {
|
|
4635
|
-
const script = await ensureHostUiScript(projectDir);
|
|
4636
|
-
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
|
|
4637
|
-
const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
|
|
4638
|
-
push('-X', body.x);
|
|
4639
|
-
push('-Y', body.y);
|
|
4640
|
-
push('-ToX', body.toX);
|
|
4641
|
-
push('-ToY', body.toY);
|
|
4642
|
-
push('-Amount', body.amount);
|
|
4643
|
-
push('-Text', body.text);
|
|
4644
|
-
push('-Button', body.button);
|
|
4645
|
-
push('-Clicks', body.clicks);
|
|
4646
|
-
push('-Title', body.title);
|
|
4647
|
-
if (shot) push('-Path', shot.hostPath);
|
|
4648
|
-
const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
|
|
4649
|
-
const parsed = parseJsonText(String(r.stdout || '').trim(), null);
|
|
4650
|
-
if (parsed) return parsed;
|
|
4651
|
-
const err = String(r.stderr || r.stdout || '').trim();
|
|
4652
|
-
if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
|
|
4653
|
-
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.' };
|
|
4654
|
-
}
|
|
4655
|
-
return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
|
|
4656
|
-
}
|
|
4657
|
-
|
|
4658
|
-
async function runHostUiMac(action, body, shot) {
|
|
4659
|
-
const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
|
|
4660
|
-
const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
|
|
4661
|
-
switch (action) {
|
|
4662
|
-
case 'screenshot': {
|
|
4663
|
-
const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
|
|
4664
|
-
return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
|
|
4665
|
-
}
|
|
4666
|
-
case 'screen_size': {
|
|
4667
|
-
const r = await osa('tell application "Finder" to get bounds of window of desktop');
|
|
4668
|
-
const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
|
|
4669
|
-
return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
|
|
4670
|
-
}
|
|
4671
|
-
case 'mouse_move':
|
|
4672
|
-
case 'click': {
|
|
4673
|
-
// System Events can click at a point; a plain move has no equivalent, so a move is a click
|
|
4674
|
-
// target set-up only. Accessibility permission is required (System Settings → Privacy).
|
|
4675
|
-
const clicks = Math.max(1, Number(body.clicks) || 1);
|
|
4676
|
-
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 };
|
|
4677
|
-
for (let i = 0; i < clicks; i++) {
|
|
4678
|
-
const r = await osa(`tell application "System Events" to click at ${point()}`);
|
|
4679
|
-
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
|
|
4680
|
-
}
|
|
4681
|
-
return { ok: true, button: 'left', clicks };
|
|
4682
|
-
}
|
|
4683
|
-
case 'type': {
|
|
4684
|
-
const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
4685
|
-
const r = await osa(`tell application "System Events" to keystroke "${text}"`);
|
|
4686
|
-
return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
|
|
4687
|
-
}
|
|
4688
|
-
case 'key': {
|
|
4689
|
-
const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
|
|
4690
|
-
for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
|
|
4691
|
-
const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
|
|
4692
|
-
const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
|
|
4693
|
-
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);
|
|
4694
|
-
const using = mods.length ? ` using {${mods.join(', ')}}` : '';
|
|
4695
|
-
const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
|
|
4696
|
-
const script = named.includes(key)
|
|
4697
|
-
? `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}`
|
|
4698
|
-
: `tell application "System Events" to keystroke "${key}"${using}`;
|
|
4699
|
-
const r = await osa(script);
|
|
4700
|
-
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
|
|
4701
|
-
}
|
|
4702
|
-
return { ok: true, keys: body.text };
|
|
4703
|
-
}
|
|
4704
|
-
case 'scroll': {
|
|
4705
|
-
const amount = Number(body.amount) || 3;
|
|
4706
|
-
const dir = amount < 0 ? 121 : 116; // page down / page up
|
|
4707
|
-
for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
|
|
4708
|
-
return { ok: true, amount };
|
|
4709
|
-
}
|
|
4710
|
-
case 'clipboard_get': {
|
|
4711
|
-
const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
|
|
4712
|
-
return { ok: true, text: String(r.stdout || '') };
|
|
4713
|
-
}
|
|
4714
|
-
case 'clipboard_set': {
|
|
4715
|
-
const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
4716
|
-
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
|
|
4717
|
-
}
|
|
4718
|
-
case 'windows': {
|
|
4719
|
-
const r = await osa('tell application "System Events" to get name of every process whose background only is false');
|
|
4720
|
-
const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
|
|
4721
|
-
return { ok: true, windows: list };
|
|
4722
|
-
}
|
|
4723
|
-
case 'focus': {
|
|
4724
|
-
const title = String(body.title || '').replace(/"/g, '');
|
|
4725
|
-
if (!title) return { ok: false, error: 'focus needs a title' };
|
|
4726
|
-
const r = await osa(`tell application "${title}" to activate`);
|
|
4727
|
-
return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
|
|
4728
|
-
}
|
|
4729
|
-
default:
|
|
4730
|
-
return { ok: false, error: `unsupported on macOS: ${action}` };
|
|
4731
|
-
}
|
|
4732
|
-
}
|
|
4733
|
-
|
|
4734
|
-
async function runHostUiLinux(action, body, shot) {
|
|
4735
|
-
const xdo = whichSync('xdotool');
|
|
4736
|
-
const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
|
|
4737
|
-
switch (action) {
|
|
4738
|
-
case 'screenshot': {
|
|
4739
|
-
const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
|
|
4740
|
-
if (!tool) return need('scrot', 'screenshot');
|
|
4741
|
-
const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
|
|
4742
|
-
: tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
|
|
4743
|
-
: tool.name === 'import' ? ['-window', 'root', shot.hostPath]
|
|
4744
|
-
: [shot.hostPath];
|
|
4745
|
-
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
|
|
4746
|
-
return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
|
|
4747
|
-
}
|
|
4748
|
-
case 'screen_size': {
|
|
4749
|
-
if (!xdo) return need('xdotool', 'screen_size');
|
|
4750
|
-
const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
|
|
4751
|
-
const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
|
|
4752
|
-
return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
|
|
4753
|
-
}
|
|
4754
|
-
case 'mouse_move':
|
|
4755
|
-
case 'click':
|
|
4756
|
-
case 'drag':
|
|
4757
|
-
case 'scroll':
|
|
4758
|
-
case 'type':
|
|
4759
|
-
case 'key':
|
|
4760
|
-
case 'windows':
|
|
4761
|
-
case 'focus': {
|
|
4762
|
-
if (!xdo) return need('xdotool', action);
|
|
4763
|
-
const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
|
|
4764
|
-
const argvFor = {
|
|
4765
|
-
mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
|
|
4766
|
-
click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
|
|
4767
|
-
drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
|
|
4768
|
-
scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
|
|
4769
|
-
type: ['type', '--delay', '12', '--', String(body.text || '')],
|
|
4770
|
-
key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
|
|
4771
|
-
windows: ['search', '--onlyvisible', '--name', '.'],
|
|
4772
|
-
focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
|
|
4773
|
-
}[action];
|
|
4774
|
-
const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
|
|
4775
|
-
if (action === 'windows') {
|
|
4776
|
-
const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
|
|
4777
|
-
const titles = [];
|
|
4778
|
-
for (const id of ids) {
|
|
4779
|
-
const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
|
|
4780
|
-
const title = String(t.stdout || '').trim();
|
|
4781
|
-
if (title) titles.push({ title, id });
|
|
4782
|
-
}
|
|
4783
|
-
return { ok: true, windows: titles };
|
|
4784
|
-
}
|
|
4785
|
-
return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
|
|
4786
|
-
}
|
|
4787
|
-
case 'clipboard_get': {
|
|
4788
|
-
const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
|
|
4789
|
-
if (!tool) return need('xclip', 'clipboard_get');
|
|
4790
|
-
const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
|
|
4791
|
-
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
|
|
4792
|
-
return { ok: true, text: String(r.stdout || '') };
|
|
4793
|
-
}
|
|
4794
|
-
case 'clipboard_set': {
|
|
4795
|
-
const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
|
|
4796
|
-
if (!tool) return need('xclip', 'clipboard_set');
|
|
4797
|
-
const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
|
|
4798
|
-
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
4799
|
-
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
|
|
4800
|
-
}
|
|
4801
|
-
default:
|
|
4802
|
-
return { ok: false, error: `unsupported on Linux: ${action}` };
|
|
4803
|
-
}
|
|
4804
|
-
}
|
|
4805
5060
|
|
|
4806
|
-
async function runHostUi(projectDir, body = {}) {
|
|
4807
|
-
const action = String(body.action || '').trim();
|
|
4808
|
-
if (!HOST_UI_ACTIONS.has(action)) {
|
|
4809
|
-
return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
|
|
4810
|
-
}
|
|
4811
|
-
const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
|
|
4812
|
-
let result;
|
|
4813
|
-
try {
|
|
4814
|
-
if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
|
|
4815
|
-
else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
|
|
4816
|
-
else result = await runHostUiLinux(action, body, shot);
|
|
4817
|
-
} catch (err) {
|
|
4818
|
-
result = { ok: false, error: err.message };
|
|
4819
|
-
}
|
|
4820
|
-
if (shot && result?.ok) {
|
|
4821
|
-
// The project folder is bind-mounted into the container, so hand back the path the bot can
|
|
4822
|
-
// actually open — otherwise it gets a Windows path it cannot read and reports failure.
|
|
4823
|
-
result.path = shot.hostPath;
|
|
4824
|
-
result.containerPath = shot.containerPath;
|
|
4825
|
-
result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
|
|
4826
|
-
}
|
|
4827
|
-
sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
|
|
4828
|
-
return { status: result?.ok ? 200 : 500, payload: result };
|
|
4829
|
-
}
|
|
4830
5061
|
|
|
4831
|
-
async function handleHostControl(req, res, projectDir) {
|
|
4832
|
-
const cfg = await readHostControlConfig(projectDir);
|
|
4833
|
-
const url = new URL(req.url, 'http://localhost');
|
|
4834
|
-
const presented = req.headers['x-openclaw-token'] || url.searchParams.get('token') || '';
|
|
4835
|
-
if (!cfg.enabled) return json(res, { ok: false, error: 'host control is disabled' }, 403);
|
|
4836
|
-
if (presented !== cfg.token) return json(res, { ok: false, error: 'invalid token' }, 401);
|
|
4837
5062
|
|
|
4838
|
-
|
|
4839
|
-
try {
|
|
4840
|
-
return json(res, await startChromeDebug());
|
|
4841
|
-
} catch (err) {
|
|
4842
|
-
return json(res, { ok: false, error: err.message }, err.status || 500);
|
|
4843
|
-
}
|
|
4844
|
-
}
|
|
4845
|
-
if (url.pathname === '/api/host/apps' && req.method === 'GET') {
|
|
4846
|
-
return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
|
|
4847
|
-
}
|
|
4848
|
-
if (url.pathname === '/api/host/ui' && req.method === 'POST') {
|
|
4849
|
-
// Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
|
|
4850
|
-
// step than opening an app, so it only answers once the operator has granted capabilities.
|
|
4851
|
-
if (cfg.ui !== true) {
|
|
4852
|
-
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);
|
|
4853
|
-
}
|
|
4854
|
-
const body = await readJson(req).catch(() => ({}));
|
|
4855
|
-
const { status, payload } = await runHostUi(projectDir, body || {});
|
|
4856
|
-
return json(res, payload, status);
|
|
4857
|
-
}
|
|
4858
|
-
if (url.pathname === '/api/host/exec' && req.method === 'POST') {
|
|
4859
|
-
const body = await readJson(req).catch(() => ({}));
|
|
4860
|
-
const name = String(body.command || '').trim().toLowerCase();
|
|
4861
|
-
if (!name) return json(res, { ok: false, error: 'missing "command"' }, 400);
|
|
4862
|
-
const bin = (cfg.commands || {})[name];
|
|
4863
|
-
if (!bin) {
|
|
4864
|
-
return json(res, {
|
|
4865
|
-
ok: false,
|
|
4866
|
-
error: `"${name}" is not in this machine's command list`,
|
|
4867
|
-
commands: Object.keys(cfg.commands || {}),
|
|
4868
|
-
}, 404);
|
|
4869
|
-
}
|
|
4870
|
-
// Args are passed literally (spawn with shell:false) so nothing in them is re-interpreted
|
|
4871
|
-
// by a shell — the executable is fixed to the allow-listed path, callers cannot pick a
|
|
4872
|
-
// different binary or inject a second command.
|
|
4873
|
-
const args = Array.isArray(body.args) ? body.args.map((a) => String(a)) : [];
|
|
4874
|
-
const input = body.input != null ? String(body.input) : null;
|
|
4875
|
-
const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || 180000, 1000), 600000);
|
|
4876
|
-
return runHostCommand(res, name, bin, args, input, timeoutMs);
|
|
4877
|
-
}
|
|
4878
|
-
if (url.pathname === '/api/host/open' && req.method === 'POST') {
|
|
4879
|
-
const body = await readJson(req).catch(() => ({}));
|
|
4880
|
-
const key = String(body.app || body.target || '').trim();
|
|
4881
|
-
if (!key) return json(res, { ok: false, error: 'missing "app"' }, 400);
|
|
4882
|
-
const path = (cfg.apps || {})[key.toLowerCase()];
|
|
4883
|
-
if (!path) {
|
|
4884
|
-
return json(res, {
|
|
4885
|
-
ok: false,
|
|
4886
|
-
error: `"${key}" is not in this machine's app list`,
|
|
4887
|
-
apps: Object.keys(cfg.apps || {}),
|
|
4888
|
-
}, 404);
|
|
4889
|
-
}
|
|
4890
|
-
openHostApp(path);
|
|
4891
|
-
sendLog(`[host-control] Đã mở "${key}" trên máy (${path}).`);
|
|
4892
|
-
return json(res, { ok: true, app: key, path });
|
|
4893
|
-
}
|
|
4894
|
-
return json(res, { ok: false, error: 'unknown endpoint' }, 404);
|
|
4895
|
-
}
|
|
5063
|
+
|
|
4896
5064
|
|
|
4897
5065
|
/**
|
|
4898
|
-
* Teach every bot in the project how to
|
|
4899
|
-
*
|
|
4900
|
-
*
|
|
4901
|
-
*
|
|
4902
|
-
*
|
|
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.
|
|
4903
5076
|
*/
|
|
4904
5077
|
async function writeHostControlAccess(projectDir, cfg) {
|
|
4905
5078
|
const openclawDir = join(projectDir, '.openclaw');
|
|
4906
5079
|
if (!existsSync(openclawDir)) return;
|
|
4907
|
-
const native = isNativeProject(projectDir);
|
|
4908
|
-
// Native bots run on the host itself; host.docker.internal only resolves from inside a container,
|
|
4909
|
-
// so a native bot curling it fails ("could not connect"). Use loopback there instead.
|
|
4910
|
-
const base = native ? `http://127.0.0.1:${HOST_CONTROL_PORT}` : `http://host.docker.internal:${HOST_CONTROL_PORT}`;
|
|
4911
|
-
const apps = Object.keys(cfg.apps || {});
|
|
4912
|
-
const commands = Object.keys(cfg.commands || {});
|
|
4913
|
-
const execBlock = commands.length ? [
|
|
4914
|
-
'',
|
|
4915
|
-
'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}`).',
|
|
4916
|
-
'Dùng để giao việc cho công cụ dòng lệnh, ví dụ Claude Code:',
|
|
4917
|
-
'',
|
|
4918
|
-
'```sh',
|
|
4919
|
-
`curl -s -X POST ${base}/api/host/exec -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
4920
|
-
' -H "content-type: application/json" -d \'{"command":"claude","args":["-p","tóm tắt repo hiện tại"]}\'',
|
|
4921
|
-
'```',
|
|
4922
|
-
'',
|
|
4923
|
-
`Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
|
|
4924
|
-
] : [];
|
|
4925
|
-
// Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
|
|
4926
|
-
// instructions. Screenshots land in the project folder, which the container already sees.
|
|
4927
|
-
const uiBlock = cfg.ui === true ? [
|
|
4928
|
-
'',
|
|
4929
|
-
'### Thao tác trên màn hình chủ',
|
|
4930
|
-
'',
|
|
4931
|
-
'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** —',
|
|
4932
|
-
'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
|
|
4933
|
-
'',
|
|
4934
|
-
'```sh',
|
|
4935
|
-
`curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
4936
|
-
' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
|
|
4937
|
-
'```',
|
|
4938
|
-
'',
|
|
4939
|
-
'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
|
|
4940
|
-
'kèm `width`/`height` để biết màn hình bao lớn.',
|
|
4941
|
-
'',
|
|
4942
|
-
'Các action khác (cùng dạng `{"action":...}`):',
|
|
4943
|
-
'',
|
|
4944
|
-
'- `screen_size` — kích thước màn hình',
|
|
4945
|
-
'- `mouse_move` + `x`,`y` — di chuột',
|
|
4946
|
-
'- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
|
|
4947
|
-
'- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
|
|
4948
|
-
'- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
|
|
4949
|
-
'- `type` + `text` — gõ chữ vào cửa sổ đang focus',
|
|
4950
|
-
'- `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',
|
|
4951
|
-
'- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
|
|
4952
|
-
'- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
|
|
4953
|
-
'',
|
|
4954
|
-
'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
|
|
4955
|
-
'nói chủ mở lại installer trong máy, đừng thử cách khác.',
|
|
4956
|
-
'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
|
|
4957
|
-
] : [];
|
|
4958
|
-
// Screen capture / recording — only advertised when the operator granted the matching tool, so
|
|
4959
|
-
// the bot never tries a binary that is not on this machine's allow-list.
|
|
4960
|
-
// Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
|
|
4961
|
-
// up there too — a native bot runs the command itself, the allow-list only gates the bridge.
|
|
4962
|
-
const hasCapture = commands.includes('screencapture') || commands.includes('screenshot') || commands.includes('ffmpeg') || (native && process.platform === 'win32');
|
|
4963
|
-
const captureBlock = hasCapture ? [
|
|
4964
|
-
'',
|
|
4965
|
-
'### Chụp / quay màn hình',
|
|
4966
|
-
'',
|
|
4967
|
-
...(commands.includes('screencapture') ? [
|
|
4968
|
-
'- 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ổ).',
|
|
4969
|
-
'- Quay: `screencapture -v -V 10 /tmp/rec.mov` (quay 10 giây rồi tự dừng).',
|
|
4970
|
-
] : []),
|
|
4971
|
-
...(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.'] : []),
|
|
4972
|
-
...(native && process.platform === 'win32' ? [
|
|
4973
|
-
'- 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).',
|
|
4974
|
-
] : []),
|
|
4975
|
-
...(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`).'] : []),
|
|
4976
|
-
'',
|
|
4977
|
-
'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.',
|
|
4978
|
-
] : [];
|
|
4979
|
-
const scriptCommands = commands.filter((c) => c === 'node' || c === 'npx' || c === 'codex' || c === 'claude');
|
|
4980
|
-
const scriptBlock = scriptCommands.length ? [
|
|
4981
|
-
'',
|
|
4982
|
-
'### Chạy script & giao việc cho CLI khác',
|
|
4983
|
-
'',
|
|
4984
|
-
`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\`).`,
|
|
4985
|
-
...(commands.includes('codex') ? [
|
|
4986
|
-
'- 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.',
|
|
4987
|
-
] : []),
|
|
4988
|
-
...(commands.includes('claude') ? [
|
|
4989
|
-
'- Giao việc cho **Claude Code**: `claude -p "việc cần làm"` (một lượt, trả stdout).',
|
|
4990
|
-
] : []),
|
|
4991
|
-
'Đâ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.',
|
|
4992
|
-
] : [];
|
|
4993
5080
|
const startTag = '<!-- OPENCLAW:HOST_CONTROL:START -->';
|
|
4994
5081
|
const endTag = '<!-- OPENCLAW:HOST_CONTROL:END -->';
|
|
4995
|
-
|
|
4996
|
-
// no bridge, no host.docker.internal (which doesn't resolve off-container anyway). DOCKER: the
|
|
4997
|
-
// bot is in a container and can't see the desktop, so it must call the installer's host service.
|
|
4998
|
-
const nativeBlock = [
|
|
4999
|
-
startTag,
|
|
5000
|
-
'',
|
|
5001
|
-
'## 🖥️ Điều khiển máy của chủ (host control — chế độ native)',
|
|
5002
|
-
'',
|
|
5003
|
-
'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):',
|
|
5004
|
-
'',
|
|
5005
|
-
'- macOS: `open -a "<Tên app>"` — ví dụ `open -a "TeamViewer"`',
|
|
5006
|
-
'- Linux: `xdg-open <app|url>` hoặc chạy binary trực tiếp',
|
|
5007
|
-
'- Windows: `cmd /c start "" "<app>"`',
|
|
5008
|
-
'',
|
|
5009
|
-
apps.length ? `App chủ gợi ý: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Chủ chưa liệt kê app — mở theo tên app có trên máy.',
|
|
5010
|
-
...captureBlock,
|
|
5011
|
-
...scriptBlock,
|
|
5012
|
-
'',
|
|
5013
|
-
...(commands.includes('codex') ? [
|
|
5014
|
-
'### Điều khiển chuột/bàn phím & nhìn màn hình',
|
|
5015
|
-
'',
|
|
5016
|
-
'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):',
|
|
5017
|
-
'',
|
|
5018
|
-
'```sh',
|
|
5019
|
-
'bin/pc-task.sh "mở TeamViewer, đọc mật khẩu đang hiện trên màn hình rồi in ra"',
|
|
5020
|
-
'```',
|
|
5021
|
-
'',
|
|
5022
|
-
'⚠️ **Đừ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 đủ).',
|
|
5023
|
-
'',
|
|
5024
|
-
'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 ý:',
|
|
5025
|
-
'',
|
|
5026
|
-
'- 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.',
|
|
5027
|
-
'- Việc GUI có thể chạy lâu: đặt timeout rộng, đừng kết luận thất bại sớm.',
|
|
5028
|
-
'- 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.',
|
|
5029
|
-
'- Đ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.',
|
|
5030
|
-
'- 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.',
|
|
5031
|
-
'',
|
|
5032
|
-
] : []),
|
|
5033
|
-
'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ử".',
|
|
5034
|
-
'',
|
|
5035
|
-
endTag,
|
|
5036
|
-
'',
|
|
5037
|
-
].join('\n');
|
|
5038
|
-
const dockerBlock = [
|
|
5082
|
+
const block = [
|
|
5039
5083
|
startTag,
|
|
5040
5084
|
'',
|
|
5041
|
-
'## 🖥️ Điều khiển máy của chủ
|
|
5042
|
-
'',
|
|
5043
|
-
'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`:',
|
|
5044
|
-
'',
|
|
5045
|
-
'```sh',
|
|
5046
|
-
`curl -s -X POST ${base}/api/browser/start-chrome -H "x-openclaw-token: ${cfg.token}"`,
|
|
5047
|
-
'```',
|
|
5085
|
+
'## 🖥️ Điều khiển máy của chủ',
|
|
5048
5086
|
'',
|
|
5049
|
-
'
|
|
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:',
|
|
5050
5089
|
'',
|
|
5051
|
-
'
|
|
5052
|
-
`
|
|
5053
|
-
' -H "content-type: application/json" -d \'{"app":"teamviewer"}\'',
|
|
5054
|
-
'```',
|
|
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.',
|
|
5055
5092
|
'',
|
|
5056
|
-
'
|
|
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.',
|
|
5057
5096
|
'',
|
|
5058
|
-
'
|
|
5059
|
-
|
|
5060
|
-
'```',
|
|
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ó.',
|
|
5061
5099
|
'',
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
// Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
|
|
5066
|
-
// container cannot read — say so instead of letting the bot hunt for a missing file.
|
|
5067
|
-
...(hasCapture ? [
|
|
5068
|
-
'',
|
|
5069
|
-
'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.',
|
|
5070
|
-
] : []),
|
|
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.',
|
|
5071
5103
|
'',
|
|
5072
|
-
'
|
|
5073
|
-
'
|
|
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.',
|
|
5074
5106
|
'',
|
|
5075
5107
|
endTag,
|
|
5076
5108
|
'',
|
|
5077
5109
|
].join('\n');
|
|
5078
|
-
const block = native ? nativeBlock : dockerBlock;
|
|
5079
5110
|
for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
|
|
5080
5111
|
if (!entry.startsWith('workspace')) continue;
|
|
5081
5112
|
const toolsMd = join(openclawDir, entry, 'TOOLS.md');
|
|
@@ -5099,46 +5130,6 @@ function removeManagedBlockFrom(content, blockId) {
|
|
|
5099
5130
|
return `${content.substring(0, startIdx).trimEnd()}\n${content.substring(endIdx + endTag.length).trimStart()}`.trim() + '\n';
|
|
5100
5131
|
}
|
|
5101
5132
|
|
|
5102
|
-
async function ensureHostControl(projectDir) {
|
|
5103
|
-
// Point the service at the project being enabled (re-points a service already running for
|
|
5104
|
-
// another project — the handler reads _hostControlProjectDir per request).
|
|
5105
|
-
_hostControlProjectDir = projectDir;
|
|
5106
|
-
const cfg = await readHostControlConfig(projectDir);
|
|
5107
|
-
if (!cfg.enabled) return { ok: false, reason: 'disabled' };
|
|
5108
|
-
// Desktop only. Opening TeamViewer or an app needs a GUI, so a headless server has nothing
|
|
5109
|
-
// to control — and, more importantly, it is where 0.0.0.0 would be a real exposure (a VPS
|
|
5110
|
-
// has a public IP). Refusing here means the service never binds on a headless box, so the
|
|
5111
|
-
// public-exposure question does not arise. A rare VPS-with-desktop can override with
|
|
5112
|
-
// OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS=1.
|
|
5113
|
-
if (isHeadlessServer() && process.env.OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS !== '1') {
|
|
5114
|
-
return { ok: false, reason: 'headless server — no desktop to control' };
|
|
5115
|
-
}
|
|
5116
|
-
if (_hostControlServer) return { ok: true, port: HOST_CONTROL_PORT };
|
|
5117
|
-
const bridgeIp = await getDockerBridgeIp().catch(() => null);
|
|
5118
|
-
const server = http.createServer((req, res) => {
|
|
5119
|
-
// Read the CURRENTLY active project each request, so re-pointing takes effect live.
|
|
5120
|
-
handleHostControl(req, res, _hostControlProjectDir || projectDir).catch((err) => json(res, { ok: false, error: err.message }, 500));
|
|
5121
|
-
});
|
|
5122
|
-
// Bind all interfaces: the container reaches the host by different addresses per platform —
|
|
5123
|
-
// docker0 (172.17.0.1) on native Linux, the Docker Desktop gateway (host.docker.internal,
|
|
5124
|
-
// e.g. 192.168.65.254) on macOS/Windows — and binding one misses the others. The token is
|
|
5125
|
-
// the guard here, not the interface: every request needs it, and the service only exists
|
|
5126
|
-
// while the operator has host control switched on.
|
|
5127
|
-
const bindOk = await new Promise((resolveP) => {
|
|
5128
|
-
server.once('error', () => resolveP(false));
|
|
5129
|
-
server.listen(HOST_CONTROL_PORT, '0.0.0.0', () => resolveP(true));
|
|
5130
|
-
});
|
|
5131
|
-
if (!bindOk) return { ok: false, reason: `port ${HOST_CONTROL_PORT} in use` };
|
|
5132
|
-
_hostControlServer = server;
|
|
5133
|
-
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.`);
|
|
5134
|
-
if (bridgeIp && process.platform === 'linux') {
|
|
5135
|
-
// ufw's default-deny drops container→host traffic silently. Scope the allow rule to the
|
|
5136
|
-
// private bridge address only, so opening the port here does not expose it to the LAN.
|
|
5137
|
-
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`])
|
|
5138
|
-
.catch(() => {});
|
|
5139
|
-
}
|
|
5140
|
-
return { ok: true, port: HOST_CONTROL_PORT, host: '0.0.0.0' };
|
|
5141
|
-
}
|
|
5142
5133
|
|
|
5143
5134
|
async function ensureChromeRelay() {
|
|
5144
5135
|
if (_chromeRelayServer) return true;
|
|
@@ -5308,6 +5299,79 @@ async function waitForDockerDaemon(timeoutMs) {
|
|
|
5308
5299
|
}
|
|
5309
5300
|
}
|
|
5310
5301
|
|
|
5302
|
+
// OpenClaw's own engines range, copied verbatim from its package.json:
|
|
5303
|
+
// ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
|
|
5304
|
+
// The gaps matter — Node 23.x and 25.0–25.8 are NOT usable — so "install the latest Node" is the
|
|
5305
|
+
// wrong instinct here. When we have to install, we install 24 LTS, which sits inside a supported
|
|
5306
|
+
// window and is what every working host in the fleet already runs.
|
|
5307
|
+
const NODE_TARGET_MAJOR = 24;
|
|
5308
|
+
function nodeVersionSupported(raw) {
|
|
5309
|
+
const m = String(raw || '').match(/v?(\d+)\.(\d+)\.(\d+)/);
|
|
5310
|
+
if (!m) return false;
|
|
5311
|
+
const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
5312
|
+
const atLeast = (a, b, c) => maj > a || (maj === a && (min > b || (min === b && pat >= c)));
|
|
5313
|
+
if (maj === 22) return atLeast(22, 22, 3);
|
|
5314
|
+
if (maj === 24) return atLeast(24, 15, 0);
|
|
5315
|
+
if (maj >= 25) return maj > 25 || atLeast(25, 9, 0);
|
|
5316
|
+
return false;
|
|
5317
|
+
}
|
|
5318
|
+
|
|
5319
|
+
// Native mode runs openclaw straight off the host, so the host's Node IS the runtime — unlike
|
|
5320
|
+
// docker mode, where the image carried its own. A customer machine that never needed Node before
|
|
5321
|
+
// (or carries one outside openclaw's supported range) must get a usable one before anything else,
|
|
5322
|
+
// otherwise the install dies deep inside `npm i -g openclaw` with an unreadable engine error.
|
|
5323
|
+
async function ensureNodeInstalled() {
|
|
5324
|
+
const current = await commandExists('node', ['--version']);
|
|
5325
|
+
if (current.ok && nodeVersionSupported(current.output)) {
|
|
5326
|
+
sendLog(`[node] Node ${current.output.trim()} hợp lệ.`);
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5329
|
+
const why = current.ok
|
|
5330
|
+
? `Node ${current.output.trim()} nằm ngoài dải OpenClaw hỗ trợ`
|
|
5331
|
+
: 'Máy chưa có Node';
|
|
5332
|
+
sendLog(`[node] ${why} — đang cài Node ${NODE_TARGET_MAJOR} LTS...`);
|
|
5333
|
+
|
|
5334
|
+
if (process.platform === 'linux') {
|
|
5335
|
+
const root = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
5336
|
+
const sudo = root ? '' : 'sudo ';
|
|
5337
|
+
// NodeSource covers deb/rpm and pins the major, so we land inside the supported window
|
|
5338
|
+
// instead of whatever "latest" happens to be that week.
|
|
5339
|
+
await run('sh', ['-c',
|
|
5340
|
+
`curl -fsSL https://deb.nodesource.com/setup_${NODE_TARGET_MAJOR}.x | ${sudo}bash - && ${sudo}apt-get install -y nodejs`,
|
|
5341
|
+
]).catch(async () => {
|
|
5342
|
+
sendLog('[node] apt không dùng được — thử nvm...');
|
|
5343
|
+
await run('sh', ['-c',
|
|
5344
|
+
`export NVM_DIR="$HOME/.nvm"; curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && . "$NVM_DIR/nvm.sh" && nvm install ${NODE_TARGET_MAJOR} && nvm alias default ${NODE_TARGET_MAJOR}`,
|
|
5345
|
+
]);
|
|
5346
|
+
});
|
|
5347
|
+
} else if (process.platform === 'darwin') {
|
|
5348
|
+
const brew = await commandExists('brew', ['--version']);
|
|
5349
|
+
if (!brew.ok) throw httpError(400, 'macOS: cần Homebrew để tự cài Node. Cài tại https://brew.sh rồi thử lại.');
|
|
5350
|
+
await run('brew', ['install', `node@${NODE_TARGET_MAJOR}`]);
|
|
5351
|
+
await run('brew', ['link', '--overwrite', '--force', `node@${NODE_TARGET_MAJOR}`]).catch(() => {});
|
|
5352
|
+
} else if (process.platform === 'win32') {
|
|
5353
|
+
const winget = await commandExists('winget', ['--version']);
|
|
5354
|
+
const choco = await commandExists('choco', ['--version']);
|
|
5355
|
+
if (winget.ok) {
|
|
5356
|
+
await run('winget', ['install', '-e', '--id', `OpenJS.NodeJS.LTS`, '--accept-source-agreements', '--accept-package-agreements']);
|
|
5357
|
+
} else if (choco.ok) {
|
|
5358
|
+
await run('choco', ['install', 'nodejs-lts', '-y']);
|
|
5359
|
+
} else {
|
|
5360
|
+
throw httpError(400, `Windows: cần winget hoặc Chocolatey để tự cài Node (hoặc cài Node ${NODE_TARGET_MAJOR} LTS thủ công tại https://nodejs.org) rồi thử lại.`);
|
|
5361
|
+
}
|
|
5362
|
+
} else {
|
|
5363
|
+
throw httpError(400, `Hệ điều hành không tự cài Node được. Hãy cài Node ${NODE_TARGET_MAJOR} LTS thủ công rồi thử lại.`);
|
|
5364
|
+
}
|
|
5365
|
+
|
|
5366
|
+
// A fresh install lands on a PATH this process never picked up, so re-check through a new shell
|
|
5367
|
+
// rather than trusting our own env.
|
|
5368
|
+
const after = await commandExists('node', ['--version']);
|
|
5369
|
+
if (!after.ok || !nodeVersionSupported(after.output)) {
|
|
5370
|
+
throw httpError(500, `Đã cài Node nhưng phiên bản hiện tại (${after.output || 'không đọc được'}) vẫn ngoài dải OpenClaw hỗ trợ. Mở lại cửa sổ dòng lệnh (hoặc đăng xuất/đăng nhập lại) để PATH cập nhật, rồi cài lại.`);
|
|
5371
|
+
}
|
|
5372
|
+
sendLog(`[node] Node ${after.output.trim()} đã sẵn sàng.`);
|
|
5373
|
+
}
|
|
5374
|
+
|
|
5311
5375
|
// Ensure Docker (engine + compose) is available before a docker-mode install, auto-installing the
|
|
5312
5376
|
// latest version appropriate for the host OS when it is missing:
|
|
5313
5377
|
// • Linux → Docker's official convenience script (get.docker.com; auto-detects the distro) + start daemon
|
|
@@ -5407,7 +5471,10 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
5407
5471
|
// Make sure Docker is present (auto-install on Linux/VPS) before doing any work — fail fast
|
|
5408
5472
|
// with a clear message rather than deep inside `docker compose up`. Native mode has no
|
|
5409
5473
|
// container, so it skips this entirely (that is much of the point of choosing it).
|
|
5410
|
-
|
|
5474
|
+
// Native has no image to carry a runtime, so the host's Node IS the runtime — get a supported
|
|
5475
|
+
// one on the machine before npm touches anything. Docker still needs the daemon instead.
|
|
5476
|
+
if (mode === 'native') await ensureNodeInstalled();
|
|
5477
|
+
else await ensureDockerInstalled(osChoice);
|
|
5411
5478
|
await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort, userTimezone });
|
|
5412
5479
|
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
5413
5480
|
// The global runtime just changed — every version gate after this point (notably
|
|
@@ -5632,6 +5699,34 @@ async function discoverDockerBotProjectRoots() {
|
|
|
5632
5699
|
// dir symlinked to it — without this filter the account home shows up as a phantom "root"
|
|
5633
5700
|
// project tab after every refresh (measured on vps_c-thu). The native marker's `label` was
|
|
5634
5701
|
// written from the ORIGINAL project dir, so a mismatch identifies the phantom.
|
|
5702
|
+
/**
|
|
5703
|
+
* Two paths can be the same project: a native install links the account home and the project dir
|
|
5704
|
+
* together, so `~/.openclaw` and `<project>/.openclaw` resolve to one directory on disk. Scanning
|
|
5705
|
+
* finds both and the dashboard then lists the SAME bots twice under two project names (seen on
|
|
5706
|
+
* win_kha as "bot" and "VT 2025", 4 identical bots online in each — 10/09/2026).
|
|
5707
|
+
*
|
|
5708
|
+
* Dedupe on where `.openclaw` really lands, and keep the entry that IS the real directory: the
|
|
5709
|
+
* link side is an artefact of the install layout, not somewhere the customer put a project.
|
|
5710
|
+
* Works whichever way the link points, which matters because the direction differs between a
|
|
5711
|
+
* fresh native install and one migrated over from docker.
|
|
5712
|
+
*/
|
|
5713
|
+
function dedupeProjectsByRealState(dirs) {
|
|
5714
|
+
const byReal = new Map();
|
|
5715
|
+
for (const dir of dirs) {
|
|
5716
|
+
if (!dir) continue;
|
|
5717
|
+
const statePath = join(dir, '.openclaw');
|
|
5718
|
+
let real = statePath;
|
|
5719
|
+
let isLink = false;
|
|
5720
|
+
try { real = fs.realpathSync(statePath); } catch {}
|
|
5721
|
+
try { isLink = fs.lstatSync(statePath).isSymbolicLink(); } catch {}
|
|
5722
|
+
const key = resolve(real).toLowerCase();
|
|
5723
|
+
const prev = byReal.get(key);
|
|
5724
|
+
// First one wins, unless we already kept a link and now meet the real directory.
|
|
5725
|
+
if (!prev || (prev.isLink && !isLink)) byReal.set(key, { dir, isLink });
|
|
5726
|
+
}
|
|
5727
|
+
return [...byReal.values()].map((v) => v.dir);
|
|
5728
|
+
}
|
|
5729
|
+
|
|
5635
5730
|
function isPhantomStateDirProject(dir) {
|
|
5636
5731
|
try {
|
|
5637
5732
|
const meta = readNativeMeta(dir);
|
|
@@ -5663,7 +5758,7 @@ async function discoverNativeProjectRoots(rootProjectDir) {
|
|
|
5663
5758
|
if (rootProjectDir && existsSync(nativeMarkerPath(rootProjectDir)) && existsSync(join(rootProjectDir, '.openclaw', 'openclaw.json'))) {
|
|
5664
5759
|
roots.add(resolve(rootProjectDir));
|
|
5665
5760
|
}
|
|
5666
|
-
return [...roots].filter((dir) => !isPhantomStateDirProject(dir));
|
|
5761
|
+
return dedupeProjectsByRealState([...roots].filter((dir) => !isPhantomStateDirProject(dir)));
|
|
5667
5762
|
}
|
|
5668
5763
|
|
|
5669
5764
|
async function findLatestProject(rootProjectDir) {
|
|
@@ -5718,7 +5813,10 @@ async function findLatestProject(rootProjectDir) {
|
|
|
5718
5813
|
candidates.push({ dir: dr, mtimeMs: (st?.mtimeMs || 0) + 1e15 });
|
|
5719
5814
|
}
|
|
5720
5815
|
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
5721
|
-
|
|
5816
|
+
// Drop the linked twin before picking a winner, or the account-home view of the same project
|
|
5817
|
+
// can outrank the project itself purely on mtime.
|
|
5818
|
+
const keep = new Set(dedupeProjectsByRealState(candidates.map((c) => c.dir)));
|
|
5819
|
+
return candidates.find((c) => keep.has(c.dir))?.dir || candidates[0]?.dir || null;
|
|
5722
5820
|
}
|
|
5723
5821
|
|
|
5724
5822
|
async function ensureProjectsLoaded(rootProjectDir) {
|
|
@@ -5729,7 +5827,10 @@ async function ensureProjectsLoaded(rootProjectDir) {
|
|
|
5729
5827
|
try {
|
|
5730
5828
|
const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
5731
5829
|
if (Array.isArray(saved?.projects)) {
|
|
5732
|
-
state
|
|
5830
|
+
// Filter on the way IN: a linked twin already written to the state file would otherwise
|
|
5831
|
+
// survive every later dedupe, because the dashboard reads state.projects directly.
|
|
5832
|
+
const keep = new Set(dedupeProjectsByRealState(saved.projects.map((p) => p && p.projectDir)));
|
|
5833
|
+
state.projects = saved.projects.filter((p) => p && keep.has(p.projectDir));
|
|
5733
5834
|
}
|
|
5734
5835
|
} catch {}
|
|
5735
5836
|
}
|
|
@@ -5774,6 +5875,11 @@ async function computeDiscoverProjects(rootProjectDir) {
|
|
|
5774
5875
|
// Drop phantom state-dir "projects" that slipped into the saved list (e.g. the account
|
|
5775
5876
|
// home after a 2026.8.x native state move) — and keep them out of the persisted state.
|
|
5776
5877
|
state.projects = state.projects.filter((p) => !isPhantomStateDirProject(p.projectDir));
|
|
5878
|
+
// The label check above only catches projects that carry a marker with a mismatched label.
|
|
5879
|
+
// A linked twin without one still slips through and the dashboard shows the same bots under
|
|
5880
|
+
// two project names, both "online" — collapse those onto the real directory.
|
|
5881
|
+
const keepDirs = new Set(dedupeProjectsByRealState(state.projects.map((p) => p.projectDir)));
|
|
5882
|
+
state.projects = state.projects.filter((p) => keepDirs.has(p.projectDir));
|
|
5777
5883
|
|
|
5778
5884
|
// In parallel: each buildProjectMeta runs runtime detection (docker calls, port probes), so a
|
|
5779
5885
|
// handful of projects turned into seconds of dashboard load when this was a sequential loop.
|
|
@@ -6903,25 +7009,18 @@ async function handler(req, res, rootProjectDir) {
|
|
|
6903
7009
|
// start-chrome-debug is the old path; kept so an already-open dashboard keeps working.
|
|
6904
7010
|
return json(res, await startChromeDebug());
|
|
6905
7011
|
}
|
|
6906
|
-
//
|
|
6907
|
-
//
|
|
6908
|
-
//
|
|
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.
|
|
6909
7015
|
if (url.pathname === '/api/host/control' && req.method === 'GET') {
|
|
6910
|
-
// Target the SELECTED project (not the launch root), so
|
|
6911
|
-
//
|
|
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.
|
|
6912
7018
|
const projectDir = await resolveProjectDir(rootProjectDir, {});
|
|
6913
7019
|
const cfg = await readHostControlConfig(projectDir);
|
|
6914
7020
|
return json(res, {
|
|
6915
7021
|
ok: true,
|
|
6916
7022
|
enabled: cfg.enabled,
|
|
6917
|
-
port: HOST_CONTROL_PORT,
|
|
6918
|
-
apps: Object.keys(cfg.apps || {}),
|
|
6919
|
-
commands: Object.keys(cfg.commands || {}),
|
|
6920
|
-
running: Boolean(_hostControlServer),
|
|
6921
7023
|
native: isNativeProject(projectDir),
|
|
6922
|
-
// What enabling will additionally grant, so the confirm dialog can spell it out.
|
|
6923
|
-
grants: Object.keys(detectHostCapabilityCommands()),
|
|
6924
|
-
codexApp: detectCodexApp(),
|
|
6925
7024
|
});
|
|
6926
7025
|
}
|
|
6927
7026
|
if (url.pathname === '/api/host/control' && req.method === 'POST') {
|
|
@@ -6929,40 +7028,28 @@ async function handler(req, res, rootProjectDir) {
|
|
|
6929
7028
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
6930
7029
|
const cfg = await readHostControlConfig(projectDir);
|
|
6931
7030
|
if (typeof body.enabled === 'boolean') cfg.enabled = body.enabled;
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
//
|
|
6935
|
-
//
|
|
6936
|
-
|
|
6937
|
-
|
|
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.
|
|
7036
|
+
// Doing it here rather than in a side script matters: the node host must live in a real
|
|
7037
|
+
// interactive desktop session, and the operator pressing this button IS in one.
|
|
7038
|
+
const computerUse = await setComputerUse(projectDir, cfg.enabled).catch((e) => {
|
|
7039
|
+
sendLog(`[host-control] computer-use: ${e.message}`);
|
|
7040
|
+
return { ok: false, error: e.message };
|
|
7041
|
+
});
|
|
6938
7042
|
await fsp.writeFile(hostControlConfigPath(projectDir), JSON.stringify(cfg, null, 2), 'utf8');
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
// Always rewrite the workspace guidance: enabling adds the block (with the token),
|
|
6942
|
-
// 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.
|
|
6943
7045
|
await writeHostControlAccess(projectDir, cfg).catch(() => {});
|
|
6944
7046
|
sendLog(`[host-control] ${cfg.enabled ? 'Đã BẬT' : 'Đã TẮT'} quyền điều khiển máy cho bot.`);
|
|
6945
|
-
|
|
6946
|
-
// the bot: install computer-use into the app and repair its MCP registration. Nothing is
|
|
6947
|
-
// installed into the OpenClaw project and the gateway never restarts.
|
|
6948
|
-
let codex = null;
|
|
6949
|
-
if (cfg.enabled && body.codex !== false && (cfg.commands || {}).codex) {
|
|
6950
|
-
const app = detectCodexApp();
|
|
6951
|
-
codex = await ensureCodexComputerUsePlugin(app, detectCodexMarketplace())
|
|
6952
|
-
.then((r) => ({ ...r, app }))
|
|
6953
|
-
.catch((err) => ({ error: err.message, app }));
|
|
6954
|
-
// The wrapper carries the sandbox flag, so a bot cannot get the invocation wrong.
|
|
6955
|
-
codex.taskScript = await writeCodexTaskScript(projectDir, (cfg.commands || {}).codex).catch(() => '');
|
|
6956
|
-
}
|
|
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.');
|
|
6957
7048
|
return json(res, {
|
|
6958
7049
|
ok: true,
|
|
6959
7050
|
enabled: cfg.enabled,
|
|
6960
|
-
started,
|
|
6961
|
-
apps: Object.keys(cfg.apps || {}),
|
|
6962
|
-
commands: Object.keys(cfg.commands || {}),
|
|
6963
|
-
granted,
|
|
6964
7051
|
native: isNativeProject(projectDir),
|
|
6965
|
-
|
|
7052
|
+
computerUse,
|
|
6966
7053
|
});
|
|
6967
7054
|
}
|
|
6968
7055
|
// Take the operator to the OS privacy pane PC control needs (screen recording, accessibility).
|
|
@@ -7419,9 +7506,6 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
7419
7506
|
ensureReopenShortcut();
|
|
7420
7507
|
if (openBrowser) openUrl(url);
|
|
7421
7508
|
printRemoteAccessHint(port).catch(() => {});
|
|
7422
|
-
// Bring the host-control service back up when the operator left it enabled, so the bot's
|
|
7423
|
-
// saved instructions keep working across installer restarts.
|
|
7424
|
-
ensureHostControl(projectDir).catch(() => {});
|
|
7425
7509
|
// Warm the probes the first page load would otherwise wait on (project list, runtime versions,
|
|
7426
7510
|
// public IP, Zalo status). They run while the browser is still starting, so the dashboard opens
|
|
7427
7511
|
// against a warm cache instead of paying for docker and CLI round-trips on first paint.
|
|
@@ -7433,4 +7517,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
7433
7517
|
]).catch(() => {});
|
|
7434
7518
|
}
|
|
7435
7519
|
|
|
7436
|
-
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 };
|