fix: macOS-only Parallels smoke harness regression (#75293)

* fix: macOS-only Parallels smoke harness regression

* fix(parallels): keep macOS update smoke script owner-readable

* fix: macOS-only Parallels smoke harness regression

* test(parallels): exercise macOS guest script identity

Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>

---------

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
clawsweeper[bot]
2026-07-28 14:12:57 -04:00
committed by GitHub
parent 430de2299c
commit a0e4ac412c
2 changed files with 138 additions and 29 deletions

View File

@@ -99,6 +99,11 @@ interface SpawnLoggedOptions {
timeoutMs?: number;
}
interface MacosUpdateExec {
execArgs: string[];
ownerUser: string;
}
interface NpmUpdateSummary {
packageSpec: string;
updateTarget: string;
@@ -1156,26 +1161,24 @@ export class NpmUpdateSmoke {
timeoutMs: number,
ctx: UpdateJobContext,
): Promise<void> {
const macosUpdateExec = this.resolveMacosUpdateExec(ctx);
const scriptPath = this.writeGuestScript(
this.macosVm,
script,
"openclaw-parallels-npm-update-macos",
{ execArgs: macosUpdateExec.execArgs, mode: "700" },
);
const macosExecArgs = this.resolveMacosUpdateExecArgs(ctx);
const sudoUserArgIndex = macosExecArgs.indexOf("-u");
const sudoUser =
sudoUserArgIndex >= 0 && sudoUserArgIndex + 1 < macosExecArgs.length
? macosExecArgs[sudoUserArgIndex + 1]
: "";
if (sudoUser) {
run("prlctl", ["exec", this.macosVm, "/usr/sbin/chown", sudoUser, scriptPath], {
run(
"prlctl",
["exec", this.macosVm, "/usr/sbin/chown", macosUpdateExec.ownerUser, scriptPath],
{
timeoutMs: 30_000,
});
}
},
);
try {
const status = await this.runStreamingToJobLog(
"prlctl",
["exec", this.macosVm, ...macosExecArgs, "/bin/bash", scriptPath],
["exec", this.macosVm, ...macosUpdateExec.execArgs, "/bin/bash", scriptPath],
timeoutMs,
ctx,
);
@@ -1187,7 +1190,7 @@ export class NpmUpdateSmoke {
}
}
private resolveMacosUpdateExecArgs(ctx: UpdateJobContext): string[] {
private resolveMacosUpdateExec(ctx: UpdateJobContext): MacosUpdateExec {
const guestPath =
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
const currentUser = run("prlctl", ["exec", this.macosVm, "--current-user", "whoami"], {
@@ -1197,7 +1200,10 @@ export class NpmUpdateSmoke {
});
const user = currentUser.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? "";
if (currentUser.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) {
return ["--current-user", "/usr/bin/env", `PATH=${guestPath}`];
return {
execArgs: ["--current-user", "/usr/bin/env", `PATH=${guestPath}`],
ownerUser: user,
};
}
const fallbackUser = this.resolveMacosDesktopUser();
@@ -1210,17 +1216,20 @@ export class NpmUpdateSmoke {
`desktop user unavailable via Parallels --current-user; using root sudo fallback for ${fallbackUser}\n`,
);
const home = this.resolveMacosDesktopHome(fallbackUser);
return [
"/usr/bin/sudo",
"-H",
"-u",
fallbackUser,
"/usr/bin/env",
`HOME=${home}`,
`USER=${fallbackUser}`,
`LOGNAME=${fallbackUser}`,
`PATH=${guestPath}`,
];
return {
execArgs: [
"/usr/bin/sudo",
"-H",
"-u",
fallbackUser,
"/usr/bin/env",
`HOME=${home}`,
`USER=${fallbackUser}`,
`LOGNAME=${fallbackUser}`,
`PATH=${guestPath}`,
],
ownerUser: fallbackUser,
};
}
private resolveMacosDesktopUser(): string {
@@ -1321,9 +1330,16 @@ export class NpmUpdateSmoke {
}
}
private writeGuestScript(vm: string, script: string, prefix: string): string {
private writeGuestScript(
vm: string,
script: string,
prefix: string,
options: { execArgs?: string[]; mode?: "700" | "755" } = {},
): string {
const execArgs = options.execArgs ?? [];
const mode = options.mode ?? "755";
const scriptPath = `/tmp/${prefix}-${randomUUID()}.sh`;
const write = run("prlctl", ["exec", vm, "/usr/bin/tee", scriptPath], {
const write = run("prlctl", ["exec", vm, ...execArgs, "/usr/bin/tee", scriptPath], {
check: false,
input: script,
quiet: true,
@@ -1333,7 +1349,7 @@ export class NpmUpdateSmoke {
throw new Error(`failed to write guest script ${scriptPath}: ${write.stderr.trim()}`);
}
try {
const chmod = run("prlctl", ["exec", vm, "/bin/chmod", "755", scriptPath], {
const chmod = run("prlctl", ["exec", vm, ...execArgs, "/bin/chmod", mode, scriptPath], {
check: false,
quiet: true,
timeoutMs: 30_000,

View File

@@ -254,6 +254,88 @@ exit 1
expect(log.match(/^cleanup$/gm)).toHaveLength(1);
});
it("uses one macOS guest identity to write and execute update scripts", async () => {
const root = makeTempDir();
const logPath = path.join(root, "prlctl.log");
const prlctlPath = path.join(root, "prlctl");
writeFileSync(
prlctlPath,
`#!/usr/bin/env bash
set -euo pipefail
log_path=${JSON.stringify(logPath)}
printf '%s\\n' "$*" >>"$log_path"
args=" $* "
if [[ "$args" == *" --current-user whoami "* ]]; then
printf 'desktop-user\\n'
exit 0
fi
if [[ "$args" == *" /usr/bin/tee /tmp/openclaw-parallels-npm-update-macos-"* ]]; then
cat >/dev/null
exit 0
fi
if [[ "$args" == *" /bin/chmod 700 /tmp/openclaw-parallels-npm-update-macos-"* ]]; then
exit 0
fi
if [[ "$args" == *" /usr/sbin/chown desktop-user /tmp/openclaw-parallels-npm-update-macos-"* ]]; then
exit 0
fi
if [[ "$args" == *" /bin/rm -f /tmp/openclaw-parallels-npm-update-macos-"* ]]; then
exit 0
fi
exit 1
`,
);
chmodSync(prlctlPath, 0o755);
await withEnvAsync(
{
OPENAI_API_KEY: "test-key",
PATH: `${root}${path.delimiter}${process.env.PATH ?? ""}`,
},
async () => {
const smoke = new NpmUpdateSmoke({
...TEST_AUTH,
dependencyTarballs: [],
registryPackageTarballs: [],
json: false,
packageSpec: "openclaw@latest",
platforms: new Set<Platform>(["macos"]),
provider: "openai",
updateTarget: "local-main",
});
const stream = vi.fn().mockResolvedValue(0);
Reflect.set(smoke, "runStreamingToJobLog", stream);
const guestMacos = Reflect.get(smoke, "guestMacos") as (
script: string,
timeoutMs: number,
ctx: { append: (chunk: string) => void },
) => Promise<void>;
const ctx = { append: vi.fn() };
await guestMacos.call(smoke, "echo update", 30_000, ctx);
const call = stream.mock.calls.at(0);
expect(call?.[0]).toBe("prlctl");
expect(call?.[1]).toEqual([
"exec",
"macOS Tahoe",
"--current-user",
"/usr/bin/env",
expect.stringMatching(/^PATH=/),
"/bin/bash",
expect.stringMatching(/^\/tmp\/openclaw-parallels-npm-update-macos-/),
]);
},
);
const log = readFileSync(logPath, "utf8");
expect(log).toContain("--current-user whoami");
expect(log).toContain("--current-user /usr/bin/env PATH=");
expect(log).toContain("/usr/bin/tee /tmp/openclaw-parallels-npm-update-macos-");
expect(log).toContain("/bin/chmod 700 /tmp/openclaw-parallels-npm-update-macos-");
expect(log).toContain("/usr/sbin/chown desktop-user");
});
it("has a one-command beta validation mode with fresh target coverage", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
@@ -841,8 +923,9 @@ exit 1
it("keeps macOS sudo fallback update scripts readable by the desktop user", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain('macosExecArgs.indexOf("-u")');
expect(script).toContain('"/usr/sbin/chown", sudoUser, scriptPath');
expect(script).toContain('"/usr/sbin/chown"');
expect(script).toContain("macosUpdateExec.ownerUser");
expect(script).toContain("ownerUser: fallbackUser");
});
it("selects macOS desktop users with homes on spaced mounted volumes", () => {
@@ -937,6 +1020,16 @@ exit 7
);
});
it("writes macOS update scripts through the desktop user transport", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("const macosUpdateExec = this.resolveMacosUpdateExec(ctx)");
expect(script).toContain('{ execArgs: macosUpdateExec.execArgs, mode: "700" }');
expect(script).toContain('["exec", vm, ...execArgs, "/usr/bin/tee", scriptPath]');
expect(script).toContain('["exec", vm, ...execArgs, "/bin/chmod", mode, scriptPath]');
expect(script).toContain("ownerUser: user");
});
it("scrubs future plugin entries before invoking old same-guest updaters", () => {
const script = readFileSync(UPDATE_SCRIPTS_PATH, "utf8");
const windowsScript = windowsUpdateScript({