From a0e4ac412c3b493eeb726eb7b3b87e7d00a711c9 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:12:57 -0400 Subject: [PATCH] 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 --- scripts/e2e/parallels/npm-update-smoke.ts | 70 +++++++------ .../parallels-npm-update-smoke.test.ts | 97 ++++++++++++++++++- 2 files changed, 138 insertions(+), 29 deletions(-) diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 850aa6d06d9b..18afbf9ba00f 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -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 { + 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, diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 706798101d0c..891b77dd0d63 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -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(["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; + 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({