Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 9 additions & 37 deletions deployer/windows_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
configures npm to use the taobao registry, and installs openclaw.
"""

import base64
import hashlib
import json
import ntpath
Expand Down Expand Up @@ -6471,7 +6470,6 @@ def ensure_defender_exclusions(self) -> bool:
dirs = [
self.node_dir, # ~/.openclaw-node (node + node_modules)
appdata / "npm" / "node_modules" / "openclaw",
appdata / "microclaw" / "runtime", # bundled runtime and sibling staging directories
Path.home() / ".openclaw", # config, skills, plugins, compile-cache
Path.home() / ".openclaw-git", # PortableGit
Path.home() / ".microclaw", # Electron desktop client
Expand All @@ -6482,7 +6480,7 @@ def ensure_defender_exclusions(self) -> bool:
marker = DEFAULT_DESKTOP_DIR / "install-state" / "defender-exclusions.json"
try:
payload = json.loads(marker.read_text(encoding="utf-8"))
if payload == {"schema": 2, "paths": normalized}:
if payload == {"schema": 1, "paths": normalized}:
self.log.info(" Windows Defender exclusions already configured")
return True
except (OSError, json.JSONDecodeError, TypeError):
Expand All @@ -6494,7 +6492,7 @@ def ensure_defender_exclusions(self) -> bool:
marker.parent.mkdir(parents=True, exist_ok=True)
temporary = marker.with_suffix(".tmp")
temporary.write_text(
json.dumps({"schema": 2, "paths": normalized}, indent=2) + "\n",
json.dumps({"schema": 1, "paths": normalized}, indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary, marker)
Expand Down Expand Up @@ -6545,52 +6543,26 @@ def _add_defender_exclusions(self, dirs: list[Path]) -> bool:
self.log.info(" Windows Defender exclusions already configured")
return True

# Preserve literal paths across both PowerShell processes. The elevated
# child must confirm the settings and return its own exit code; launching
# it successfully does not mean Defender accepted the exclusions.
safe_paths = ",".join("'" + str(d).replace("'", "''") + "'" for d in missing)
elevated_script = (
"$ErrorActionPreference = 'Stop'; "
"try { "
f"$requestedPaths = @({safe_paths}); "
"Add-MpPreference -ExclusionPath $requestedPaths -ErrorAction Stop; "
"$configuredPaths = @((Get-MpPreference -ErrorAction Stop).ExclusionPath | "
"Where-Object { $_ } | ForEach-Object { "
"[IO.Path]::GetFullPath($_).TrimEnd([char[]]'\\/') }); "
"foreach ($requestedPath in $requestedPaths) { "
"$normalizedPath = [IO.Path]::GetFullPath($requestedPath).TrimEnd([char[]]'\\/'); "
"if ($configuredPaths -notcontains $normalizedPath) { "
"throw 'Defender did not retain a requested exclusion' } }; "
"exit 0 "
"} catch { exit 1 }"
)
encoded = base64.b64encode(elevated_script.encode("utf-16le")).decode("ascii")
launch_script = (
"$ErrorActionPreference = 'Stop'; try { "
"$defenderProcess = Start-Process powershell.exe -Verb RunAs -Wait "
"-WindowStyle Hidden -PassThru "
f"-ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','{encoded}'); "
"exit $defenderProcess.ExitCode } catch { exit 1 }"
)
# Add-MpPreference always requires admin — elevate directly
safe_paths = ",".join(f"''{str(d).replace(chr(39), chr(39) * 2)}''" for d in missing)
try:
result = self._run(
[
"powershell",
"-NoProfile",
"-Command",
launch_script,
f"Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden "
f"-ArgumentList '-NoProfile','-Command',"
f"'Add-MpPreference -ExclusionPath {safe_paths}'",
],
capture_output=True,
timeout=30,
)
if result.returncode != 0:
self.log.warning(
" Defender exclusions were not confirmed. Administrator approval or "
"organization policy may prevent changes; installation will continue."
)
self.log.warning(" Defender exclusion command did not complete successfully")
return False
for d in missing:
self.log.info(f" Defender exclusion added and confirmed: {d}")
self.log.info(f" Defender exclusion added: {d}")
return True
except Exception as e:
self.log.warning(f" Defender 排除项添加失败(需要管理员权限): {e}")
Expand Down
110 changes: 96 additions & 14 deletions desktop/src/bundled-windows-node-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
findBundledDevicePairRequest,
findBundledNodePairRequest,
inspectBundledNodeList,
waitForBundledNodeReconnect,
replaceFileWithRetry,
revokeActivationLeaseFile,
sanitizeBundledHostDiagnostic,
Expand Down Expand Up @@ -825,26 +826,23 @@ describe("bundled Windows node host", () => {
).toBe("owned");
});

it("waits for the old connection to close and reconnects after node reapproval", async () => {
it("keeps the node process while its client reconnects after node reapproval", async () => {
const nodeId = "d".repeat(64);
const commands = [...WINDOWS_NODE_MXC_NODE_COMMANDS];
const startOptions = { requestedStrength: "off" };
const initialProcess = { exitCode: null, signalCode: null };
const restartedProcess = { exitCode: null, signalCode: null };
const host = new BundledWindowsNodeHost();
Object.assign(host, { process: initialProcess, startOptions });
vi.spyOn(host, "ensureIdentityNodeId").mockReturnValue(nodeId);
const order: string[] = [];
const stop = vi.spyOn(host, "stop");
const start = vi.spyOn(host, "start").mockImplementation(async (options) => {
order.push("start");
Object.assign(host, { process: restartedProcess, startOptions: options });
});
const start = vi.spyOn(host, "start").mockResolvedValue();
let approved = false;
let nodeListCalls = 0;
const node = {
nodeId,
connected: true,
connectedAtMs: 1000,
commands,
displayName: "MicroClaw Bundled Windows Node",
};
Expand All @@ -864,15 +862,19 @@ describe("bundled Windows node host", () => {
case "node.list":
nodeListCalls += 1;
if (nodeListCalls === 1) {
order.push("previous connected");
order.push("before approval");
return { nodes: [node] };
}
if (nodeListCalls === 2) {
order.push("previous connected");
return { nodes: [node] };
}
if (nodeListCalls === 3) {
order.push("previous closed");
return { nodes: [] };
}
order.push("new connected");
return { nodes: [node] };
if (nodeListCalls === 4) order.push("new connected");
return { nodes: [{ ...node, connectedAtMs: 2000 }] };
default:
throw new Error(`Unexpected request: ${method}`);
}
Expand All @@ -881,18 +883,19 @@ describe("bundled Windows node host", () => {

await host.ensurePaired(gateway);

expect(stop).toHaveBeenCalledOnce();
expect(start).toHaveBeenCalledOnce();
expect(stop).not.toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
expect(Reflect.get(host, "process")).toBe(initialProcess);
expect(order).toEqual([
"before approval",
"approve",
"previous connected",
"previous closed",
"start",
"new connected",
]);
});

it("does not restart a stopped node while waiting for pairing reconnection", async () => {
it("does not approve or restart a stopped node after a pairing snapshot returns", async () => {
const nodeId = "d".repeat(64);
const commands = [...WINDOWS_NODE_MXC_NODE_COMMANDS];
const host = new BundledWindowsNodeHost();
Expand Down Expand Up @@ -929,8 +932,87 @@ describe("bundled Windows node host", () => {
host.stop(true);
release({ nodes: [] });

await expect(pairing).rejects.toThrow("changed while waiting for pairing reconnection");
await expect(pairing).rejects.toThrow("process was replaced during pairing");
expect(start).not.toHaveBeenCalled();
expect(gateway.request).not.toHaveBeenCalledWith("node.pair.approve", expect.anything());
});

it("recognizes a newer connection when polling did not observe the brief disconnect", async () => {
const nodeId = "d".repeat(64);
const gateway = {
request: vi.fn().mockResolvedValue({
nodes: [
{
nodeId,
connected: true,
connectedAtMs: 2000,
commands: [...WINDOWS_NODE_MXC_NODE_COMMANDS],
},
],
}),
} as unknown as BundledWindowsNodeGateway;
await expect(
waitForBundledNodeReconnect({
gateway,
nodeId,
previousConnection: { connected: true, connectedAtMs: 1000 },
assertCurrent: () => undefined,
}),
).resolves.toBe(true);
});

it("does not treat the old connection as proof of pairing reconnection", async () => {
const nodeId = "d".repeat(64);
let elapsed = 0;
const gateway = {
request: vi.fn().mockResolvedValue({
nodes: [
{
nodeId,
connected: true,
connectedAtMs: 1000,
commands: [...WINDOWS_NODE_MXC_NODE_COMMANDS],
},
],
}),
} as unknown as BundledWindowsNodeGateway;
await expect(
waitForBundledNodeReconnect({
gateway,
nodeId,
previousConnection: { connected: true, connectedAtMs: 1000 },
assertCurrent: () => undefined,
now: () => elapsed,
wait: async (milliseconds) => {
elapsed += milliseconds;
},
}),
).resolves.toBe(false);
expect(elapsed).toBe(5000);
});

it("still rejects unexpected commands on a reconnected node", async () => {
const nodeId = "d".repeat(64);
const gateway = {
request: vi.fn().mockResolvedValue({
nodes: [
{
nodeId,
connected: true,
connectedAtMs: 2000,
commands: ["device.info"],
},
],
}),
} as unknown as BundledWindowsNodeGateway;
await expect(
waitForBundledNodeReconnect({
gateway,
nodeId,
previousConnection: { connected: true, connectedAtMs: 1000 },
assertCurrent: () => undefined,
}),
).rejects.toThrow("unexpected command surface");
});

it("selects only the exact bundled identity and ignores foreign same-name records", () => {
Expand Down
Loading
Loading