Skip to content

Commit 902516b

Browse files
committed
fix(managed-agent): check latest Playground version before launch
Resolve the requested npm version before reusing local builds or running processes, fetch mismatched versions on demand, and preserve explicit binary overrides. Add launcher regression tests and sync the SDK 0.7.0 lockfile.
1 parent f44fb2e commit 902516b

3 files changed

Lines changed: 199 additions & 14 deletions

File tree

packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { spawn, type ChildProcess } from "node:child_process";
1+
import { execFile, spawn, type ChildProcess } from "node:child_process";
22
import { createHash, randomBytes } from "node:crypto";
33
import { existsSync, readFileSync } from "node:fs";
44
import { createRequire } from "node:module";
55
import { dirname, resolve } from "node:path";
6+
import { promisify } from "node:util";
67
import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core";
78
import { emitBare } from "bailian-cli-runtime";
89

910
const PLAYGROUND_PACKAGE = "@openagentpack/playground";
11+
const execFileAsync = promisify(execFile);
1012
const DEFAULT_PORT = 4848;
1113
const PLAYGROUND_URL_PATTERN = /running at http:\/\/localhost:(\d+)/i;
1214

@@ -56,7 +58,7 @@ export async function launchManagedAgentPlayground(
5658
options.surface === "workbench" ? (options.project ?? ".") : (options.file ?? "agents.yaml"),
5759
);
5860
const projectId = createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
59-
const launcher = resolveLauncher();
61+
const launcher = await resolveLauncher();
6062
const existing = await probeExistingPlayground(port);
6163
if (existing) {
6264
const reusable =
@@ -147,7 +149,7 @@ function assertSupportedNodeVersion(): void {
147149
);
148150
}
149151

150-
function resolveLauncher(): Launcher {
152+
export async function resolveLauncher(): Promise<Launcher> {
151153
const explicit =
152154
process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN?.trim() ||
153155
process.env.AGENTS_PLAYGROUND_BIN?.trim();
@@ -161,23 +163,69 @@ function resolveLauncher(): Launcher {
161163
return { command: process.execPath, args: [explicit], fetched: false };
162164
}
163165

166+
const requestedVersion = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION?.trim() || "latest";
167+
const { stdout } = await execFileAsync(
168+
"npm",
169+
[
170+
"view",
171+
`${PLAYGROUND_PACKAGE}@${requestedVersion}`,
172+
"version",
173+
"--json",
174+
"--prefer-online",
175+
"--fetch-retries=0",
176+
"--fetch-timeout=10000",
177+
],
178+
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
179+
);
180+
let resolvedVersion: unknown;
181+
try {
182+
resolvedVersion = JSON.parse(stdout);
183+
} catch {
184+
resolvedVersion = undefined;
185+
}
186+
if (
187+
typeof resolvedVersion !== "string" ||
188+
!/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(resolvedVersion)
189+
) {
190+
throw new BailianError(
191+
"npm did not return a single valid Playground version. Specify an exact version or dist-tag. / npm 未返回唯一有效的 Playground 版本号。请指定精确版本或 dist-tag。",
192+
ExitCode.GENERAL,
193+
);
194+
}
195+
164196
const installed = resolveInstalledPlayground();
165-
if (installed) return installed;
197+
if (installed?.version === resolvedVersion) return installed;
166198

167199
const monorepoBinary = findLocalPlaygroundBin(process.cwd());
168-
if (monorepoBinary) {
169-
return { command: process.execPath, args: [monorepoBinary], fetched: false };
200+
if (
201+
monorepoBinary &&
202+
readPlaygroundVersion(resolve(dirname(monorepoBinary), "../../package.json")) ===
203+
resolvedVersion
204+
) {
205+
return {
206+
command: process.execPath,
207+
args: [monorepoBinary],
208+
version: resolvedVersion,
209+
fetched: false,
210+
};
170211
}
171212

172-
const requestedVersion = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION?.trim() || "latest";
173213
return {
174214
command: "npx",
175-
args: ["-y", `${PLAYGROUND_PACKAGE}@${requestedVersion}`],
176-
version: requestedVersion === "latest" ? undefined : requestedVersion,
215+
args: ["-y", `${PLAYGROUND_PACKAGE}@${resolvedVersion}`],
216+
version: resolvedVersion,
177217
fetched: true,
178218
};
179219
}
180220

221+
function readPlaygroundVersion(manifestPath: string): string | undefined {
222+
try {
223+
return (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: string }).version;
224+
} catch {
225+
return undefined;
226+
}
227+
}
228+
181229
function resolveInstalledPlayground(): Launcher | undefined {
182230
try {
183231
const require = createRequire(import.meta.url);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
5+
6+
const registry = vi.hoisted(() => ({
7+
version: '"0.7.0"',
8+
error: null as Error | null,
9+
manifest: "",
10+
query: vi.fn(),
11+
}));
12+
13+
vi.mock("node:child_process", async (importOriginal) => ({
14+
...(await importOriginal<typeof import("node:child_process")>()),
15+
execFile: (
16+
command: string,
17+
args: string[],
18+
options: unknown,
19+
callback: (error: Error | null, output: { stdout: string }) => void,
20+
) => {
21+
registry.query(command, args, options);
22+
callback(registry.error, { stdout: registry.version });
23+
},
24+
}));
25+
26+
vi.mock("node:module", () => ({
27+
createRequire: () => ({ resolve: () => registry.manifest }),
28+
}));
29+
30+
import { resolveLauncher } from "../src/commands/managed-agent/_engine/playground-launcher.ts";
31+
32+
let directory: string;
33+
let binary: string;
34+
35+
beforeEach(async () => {
36+
directory = await mkdtemp(join(tmpdir(), "playground-launcher-"));
37+
binary = join(directory, "playground.js");
38+
registry.manifest = join(directory, "package.json");
39+
registry.version = '"0.7.0"';
40+
registry.error = null;
41+
registry.query.mockClear();
42+
await writeFile(binary, "");
43+
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", "");
44+
vi.stubEnv("AGENTS_PLAYGROUND_BIN", "");
45+
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", "");
46+
vi.spyOn(process, "cwd").mockReturnValue(directory);
47+
});
48+
49+
afterEach(async () => {
50+
vi.unstubAllEnvs();
51+
vi.restoreAllMocks();
52+
await rm(directory, { recursive: true, force: true });
53+
});
54+
55+
async function install(version: string) {
56+
await writeFile(registry.manifest, JSON.stringify({ version, bin: "playground.js" }));
57+
}
58+
59+
test("checks latest on every resolution and reuses only the matching installed version", async () => {
60+
await install("0.7.0");
61+
expect(await resolveLauncher()).toMatchObject({
62+
args: [binary],
63+
version: "0.7.0",
64+
fetched: false,
65+
});
66+
registry.version = '"0.8.0"';
67+
expect(await resolveLauncher()).toMatchObject({
68+
command: "npx",
69+
args: ["-y", "@openagentpack/playground@0.8.0"],
70+
version: "0.8.0",
71+
fetched: true,
72+
});
73+
expect(registry.query).toHaveBeenCalledTimes(2);
74+
expect(registry.query).toHaveBeenCalledWith(
75+
"npm",
76+
expect.arrayContaining([
77+
"view",
78+
"@openagentpack/playground@latest",
79+
"--prefer-online",
80+
"--fetch-timeout=10000",
81+
]),
82+
expect.objectContaining({ timeout: 15_000 }),
83+
);
84+
});
85+
86+
test("downloads the resolved exact version when local installation is old or missing", async () => {
87+
for (const version of ["0.6.0", undefined]) {
88+
if (version) await install(version);
89+
else await rm(registry.manifest);
90+
expect(await resolveLauncher()).toMatchObject({
91+
command: "npx",
92+
args: ["-y", "@openagentpack/playground@0.7.0"],
93+
});
94+
}
95+
});
96+
97+
test("explicit version overrides an incompatible installed version", async () => {
98+
await install("0.6.0");
99+
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", "0.7.0");
100+
expect(await resolveLauncher()).toMatchObject({ fetched: true, version: "0.7.0" });
101+
expect(registry.query.mock.calls[0]?.[1]).toContain("@openagentpack/playground@0.7.0");
102+
});
103+
104+
test("explicit binary remains an offline development override", async () => {
105+
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", binary);
106+
expect(await resolveLauncher()).toMatchObject({ args: [binary], fetched: false });
107+
expect(registry.query).not.toHaveBeenCalled();
108+
});
109+
110+
test("registry failure does not silently reuse an old installation", async () => {
111+
await install("0.6.0");
112+
registry.error = new Error("registry unavailable");
113+
await expect(resolveLauncher()).rejects.toThrow("registry unavailable");
114+
});
115+
116+
test("rejects malformed or ambiguous registry metadata with localized diagnostics", async () => {
117+
for (const output of ["invalid", '["0.6.0","0.7.0"]', '"--unsafe"']) {
118+
registry.version = output;
119+
await expect(resolveLauncher()).rejects.toThrow("single valid Playground version");
120+
await expect(resolveLauncher()).rejects.toThrow("唯一有效的 Playground 版本号");
121+
}
122+
});
123+
124+
test("local source builds must also match the resolved version", async () => {
125+
const packageRoot = join(directory, "packages/playground");
126+
const sourceBinary = join(packageRoot, "dist/bin/playground.js");
127+
await mkdir(join(packageRoot, "dist/bin"), { recursive: true });
128+
await writeFile(sourceBinary, "");
129+
await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.6.0" }));
130+
expect(await resolveLauncher()).toMatchObject({ fetched: true });
131+
await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.7.0" }));
132+
expect(await resolveLauncher()).toMatchObject({
133+
args: [sourceBinary],
134+
version: "0.7.0",
135+
fetched: false,
136+
});
137+
});

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)