diff --git a/packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts b/packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts index 69c83a2..f9e3b06 100644 --- a/packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts +++ b/packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts @@ -1,12 +1,14 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; +import { promisify } from "node:util"; import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; const PLAYGROUND_PACKAGE = "@openagentpack/playground"; +const execFileAsync = promisify(execFile); const DEFAULT_PORT = 4848; const PLAYGROUND_URL_PATTERN = /running at http:\/\/localhost:(\d+)/i; @@ -56,7 +58,7 @@ export async function launchManagedAgentPlayground( options.surface === "workbench" ? (options.project ?? ".") : (options.file ?? "agents.yaml"), ); const projectId = createHash("sha256").update(sourcePath).digest("hex").slice(0, 16); - const launcher = resolveLauncher(); + const launcher = await resolveLauncher(); const existing = await probeExistingPlayground(port); if (existing) { const reusable = @@ -147,7 +149,7 @@ function assertSupportedNodeVersion(): void { ); } -function resolveLauncher(): Launcher { +export async function resolveLauncher(): Promise { const explicit = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN?.trim() || process.env.AGENTS_PLAYGROUND_BIN?.trim(); @@ -161,23 +163,69 @@ function resolveLauncher(): Launcher { return { command: process.execPath, args: [explicit], fetched: false }; } - const installed = resolveInstalledPlayground(); - if (installed) return installed; - - const monorepoBinary = findLocalPlaygroundBin(process.cwd()); - if (monorepoBinary) { - return { command: process.execPath, args: [monorepoBinary], fetched: false }; + const requestedVersion = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION?.trim() || "latest"; + const { stdout } = await execFileAsync( + "npm", + [ + "view", + `${PLAYGROUND_PACKAGE}@${requestedVersion}`, + "version", + "--json", + "--prefer-online", + "--fetch-retries=0", + "--fetch-timeout=10000", + ], + { timeout: 15_000, maxBuffer: 1024 * 1024 }, + ); + let resolvedVersion: unknown; + try { + resolvedVersion = JSON.parse(stdout); + } catch { + resolvedVersion = undefined; + } + if ( + typeof resolvedVersion !== "string" || + !/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(resolvedVersion) + ) { + throw new BailianError( + "npm did not return a single valid Playground version. Specify an exact version or dist-tag. / npm 未返回唯一有效的 Playground 版本号。请指定精确版本或 dist-tag。", + ExitCode.GENERAL, + ); + } + + const installed = resolveInstalledPlayground(); + if (installed?.version === resolvedVersion) return installed; + + const monorepoBinary = findLocalPlaygroundBin(process.cwd()); + if ( + monorepoBinary && + readPlaygroundVersion(resolve(dirname(monorepoBinary), "../../package.json")) === + resolvedVersion + ) { + return { + command: process.execPath, + args: [monorepoBinary], + version: resolvedVersion, + fetched: false, + }; } - const requestedVersion = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION?.trim() || "latest"; return { command: "npx", - args: ["-y", `${PLAYGROUND_PACKAGE}@${requestedVersion}`], - version: requestedVersion === "latest" ? undefined : requestedVersion, + args: ["-y", `${PLAYGROUND_PACKAGE}@${resolvedVersion}`], + version: resolvedVersion, fetched: true, }; } +function readPlaygroundVersion(manifestPath: string): string | undefined { + try { + return (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: string }).version; + } catch { + return undefined; + } +} + function resolveInstalledPlayground(): Launcher | undefined { try { const require = createRequire(import.meta.url); diff --git a/packages/commands/tests/managed-agent-playground-launcher.test.ts b/packages/commands/tests/managed-agent-playground-launcher.test.ts new file mode 100644 index 0000000..e3d62cc --- /dev/null +++ b/packages/commands/tests/managed-agent-playground-launcher.test.ts @@ -0,0 +1,137 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; + +const registry = vi.hoisted(() => ({ + version: '"0.7.0"', + error: null as Error | null, + manifest: "", + query: vi.fn(), +})); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + execFile: ( + command: string, + args: string[], + options: unknown, + callback: (error: Error | null, output: { stdout: string }) => void, + ) => { + registry.query(command, args, options); + callback(registry.error, { stdout: registry.version }); + }, +})); + +vi.mock("node:module", () => ({ + createRequire: () => ({ resolve: () => registry.manifest }), +})); + +import { resolveLauncher } from "../src/commands/managed-agent/_engine/playground-launcher.ts"; + +let directory: string; +let binary: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "playground-launcher-")); + binary = join(directory, "playground.js"); + registry.manifest = join(directory, "package.json"); + registry.version = '"0.7.0"'; + registry.error = null; + registry.query.mockClear(); + await writeFile(binary, ""); + vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", ""); + vi.stubEnv("AGENTS_PLAYGROUND_BIN", ""); + vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", ""); + vi.spyOn(process, "cwd").mockReturnValue(directory); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); + +async function install(version: string) { + await writeFile(registry.manifest, JSON.stringify({ version, bin: "playground.js" })); +} + +test("checks latest on every resolution and reuses only the matching installed version", async () => { + await install("0.7.0"); + expect(await resolveLauncher()).toMatchObject({ + args: [binary], + version: "0.7.0", + fetched: false, + }); + registry.version = '"0.8.0"'; + expect(await resolveLauncher()).toMatchObject({ + command: "npx", + args: ["-y", "@openagentpack/playground@0.8.0"], + version: "0.8.0", + fetched: true, + }); + expect(registry.query).toHaveBeenCalledTimes(2); + expect(registry.query).toHaveBeenCalledWith( + "npm", + expect.arrayContaining([ + "view", + "@openagentpack/playground@latest", + "--prefer-online", + "--fetch-timeout=10000", + ]), + expect.objectContaining({ timeout: 15_000 }), + ); +}); + +test("downloads the resolved exact version when local installation is old or missing", async () => { + for (const version of ["0.6.0", undefined]) { + if (version) await install(version); + else await rm(registry.manifest); + expect(await resolveLauncher()).toMatchObject({ + command: "npx", + args: ["-y", "@openagentpack/playground@0.7.0"], + }); + } +}); + +test("explicit version overrides an incompatible installed version", async () => { + await install("0.6.0"); + vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", "0.7.0"); + expect(await resolveLauncher()).toMatchObject({ fetched: true, version: "0.7.0" }); + expect(registry.query.mock.calls[0]?.[1]).toContain("@openagentpack/playground@0.7.0"); +}); + +test("explicit binary remains an offline development override", async () => { + vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", binary); + expect(await resolveLauncher()).toMatchObject({ args: [binary], fetched: false }); + expect(registry.query).not.toHaveBeenCalled(); +}); + +test("registry failure does not silently reuse an old installation", async () => { + await install("0.6.0"); + registry.error = new Error("registry unavailable"); + await expect(resolveLauncher()).rejects.toThrow("registry unavailable"); +}); + +test("rejects malformed or ambiguous registry metadata with localized diagnostics", async () => { + for (const output of ["invalid", '["0.6.0","0.7.0"]', '"--unsafe"']) { + registry.version = output; + await expect(resolveLauncher()).rejects.toThrow("single valid Playground version"); + await expect(resolveLauncher()).rejects.toThrow("唯一有效的 Playground 版本号"); + } +}); + +test("local source builds must also match the resolved version", async () => { + const packageRoot = join(directory, "packages/playground"); + const sourceBinary = join(packageRoot, "dist/bin/playground.js"); + await mkdir(join(packageRoot, "dist/bin"), { recursive: true }); + await writeFile(sourceBinary, ""); + await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.6.0" })); + expect(await resolveLauncher()).toMatchObject({ fetched: true }); + await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.7.0" })); + expect(await resolveLauncher()).toMatchObject({ + args: [sourceBinary], + version: "0.7.0", + fetched: false, + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27333d1..5c4626f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,8 +189,8 @@ importers: packages/commands: dependencies: '@openagentpack/sdk': - specifier: 0.5.0 - version: 0.5.0 + specifier: 0.7.0 + version: 0.7.0 bailian-cli-core: specifier: workspace:* version: link:../core @@ -1124,8 +1124,8 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@openagentpack/sdk@0.5.0': - resolution: {integrity: sha512-9mNMvPWuoiK5NQVFN5i2qAhUAFSmw9jO8xrMDXXfa6eSu0XlEAPZzR6DDg4HdhW+ex60KgA+fcNA0h0LRF552A==} + '@openagentpack/sdk@0.7.0': + resolution: {integrity: sha512-MjyKIFvPi4aT1TIcvGUB0bnD4Tly9aMCncjI9+EYG6CPWpuGO4DGj+4zlfBCx1fvtD1G1w4Zi0m8A8ZodqkK9Q==} engines: {node: '>=18.17.0'} '@oxc-project/runtime@0.129.0': @@ -4287,7 +4287,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@openagentpack/sdk@0.5.0': + '@openagentpack/sdk@0.7.0': dependencies: jszip: 3.10.1 yaml: 2.9.0