clawconnect-agent 0.2.14 → 0.2.16

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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +1 -1
  3. package/dist/commands/install.js +5 -3
  4. package/dist/commands/install.js.map +1 -1
  5. package/dist/commands/run.js +3 -0
  6. package/dist/commands/run.js.map +1 -1
  7. package/dist/commands/status.js +5 -4
  8. package/dist/commands/status.js.map +1 -1
  9. package/dist/config/profile-log-rotation.d.ts +1 -1
  10. package/dist/config/profile-log-rotation.js +1 -1
  11. package/dist/config/profile-log-rotation.js.map +1 -1
  12. package/dist/hermes/runtime/hermes-runtime-chat-file-transfer.d.ts +28 -0
  13. package/dist/hermes/runtime/hermes-runtime-chat-file-transfer.js +53 -55
  14. package/dist/hermes/runtime/hermes-runtime-chat-file-transfer.js.map +1 -1
  15. package/dist/hermes/runtime/hermes-runtime-history-turn-output.d.ts +30 -0
  16. package/dist/hermes/runtime/hermes-runtime-history-turn-output.js +61 -0
  17. package/dist/hermes/runtime/hermes-runtime-history-turn-output.js.map +1 -0
  18. package/dist/hermes/runtime/hermes-runtime-history.js +24 -3
  19. package/dist/hermes/runtime/hermes-runtime-history.js.map +1 -1
  20. package/dist/hermes/runtime/hermes-runtime-state-db.js +28 -0
  21. package/dist/hermes/runtime/hermes-runtime-state-db.js.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/openclaw/handlers/local-handlers.js +1 -1
  25. package/dist/openclaw/handlers/local-handlers.js.map +1 -1
  26. package/dist/platform/service-manager-windows.d.ts +39 -29
  27. package/dist/platform/service-manager-windows.js +256 -448
  28. package/dist/platform/service-manager-windows.js.map +1 -1
  29. package/dist/platform/timestamped-console.d.ts +16 -0
  30. package/dist/platform/timestamped-console.js +41 -0
  31. package/dist/platform/timestamped-console.js.map +1 -0
  32. package/dist/platform/windows-process-control.d.ts +16 -0
  33. package/dist/platform/windows-process-control.js +91 -0
  34. package/dist/platform/windows-process-control.js.map +1 -0
  35. package/dist/platform/windows-service-host-source.d.ts +23 -0
  36. package/dist/platform/windows-service-host-source.js +357 -0
  37. package/dist/platform/windows-service-host-source.js.map +1 -0
  38. package/dist/platform/windows-service-legacy-startup.d.ts +27 -0
  39. package/dist/platform/windows-service-legacy-startup.js +101 -0
  40. package/dist/platform/windows-service-legacy-startup.js.map +1 -0
  41. package/dist/platform/windows-service-paths.d.ts +15 -0
  42. package/dist/platform/windows-service-paths.js +30 -0
  43. package/dist/platform/windows-service-paths.js.map +1 -0
  44. package/package.json +1 -1
@@ -1,31 +1,22 @@
1
1
  import { execFileSync } from "child_process";
2
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { ensureLogDir, getProfileErrorLogPath, getProfileLogDir, getProfileLogPath, getProgramArgs, resolveServiceEntryPath, setRestrictiveFilePermissions, } from "./service-manager-common.js";
5
- import { getActiveProfile, listProfileNames, normalizeProfileName } from "../config/profile.js";
4
+ import { ensureLogDir, getProfileErrorLogPath, getProfileLogDir, getProfileLogPath, getProgramArgs, } from "./service-manager-common.js";
5
+ import { getActiveProfile, normalizeProfileName } from "../config/profile.js";
6
6
  import { decodeTextBuffer } from "./text-file-decoder.js";
7
- export const TASK_NAME = "ClawConnectAgent";
8
- export const WINDOWS_SERVICE_RUNNER_NAME = "clawconnect-service.ps1";
9
- export const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
10
- export const WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS = 5_000;
11
- export function windowsTaskName(profile) {
12
- const normalized = normalizeProfileName(profile);
13
- return normalized ? `${TASK_NAME}-${normalized}` : TASK_NAME;
14
- }
15
- function qps(value) {
16
- return `'${value.replace(/'/g, "''")}'`;
17
- }
18
- export function buildWindowsPowerShellBootstrap(command) {
19
- return [
20
- // Native process stdout is UTF-8; teach PowerShell to decode it correctly
21
- // and keep redirection writes in UTF-8 so log readers see stable text.
22
- `$utf8NoBom = [System.Text.UTF8Encoding]::new($false)`,
23
- `[Console]::InputEncoding = $utf8NoBom`,
24
- `[Console]::OutputEncoding = $utf8NoBom`,
25
- `$OutputEncoding = $utf8NoBom`,
26
- `$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'`,
27
- command,
28
- ].join("; ");
7
+ import { buildWindowsServiceHostConfig, buildWindowsServiceHostSource, WINDOWS_HOST_GRANT_LOGON_RIGHT_FLAG, } from "./windows-service-host-source.js";
8
+ import { removeLegacyWindowsStartup } from "./windows-service-legacy-startup.js";
9
+ import { isProcessTimeoutError, killWindowsProcess, reportWindowsQueryTimeout, WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS, } from "./windows-process-control.js";
10
+ import { windowsServiceHostConfigPath, windowsServiceHostExePath, windowsServiceHostSourcePath, windowsServiceName, windowsServiceRunnerPath, } from "./windows-service-paths.js";
11
+ export { windowsServiceName } from "./windows-service-paths.js";
12
+ export { buildWindowsProcessQueryScript, WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS, } from "./windows-process-control.js";
13
+ export const WINDOWS_SERVICE_STATE_POLL_INTERVAL_MS = 500;
14
+ export const WINDOWS_SERVICE_STATE_TIMEOUT_MS = 30_000;
15
+ /** 失败后第 1、2 次 5 秒重启,之后 60 秒;24 小时无失败则清零计数。 */
16
+ export const WINDOWS_SERVICE_FAILURE_ACTIONS = "restart/5000/restart/5000/restart/60000";
17
+ export const WINDOWS_SERVICE_FAILURE_RESET_SECONDS = 86_400;
18
+ function defaultSleepSync(ms) {
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
29
20
  }
30
21
  export function normalizeWindowsServiceLogEncoding(path) {
31
22
  if (!existsSync(path)) {
@@ -36,510 +27,327 @@ export function normalizeWindowsServiceLogEncoding(path) {
36
27
  writeFileSync(path, decoded.text, "utf8");
37
28
  }
38
29
  }
39
- export function buildWindowsServiceProcessCommand(args, logPath, errorLogPath) {
40
- const [execPath, ...execArgs] = args;
41
- if (!execPath) {
42
- throw new Error("Windows service executable is required");
43
- }
44
- return buildWindowsPowerShellBootstrap(`& ${qps(execPath)} ${execArgs.map((arg) => qps(arg)).join(" ")} >> ${qps(logPath)} 2>> ${qps(errorLogPath)}`);
30
+ export function buildWindowsServiceStateScript() {
31
+ return `[string](Get-Service -Name $env:CLAW_SERVICE_NAME -ErrorAction Stop).Status`;
32
+ }
33
+ /** 服务账户:域/计算机名\用户名。服务必须以当前用户运行,才能读写其目录下的配置、日志与本机网关。 */
34
+ export function windowsServiceAccount(env = process.env) {
35
+ const user = env.USERNAME?.trim();
36
+ const domain = (env.USERDOMAIN ?? env.COMPUTERNAME)?.trim();
37
+ if (!user || !domain) {
38
+ throw new Error("Cannot determine the current Windows account (USERNAME / USERDOMAIN are not set)");
39
+ }
40
+ return `${domain}\\${user}`;
45
41
  }
46
- export function windowsServiceRunnerPath(profile) {
47
- return join(getProfileLogDir(profile), WINDOWS_SERVICE_RUNNER_NAME);
42
+ /**
43
+ * 创建服务并设置当前用户凭据。密码只经 Read-Host -AsSecureString 在 PowerShell 进程内流转,
44
+ * 不进入命令行参数,也不经过 Node。创建前先校验密码,避免注册一个永远以 1069 启动失败的服务。
45
+ */
46
+ export function buildWindowsServiceCreateScript() {
47
+ return [
48
+ `$ErrorActionPreference = 'Stop'`,
49
+ `$secure = Read-Host -Prompt ('Windows password for ' + $env:CLAW_SERVICE_ACCOUNT) -AsSecureString`,
50
+ `if ($secure.Length -eq 0) { throw 'A Windows service cannot log on with an empty password.' }`,
51
+ `$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)`,
52
+ `try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }`,
53
+ `Add-Type -AssemblyName System.DirectoryServices.AccountManagement`,
54
+ `$contextType = if ($env:USERDOMAIN -eq $env:COMPUTERNAME) { 'Machine' } else { 'Domain' }`,
55
+ `$context = New-Object System.DirectoryServices.AccountManagement.PrincipalContext($contextType)`,
56
+ `if (-not $context.ValidateCredentials($env:USERNAME, $plain)) { throw 'The Windows password is incorrect.' }`,
57
+ `$plain = $null`,
58
+ `$credential = New-Object System.Management.Automation.PSCredential($env:CLAW_SERVICE_ACCOUNT, $secure)`,
59
+ `New-Service -Name $env:CLAW_SERVICE_NAME -BinaryPathName $env:CLAW_SERVICE_BINPATH -DisplayName $env:CLAW_SERVICE_NAME -Description 'ClawConnect host agent' -StartupType Automatic -Credential $credential | Out-Null`,
60
+ ].join("\n");
48
61
  }
49
- export function buildWindowsServiceTaskCommand(runnerPath) {
50
- return `powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${runnerPath}"`;
62
+ /** SCM 启动宿主时传入的参数:服务名 + 宿主配置路径。 */
63
+ export function buildWindowsServiceBinaryPath(hostExePath, serviceName, configPath) {
64
+ return `"${hostExePath}" "${serviceName}" "${configPath}"`;
65
+ }
66
+ export function resolveWindowsCscPath(fileExists = existsSync, windowsDir = process.env.windir ?? process.env.SystemRoot ?? "C:\\Windows") {
67
+ const candidates = [
68
+ join(windowsDir, "Microsoft.NET", "Framework64", "v4.0.30319", "csc.exe"),
69
+ join(windowsDir, "Microsoft.NET", "Framework", "v4.0.30319", "csc.exe"),
70
+ ];
71
+ return candidates.find((candidate) => fileExists(candidate));
51
72
  }
52
- export function buildWindowsStartupRegistryArgs(taskName, taskCommand) {
73
+ export function buildWindowsServiceHostCompileArgs(sourcePath, outputPath) {
53
74
  return [
54
- "add",
55
- WINDOWS_RUN_KEY,
56
- "/v",
57
- taskName,
58
- "/t",
59
- "REG_SZ",
60
- "/d",
61
- taskCommand,
62
- "/f",
75
+ "/nologo",
76
+ "/target:exe",
77
+ "/optimize+",
78
+ `/out:${outputPath}`,
79
+ "/reference:System.ServiceProcess.dll",
80
+ sourcePath,
63
81
  ];
64
82
  }
65
- function removeWindowsStartupEntry(taskName, execFileSyncImpl = execFileSync) {
83
+ export function isWindowsElevated(execFileSyncImpl = execFileSync) {
66
84
  try {
67
- execFileSyncImpl("reg.exe", ["delete", WINDOWS_RUN_KEY, "/v", taskName, "/f"], {
68
- stdio: "pipe",
69
- windowsHide: true,
70
- });
85
+ // fltmc 只有提权后的进程才能成功执行,比解析 whoami 输出更不依赖系统语言。
86
+ execFileSyncImpl("fltmc", [], { stdio: "pipe", windowsHide: true, timeout: WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS });
71
87
  return true;
72
88
  }
73
89
  catch {
74
90
  return false;
75
91
  }
76
92
  }
77
- function hasWindowsStartupEntry(taskName, execFileSyncImpl = execFileSync, onQueryTimeout = reportWindowsQueryTimeout) {
78
- try {
79
- execFileSyncImpl("reg.exe", ["query", WINDOWS_RUN_KEY, "/v", taskName], {
80
- stdio: "pipe",
81
- windowsHide: true,
82
- timeout: WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS,
83
- });
93
+ function requireElevation(action, profile, deps) {
94
+ if (isWindowsElevated(deps.execFileSyncImpl)) {
84
95
  return true;
85
96
  }
86
- catch (error) {
87
- if (isProcessTimeoutError(error)) {
88
- onQueryTimeout("startup registry");
89
- }
90
- return false;
91
- }
97
+ const profileArg = profile ? ` --profile ${profile}` : "";
98
+ console.error(`[clawconnect] ${action} a Windows service requires an elevated terminal. `
99
+ + `Open PowerShell with "Run as administrator" and run: clawconnect install${profileArg}`);
100
+ return false;
92
101
  }
93
- function hasWindowsScheduledTask(taskName, execFileSyncImpl = execFileSync, onQueryTimeout = reportWindowsQueryTimeout) {
102
+ export function queryWindowsServiceState(serviceName, deps = {}) {
103
+ const execFileSyncImpl = deps.execFileSyncImpl ?? execFileSync;
94
104
  try {
95
- execFileSyncImpl("schtasks", ["/query", "/tn", taskName], {
105
+ const output = execFileSyncImpl("powershell", ["-NoProfile", "-NonInteractive", "-Command", buildWindowsServiceStateScript()], {
96
106
  stdio: "pipe",
97
- windowsHide: true,
107
+ env: { ...process.env, CLAW_SERVICE_NAME: serviceName },
98
108
  timeout: WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS,
99
- });
100
- return true;
109
+ windowsHide: true,
110
+ }).toString().trim().toLowerCase();
111
+ if (output === "running")
112
+ return "running";
113
+ if (output === "stopped")
114
+ return "stopped";
115
+ if (output.endsWith("pending") || output === "paused")
116
+ return "pending";
117
+ return "unknown";
101
118
  }
102
119
  catch (error) {
103
120
  if (isProcessTimeoutError(error)) {
104
- onQueryTimeout("scheduled task");
121
+ (deps.onQueryTimeout ?? reportWindowsQueryTimeout)("service state");
122
+ return "unknown";
105
123
  }
106
- return false;
124
+ // Get-Service 找不到服务会以非零退出码结束。
125
+ return "missing";
107
126
  }
108
127
  }
109
- export function migrateLegacyWindowsStartup(profile, options = {}) {
110
- const resolvedProfile = normalizeProfileName(profile);
111
- if (!resolvedProfile) {
112
- // The unsuffixed task is still the current task for the default profile.
113
- return true;
114
- }
115
- const defaultProfileConfigured = options.defaultProfileConfigured
116
- ?? listProfileNames().includes("default");
117
- if (defaultProfileConfigured) {
118
- // A configured default profile legitimately owns the unsuffixed task and Run value.
119
- return true;
120
- }
121
- const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
122
- const onQueryTimeout = options.onQueryTimeout ?? reportWindowsQueryTimeout;
123
- let queryTimedOut = false;
124
- const reportQueryTimeout = (probe) => {
125
- queryTimedOut = true;
126
- onQueryTimeout(probe);
127
- };
128
- const legacyTaskExists = hasWindowsScheduledTask(TASK_NAME, execFileSyncImpl, reportQueryTimeout);
129
- const legacyStartupExists = hasWindowsStartupEntry(TASK_NAME, execFileSyncImpl, reportQueryTimeout);
130
- if (queryTimedOut) {
131
- return false;
132
- }
133
- if (!legacyTaskExists && !legacyStartupExists) {
134
- return true;
135
- }
136
- let migrated = true;
137
- if (legacyTaskExists) {
138
- try {
139
- execFileSyncImpl("schtasks", ["/end", "/tn", TASK_NAME], {
140
- stdio: "pipe",
141
- windowsHide: true,
142
- });
143
- }
144
- catch {
145
- // The legacy task can be installed without currently running.
146
- }
147
- try {
148
- execFileSyncImpl("schtasks", ["/delete", "/tn", TASK_NAME, "/f"], {
149
- stdio: "pipe",
150
- windowsHide: true,
151
- });
152
- }
153
- catch {
154
- migrated = false;
128
+ function waitForServiceState(serviceName, wanted, deps) {
129
+ const sleep = deps.sleepSync ?? defaultSleepSync;
130
+ const attempts = Math.ceil(WINDOWS_SERVICE_STATE_TIMEOUT_MS / WINDOWS_SERVICE_STATE_POLL_INTERVAL_MS);
131
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
132
+ if (queryWindowsServiceState(serviceName, deps) === wanted) {
133
+ return true;
155
134
  }
135
+ sleep(WINDOWS_SERVICE_STATE_POLL_INTERVAL_MS);
156
136
  }
157
- if (legacyStartupExists && !removeWindowsStartupEntry(TASK_NAME, execFileSyncImpl)) {
158
- migrated = false;
159
- }
160
- (options.stopLegacyProcess ?? (() => { killWindowsProcess(undefined); }))();
161
- return migrated;
137
+ return false;
162
138
  }
163
- export function buildWindowsScheduledTaskStateScript() {
164
- return `[string](Get-ScheduledTask -TaskName $env:CLAW_TASK_NAME -ErrorAction Stop).State`;
139
+ function runServiceControl(args, deps) {
140
+ (deps.execFileSyncImpl ?? execFileSync)("sc.exe", args, { stdio: "pipe", windowsHide: true });
165
141
  }
166
- function isWindowsScheduledTaskRunning(taskName, execFileSyncImpl = execFileSync, onQueryTimeout = reportWindowsQueryTimeout) {
142
+ function stopServiceAndWait(serviceName, deps) {
143
+ const state = queryWindowsServiceState(serviceName, deps);
144
+ if (state === "missing" || state === "stopped") {
145
+ return true;
146
+ }
167
147
  try {
168
- const output = execFileSyncImpl("powershell", ["-NoProfile", "-Command", buildWindowsScheduledTaskStateScript()], {
169
- stdio: "pipe",
170
- env: { ...process.env, CLAW_TASK_NAME: taskName },
171
- timeout: WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS,
172
- windowsHide: true,
173
- })
174
- .toString()
175
- .trim();
176
- return /^running$/i.test(output);
148
+ runServiceControl(["stop", serviceName], deps);
177
149
  }
178
- catch (error) {
179
- if (isProcessTimeoutError(error)) {
180
- onQueryTimeout("scheduled task state");
181
- }
182
- return false;
150
+ catch {
151
+ // 已处于停止中等状态时 sc stop 会报错,以下面的等待结果为准。
183
152
  }
153
+ return waitForServiceState(serviceName, "stopped", deps);
184
154
  }
185
- function isProcessTimeoutError(error) {
186
- if (!error || typeof error !== "object") {
187
- return false;
155
+ function writeServiceHostConfig(profile, deps = {}) {
156
+ if (deps.writeConfig) {
157
+ deps.writeConfig(profile);
158
+ return;
188
159
  }
189
- const candidate = error;
190
- return candidate.code === "ETIMEDOUT" || candidate.killed === true;
191
- }
192
- function reportWindowsQueryTimeout(probe) {
193
- console.error(`[clawconnect] Windows ${probe} query timed out after ${WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS}ms; continuing with an unknown result.`);
194
- }
195
- export function buildWindowsDetachedRunnerStartScript() {
196
- return [
197
- `$runnerArgument = '"' + $env:CLAW_RUNNER_PATH + '"'`,
198
- `$process = Start-Process -FilePath 'powershell.exe' -WindowStyle Hidden -ArgumentList @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', $runnerArgument) -PassThru`,
199
- `[string]$process.Id`,
200
- ].join("; ");
160
+ const logPath = getProfileLogPath(profile);
161
+ const errorLogPath = getProfileErrorLogPath(profile);
162
+ ensureLogDir(profile);
163
+ // 旧版 PowerShell 重定向写出的是 UTF-16 日志;宿主按原始 UTF-8 字节追加,先归一化避免混合编码。
164
+ normalizeWindowsServiceLogEncoding(logPath);
165
+ normalizeWindowsServiceLogEncoding(errorLogPath);
166
+ const [executablePath, ...executableArgs] = getProgramArgs(profile);
167
+ const configPath = windowsServiceHostConfigPath(profile);
168
+ writeFileSync(configPath, buildWindowsServiceHostConfig({
169
+ executablePath,
170
+ executableArgs,
171
+ workingDirectory: getProfileLogDir(profile),
172
+ stdoutLogPath: logPath,
173
+ stderrLogPath: errorLogPath,
174
+ }), "utf8");
201
175
  }
202
- function startWindowsRunner(runnerPath) {
176
+ function compileServiceHost(profile, deps) {
177
+ if (deps.compileHost) {
178
+ deps.compileHost(profile);
179
+ return;
180
+ }
181
+ const cscPath = deps.cscPath ?? resolveWindowsCscPath();
182
+ if (!cscPath) {
183
+ throw new Error("The .NET Framework 4 C# compiler (csc.exe) was not found; it is required to build the service host");
184
+ }
185
+ const sourcePath = windowsServiceHostSourcePath(profile);
186
+ writeFileSync(sourcePath, buildWindowsServiceHostSource(), "utf8");
203
187
  try {
204
- // Node 的 detached spawn 在部分普通用户/远控会话中会无错误返回、但子进程随即消失。
205
- // 由 Windows PowerShell 的 Start-Process 创建独立隐藏进程,并要求返回真实 PID 后才算启动成功。
206
- const output = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", buildWindowsDetachedRunnerStartScript()], {
207
- stdio: "pipe",
208
- env: { ...process.env, CLAW_RUNNER_PATH: runnerPath },
209
- timeout: WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS,
210
- windowsHide: true,
211
- }).toString().trim();
212
- return /^\d+$/.test(output);
188
+ (deps.execFileSyncImpl ?? execFileSync)(cscPath, buildWindowsServiceHostCompileArgs(sourcePath, windowsServiceHostExePath(profile)), { stdio: "pipe", windowsHide: true });
213
189
  }
214
190
  catch (error) {
215
- console.error("[clawconnect] Failed to start Windows service runner:", error);
216
- return false;
191
+ const output = error.stdout?.toString().trim();
192
+ throw new Error(`Failed to compile the Windows service host${output ? `:\n${output}` : ""}`);
217
193
  }
218
194
  }
219
- function startScheduledTaskOrRunner(taskName, runnerPath) {
195
+ function createService(profile, serviceName, deps) {
196
+ const execFileSyncImpl = deps.execFileSyncImpl ?? execFileSync;
197
+ const account = deps.account ?? windowsServiceAccount();
198
+ const hostExePath = windowsServiceHostExePath(profile);
199
+ execFileSyncImpl("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", buildWindowsServiceCreateScript()], {
200
+ // 继承 stdio:密码提示必须直接出现在用户的终端里。
201
+ stdio: "inherit",
202
+ env: {
203
+ ...process.env,
204
+ CLAW_SERVICE_NAME: serviceName,
205
+ CLAW_SERVICE_BINPATH: buildWindowsServiceBinaryPath(hostExePath, serviceName, windowsServiceHostConfigPath(profile)),
206
+ CLAW_SERVICE_ACCOUNT: account,
207
+ },
208
+ });
209
+ execFileSyncImpl(hostExePath, [WINDOWS_HOST_GRANT_LOGON_RIGHT_FLAG, account], { stdio: "pipe", windowsHide: true });
210
+ runServiceControl([
211
+ "failure", serviceName,
212
+ "reset=", String(WINDOWS_SERVICE_FAILURE_RESET_SECONDS),
213
+ "actions=", WINDOWS_SERVICE_FAILURE_ACTIONS,
214
+ ], deps);
215
+ // 宿主进程以非零码退出也算失败,同样触发上面的自动重启。
216
+ runServiceControl(["failureflag", serviceName, "1"], deps);
217
+ }
218
+ function startServiceAndWait(serviceName, deps) {
220
219
  try {
221
- execFileSync("schtasks", ["/run", "/tn", taskName], { stdio: "pipe" });
220
+ runServiceControl(["start", serviceName], deps);
222
221
  }
223
- catch {
224
- startWindowsRunner(runnerPath);
222
+ catch (error) {
223
+ const detail = error.stdout?.toString().trim();
224
+ console.error(`[clawconnect] Failed to start Windows service ${serviceName}${detail ? `: ${detail}` : ""}`);
225
+ return false;
225
226
  }
226
- }
227
- function installWindowsStartupEntry(taskName, taskCommand, runnerPath) {
228
- execFileSync("reg.exe", buildWindowsStartupRegistryArgs(taskName, taskCommand), {
229
- stdio: "pipe",
230
- windowsHide: true,
231
- });
232
- if (!startWindowsRunner(runnerPath)) {
233
- throw new Error("Windows startup entry was created, but its service runner did not start");
227
+ if (!waitForServiceState(serviceName, "running", deps)) {
228
+ console.error(`[clawconnect] Windows service ${serviceName} did not reach the running state.`);
229
+ return false;
234
230
  }
231
+ return true;
235
232
  }
236
- export function writeWindowsServiceRunner(profile, args, logPath, errorLogPath) {
237
- const runnerPath = windowsServiceRunnerPath(profile);
238
- const script = `${buildWindowsServiceProcessCommand(args, logPath, errorLogPath)}\r\n`;
239
- // Windows PowerShell 5.1 treats UTF-8 files without a BOM as the active ANSI
240
- // code page. Keep the BOM so user names and install paths remain intact.
241
- const utf8WithBom = Buffer.concat([
242
- Buffer.from([0xef, 0xbb, 0xbf]),
243
- Buffer.from(script, "utf8"),
244
- ]);
245
- writeFileSync(runnerPath, utf8WithBom);
246
- setRestrictiveFilePermissions(runnerPath);
247
- return runnerPath;
248
- }
249
- function refreshWindowsServiceRunner(profile) {
233
+ export function installWindowsService(profile, deps = {}) {
250
234
  const resolvedProfile = normalizeProfileName(profile ?? getActiveProfile());
251
- const logPath = getProfileLogPath(resolvedProfile);
252
- const errorLogPath = getProfileErrorLogPath(resolvedProfile);
253
- ensureLogDir(resolvedProfile);
254
- // 升级 npm 包后 runner 必须改用当前 Node/CLI 绝对路径;同时先归一化旧版 PowerShell 日志编码。
255
- normalizeWindowsServiceLogEncoding(logPath);
256
- normalizeWindowsServiceLogEncoding(errorLogPath);
257
- return writeWindowsServiceRunner(resolvedProfile, getProgramArgs(resolvedProfile), logPath, errorLogPath);
258
- }
259
- /**
260
- * Kill any running node processes that are executing this specific script.
261
- * Uses execFileSync to bypass cmd.exe, avoiding CMD quote-mangling.
262
- * Returns true if any processes were killed.
263
- */
264
- export function buildWindowsProcessQueryScript(profile) {
265
- const normalizedProfile = normalizeProfileName(profile);
266
- const profileFilter = normalizedProfile
267
- ? `($_.CommandLine -match ('(?:^|\\s)--profile(?:\\s+|=)' + [regex]::Escape($env:CLAW_PROFILE_NAME) + '(?:\\s|$)') -or $_.CommandLine -match [regex]::Escape($env:CLAW_RUNNER_PATH))`
268
- : `($_.CommandLine -notmatch '(?:^|\\s)--profile(?:\\s|=)')`;
269
- return `Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -OperationTimeoutSec 3 | Where-Object { ($_.CommandLine -match 'clawconnect' -or $_.CommandLine -match [regex]::Escape($env:CLAW_SCRIPT_PATH) -or $_.CommandLine -match [regex]::Escape($env:CLAW_RUNNER_PATH)) -and ${profileFilter} } | Select-Object -ExpandProperty ProcessId`;
270
- }
271
- function windowsProcessQueryEnv(profile, scriptPath = process.argv[1]) {
272
- const normalizedProfile = normalizeProfileName(profile);
273
- return {
274
- ...process.env,
275
- CLAW_SCRIPT_PATH: scriptPath ? resolveServiceEntryPath(scriptPath) : "",
276
- CLAW_RUNNER_PATH: windowsServiceRunnerPath(normalizedProfile),
277
- CLAW_PROFILE_NAME: normalizedProfile ?? "",
278
- };
279
- }
280
- function killWindowsProcess(profile) {
281
- const scriptPath = process.argv[1];
282
- if (!scriptPath)
235
+ const serviceName = windowsServiceName(resolvedProfile);
236
+ if (!requireElevation("Installing", resolvedProfile, deps)) {
283
237
  return false;
284
- const currentPid = process.pid;
238
+ }
285
239
  try {
286
- const psScript = buildWindowsProcessQueryScript(profile);
287
- const output = execFileSync("powershell", ["-NoProfile", "-Command", psScript], {
288
- stdio: "pipe",
289
- env: windowsProcessQueryEnv(profile),
290
- timeout: WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS,
291
- windowsHide: true,
292
- })
293
- .toString()
294
- .trim();
295
- const pids = output
296
- .split(/\r?\n/)
297
- .map((p) => p.trim())
298
- .filter((p) => /\d+/.test(p) && Number(p) !== currentPid);
299
- if (pids.length === 0)
300
- return false;
301
- // Step 1: graceful shutdown — taskkill without /F sends WM_CLOSE.
302
- // For console/background Node.js processes this is best-effort only.
303
- for (const pid of pids) {
304
- try {
305
- execFileSync("taskkill", ["/PID", String(pid)], { stdio: "pipe" });
306
- }
307
- catch {
308
- // ignore — will fall back to /F below
309
- }
240
+ // 先清理旧计划任务 / Run 项并结束遗留 Agent:否则下次登录会多出一个实例与服务抢占 Relay 会话。
241
+ if (!removeLegacyWindowsStartup(resolvedProfile, {
242
+ execFileSyncImpl: deps.execFileSyncImpl,
243
+ onQueryTimeout: deps.onQueryTimeout,
244
+ stopLegacyProcess: deps.killProcess ? (target) => { deps.killProcess?.(target); } : undefined,
245
+ })) {
246
+ throw new Error(`Unable to remove the legacy Windows startup entry ${serviceName}`);
310
247
  }
311
- // Step 2: give processes a brief window to flush / clean up
312
- try {
313
- execFileSync("powershell", ["-NoProfile", "-Command", "Start-Sleep -Seconds 3"], { stdio: "pipe" });
248
+ if (!stopServiceAndWait(serviceName, deps)) {
249
+ throw new Error(`Windows service ${serviceName} did not stop; cannot replace its host executable`);
314
250
  }
315
- catch {
316
- // ignore
251
+ (deps.killProcess ?? killWindowsProcess)(resolvedProfile);
252
+ compileServiceHost(resolvedProfile, deps);
253
+ writeServiceHostConfig(resolvedProfile, deps);
254
+ // 已存在的服务保留原账户与凭据,更新/重装不需要再次输入密码。
255
+ if (queryWindowsServiceState(serviceName, deps) === "missing") {
256
+ createService(resolvedProfile, serviceName, deps);
317
257
  }
318
- // Step 3: force-kill anything still alive
319
- for (const pid of pids) {
320
- try {
321
- execFileSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "pipe" });
322
- }
323
- catch {
324
- // ignore — probably already exited gracefully
325
- }
326
- }
327
- return true;
258
+ return startServiceAndWait(serviceName, deps);
328
259
  }
329
260
  catch (err) {
330
- console.error("[clawconnect] Failed to kill Windows process:", err);
261
+ console.error("[clawconnect] Failed to install Windows service:", err instanceof Error ? err.message : err);
331
262
  return false;
332
263
  }
333
264
  }
334
- export function installWindowsService(profile) {
265
+ export function restartWindowsService(profile, deps = {}) {
335
266
  const resolvedProfile = normalizeProfileName(profile ?? getActiveProfile());
336
- const taskName = windowsTaskName(resolvedProfile);
267
+ const serviceName = windowsServiceName(resolvedProfile);
268
+ if (queryWindowsServiceState(serviceName, deps) === "missing") {
269
+ return installWindowsService(resolvedProfile, deps);
270
+ }
271
+ if (!requireElevation("Restarting", resolvedProfile, deps)) {
272
+ return false;
273
+ }
337
274
  try {
338
- if (!migrateLegacyWindowsStartup(resolvedProfile)) {
339
- throw new Error(`Unable to remove legacy Windows startup ${TASK_NAME}`);
340
- }
341
- try {
342
- execFileSync("schtasks", ["/end", "/tn", taskName], { stdio: "pipe" });
343
- }
344
- catch {
345
- // First install or already stopped.
346
- }
347
- killWindowsProcess(resolvedProfile);
348
- // Keep the scheduled task command short. schtasks limits /tr to 261
349
- // characters, while NVM/npm paths plus UTF-8 bootstrap and log redirects
350
- // can easily exceed it when embedded inline.
351
- const runnerPath = refreshWindowsServiceRunner(resolvedProfile);
352
- const taskCommand = buildWindowsServiceTaskCommand(runnerPath);
353
- const taskArguments = taskCommand.slice("powershell.exe ".length);
354
- // ── Attempt 1: PowerShell ScheduledTasks module ──────────────────────
355
- // Supports RestartOnFailure (crash recovery) and full settings control.
356
- // Available on Windows 10 / Server 2016+.
357
- try {
358
- // We use 'powershell -WindowStyle Hidden' to launch node without a visible console window.
359
- // This is the most reliable native way to run a background console app on login.
360
- // Stdout/stderr are redirected to log files, matching the behavior of the
361
- // Linux (systemd/nohup) and macOS (launchd) service managers.
362
- const psScript = [
363
- `$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${qps(taskArguments)}`,
364
- `$trigger = New-ScheduledTaskTrigger -AtLogon`,
365
- `$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited`,
366
- `$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries`,
367
- `Register-ScheduledTask -TaskName ${qps(taskName)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`,
368
- ].join("\n");
369
- execFileSync("powershell", ["-NoProfile", "-Command", psScript], { stdio: "pipe" });
370
- removeWindowsStartupEntry(taskName);
371
- startScheduledTaskOrRunner(taskName, runnerPath);
372
- return true;
275
+ if (!stopServiceAndWait(serviceName, deps)) {
276
+ throw new Error(`Windows service ${serviceName} did not stop`);
373
277
  }
374
- catch {
375
- // Fall through to basic schtasks below
376
- }
377
- // ── Attempt 2: Basic schtasks (no crash recovery) ────────────────────
378
- try {
379
- execFileSync("schtasks", [
380
- "/create",
381
- "/tn",
382
- taskName,
383
- "/tr",
384
- taskCommand,
385
- "/sc",
386
- "onlogon",
387
- "/rl",
388
- "LIMITED",
389
- "/f",
390
- ], { stdio: "pipe" });
391
- removeWindowsStartupEntry(taskName);
392
- startScheduledTaskOrRunner(taskName, runnerPath);
393
- return true;
394
- }
395
- catch {
396
- // Some Windows policies reserve Task Scheduler registration for
397
- // administrators. HKCU Run is a per-user startup mechanism and does not
398
- // require elevation.
399
- }
400
- // An older task can be readable but not replaceable by the current
401
- // unelevated shell. Keep that login trigger instead of installing a second
402
- // autostart path, and launch the newly written runner immediately.
403
- if (hasWindowsScheduledTask(taskName)) {
404
- removeWindowsStartupEntry(taskName);
405
- startScheduledTaskOrRunner(taskName, runnerPath);
406
- return true;
407
- }
408
- installWindowsStartupEntry(taskName, taskCommand, runnerPath);
409
- return true;
278
+ // npm install -g 可能改变 Node 或全局包路径;重启必须刷新宿主配置,不能继续执行旧入口。
279
+ writeServiceHostConfig(resolvedProfile, deps);
280
+ return startServiceAndWait(serviceName, deps);
410
281
  }
411
282
  catch (err) {
412
- console.error("[clawconnect] Failed to install Windows background startup:", err);
283
+ console.error("[clawconnect] Failed to restart Windows service:", err instanceof Error ? err.message : err);
413
284
  return false;
414
285
  }
415
286
  }
416
- export function uninstallWindowsService(profile) {
287
+ export function stopWindowsService(profile, deps = {}) {
417
288
  const resolvedProfile = normalizeProfileName(profile ?? getActiveProfile());
418
- const taskName = windowsTaskName(resolvedProfile);
419
- const runnerPath = windowsServiceRunnerPath(resolvedProfile);
289
+ const serviceName = windowsServiceName(resolvedProfile);
290
+ const state = queryWindowsServiceState(serviceName, deps);
291
+ let changed = false;
292
+ if (state === "running" || state === "pending") {
293
+ if (!requireElevation("Stopping", resolvedProfile, deps)) {
294
+ return false;
295
+ }
296
+ changed = stopServiceAndWait(serviceName, deps);
297
+ }
298
+ // 前台手动启动的 Agent 不归服务管,stop 也要一并结束。
299
+ return (deps.killProcess ?? killWindowsProcess)(resolvedProfile) || changed;
300
+ }
301
+ export function uninstallWindowsService(profile, deps = {}) {
302
+ const resolvedProfile = normalizeProfileName(profile ?? getActiveProfile());
303
+ const serviceName = windowsServiceName(resolvedProfile);
304
+ const state = queryWindowsServiceState(serviceName, deps);
305
+ if (state !== "missing" && !requireElevation("Removing", resolvedProfile, deps)) {
306
+ return false;
307
+ }
420
308
  try {
421
309
  let changed = false;
422
- try {
423
- execFileSync("schtasks", ["/delete", "/tn", taskName, "/f"], { stdio: "pipe" });
424
- changed = true;
425
- }
426
- catch {
427
- // task might not exist
428
- }
429
- if (killWindowsProcess(resolvedProfile)) {
430
- changed = true;
431
- }
432
- if (removeWindowsStartupEntry(taskName)) {
310
+ if (state !== "missing") {
311
+ if (!stopServiceAndWait(serviceName, deps)) {
312
+ throw new Error(`Windows service ${serviceName} did not stop`);
313
+ }
314
+ runServiceControl(["delete", serviceName], deps);
433
315
  changed = true;
434
316
  }
435
- try {
436
- unlinkSync(runnerPath);
317
+ if ((deps.killProcess ?? killWindowsProcess)(resolvedProfile)) {
437
318
  changed = true;
438
319
  }
439
- catch {
440
- // runner might not exist
320
+ for (const path of [
321
+ windowsServiceHostExePath(resolvedProfile),
322
+ windowsServiceHostSourcePath(resolvedProfile),
323
+ windowsServiceHostConfigPath(resolvedProfile),
324
+ windowsServiceRunnerPath(resolvedProfile),
325
+ ]) {
326
+ if (existsSync(path)) {
327
+ rmSync(path, { force: true });
328
+ changed = true;
329
+ }
441
330
  }
442
331
  return changed;
443
332
  }
444
333
  catch (err) {
445
- console.error("[clawconnect] Failed to uninstall Windows task:", err);
334
+ console.error("[clawconnect] Failed to uninstall Windows service:", err instanceof Error ? err.message : err);
446
335
  return false;
447
336
  }
448
337
  }
449
- export function restartWindowsService(profile) {
338
+ export function getWindowsServiceStatus(profile, deps = {}) {
450
339
  const resolvedProfile = normalizeProfileName(profile ?? getActiveProfile());
451
- const taskName = windowsTaskName(resolvedProfile);
452
- if (!migrateLegacyWindowsStartup(resolvedProfile)) {
453
- console.error(`[clawconnect] Failed to remove legacy Windows startup ${TASK_NAME}`);
454
- return false;
455
- }
456
- // Try to stop the task first
457
- try {
458
- execFileSync("schtasks", ["/end", "/tn", taskName], { stdio: "pipe" });
459
- }
460
- catch {
461
- // ignore if not running
462
- }
463
- killWindowsProcess(resolvedProfile);
464
- let runnerPath;
465
- try {
466
- // `npm install -g` 可能改变 Node 或全局包路径;普通 restart 也必须刷新 runner,不能继续执行旧版本入口。
467
- runnerPath = refreshWindowsServiceRunner(resolvedProfile);
468
- }
469
- catch (error) {
470
- console.error("[clawconnect] Failed to refresh Windows service runner:", error);
471
- return false;
472
- }
473
- if (hasWindowsStartupEntry(taskName)) {
474
- return startWindowsRunner(runnerPath);
475
- }
476
- try {
477
- execFileSync("schtasks", ["/run", "/tn", taskName], { stdio: "pipe" });
478
- return true;
479
- }
480
- catch {
481
- // If the task doesn't exist, install it
482
- return installWindowsService(resolvedProfile);
483
- }
484
- }
485
- export function stopWindowsService(profile) {
486
- const resolvedProfile = normalizeProfileName(profile ?? getActiveProfile());
487
- const taskName = windowsTaskName(resolvedProfile);
488
- try {
489
- execFileSync("schtasks", ["/end", "/tn", taskName], { stdio: "pipe" });
490
- }
491
- catch {
492
- // ignore if not running
493
- }
494
- return killWindowsProcess(resolvedProfile);
495
- }
496
- export function getWindowsServiceStatus(profile, options = {}) {
497
- const resolvedProfile = normalizeProfileName(profile ?? getActiveProfile());
498
- const taskName = windowsTaskName(resolvedProfile);
499
- const logPath = getProfileLogPath(resolvedProfile);
500
- const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
501
- const onQueryTimeout = options.onQueryTimeout ?? reportWindowsQueryTimeout;
502
- const installedViaTask = hasWindowsScheduledTask(taskName, execFileSyncImpl, onQueryTimeout);
503
- const installedViaStartup = hasWindowsStartupEntry(taskName, execFileSyncImpl, onQueryTimeout);
504
- const installed = installedViaTask || installedViaStartup;
505
- // Task Scheduler already tracks the blocking PowerShell runner. Prefer that
506
- // state over Win32_Process.CommandLine, which can be blank under Windows
507
- // privacy/UAC policies even for processes owned by the current user.
508
- let running = installedViaTask
509
- && isWindowsScheduledTaskRunning(taskName, execFileSyncImpl, onQueryTimeout);
510
- if (installed && !running) {
511
- try {
512
- const scriptPath = options.scriptPath ?? process.argv[1];
513
- if (scriptPath) {
514
- const psScript = buildWindowsProcessQueryScript(resolvedProfile);
515
- const output = execFileSyncImpl("powershell", ["-NoProfile", "-Command", psScript], {
516
- stdio: "pipe",
517
- env: windowsProcessQueryEnv(resolvedProfile, scriptPath),
518
- timeout: WINDOWS_READ_ONLY_QUERY_TIMEOUT_MS,
519
- windowsHide: true,
520
- })
521
- .toString()
522
- .trim();
523
- running = /\d+/.test(output);
524
- }
525
- }
526
- catch (error) {
527
- if (isProcessTimeoutError(error)) {
528
- onQueryTimeout("running process");
529
- }
530
- }
531
- }
340
+ const serviceName = windowsServiceName(resolvedProfile);
341
+ const state = queryWindowsServiceState(serviceName, deps);
532
342
  return {
533
343
  platform: "windows",
534
- installed,
535
- running,
536
- serviceName: taskName,
537
- manager: installedViaTask ? "Windows Task Scheduler" : "Windows Startup",
538
- servicePath: windowsServiceRunnerPath(resolvedProfile),
539
- logPath,
540
- startHint: installedViaTask
541
- ? `schtasks /run /tn "${taskName}"`
542
- : buildWindowsServiceTaskCommand(windowsServiceRunnerPath(resolvedProfile)),
344
+ installed: state !== "missing",
345
+ running: state === "running",
346
+ serviceName,
347
+ manager: "Windows Service",
348
+ servicePath: windowsServiceHostExePath(resolvedProfile),
349
+ logPath: getProfileLogPath(resolvedProfile),
350
+ startHint: `sc.exe start "${serviceName}"`,
543
351
  };
544
352
  }
545
353
  //# sourceMappingURL=service-manager-windows.js.map