| import fs from "node:fs/promises"; |
| import path from "node:path"; |
| import { afterEach, describe, expect, it, vi } from "vitest"; |
| import * as service from "../../daemon/service.js"; |
| import { |
| createMockGatewayService, |
| mockSystemAccountHome, |
| } from "../../daemon/service.test-helpers.js"; |
| import { writePackageRoot } from "../../infra/package-update-steps.test-support.js"; |
| import { CONTROL_PLANE_UPDATE_SENTINEL_META_ENV } from "../../infra/update-control-plane-sentinel.js"; |
| import { pkgQueryResult } from "../../infra/update-freebsd-pkg-ownership.test-support.js"; |
| import * as updateRunner from "../../infra/update-runner.js"; |
| import * as exec from "../../process/exec.js"; |
| import { withTestDir } from "../../test-helpers/temp-dir.js"; |
| import { withEnvAsync } from "../../test-utils/env.js"; |
| import { withMockedPlatform } from "../../test-utils/vitest-spies.js"; |
| import * as shared from "./shared.js"; |
| import { updateGitInstall } from "./update-command-git.js"; |
| import { prepareUpdateCommand, resolveUpdateCommandAdmissionEnv } from "./update-command-run.js"; |
| import { resolveManagedServicePackageUpdatePlan } from "./update-command-service-plan.js"; |
| import { resolveUpdateCommandTarget } from "./update-command-target.js"; |
|
|
| afterEach(() => vi.restoreAllMocks()); |
|
|
| async function withPackageRoots( |
| run: (base: string, requested: string, managed: string) => Promise<void>, |
| ) { |
| await withTestDir({ prefix: "openclaw-update-pkg-" }, async (base) => { |
| const requested = path.join(base, "requested", "lib", "node_modules", "openclaw"); |
| const managed = path.join(base, "managed", "lib", "node_modules", "openclaw"); |
| await writePackageRoot(requested, "1.0.0"); |
| await writePackageRoot(managed, "1.0.0"); |
| await withEnvAsync( |
| { |
| HOME: base, |
| OPENCLAW_HOME: undefined, |
| OPENCLAW_PROFILE: undefined, |
| OPENCLAW_STATE_DIR: undefined, |
| OPENCLAW_CONFIG_PATH: undefined, |
| OPENCLAW_SUPERVISOR_MODE: undefined, |
| OPENCLAW_UPDATE_POST_CORE: undefined, |
| OPENCLAW_UPDATE_RUN_ID: undefined, |
| [CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: undefined, |
| }, |
| async () => { |
| mockSystemAccountHome(); |
| await withMockedPlatform("freebsd", () => run(base, requested, managed)); |
| }, |
| ); |
| }); |
| } |
|
|
| describe("FreeBSD pkg update admission", () => { |
| it.each(["owned", "unknown", "unowned"])( |
| "admits the requested root before package-manager probes (%s)", |
| async (ownership) => { |
| await withPackageRoots(async (_base, root) => { |
| const command = vi.spyOn(exec, "runCommandWithTimeout").mockResolvedValue({ |
| stdout: `${path.dirname(root)}\n`, |
| stderr: "", |
| code: 0, |
| signal: null, |
| killed: false, |
| termination: "exit", |
| }); |
| vi.spyOn(exec, "runCommandBuffered").mockResolvedValue( |
| pkgQueryResult( |
| ownership === "owned" ? `${root}/package.json\n` : "", |
| ownership === "unknown" ? { code: 1 } : {}, |
| ), |
| ); |
| const admission = shared.resolveGlobalManager({ |
| root, |
| installKind: "package", |
| timeoutMs: 1000, |
| }); |
| if (ownership === "unowned") { |
| await expect(admission).resolves.toBe("npm"); |
| expect(command).toHaveBeenCalled(); |
| } else { |
| await expect(admission).rejects.toMatchObject({ |
| reason: ownership === "owned" ? "pkg-owned-install" : "pkg-ownership-unavailable", |
| }); |
| expect(command).not.toHaveBeenCalled(); |
| } |
| }); |
| }, |
| ); |
|
|
| it("reuses completed admission after a long confirmation without timing out path inspection", async () => { |
| await withPackageRoots(async (_base, root) => { |
| vi.spyOn(shared, "resolveUpdateRoot").mockResolvedValue(root); |
| vi.spyOn(service, "resolveGatewayService").mockReturnValue(createMockGatewayService()); |
| const query = vi.spyOn(exec, "runCommandBuffered").mockResolvedValue(pkgQueryResult()); |
| const prepared = await prepareUpdateCommand({ dryRun: true }); |
| vi.spyOn(Date, "now").mockReturnValue(Date.now() + 60_000); |
| await expect( |
| resolveUpdateCommandAdmissionEnv({ |
| root, |
| opts: { dryRun: true }, |
| pkgOwnership: prepared.pkgOwnership, |
| }), |
| ).resolves.toBeDefined(); |
| expect(query).toHaveBeenCalledTimes(1); |
| }); |
| }); |
|
|
| it("rechecks the selected Git root after its mutation preparation finishes", async () => { |
| await withPackageRoots(async (_base, root) => { |
| let claimed = false; |
| const query = vi |
| .spyOn(exec, "runCommandBuffered") |
| .mockImplementation(async () => pkgQueryResult(claimed ? `${root}/package.json\n` : "")); |
| const publish = vi.fn(); |
| vi.spyOn(updateRunner, "runGatewayUpdate").mockImplementation(async (options) => { |
| await options?.beforeGitMutation?.({}); |
| publish(); |
| return { status: "ok", mode: "git", root, steps: [], durationMs: 0 }; |
| }); |
| await expect( |
| updateGitInstall({ |
| root, |
| switchToGit: false, |
| installKind: "git", |
| timeoutMs: 1000, |
| startedAt: Date.now(), |
| progress: {}, |
| channel: "dev", |
| tag: "dev", |
| beforeGitMutation: async () => { |
| claimed = true; |
| }, |
| getManagedServiceEnv: () => undefined, |
| getSnapshotSource: async () => ({ config: {}, env: process.env }), |
| allowGatewayServiceRepair: false, |
| allowGatewayActivation: false, |
| }), |
| ).rejects.toMatchObject({ reason: "pkg-owned-install" }); |
| expect(query).toHaveBeenCalledTimes(2); |
| expect(publish).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| it("refuses a selected pkg-owned Git destination before checkout or manager effects", async () => { |
| await withPackageRoots(async (_base, requested, selected) => { |
| vi.spyOn(shared, "resolveGitInstallDir").mockReturnValue(selected); |
| const manager = vi |
| .spyOn(shared, "resolveGlobalManager") |
| .mockRejectedValue(new Error("unexpected manager admission")); |
| const checkout = vi |
| .spyOn(shared, "ensureGitCheckout") |
| .mockRejectedValue(new Error("unexpected checkout mutation")); |
| const getSnapshotSource = vi.fn(async () => ({ config: {}, env: process.env })); |
| vi.spyOn(exec, "runCommandBuffered").mockResolvedValue( |
| pkgQueryResult(`${selected}/package.json\n`), |
| ); |
| await expect( |
| updateGitInstall({ |
| root: requested, |
| switchToGit: true, |
| installKind: "package", |
| timeoutMs: 1000, |
| startedAt: Date.now(), |
| progress: {}, |
| channel: "dev", |
| tag: "dev", |
| getManagedServiceEnv: () => undefined, |
| getSnapshotSource, |
| allowGatewayServiceRepair: false, |
| allowGatewayActivation: false, |
| }), |
| ).rejects.toMatchObject({ reason: "pkg-owned-install" }); |
| expect(manager).not.toHaveBeenCalled(); |
| expect(checkout).not.toHaveBeenCalled(); |
| expect(getSnapshotSource).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| it.each([ |
| { name: "ordinary update", opts: {} }, |
| { name: "no restart", opts: { restart: false } }, |
| { name: "dry run", opts: { dryRun: true } }, |
| { name: "package-to-Git switch", opts: { channel: "dev" } }, |
| ])( |
| "refuses the invoking pkg root before service planning or state writes: $name", |
| async ({ opts }) => { |
| await withPackageRoots(async (base, root) => { |
| vi.spyOn(shared, "resolveUpdateRoot").mockResolvedValue(root); |
| const readCommand = vi.fn(); |
| vi.spyOn(service, "resolveGatewayService").mockReturnValue( |
| createMockGatewayService({ readCommand }), |
| ); |
| vi.spyOn(exec, "runCommandBuffered").mockResolvedValue( |
| pkgQueryResult(`${root}/package.json\n`), |
| ); |
| await expect(prepareUpdateCommand(opts)).rejects.toMatchObject({ |
| reason: "pkg-owned-install", |
| }); |
| expect(readCommand).not.toHaveBeenCalled(); |
| await expect(fs.stat(path.join(base, ".openclaw"))).rejects.toMatchObject({ |
| code: "ENOENT", |
| }); |
| }); |
| }, |
| ); |
|
|
| it.each(["state", "profile"])( |
| "does not let a non-default %s bypass pkg ownership", |
| async (selector) => { |
| await withPackageRoots(async (base, root) => { |
| vi.spyOn(shared, "resolveUpdateRoot").mockResolvedValue(root); |
| vi.spyOn(exec, "runCommandBuffered").mockResolvedValue( |
| pkgQueryResult(`${root}/package.json\n`), |
| ); |
| await withEnvAsync( |
| selector === "state" |
| ? { OPENCLAW_STATE_DIR: path.join(base, "custom-state") } |
| : { OPENCLAW_PROFILE: "alternate" }, |
| async () => { |
| await expect(prepareUpdateCommand({ restart: false })).rejects.toMatchObject({ |
| reason: "pkg-owned-install", |
| }); |
| }, |
| ); |
| }); |
| }, |
| ); |
|
|
| it.each(["requested", "managed"])( |
| "refuses the %s pkg root before applying a service root/runtime redirect", |
| async (owner) => { |
| await withPackageRoots(async (_base, requested, managed) => { |
| const readCommand = vi.fn(async () => ({ |
| programArguments: [ |
| "/fixture/bin/node", |
| path.join(managed, "dist", "index.js"), |
| "gateway", |
| "run", |
| ], |
| })); |
| vi.spyOn(service, "resolveGatewayService").mockReturnValue( |
| createMockGatewayService({ readCommand }), |
| ); |
| const owned = owner === "requested" ? requested : managed; |
| vi.spyOn(exec, "runCommandBuffered").mockResolvedValue( |
| pkgQueryResult(`${owned}/package.json\n`), |
| ); |
| await expect( |
| resolveManagedServicePackageUpdatePlan({ root: requested }), |
| ).rejects.toMatchObject({ reason: "pkg-owned-install" }); |
| expect(readCommand).toHaveBeenCalledTimes(owner === "requested" ? 0 : 1); |
| }); |
| }, |
| ); |
|
|
| it("checks pkg ownership before reading admission environment from a service", async () => { |
| await withPackageRoots(async (_base, root) => { |
| const readCommand = vi.fn(); |
| const isAbsent = vi.fn(); |
| vi.spyOn(service, "resolveGatewayService").mockReturnValue( |
| createMockGatewayService({ readCommand, isAbsent }), |
| ); |
| vi.spyOn(exec, "runCommandBuffered").mockResolvedValue( |
| pkgQueryResult(`${root}/package.json\n`), |
| ); |
| await expect( |
| resolveUpdateCommandAdmissionEnv({ root, opts: { restart: false } }), |
| ).rejects.toMatchObject({ reason: "pkg-owned-install" }); |
| expect(readCommand).not.toHaveBeenCalled(); |
| expect(isAbsent).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| it("checks a precomputed service redirect before admitting its target", async () => { |
| await withPackageRoots(async (_base, root, managed) => { |
| vi.spyOn(shared, "resolveUpdateRoot").mockResolvedValue(root); |
| vi.spyOn(service, "resolveGatewayService").mockReturnValue(createMockGatewayService()); |
| vi.spyOn(exec, "runCommandBuffered").mockResolvedValue( |
| pkgQueryResult(`${managed}/package.json\n`), |
| ); |
| const prepared = await prepareUpdateCommand({ dryRun: true }); |
| prepared.servicePlan = { |
| rootRedirect: { root: managed, previousRoot: root }, |
| nodeRunner: "/fixture/bin/node", |
| }; |
| const enter = vi.fn(async () => { |
| throw new Error("Package ownership must precede executor admission"); |
| }); |
| await expect( |
| resolveUpdateCommandTarget( |
| { dryRun: true }, |
| { triageTarget: { env: process.env } }, |
| undefined, |
| prepared, |
| { enter }, |
| 1000, |
| ), |
| ).rejects.toMatchObject({ reason: "pkg-owned-install" }); |
| expect(enter).not.toHaveBeenCalled(); |
| }); |
| }); |
| }); |
|
|