Skip to content

Commit 0faa38b

Browse files
committed
fix(daemon): clean stale Windows startup fallback
1 parent 0ee5f47 commit 0faa38b

4 files changed

Lines changed: 98 additions & 24 deletions

File tree

src/daemon/inspect.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ describe("findExtraGatewayServices (win32)", () => {
358358
execSchtasksMock.mockResolvedValueOnce({
359359
code: 0,
360360
stdout: [
361-
"TaskName: OpenClaw Gateway",
361+
"TaskName: \\OpenClaw Gateway",
362362
"Task To Run: C:\\Program Files\\OpenClaw\\openclaw.exe gateway run",
363363
"",
364364
"TaskName: Clawdbot Legacy",

src/daemon/inspect.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ function isOpenClawGatewaySystemdService(name: string, contents: string): boolea
195195
}
196196

197197
function isOpenClawGatewayTaskName(name: string): boolean {
198-
const normalized = normalizeLowercaseStringOrEmpty(name);
198+
const normalized = normalizeLowercaseStringOrEmpty(name).replace(/^\\+/, "");
199199
if (!normalized) {
200200
return false;
201201
}

src/daemon/schtasks.startup-fallback.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,15 @@ function notYetRunTaskQueryOutput() {
248248
].join("\r\n");
249249
}
250250

251+
function runningTaskQueryOutput() {
252+
return [
253+
"Status: Running",
254+
"Last Run Time: 11/30/1999 12:00:00 AM",
255+
"Last Run Result: 267009",
256+
"",
257+
].join("\r\n");
258+
}
259+
251260
beforeEach(() => {
252261
resetSchtasksBaseMocks();
253262
findVerifiedGatewayListenerPidsOnPortSync.mockReset();
@@ -320,6 +329,34 @@ describe("Windows startup fallback", () => {
320329
});
321330
});
322331

332+
it("removes a stale Startup-folder launcher after Scheduled Task install succeeds", async () => {
333+
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
334+
schtasksResponses.push(
335+
{ code: 0, stdout: "", stderr: "" },
336+
{ code: 1, stdout: "", stderr: "not found" },
337+
{ code: 0, stdout: "", stderr: "" },
338+
{ code: 0, stdout: "", stderr: "" },
339+
{ code: 0, stdout: "", stderr: "" },
340+
{ code: 0, stdout: runningTaskQueryOutput(), stderr: "" },
341+
);
342+
const startupEntryPath = await writeStartupFallbackEntry(env);
343+
const hiddenStartupEntryPath = resolveStartupEntryPath(env, "vbs");
344+
await fs.writeFile(hiddenStartupEntryPath, "' stale hidden launcher\r\n", "utf8");
345+
const stdout = new PassThrough();
346+
let printed = "";
347+
stdout.on("data", (chunk) => {
348+
printed += String(chunk);
349+
});
350+
351+
await installGatewayScheduledTask(env, stdout);
352+
353+
await expect(fs.access(startupEntryPath)).rejects.toThrow();
354+
await expect(fs.access(hiddenStartupEntryPath)).rejects.toThrow();
355+
expect(printed).toContain("Removed Windows login item");
356+
expect(printed).toContain("Installed Scheduled Task");
357+
});
358+
});
359+
323360
it("falls back to a Startup-folder launcher when schtasks create returns Spanish access denied", async () => {
324361
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
325362
addStartupFallbackMissingResponses([
@@ -376,10 +413,12 @@ describe("Windows startup fallback", () => {
376413
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
377414
fastForwardTaskStartWait();
378415
addAcceptedRunNeverStartsResponses();
416+
const startupEntryPath = await writeStartupFallbackEntry(env);
379417

380418
await installGatewayScheduledTask(env);
381419

382420
expectStartupFallbackSpawn();
421+
await expect(fs.access(startupEntryPath)).resolves.toBeUndefined();
383422
});
384423
});
385424

src/daemon/schtasks.ts

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ function resolveStartupEntryPath(env: GatewayServiceEnv, extension?: "cmd" | "vb
9292

9393
function resolveStartupEntryPaths(env: GatewayServiceEnv): string[] {
9494
const primaryPath = resolveStartupEntryPath(env);
95-
const legacyCmdPath = resolveStartupEntryPath(env, "cmd");
96-
return uniqueStrings([primaryPath, legacyCmdPath]);
95+
const cmdPath = resolveStartupEntryPath(env, "cmd");
96+
const hiddenPath = resolveStartupEntryPath(env, "vbs");
97+
return uniqueStrings([primaryPath, cmdPath, hiddenPath]);
9798
}
9899

99100
// `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe.
@@ -462,6 +463,18 @@ async function isStartupEntryInstalled(env: GatewayServiceEnv): Promise<boolean>
462463
return false;
463464
}
464465

466+
async function removeStartupEntries(
467+
env: GatewayServiceEnv,
468+
stdout?: NodeJS.WritableStream,
469+
): Promise<void> {
470+
for (const startupEntryPath of resolveStartupEntryPaths(env)) {
471+
try {
472+
await fs.unlink(startupEntryPath);
473+
stdout?.write(`${formatLine("Removed Windows login item", startupEntryPath)}\n`);
474+
} catch {}
475+
}
476+
}
477+
465478
async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise<boolean> {
466479
const taskName = resolveTaskName(env);
467480
const res = await execSchtasks(["/Query", "/TN", taskName]).catch(() => ({
@@ -995,11 +1008,14 @@ async function updateExistingScheduledTask(params: {
9951008
} finally {
9961009
await fs.rm(path.dirname(upgradeXmlPath), { recursive: true, force: true }).catch(() => {});
9971010
}
998-
await runScheduledTaskOrThrow({
1011+
const runResult = await runScheduledTaskOrThrow({
9991012
taskName: params.taskName,
10001013
env: params.env,
10011014
scriptPath: params.scriptPath,
10021015
});
1016+
if (runResult.taskStarted) {
1017+
await removeStartupEntries(params.env, params.stdout);
1018+
}
10031019
writeFormattedLines(
10041020
params.stdout,
10051021
[
@@ -1014,15 +1030,21 @@ async function updateExistingScheduledTask(params: {
10141030
async function shouldFallbackScheduledTaskLaunch(params: {
10151031
env: GatewayServiceEnv;
10161032
scriptPath: string;
1017-
}): Promise<boolean> {
1033+
}): Promise<{ shouldLaunchFallback: boolean; taskStarted: boolean }> {
10181034
const readLaunchObservation = async (): Promise<{
1019-
state: "running" | "not-yet-run" | "other";
1035+
state: "confirmed-running" | "running" | "not-yet-run" | "other";
10201036
signature: string;
10211037
}> => {
10221038
const runtime = await readScheduledTaskRuntime(params.env).catch(() => null);
10231039
if (runtime?.status === "running") {
1040+
const normalizedResult = normalizeTaskResultCode(runtime.lastRunResult);
1041+
const state = normalizedResult
1042+
? RUNNING_RESULT_CODES.has(normalizedResult)
1043+
? "confirmed-running"
1044+
: "running"
1045+
: "running";
10241046
return {
1025-
state: "running",
1047+
state,
10261048
signature: [runtime.state, runtime.lastRunTime, runtime.lastRunResult, runtime.detail]
10271049
.filter(Boolean)
10281050
.join("|"),
@@ -1104,39 +1126,54 @@ async function shouldFallbackScheduledTaskLaunch(params: {
11041126
};
11051127

11061128
const initial = await readLaunchObservation();
1129+
if (initial.state === "confirmed-running") {
1130+
return { shouldLaunchFallback: false, taskStarted: true };
1131+
}
11071132
if (initial.state !== "not-yet-run") {
1108-
return false;
1133+
return { shouldLaunchFallback: false, taskStarted: false };
11091134
}
11101135

11111136
const deadline = Date.now() + SCHEDULED_TASK_FALLBACK_TIMEOUT_MS;
11121137
while (Date.now() < deadline) {
11131138
await sleep(SCHEDULED_TASK_FALLBACK_POLL_MS);
11141139
const current = await readLaunchObservation();
1140+
if (current.state === "confirmed-running") {
1141+
return { shouldLaunchFallback: false, taskStarted: true };
1142+
}
1143+
if (current.state === "running") {
1144+
return { shouldLaunchFallback: false, taskStarted: false };
1145+
}
11151146
if (current.state !== "not-yet-run") {
1116-
return false;
1147+
return { shouldLaunchFallback: false, taskStarted: false };
11171148
}
11181149
if (current.signature !== initial.signature) {
1119-
return false;
1150+
return { shouldLaunchFallback: false, taskStarted: false };
11201151
}
11211152
}
1122-
return !(await hasLaunchEvidence());
1153+
return {
1154+
shouldLaunchFallback: !(await hasLaunchEvidence()),
1155+
taskStarted: false,
1156+
};
11231157
}
11241158

11251159
async function runScheduledTaskOrThrow(params: {
11261160
taskName: string;
11271161
env: GatewayServiceEnv;
11281162
scriptPath: string;
1129-
}): Promise<void> {
1163+
}): Promise<{ fallbackLaunched: boolean; taskStarted: boolean }> {
11301164
const run = await execSchtasks(["/Run", "/TN", params.taskName]);
11311165
if (run.code !== 0) {
11321166
throw new Error(`schtasks run failed: ${run.stderr || run.stdout}`.trim());
11331167
}
1134-
if (
1135-
!(await shouldFallbackScheduledTaskLaunch({ env: params.env, scriptPath: params.scriptPath }))
1136-
) {
1137-
return;
1168+
const launchResult = await shouldFallbackScheduledTaskLaunch({
1169+
env: params.env,
1170+
scriptPath: params.scriptPath,
1171+
});
1172+
if (!launchResult.shouldLaunchFallback) {
1173+
return { fallbackLaunched: false, taskStarted: launchResult.taskStarted };
11381174
}
11391175
await launchFallbackTaskScript(params.env);
1176+
return { fallbackLaunched: true, taskStarted: false };
11401177
}
11411178

11421179
async function activateScheduledTask(params: {
@@ -1211,11 +1248,14 @@ async function activateScheduledTask(params: {
12111248
throw new Error(`schtasks create failed: ${detail}`.trim());
12121249
}
12131250

1214-
await runScheduledTaskOrThrow({
1251+
const runResult = await runScheduledTaskOrThrow({
12151252
taskName,
12161253
env: params.env,
12171254
scriptPath: params.scriptPath,
12181255
});
1256+
if (runResult.taskStarted) {
1257+
await removeStartupEntries(params.env, params.stdout);
1258+
}
12191259
// Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline).
12201260
writeFormattedLines(
12211261
params.stdout,
@@ -1252,12 +1292,7 @@ export async function uninstallScheduledTask({
12521292
await execSchtasks(["/Delete", "/F", "/TN", taskName]);
12531293
}
12541294

1255-
for (const startupEntryPath of resolveStartupEntryPaths(env)) {
1256-
try {
1257-
await fs.unlink(startupEntryPath);
1258-
stdout.write(`${formatLine("Removed Windows login item", startupEntryPath)}\n`);
1259-
} catch {}
1260-
}
1295+
await removeStartupEntries(env, stdout);
12611296

12621297
const scriptPath = resolveTaskScriptPath(env);
12631298
const launcherPath = resolveTaskLauncherScriptPath(env, scriptPath);

0 commit comments

Comments
 (0)