create-openclaw-bot 5.17.0 → 5.17.1

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