Skip to content

Commit 79b96b1

Browse files
committed
fix: harden update restart script creation
1 parent 5e0850f commit 79b96b1

2 files changed

Lines changed: 75 additions & 3 deletions

File tree

src/cli/update-cli/restart-helper.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ describe("restart-helper", () => {
3434
throw error;
3535
}
3636
});
37+
await fs.rmdir(path.dirname(scriptPath)).catch((error: unknown) => {
38+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
39+
throw error;
40+
}
41+
});
3742
}
3843

3944
async function makeTempDir(prefix: string) {
@@ -135,9 +140,63 @@ exit 0
135140
expect(content).toContain("systemctl --user restart 'openclaw-gateway.service'");
136141
// Script should self-cleanup
137142
expect(content).toContain('rm -f "$0"');
143+
expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true');
138144
await cleanupScript(scriptPath);
139145
});
140146

147+
it("creates restart scripts in a private temp directory with exclusive creation", async () => {
148+
Object.defineProperty(process, "platform", { value: "linux" });
149+
const timestamp = 1_727_201_234_567;
150+
const oldCandidatePath = path.join(os.tmpdir(), `openclaw-restart-${timestamp}.sh`);
151+
const victimDir = await makeTempDir("openclaw-restart-helper-victim-");
152+
const victimPath = path.join(victimDir, "restart.sh");
153+
await fs.rm(oldCandidatePath, { force: true });
154+
await fs.writeFile(victimPath, "preexisting script\n", "utf-8");
155+
156+
let candidateIsSymlink = false;
157+
try {
158+
await fs.symlink(victimPath, oldCandidatePath);
159+
candidateIsSymlink = true;
160+
} catch {
161+
await fs.writeFile(oldCandidatePath, "preexisting script\n", { flag: "wx" });
162+
}
163+
164+
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(timestamp);
165+
const writeFileSpy = vi.spyOn(fs, "writeFile");
166+
167+
try {
168+
const { scriptPath } = await prepareAndReadScript({
169+
OPENCLAW_PROFILE: "default",
170+
});
171+
const scriptDir = path.dirname(scriptPath);
172+
const relativeScriptDir = path.relative(os.tmpdir(), scriptDir);
173+
174+
expect(scriptPath).not.toBe(oldCandidatePath);
175+
expect(scriptDir).not.toBe(os.tmpdir());
176+
expect(relativeScriptDir).not.toBe("");
177+
expect(relativeScriptDir.startsWith("..")).toBe(false);
178+
expect(path.isAbsolute(relativeScriptDir)).toBe(false);
179+
expect(path.basename(scriptDir)).toMatch(/^openclaw-restart-/);
180+
expect(writeFileSpy).toHaveBeenLastCalledWith(
181+
scriptPath,
182+
expect.any(String),
183+
expect.objectContaining({ flag: "wx", mode: 0o755 }),
184+
);
185+
await expect(fs.readFile(victimPath, "utf-8")).resolves.toBe("preexisting script\n");
186+
if (!candidateIsSymlink) {
187+
await expect(fs.readFile(oldCandidatePath, "utf-8")).resolves.toBe(
188+
"preexisting script\n",
189+
);
190+
}
191+
await cleanupScript(scriptPath);
192+
} finally {
193+
dateSpy.mockRestore();
194+
writeFileSpy.mockRestore();
195+
await fs.rm(oldCandidatePath, { force: true });
196+
await fs.rm(victimDir, { recursive: true, force: true });
197+
}
198+
});
199+
141200
it("uses OPENCLAW_SYSTEMD_UNIT override for systemd scripts", async () => {
142201
Object.defineProperty(process, "platform", { value: "linux" });
143202
const { scriptPath, content } = await prepareAndReadScript({
@@ -203,6 +262,7 @@ exit 1
203262
expect(content).toContain("launchctl bootstrap 'gui/501'");
204263
expect(content).toContain("Bootstrap loads RunAtLoad agents");
205264
expect(content).toContain('rm -f "$0"');
265+
expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true');
206266
await cleanupScript(scriptPath);
207267
});
208268

@@ -379,6 +439,7 @@ exit 0
379439
expect(content).toContain("openclaw restart launched startup fallback");
380440
expectWindowsRestartWaitOrdering(content);
381441
expect(content).toContain('del "%~f0" >nul 2>&1');
442+
expect(content).toContain('rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1');
382443
await cleanupScript(scriptPath);
383444
});
384445

src/cli/update-cli/restart-helper.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ export async function prepareRestartScript(
6868
env: NodeJS.ProcessEnv = process.env,
6969
gatewayPort: number = DEFAULT_GATEWAY_PORT,
7070
): Promise<string | null> {
71-
const tmpDir = os.tmpdir();
7271
const timestamp = Date.now();
7372
const platform = process.platform;
7473

@@ -110,8 +109,10 @@ else
110109
fi
111110
fi
112111
# Self-cleanup
112+
script_dir=$(dirname "$0")
113113
exec 3>&-
114114
rm -f "$0"
115+
rmdir "$script_dir" 2>/dev/null || true
115116
exit "$status"
116117
`;
117118
} else if (platform === "darwin") {
@@ -157,7 +158,9 @@ else
157158
printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
158159
fi
159160
# Self-cleanup (log is retained under the OpenClaw state logs directory).
161+
script_dir=$(dirname "$0")
160162
rm -f "$0"
163+
rmdir "$script_dir" 2>/dev/null || true
161164
exit "$status"
162165
`;
163166
} else if (platform === "win32") {
@@ -177,9 +180,11 @@ REM Keep this as a cmd wrapper so Group Policy script execution policies
177180
REM cannot block the update restart handoff before schtasks.exe runs.
178181
setlocal
179182
set "OPENCLAW_RESTART_SCRIPT=%~f0"
183+
set "OPENCLAW_RESTART_SCRIPT_DIR=%~dp0."
180184
powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:OPENCLAW_RESTART_SCRIPT; $s=Get-Content -Raw -LiteralPath $p; $m='# POWERSHELL'; $i=$s.IndexOf($m); if ($i -lt 0) { exit 1 }; Invoke-Expression $s.Substring($i)"
181185
set "status=%ERRORLEVEL%"
182186
del "%~f0" >nul 2>&1
187+
rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1
183188
exit /b %status%
184189
# POWERSHELL
185190
# Wait briefly to ensure file locks are released after update.
@@ -370,8 +375,14 @@ exit $status
370375
return null;
371376
}
372377

373-
const scriptPath = path.join(tmpDir, filename);
374-
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 });
378+
const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-restart-"));
379+
const scriptPath = path.join(scriptDir, filename);
380+
try {
381+
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755, flag: "wx" });
382+
} catch (error) {
383+
await fs.rm(scriptDir, { recursive: true, force: true }).catch(() => {});
384+
throw error;
385+
}
375386
return scriptPath;
376387
} catch {
377388
// If we can't write the script, we'll fall back to the standard restart method

0 commit comments

Comments
 (0)