From ee7ccddd9b5496c2b719f135293d42720e402331 Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Thu, 8 Oct 2026 19:44:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20StepPage=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=87=86=E5=A4=87=E5=92=8C=E7=BB=88=E7=AB=AF=20PATH?= =?UTF-8?q?=20=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + apps/cli/src/main.ts | 27 +- docs/step-configuration.md | 25 ++ infra/release/install.ps1 | 8 + infra/release/install.sh | 25 +- packages/coding-agent/src/index.ts | 2 + .../coding-agent/src/step/installation.ts | 54 ++++ .../coding-agent/src/step/local-update.ts | 14 + .../coding-agent/src/step/mcp-startup.test.ts | 176 ++++++++++++- packages/coding-agent/src/step/mcp.ts | 55 ++-- packages/coding-agent/src/step/plugins.ts | 39 ++- packages/coding-agent/src/step/shell-path.ts | 75 ++++++ .../src/step/steppage-provision.ts | 170 ++++++++++++ .../test/step-installation.test.ts | 248 ++++++++++++++++++ .../coding-agent/test/step-plugins.test.ts | 28 +- .../coding-agent/test/step-shell-path.test.ts | 82 ++++++ .../test/step-update-command.test.ts | 63 +++++ scripts/release-bundle.test.mjs | 61 ++++- 18 files changed, 1099 insertions(+), 57 deletions(-) create mode 100644 packages/coding-agent/src/step/installation.ts create mode 100644 packages/coding-agent/src/step/shell-path.ts create mode 100644 packages/coding-agent/src/step/steppage-provision.ts create mode 100644 packages/coding-agent/test/step-installation.test.ts create mode 100644 packages/coding-agent/test/step-shell-path.test.ts diff --git a/README.md b/README.md index 5a2374d6..3e282981 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ step --help Upgrade later with `step update`\. +On macOS/Linux/WSL, installation and upgrades also prepare StepPage, which requires +Node.js 20 or newer on PATH. If StepPage setup fails, Step remains usable and retries +on the next session. Run `step mcp prepare` to retry setup manually. + ## Sign in to the Step provider The default Step Code entrypoint exposes one built-in model provider: **Step (StepFun)**. The profiles below select the Step service region and billing method; they are not separate providers. diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 0359725f..27fe6b34 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -23,6 +23,7 @@ import { decorateStepCodeSettingsManager, describeStepMcpImportOutcome, ensureStepGlobalConfig, + ensureStepShellPath, flushStderrDevLog, getLegacyStepAuthPath, getStepAuthPath, @@ -44,6 +45,7 @@ import { normalizeStepSessionSelectorArgs, parseStepUpdateCommand, prepareMain, + prepareStepInstallation, readFeedbackUsername, readGlobalStepConfig, readOrCreateStepDeviceId, @@ -240,6 +242,7 @@ const stepMainOptions: MainOptions = { // Auth-only startup uses the same InteractiveMode for its OAuth dialog, // but must never be interrupted by a binary update prompt. if (process.argv[2] === "login" || process.argv[2] === "logout") return true; + for (const warning of await ensureStepShellPath()) ui.notify(warning, "warning"); const outcome = await maybeUpdateStep({ version: STEPCODE_VERSION, storageRootDir: resolveStepStorageRoot(), @@ -309,7 +312,11 @@ let telemetryExitReason: "normal" | "error" = "normal"; let forceOneShotExit = false; try { await telemetryIdentityReady; - if (!isTopLevelLogout && !(isTopLevelLogin && topLevelAuthHelp)) { + if ( + !isTopLevelLogout && + !(isTopLevelLogin && topLevelAuthHelp) && + !(isTopLevelMcp && process.argv[3] === "prepare") + ) { // Normalize only an old product-shaped credential file that is already // inside the canonical StepCode path. This is the credential store, not // settings: Step reads no configuration from a legacy layout. @@ -354,7 +361,21 @@ try { const json = args.includes("--json"); const config = readGlobalStepConfig(process.env); const servers = config.mcp_servers ?? {}; - if (subcommand === "list" || subcommand === "get") { + if (subcommand === "prepare") { + if (args.length !== 1) { + process.stderr.write("Usage: step mcp prepare\n"); + process.exitCode = 1; + } else { + try { + for (const warning of await prepareStepInstallation()) process.stderr.write(`Warning: ${warning}\n`); + } catch (error) { + process.stderr.write( + `Warning: StepPage preparation failed; Step remains usable. ${error instanceof Error ? error.message : String(error)}\n`, + ); + } + } + forceOneShotExit = true; + } else if (subcommand === "list" || subcommand === "get") { const name = subcommand === "get" ? args[1] : undefined; const selected = name ? (servers[name] ? { [name]: servers[name] } : {}) : servers; if (json) process.stdout.write(`${JSON.stringify(selected)}\n`); @@ -492,7 +513,7 @@ try { ); } } else { - process.stderr.write("Usage: step mcp list|get|add|remove|login|logout\n"); + process.stderr.write("Usage: step mcp list|get|add|remove|login|logout|prepare\n"); process.exitCode = 1; } } else if (isTopLevelLogin) { diff --git a/docs/step-configuration.md b/docs/step-configuration.md index 1455946c..4e277635 100644 --- a/docs/step-configuration.md +++ b/docs/step-configuration.md @@ -140,3 +140,28 @@ requests. Print and RPC sessions still wait for the initial tool catalog before accepting work. Closing or replacing a session cancels pending MCP connections and prevents their late tools or warnings from reaching the new session. + +The macOS/Linux installer and `step update` run `step mcp prepare` after +installing the new binary. This prepares the built-in StepPage executable before +the installer finishes. StepPage requires Node.js 20 or newer on PATH; Step itself +does not require a separately installed Node.js. Download or prerequisite failures +show a warning and leave Step usable. + +Before connecting `steppage__steppage`, Step verifies `steppage-mcp --version` +and repairs a missing or broken managed executable. Existing preinstallation +markers do not suppress this repair. Healthy executables in `~/.local/bin` can +be used even when the current shell's PATH lacks that directory. Failed setup is +retried on the next session; `step mcp prepare` also retries without requiring +sign-in. The server name combines the plugin ID and its server name with `__`. +Automatic StepPage installation is supported on macOS/Linux, including WSL. + +Preparation preserves credentials, custom server commands, global overrides, +disabled servers, and intentionally uninstalled plugins. A global declaration +that still uses the default `steppage-mcp` command without arguments shares the +built-in executable repair; its environment and other settings remain unchanged. +Global configuration takes precedence over user plugins, which take precedence +over trusted project plugins. An explicit disable prevents a lower-priority copy +from starting. Standalone interactive +startup also repairs the managed Step PATH block for users upgraded by an older +updater. The installer checks the shell profile even when the current terminal +already has Step on PATH. Set `STEP_NO_MODIFY_PATH=1` to manage PATH yourself. diff --git a/infra/release/install.ps1 b/infra/release/install.ps1 index 205964e6..4315f365 100644 --- a/infra/release/install.ps1 +++ b/infra/release/install.ps1 @@ -580,6 +580,14 @@ try { Write-Progress-Step -Step 6 -Total 6 -Message 'running smoke test' Test-Install -BinaryPath (Join-Path $InstallDir 'step.exe') + try { + & (Join-Path $InstallDir 'step.exe') mcp prepare + if ($LASTEXITCODE -ne 0) { + Write-Warning 'StepPage is not ready; Step remains usable. Run step mcp prepare after upgrading.' + } + } catch { + Write-Warning "StepPage preparation failed; Step remains usable. $($_.Exception.Message)" + } Write-Result -ResolvedVersion $resolvedVersion } catch { Write-Host " error: $($_.Exception.Message)" -ForegroundColor Red diff --git a/infra/release/install.sh b/infra/release/install.sh index e43bf176..a8879e25 100644 --- a/infra/release/install.sh +++ b/infra/release/install.sh @@ -154,11 +154,17 @@ strip_step_block() { # Remove a previously written, WELL-FORMED "# stepcode ... # stepcode end" # block (and any blank lines directly above it) so reinstalls stay idempotent. # A lone start marker with no matching end (a hand-edited rc, an interrupted - # prior write, or an unrelated "# stepcode" comment) is emitted verbatim - # rather than truncating everything below it to EOF. Returns non-zero (leaving - # the file untouched) if the rewrite could not be produced, so the caller can + # prior write, or an unrelated "# stepcode" comment) leaves the file untouched. + # Returns non-zero for malformed markers or a failed rewrite, so the caller can # avoid appending a duplicate block on top of one it failed to remove. local file="$1" tmp + # Never combine a dangling/nested marker with a newly appended block: the + # next reinstall could otherwise remove user content between those markers. + awk ' + $0 == "# stepcode" { if (inblock) exit 1; inblock = 1 } + $0 == "# stepcode end" { if (!inblock) exit 1; inblock = 0 } + END { if (inblock) exit 1 } + ' "$file" || return 1 tmp="$(mktemp "${file}.step.XXXXXX")" || return 1 if awk ' { @@ -192,14 +198,14 @@ configure_shell_path() { # drops the binary into a directory nothing sources, so a fresh shell reports # "command not found: step". Opt out with STEP_NO_MODIFY_PATH=1 (package # managers, CI, or callers that manage PATH themselves). - case ":${PATH}:" in *":${INSTALL_DIR}:"*) return 0 ;; esac + # An inherited PATH says nothing about what a fresh shell will load. if [[ -n "${STEP_NO_MODIFY_PATH:-}" ]]; then path_hint; return 0; fi # The install dir is interpolated into a shell-sourced file. Refuse to edit an # rc when a path contains characters that could break quoting or inject a # command, and fall back to a manual hint instead. case "${INSTALL_DIR}:${AGENT_DIR}/bin" in - *'"'* | *'`'* | *'$'* | *$'\n'*) + *'"'* | *'`'* | *'$'* | *'\'* | *$'\n'* | *$'\r'*) printf 'note: install dir contains characters unsafe to write into a shell profile; add it to PATH manually:\n export PATH="%s:%s/bin:$PATH"\n' "$INSTALL_DIR" "$AGENT_DIR" >&2 return 0 ;; @@ -238,7 +244,7 @@ configure_shell_path() { printf 'warning: could not update the existing stepcode block in %s; left it unchanged\n' "$rc" >&2 continue fi - if printf '\n# stepcode\ncase ":$PATH:" in\n *":%s:"*) ;;\n *) export PATH="%s:%s/bin:$PATH" ;;\nesac\n# stepcode end\n' "$INSTALL_DIR" "$INSTALL_DIR" "$AGENT_DIR" >>"$rc"; then + if printf '\n# stepcode\ncase ":$PATH:" in\n *":%s:"*) ;;\n *) export PATH="%s:$PATH" ;;\nesac\ncase ":$PATH:" in\n *":%s/bin:"*) ;;\n *) export PATH="%s/bin:$PATH" ;;\nesac\n# stepcode end\n' "$INSTALL_DIR" "$INSTALL_DIR" "$AGENT_DIR" "$AGENT_DIR" >>"$rc"; then printf 'added %s to PATH in %s\n' "$INSTALL_DIR" "$rc" updated=1 fi @@ -316,8 +322,13 @@ main() { for dir in native theme assets export-html docs examples; do copy_runtime_dir "$archive_root" "$dir"; done install_managed_tools "$archive_root" "$destination" --version >/dev/null || die 'installed Step failed its smoke test' - printf 'installed StepCode %s to %s\n' "$VERSION" "$destination" configure_shell_path + # The new binary owns built-in preparation, including legacy markers and + # executable health checks. Older pinned binaries may not know this command. + if ! "$destination" mcp prepare; then + printf 'warning: StepPage is not ready; Step remains usable. Run step mcp prepare after upgrading.\n' >&2 + fi + printf 'installed StepCode %s to %s\n' "$VERSION" "$destination" } main "$@" diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8e933536..b41b0226 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -593,6 +593,7 @@ export { export { runFeedbackCommand } from "./step/feedback/command.ts"; export { readFeedbackUsername } from "./step/feedback/context.ts"; export { STEP_INIT_PROMPT } from "./step/init-prompt.ts"; +export { prepareStepInstallation } from "./step/installation.ts"; export { maybeUpdateStep, runStepUpdateCommand } from "./step/local-update.ts"; export { isStepInteractiveLoginStartup, @@ -673,6 +674,7 @@ export { type StepSettingsManagerCreateOptions, type StepSettingsPaths, } from "./step/settings-manager.ts"; +export { ensureStepShellPath } from "./step/shell-path.ts"; export { flushStderrDevLog, installProcessStderrDevLogCapture, diff --git a/packages/coding-agent/src/step/installation.ts b/packages/coding-agent/src/step/installation.ts new file mode 100644 index 00000000..ed28c7fe --- /dev/null +++ b/packages/coding-agent/src/step/installation.ts @@ -0,0 +1,54 @@ +import { readGlobalStepConfig } from "./config-toml.ts"; +import { resolveStepMcpEnvironment } from "./mcp-environment.ts"; +import { + defaultStepMarketplacesDir, + defaultStepPluginsDir, + ensureBuiltinPluginsInstalled, + listStepPluginDirectories, + readStepPluginManifest, +} from "./plugins.ts"; +import { ensureStepShellPath } from "./shell-path.ts"; +import { ensureStepPageReady, isManagedStepPageCommand } from "./steppage-provision.ts"; + +/** Used by installers and the new binary after an update; failures remain warnings. */ +export async function prepareStepInstallation( + input: { env?: NodeJS.ProcessEnv; executablePath?: string } = {}, +): Promise { + const env = input.env ?? process.env; + const warnings = await ensureStepShellPath(input); + const pluginsDir = defaultStepPluginsDir(env); + const preinstalled = await ensureBuiltinPluginsInstalled({ + pluginsDir, + marketplacesDir: defaultStepMarketplacesDir(env), + }); + warnings.push(...preinstalled.warnings); + const globalServer = readGlobalStepConfig(env).mcp_servers?.steppage__steppage; + if (globalServer?.enabled === false) return warnings; + for (const directory of await listStepPluginDirectories(pluginsDir)) { + const { manifest } = await readStepPluginManifest(directory); + const declared = manifest?.mcpServers; + if (manifest?.id !== "steppage" || !declared || typeof declared === "string") continue; + const builtinServer = declared.steppage; + if (!builtinServer || typeof builtinServer !== "object" || Array.isArray(builtinServer)) continue; + const server = globalServer ?? builtinServer; + if (!server || typeof server !== "object" || Array.isArray(server)) continue; + const declaration = server as Record; + if ( + declaration.enabled === false || + !isManagedStepPageCommand(declaration, manifest.provision) || + !isManagedStepPageCommand(builtinServer as Record, manifest.provision) + ) + continue; + const declaredEnv = + declaration.env && typeof declaration.env === "object" && !Array.isArray(declaration.env) + ? Object.fromEntries( + Object.entries(declaration.env).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : undefined; + const result = await ensureStepPageReady({ env: { ...env, ...resolveStepMcpEnvironment(declaredEnv, { env }) } }); + if (result.error) warnings.push(result.error); + } + return warnings; +} diff --git a/packages/coding-agent/src/step/local-update.ts b/packages/coding-agent/src/step/local-update.ts index 7c4ba717..ac85d65f 100644 --- a/packages/coding-agent/src/step/local-update.ts +++ b/packages/coding-agent/src/step/local-update.ts @@ -127,6 +127,20 @@ export async function runStepUpdateCommand(input: StepUpdateCommandInput = {}): const smoke = await verifyBinary(stagedBinary, targetVersion); if (!smoke.ok) throw new Error(smoke.message); await replaceInstallation(installDir, stagedBinary, archiveRoot, binaryName, tempRoot); + // Run the installed version's preparation code, never the old updater's + // manifest assumptions. Optional resources must not roll back a good Step. + try { + const child = spawn(path.join(installDir, binaryName), ["mcp", "prepare"], { env, stdio: "inherit" }); + const code = await waitForChildProcess(child); + if (code !== 0) + process.stderr.write( + "Warning: StepPage is not ready; Step remains usable. Run step mcp prepare to retry.\n", + ); + } catch (error) { + process.stderr.write( + `Warning: StepPage preparation failed; Step remains usable. ${error instanceof Error ? error.message : String(error)}\n`, + ); + } process.stdout.write(`Updated Step from ${currentVersion} to ${targetVersion}.\n`); return 0; } catch (error) { diff --git a/packages/coding-agent/src/step/mcp-startup.test.ts b/packages/coding-agent/src/step/mcp-startup.test.ts index 9aca5993..71d127c1 100644 --- a/packages/coding-agent/src/step/mcp-startup.test.ts +++ b/packages/coding-agent/src/step/mcp-startup.test.ts @@ -1,14 +1,18 @@ -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; + import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; + import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { afterEach, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + import { createEventBus } from "../core/event-bus.ts"; import { createExtensionRuntime, loadExtensionFromFactory } from "../core/extensions/loader.ts"; import type { ExtensionMode } from "../core/extensions/types.ts"; import type { StepConfigDocument } from "./config-toml.ts"; + import { ambiguousPluginServerNames, bareServerName, @@ -20,21 +24,53 @@ import { resolveStepMcpServer, } from "./mcp.ts"; +import type { StepPageReadiness } from "./steppage-provision.ts"; + const config = vi.hoisted(() => ({ value: {} as StepConfigDocument })); +const builtin = vi.hoisted(() => ({ enabled: false, disabledUser: false })); +const preinstall = vi.hoisted(() => vi.fn<() => Promise<{ installed: []; warnings: string[] }>>()); +const readiness = vi.hoisted(() => vi.fn<(input: { signal?: AbortSignal }) => Promise>()); vi.mock("./config-toml.ts", () => ({ readGlobalStepConfig: () => config.value })); const pluginMocks = vi.hoisted(() => ({ dirs: [] as string[], manifests: new Map(), })); vi.mock("./plugins.ts", () => ({ - defaultStepPluginsDir: () => "/unused-test-plugins", - listStepPluginDirectories: async () => pluginMocks.dirs, - readStepPluginManifest: async (dir: string) => ({ manifest: pluginMocks.manifests.get(dir), errors: [] }), - ensureBuiltinPluginsInstalled: async () => ({ installed: [], warnings: [] }), + defaultStepPluginsDir: (_env: unknown, options?: { project?: boolean }) => + options?.project ? "/unused-project-plugins" : "/unused-test-plugins", + listStepPluginDirectories: async (root: string) => (builtin.enabled ? [`${root}/steppage`] : pluginMocks.dirs), + readStepPluginManifest: async (directory: string) => ({ + manifest: builtin.enabled + ? { + id: "steppage", + provision: { command: "steppage-mcp", installer: "steppageInstaller" }, + mcpServers: { + steppage: { + command: "steppage-mcp", + cwd: process.cwd(), + enabled: !(builtin.disabledUser && directory.startsWith("/unused-test-plugins")), + }, + }, + } + : pluginMocks.manifests.get(directory), + errors: [], + }), + ensureBuiltinPluginsInstalled: preinstall, provisionBuiltinPlugin: async () => undefined, })); +vi.mock("./steppage-provision.ts", async (importOriginal) => ({ + ...(await importOriginal()), + ensureStepPageReady: readiness, +})); vi.mock("./mcp-oauth.ts", () => ({ hasStoredMcpOAuthCredential: () => false })); const cleanups: Array<() => Promise> = []; +beforeEach(() => { + config.value = {}; + builtin.enabled = false; + builtin.disabledUser = false; + preinstall.mockReset().mockResolvedValue({ installed: [], warnings: [] }); + readiness.mockReset(); +}); afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); pluginMocks.dirs = []; @@ -521,3 +557,131 @@ test("a config.toml http_headers value is sent verbatim, not interpolated", asyn const found = discovered.find((server) => server.name === "literal"); expect(found?.declaration.http_headers?.Authorization).toBe(literal); }); + +test.skipIf(process.platform === "win32")( + "StepPage waits for preparation before spawning while peers remain usable", + async () => { + vi.stubEnv("STEPCODE_STEPPAGE_INSTALLER_URL", "https://example.invalid/setup.sh"); + vi.stubEnv("STEP_TEST_SETUP_SECRET", "setup-only"); + const root = await mkdtemp(join(tmpdir(), "step-mcp-preparation-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const command = join(root, "peer"); + const started = join(root, "started"); + await writeFile( + command, + `#!${process.execPath} +const fs = require("node:fs"); +fs.writeFileSync(${JSON.stringify(started)}, JSON.stringify({ + setupSecret: process.env.STEP_TEST_SETUP_SECRET ?? null, + installerUrl: process.env.STEPCODE_STEPPAGE_INSTALLER_URL ?? null, +})); +require("node:readline").createInterface({ input: process.stdin }).on("line", line => { + const message = JSON.parse(line); + if (message.id === undefined) return; + const result = message.method === "initialize" + ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } + : { tools: [{ name: "ready", inputSchema: { type: "object" } }] }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\\n"); +}); +`, + ); + await chmod(command, 0o755); + let release = (_result: StepPageReadiness) => {}; + readiness.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + builtin.enabled = true; + const peer = await slowServer(); + config.value = { mcp_servers: { peer: { url: peer.url } } }; + const harness = await setup("tui"); + await harness.start(); + await vi.waitFor(() => expect(readiness).toHaveBeenCalledOnce()); + expect(readiness).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + STEPCODE_STEPPAGE_INSTALLER_URL: "https://example.invalid/setup.sh", + STEP_TEST_SETUP_SECRET: "setup-only", + }), + }), + ); + peer.release(); + await vi.waitFor(() => expect(harness.extension.tools.size).toBe(1)); + await expect(readFile(started)).rejects.toMatchObject({ code: "ENOENT" }); + expect(getStepMcpStatuses()).toContainEqual({ name: "steppage__steppage", status: "connecting", toolCount: 0 }); + release({ command, installed: true }); + await vi.waitFor(() => expect(harness.extension.tools.size).toBe(2)); + expect(JSON.parse(await readFile(started, "utf8"))).toEqual({ setupSecret: null, installerUrl: null }); + expect(getStepMcpStatuses()).toContainEqual({ name: "steppage__steppage", status: "connected", toolCount: 1 }); + expect(harness.notify).not.toHaveBeenCalled(); + }, +); + +test("StepPage preparation failure reports the remedy before spawning a missing command", async () => { + builtin.enabled = true; + readiness.mockResolvedValue({ + installed: false, + error: "StepPage is not ready: Node.js >= 20 is required. Run step mcp prepare.", + }); + const harness = await setup("print"); + await harness.start(); + expect(getStepMcpStatuses()[0]?.status).toBe("failed"); + expect(harness.notify).toHaveBeenCalledWith(expect.stringContaining("Node.js >= 20"), "warning"); +}); + +test("an explicit global disable suppresses the builtin server", async () => { + builtin.enabled = true; + config.value = { mcp_servers: { steppage__steppage: { enabled: false } } }; + expect(await discoverStepMcpServers(process.cwd(), false)).toEqual([]); + expect(readiness).not.toHaveBeenCalled(); +}); + +test("a custom global StepPage server bypasses automatic preparation", async () => { + builtin.enabled = true; + const peer = await slowServer(); + config.value = { mcp_servers: { steppage__steppage: { url: peer.url } } }; + const harness = await setup("tui"); + await harness.start(); + peer.release(); + await vi.waitFor(() => expect(harness.extension.tools.size).toBe(1)); + expect(readiness).not.toHaveBeenCalled(); +}); + +test("a user-level disable cannot be revived by a trusted project's plugin copy", async () => { + builtin.enabled = true; + builtin.disabledUser = true; + expect(await discoverStepMcpServers(process.cwd(), true)).toEqual([]); +}); + +test("a global declaration of the default command retains builtin preparation metadata", async () => { + builtin.enabled = true; + config.value = { mcp_servers: { steppage__steppage: { command: "steppage-mcp", env: { CUSTOM_VALUE: "keep" } } } }; + const servers = await discoverStepMcpServers(process.cwd(), false); + expect(servers).toHaveLength(1); + expect(servers[0].provision).toEqual({ command: "steppage-mcp", installer: "steppageInstaller" }); + expect(servers[0].declaration.env).toEqual({ CUSTOM_VALUE: "keep" }); + readiness.mockResolvedValue({ installed: false, error: "StepPage is not ready: test prerequisite" }); + const harness = await setup("print"); + await harness.start(); + expect(readiness).toHaveBeenCalledOnce(); + expect(harness.notify).toHaveBeenCalledWith(expect.stringContaining("test prerequisite"), "warning"); +}); + +test("shutdown suppresses late warnings from manifest preinstallation", async () => { + let release = (_result: { installed: []; warnings: string[] }) => {}; + preinstall.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const harness = await setup("tui"); + await harness.start(); + await vi.waitFor(() => expect(preinstall).toHaveBeenCalledOnce()); + const shutdown = harness.stop(); + release({ installed: [], warnings: ["late manifest warning"] }); + await shutdown; + expect(harness.notify).not.toHaveBeenCalled(); +}); diff --git a/packages/coding-agent/src/step/mcp.ts b/packages/coding-agent/src/step/mcp.ts index db6b9854..055d0d3e 100644 --- a/packages/coding-agent/src/step/mcp.ts +++ b/packages/coding-agent/src/step/mcp.ts @@ -18,11 +18,11 @@ import { defaultStepPluginsDir, ensureBuiltinPluginsInstalled, listStepPluginDirectories, - provisionBuiltinPlugin, provisionInstallCommand, readStepPluginManifest, type StepPluginProvision, } from "./plugins.ts"; +import { ensureStepPageReady, isManagedStepPageCommand } from "./steppage-provision.ts"; import { STEPCODE_VERSION } from "./version.ts"; export { resolveStepMcpEnvironment } from "./mcp-environment.ts"; @@ -121,17 +121,12 @@ export function createStepMcpExtension(): ExtensionFactory { // entire initialization path behind the slowest server. await yieldToEventLoop(); if (controller.signal.aborted) return; - // Provision the built-in StepPage plugin into a fresh install so its - // deploy tools appear without an explicit `/plugin install`. Copying the - // manifest is fast and blocks discovery so this session sees it; - // installing the MCP executable is fired in the background so a first - // launch never waits on the network. A still-missing executable then - // surfaces the normal actionable connect-failure remedy below. + // The old marker records manifest/uninstall intent only. Executable + // readiness is checked for each installed built-in before connecting. try { const preinstalled = await ensureBuiltinPluginsInstalled(); - for (const plugin of preinstalled.installed) { - if (plugin.provision) void provisionBuiltinPlugin(plugin.provision).catch(() => undefined); - } + if (controller.signal.aborted) return; + for (const warning of preinstalled.warnings) ctx.ui.notify(warning, "warning"); } catch { // Best-effort: discovery still runs with whatever is already installed. } @@ -338,15 +333,22 @@ export async function discoverStepMcpServers(cwd: string, projectTrusted: boolea const roots = [defaultStepPluginsDir(process.env)]; if (projectTrusted) roots.push(defaultStepPluginsDir(process.env, { cwd, project: true })); const result: DiscoveredServer[] = []; + const globalServers = new Map(); const seen = new Set(); const config = readGlobalStepConfig(process.env); for (const [name, declaration] of Object.entries(config.mcp_servers ?? {})) { - if (!isRecord(declaration) || declaration.enabled === false) continue; + if (!isRecord(declaration)) continue; + if (declaration.enabled === false) { + seen.add(name); + continue; + } if (typeof declaration.command !== "string" && typeof declaration.url !== "string") continue; const normalized = normalizeDeclaration(declaration); if (typeof normalized.command !== "string" && typeof normalized.url !== "string") continue; seen.add(name); - result.push({ name, declaration: normalized }); + const server = { name, declaration: normalized }; + result.push(server); + globalServers.set(name, server); } for (const root of roots) { for (const pluginDir of await listStepPluginDirectories(root)) { @@ -359,9 +361,24 @@ export async function discoverStepMcpServers(cwd: string, projectTrusted: boolea // `command` here dropped remote servers silently, even though the // global-config path and `normalizeDeclaration` both accept a url — // so the same server worked from config.toml but not from a plugin. - if (!isRecord(value) || !hasTransport(value)) continue; + if (!isRecord(value)) continue; const name = `${parsed.manifest.id}${PLUGIN_SERVER_SEPARATOR}${serverName}`; - if (seen.has(name)) continue; + if (seen.has(name)) { + const global = globalServers.get(name); + if ( + name === STEPPAGE_SERVER_NAME && + global && + isManagedStepPageCommand(global.declaration, parsed.manifest.provision) && + isManagedStepPageCommand(value, parsed.manifest.provision) + ) + global.provision = parsed.manifest.provision; + continue; + } + if (value.enabled === false) { + seen.add(name); + continue; + } + if (!hasTransport(value)) continue; seen.add(name); const discovered: DiscoveredServer = { name, @@ -541,8 +558,16 @@ export async function connectStepMcpServer( let transport: StdioClientTransport | StreamableHTTPClientTransport; if (input.declaration.command) { const env = resolveStepMcpEnvironment(input.declaration.env); + let command = input.declaration.command; + if (input.name === STEPPAGE_SERVER_NAME && isManagedStepPageCommand(input.declaration, input.provision)) { + // The trusted installer needs host setup variables (proxy/installer URL). + // The MCP transport still receives only the restricted server environment. + const readiness = await ensureStepPageReady({ env: { ...process.env, ...env }, signal: abortSignal }); + if (readiness.error) throw new Error(readiness.error); + command = readiness.command ?? command; + } transport = new StdioClientTransport({ - command: input.declaration.command, + command, args: input.declaration.args, cwd: input.declaration.cwd, env, diff --git a/packages/coding-agent/src/step/plugins.ts b/packages/coding-agent/src/step/plugins.ts index 2343d40c..d36aec2e 100644 --- a/packages/coding-agent/src/step/plugins.ts +++ b/packages/coding-agent/src/step/plugins.ts @@ -19,6 +19,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionFactory } from ".. import { resolveStepConfigDir } from "./environment.ts"; import { resolveStepMcpEnvironment, STEP_LOGIN_SUPPLIED_ENV } from "./mcp-environment.ts"; import { isPathContained } from "./path-containment.ts"; +import { ensureStepPageReady, stepPageInstallerUrl } from "./steppage-provision.ts"; import { resolveStepStorageRoot } from "./storage-root.ts"; import { type StepTelemetryReporter, trackStepTelemetry } from "./telemetry.ts"; @@ -33,7 +34,6 @@ export const MARKETPLACE_MANIFEST_CANDIDATES: readonly string[] = [ ]; export const MARKETPLACE_MANIFEST_RELATIVE_PATH = MARKETPLACE_MANIFEST_CANDIDATES[0]!; export const BUILTIN_MARKETPLACE_NAME = "builtin"; -const STEPPAGE_INSTALLER_URL = "https://dl.stepfun.com/steppage-mcp/p/install.sh"; const BUILTIN_FINGERPRINT_FILE = ".stepcode-builtin-fingerprint"; /** Records which built-in plugins have already been auto-installed, so a plugin @@ -566,7 +566,7 @@ export async function installMarketplacePlugin( /** Resolve the StepPage installer URL, honouring the shell override. */ export function resolveProvisionInstallerUrl(env: NodeJS.ProcessEnv = process.env): string { - return env.STEPCODE_STEPPAGE_INSTALLER_URL?.trim() || STEPPAGE_INSTALLER_URL; + return stepPageInstallerUrl(env); } /** The shell command that installs a provisionable plugin executable, when one is declared. */ @@ -585,22 +585,10 @@ export async function provisionBuiltinPlugin(provision: StepPluginProvision): Pr } const install = provisionInstallCommand(provision); if (!install) return undefined; - try { - await execFileAsync("sh", ["-c", `command -v ${shellQuote(provision.command)}`], { timeout: 10_000 }); - return undefined; - } catch { - // Expected when a plugin is first installed. - } - try { - await execFileAsync("sh", ["-c", install], { - env: process.env, - timeout: 120_000, - maxBuffer: 1_000_000, - }); - return `Installed ${provision.command} from the StepPage installer.`; - } catch (error) { - return `Could not install ${provision.command} automatically: ${describe(error)}. Run the StepPage installer manually: ${install}`; - } + const result = await ensureStepPageReady(); + return ( + result.error ?? (result.installed ? `Installed ${provision.command} from the StepPage installer.` : undefined) + ); } function shellQuote(value: string): string { @@ -781,9 +769,16 @@ export async function listInstalledStepPlugins( ): Promise<{ plugins: InstalledStepPlugin[]; warnings: string[] }> { const warnings: string[] = []; const byId = new Map(); + const userDir = input.userDir ?? defaultStepPluginsDir(); + const [userRoot, projectRoot] = await Promise.all([ + fs.realpath(userDir).catch(() => path.resolve(userDir)), + input.projectDir ? fs.realpath(input.projectDir).catch(() => path.resolve(input.projectDir!)) : undefined, + ]); + // Starting in HOME (or a symlink to it) makes both scopes point at the same + // installation. Keep its user scope instead of reporting a false override. const roots: Array<{ path: string; source: "user" | "project" }> = [ - ...(input.projectDir ? [{ path: input.projectDir, source: "project" as const }] : []), - { path: input.userDir ?? defaultStepPluginsDir(), source: "user" }, + ...(input.projectDir && projectRoot !== userRoot ? [{ path: input.projectDir, source: "project" as const }] : []), + { path: userDir, source: "user" }, ]; for (const root of roots) { for (const pluginDir of await listStepPluginDirectories(root.path)) { @@ -877,8 +872,8 @@ export interface EnsureBuiltinPluginsResult { * reinstalled on the next launch. * * Only the declarative manifest is copied here (the executable is not - * provisioned) so startup stays fast; each installed plugin's `provision` - * descriptor is returned for the caller to install its executable out of band. + * provisioned) so startup stays fast. Executable readiness is checked separately + * before connecting, including manifests already installed by older versions. */ export async function ensureBuiltinPluginsInstalled( input: { pluginsDir?: string; marketplacesDir?: string } = {}, diff --git a/packages/coding-agent/src/step/shell-path.ts b/packages/coding-agent/src/step/shell-path.ts new file mode 100644 index 00000000..b5998749 --- /dev/null +++ b/packages/coding-agent/src/step/shell-path.ts @@ -0,0 +1,75 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; +import { resolveStepAgentDir, resolveStepHomeDir } from "./environment.ts"; +import { resolveStepExecutablePath } from "./local-update.ts"; + +/** Idempotent migration for standalone users whose old updater bypassed install.sh. */ +export async function ensureStepShellPath( + input: { env?: NodeJS.ProcessEnv; executablePath?: string; platform?: NodeJS.Platform } = {}, +): Promise { + const env = input.env ?? process.env; + const platform = input.platform ?? process.platform; + const executable = input.executablePath ?? resolveStepExecutablePath(env); + if (!executable || platform === "win32" || env.STEP_NO_MODIFY_PATH) return []; + const installDir = dirname(executable); + const managedBin = join(resolveStepAgentDir(env), "bin"); + const home = resolveStepHomeDir(env); + const shell = basename(env.SHELL ?? ""); + if ([installDir, managedBin].some((value) => /["`$\\\r\n]/u.test(value))) + return ["Could not persist Step PATH safely; add the installation directory to your shell profile manually."]; + if (shell === "csh" || shell === "tcsh") + return [`Add ${installDir} and ${managedBin} to your ${shell} PATH manually.`]; + const profiles = + shell === "zsh" + ? [join(env.ZDOTDIR || home, ".zshrc")] + : shell === "fish" + ? [join(env.XDG_CONFIG_HOME || join(home, ".config"), "fish", "config.fish")] + : shell === "bash" + ? [join(home, ".bashrc"), ...(platform === "darwin" ? [join(home, ".bash_profile")] : [])] + : [join(home, ".profile")]; + const body = + shell === "fish" + ? `fish_add_path "${installDir}" "${managedBin}"\n` + : [installDir, managedBin] + .map( + (directory) => + `case ":$PATH:" in\n *":${directory}:"*) ;;\n *) export PATH="${directory}:$PATH" ;;\nesac\n`, + ) + .join(""); + const block = `# stepcode\n${body}# stepcode end\n`; + const warnings: string[] = []; + for (const profile of profiles) { + let release: (() => Promise) | undefined; + try { + await mkdir(dirname(profile), { recursive: true }); + release = await lockfile.lock(profile, { realpath: false }); + let previous = ""; + try { + previous = await readFile(profile, "utf8"); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; + } + if (previous.includes(block)) continue; + let inBlock = false; + for (const marker of previous.match(/^# stepcode(?: end)?\r?$/gmu) ?? []) { + const opening = marker.trimEnd() === "# stepcode"; + if (opening === inBlock) throw new Error("an incomplete or nested Step PATH block was left unchanged"); + inBlock = opening; + } + if (inBlock) throw new Error("an incomplete Step PATH block was left unchanged"); + const preserved = previous.replace(/^# stepcode\r?\n[\s\S]*?^# stepcode end\r?\n?/gmu, ""); + await writeFile(profile, `${preserved}${preserved.endsWith("\n") || !preserved ? "" : "\n"}\n${block}`, { + encoding: "utf8", + mode: 0o600, + }); + } catch (error) { + warnings.push( + `Could not persist Step PATH in ${profile}: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + await release?.().catch(() => undefined); + } + } + return warnings; +} diff --git a/packages/coding-agent/src/step/steppage-provision.ts b/packages/coding-agent/src/step/steppage-provision.ts new file mode 100644 index 00000000..ed27b015 --- /dev/null +++ b/packages/coding-agent/src/step/steppage-provision.ts @@ -0,0 +1,170 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import lockfile from "proper-lockfile"; +import { resolveStepHomeDir } from "./environment.ts"; +import type { StepPluginProvision } from "./plugins.ts"; + +const execFileAsync = promisify(execFile); +export const DEFAULT_STEPPAGE_INSTALLER_URL = "https://dl.stepfun.com/steppage-mcp/p/install.sh"; + +export interface StepPageReadiness { + command?: string; + error?: string; + installed: boolean; +} + +export function stepPageInstallerUrl(env: NodeJS.ProcessEnv = process.env): string { + return env.STEPCODE_STEPPAGE_INSTALLER_URL?.trim() || DEFAULT_STEPPAGE_INSTALLER_URL; +} + +/** Default executable ownership may survive in an old global declaration. */ +export function isManagedStepPageCommand( + declaration: { command?: unknown; args?: unknown }, + provision?: StepPluginProvision, +): boolean { + return ( + typeof declaration.command === "string" && + declaration.command.trim() === "steppage-mcp" && + (!Array.isArray(declaration.args) || declaration.args.length === 0) && + provision?.command === "steppage-mcp" && + provision.installer === "steppageInstaller" + ); +} + +/** Keep the lock until the installer and its children stop writing files. */ +async function runInstaller(script: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise { + signal.throwIfAborted(); + await new Promise((resolve, reject) => { + const child = spawn("sh", ["-c", script], { env, detached: true, stdio: ["ignore", "pipe", "pipe"] }); + let failure: Error | undefined; + let stderr = ""; + const stop = (error: Error) => { + failure ??= error; + if (child.pid) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + } + }; + const abort = () => stop(new Error("StepPage installation was cancelled.")); + const timer = setTimeout(() => stop(new Error("StepPage installation timed out.")), 120_000); + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + }; + child.stdout.resume(); + child.stderr.on("data", (chunk: Buffer) => { + stderr = `${stderr}${chunk.toString()}`.slice(-8_192); + }); + child.on("error", (error) => { + cleanup(); + reject(error); + }); + child.on("close", (code) => { + cleanup(); + if (failure) reject(failure); + else if (code !== 0) reject(new Error(`StepPage installer exited with ${code}: ${stderr.trim()}`)); + else resolve(); + }); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); +} + +/** Only probes executables; never starts an MCP session or requires login. */ +async function findHealthyStepPage(env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { + const managed = join(resolveStepHomeDir(env), ".local", "bin", "steppage-mcp"); + for (const command of ["steppage-mcp", managed]) { + signal?.throwIfAborted(); + try { + const result = await execFileAsync(command, ["--version"], { env, signal, timeout: 5_000, maxBuffer: 8_192 }); + if (/^v?\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(result.stdout.trim())) return command; + } catch { + signal?.throwIfAborted(); + } + } + return undefined; +} + +/** Repair the managed executable without changing plugin manifests or credentials. */ +export async function ensureStepPageReady( + input: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; platform?: NodeJS.Platform } = {}, +): Promise { + const env = input.env ?? process.env; + const compromised = new AbortController(); + const signal = AbortSignal.any([compromised.signal, ...(input.signal ? [input.signal] : [])]); + signal?.throwIfAborted(); + const healthy = await findHealthyStepPage(env, signal); + if (healthy) return { command: healthy, installed: false }; + if ((input.platform ?? process.platform) === "win32") { + return { + installed: false, + error: "StepPage is not ready: automatic installation is currently supported on macOS and Linux. Step remains usable.", + }; + } + let release: (() => Promise) | undefined; + try { + const directory = join(resolveStepHomeDir(env), ".steppage-mcp"); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const deadline = Date.now() + 130_000; + while (!release) { + signal?.throwIfAborted(); + try { + release = await lockfile.lock(join(directory, ".stepcode-provision"), { + realpath: false, + stale: 150_000, + update: 10_000, + onCompromised: (error) => compromised.abort(error), + }); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ELOCKED") || Date.now() >= deadline) + throw error; + await delay(100, undefined, { signal }); + } + } + // Another Step process may have completed installation while we waited. + const available = await findHealthyStepPage(env, signal); + if (available) return { command: available, installed: false }; + let nodeVersion: string; + try { + nodeVersion = (await execFileAsync("node", ["--version"], { env, signal, timeout: 5_000 })).stdout.trim(); + } catch { + signal?.throwIfAborted(); + throw new Error("Node.js >= 20 is required for StepPage; install Node.js and retry."); + } + const major = /^v(\d+)\./u.exec(nodeVersion)?.[1]; + if (!major || Number(major) < 20) + throw new Error(`Node.js >= 20 is required for StepPage; found ${nodeVersion}.`); + // Download before executing so a failed curl cannot be hidden by `| sh`. + const script = await execFileAsync("curl", ["-fsSL", "--max-time", "30", stepPageInstallerUrl(env)], { + env, + signal, + timeout: 35_000, + maxBuffer: 1_000_000, + }); + if (!script.stdout.trim()) throw new Error("The StepPage installer download was empty."); + await runInstaller(script.stdout, env, signal); + const command = await findHealthyStepPage(env, signal); + if (!command) throw new Error("The StepPage installer completed, but steppage-mcp failed its version check."); + return { command, installed: true }; + } catch (error) { + input.signal?.throwIfAborted(); + let detail = error instanceof Error ? error.message : String(error); + for (const [key, value] of Object.entries(env)) { + if (value && value.length >= 4 && /key|token|secret|password|authorization/iu.test(key)) + detail = detail.replaceAll(value, "[redacted]"); + } + const installer = `curl -fsSL '${stepPageInstallerUrl(env).replace(/'/gu, "'\\''")}' | sh`; + return { + installed: false, + error: `StepPage is not ready: ${detail.slice(-2_000)} Step remains usable; StepPage setup will retry on the next launch. Install it with: ${installer}, then restart Step. You can also run: step mcp prepare, then restart Step.`, + }; + } finally { + await release?.().catch(() => undefined); + } +} diff --git a/packages/coding-agent/test/step-installation.test.ts b/packages/coding-agent/test/step-installation.test.ts new file mode 100644 index 00000000..ddeff444 --- /dev/null +++ b/packages/coding-agent/test/step-installation.test.ts @@ -0,0 +1,248 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, test, vi } from "vitest"; +import { prepareStepInstallation } from "../src/step/installation.ts"; +import { uninstallPlugin } from "../src/step/plugins.ts"; +import { ensureStepPageReady } from "../src/step/steppage-provision.ts"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function executable(file: string, source: string) { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, source); + await chmod(file, 0o755); +} + +const installerSource = `#!/bin/sh +set -eu +echo attempt >> "$HOME/installation-attempts" +mkdir -p "$HOME/.local/bin" +printf '#!/bin/sh\\nprintf "1.0.0\\\\n"\\n' > "$HOME/.local/bin/steppage-mcp" +chmod +x "$HOME/.local/bin/steppage-mcp" +`; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "step-installation-")); + roots.push(root); + const bin = join(root, "fixture-bin"); + await executable(join(bin, "node"), '#!/bin/sh\nprintf "v24.0.0\\n"\n'); + await executable( + join(bin, "curl"), + '#!/bin/sh\ncase "$*" in *file://*) exec /usr/bin/curl "$@" ;; *) echo "test refuses network downloads" >&2; exit 1 ;; esac\n', + ); + const installer = join(root, "installer.sh"); + await writeFile(installer, installerSource); + const env: NodeJS.ProcessEnv = { + HOME: root, + PATH: `${bin}:/usr/bin:/bin`, + SHELL: "/bin/zsh", + STEPFUN_API_KEY: "fixture-credential", + STEP_CODING_AGENT_DIR: join(root, ".stepcode", "agent"), + STEPCODE_STEPPAGE_INSTALLER_URL: pathToFileURL(installer).href, + }; + return { root, bin, installer, env, input: { env, executablePath: join(root, "step-bin", "step") } }; +} + +test.skipIf(process.platform === "win32")( + "fresh installation waits for a verified executable and preserves credentials", + async () => { + const f = await fixture(); + await mkdir(join(f.root, ".stepcode")); + await writeFile(join(f.root, ".stepcode", "auth.json"), '{"existing":"preserve"}'); + expect(await prepareStepInstallation(f.input)).toEqual([]); + expect(await readFile(join(f.root, "installation-attempts"), "utf8")).toBe("attempt\n"); + expect(await readFile(join(f.root, ".stepcode", "auth.json"), "utf8")).toBe('{"existing":"preserve"}'); + expect(await ensureStepPageReady({ env: f.env })).toMatchObject({ + command: join(f.root, ".local", "bin", "steppage-mcp"), + installed: false, + }); + }, +); + +test.skipIf(process.platform === "win32")("legacy markers do not prevent repair of a broken executable", async () => { + const f = await fixture(); + expect(await prepareStepInstallation(f.input)).toEqual([]); + await executable( + join(f.root, ".local", "bin", "steppage-mcp"), + '#!/bin/sh\necho "Cannot find module" >&2\nexit 1\n', + ); + expect(await prepareStepInstallation(f.input)).toEqual([]); + expect(await readFile(join(f.root, "installation-attempts"), "utf8")).toBe("attempt\nattempt\n"); + expect(await readFile(join(f.root, ".stepcode", "plugins", ".stepcode-preinstalled"), "utf8")).toBe( + '["steppage"]\n', + ); +}); + +test.skipIf(process.platform === "win32")( + "failed setup is a warning and retries despite an existing marker", + async () => { + const f = await fixture(); + await writeFile(f.installer, '#!/bin/sh\necho "download failed" >&2\nexit 1\n'); + const warnings = await prepareStepInstallation(f.input); + expect(warnings.join(" ")).toContain("StepPage is not ready"); + expect(warnings.join(" ")).toContain("Step remains usable"); + expect(warnings.join(" ")).toContain( + `Install it with: curl -fsSL '${f.env.STEPCODE_STEPPAGE_INSTALLER_URL}' | sh, then restart Step.`, + ); + expect(warnings.join(" ")).toContain("step mcp prepare, then restart Step"); + await writeFile(f.installer, installerSource); + expect(await prepareStepInstallation(f.input)).toEqual([]); + expect(await readFile(join(f.root, "installation-attempts"), "utf8")).toBe("attempt\n"); + }, +); + +test.skipIf(process.platform === "win32")( + "a failed installer download cannot be mistaken for successful setup", + async () => { + const f = await fixture(); + f.env.STEPCODE_STEPPAGE_INSTALLER_URL = pathToFileURL(join(f.root, "missing.sh")).href; + expect((await ensureStepPageReady({ env: f.env })).error).toContain("StepPage is not ready"); + await expect(readFile(join(f.root, "installation-attempts"))).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +test.skipIf(process.platform === "win32")( + "healthy managed executables work outside PATH without downloading again", + async () => { + const f = await fixture(); + const command = join(f.root, ".local", "bin", "steppage-mcp"); + await executable(command, '#!/bin/sh\nprintf "1.0.0\\n"\n'); + f.env.STEPCODE_STEPPAGE_INSTALLER_URL = "file:///missing-installer.sh"; + expect(await ensureStepPageReady({ env: f.env })).toEqual({ command, installed: false }); + }, +); + +test.skipIf(process.platform === "win32")( + "concurrent repairs download once and reuse the completed executable", + async () => { + const f = await fixture(); + await writeFile(f.installer, installerSource.replace("set -eu", "set -eu\nsleep 0.2")); + const results = await Promise.all([ensureStepPageReady({ env: f.env }), ensureStepPageReady({ env: f.env })]); + expect(results.every((result) => result.command && !result.error)).toBe(true); + expect(results.filter((result) => result.installed)).toHaveLength(1); + expect(await readFile(join(f.root, "installation-attempts"), "utf8")).toBe("attempt\n"); + }, +); + +test.skipIf(process.platform === "win32")("missing and old Node versions leave setup retryable", async () => { + const f = await fixture(); + await rm(join(f.bin, "node")); + f.env.PATH = f.bin; + expect((await ensureStepPageReady({ env: f.env })).error).toContain("Node.js >= 20"); + await executable(join(f.bin, "node"), '#!/bin/sh\nprintf "v18.0.0\\n"\n'); + expect((await ensureStepPageReady({ env: f.env })).error).toContain("found v18.0.0"); + await expect(readFile(join(f.root, "installation-attempts"))).rejects.toMatchObject({ code: "ENOENT" }); +}); + +test.skipIf(process.platform === "win32")( + "explicit uninstall stays uninstalled across setup and upgrade preparation", + async () => { + const f = await fixture(); + expect(await prepareStepInstallation(f.input)).toEqual([]); + await uninstallPlugin(join(f.root, ".stepcode", "plugins"), "steppage"); + await rm(join(f.root, ".local", "bin", "steppage-mcp")); + expect(await prepareStepInstallation(f.input)).toEqual([]); + await expect( + readFile(join(f.root, ".stepcode", "plugins", "steppage", "step.plugin.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(f.root, "installation-attempts"), "utf8")).toBe("attempt\n"); + }, +); + +test.skipIf(process.platform === "win32")( + "customized plugin commands are preserved and never provisioned", + async () => { + const f = await fixture(); + expect(await prepareStepInstallation(f.input)).toEqual([]); + const path = join(f.root, ".stepcode", "plugins", "steppage", "step.plugin.json"); + const manifest = JSON.parse(await readFile(path, "utf8")); + manifest.mcpServers.steppage.command = "/custom/steppage"; + const custom = JSON.stringify(manifest); + await writeFile(path, custom); + await rm(join(f.root, ".local", "bin", "steppage-mcp")); + expect(await prepareStepInstallation(f.input)).toEqual([]); + expect(await readFile(path, "utf8")).toBe(custom); + expect(await readFile(join(f.root, "installation-attempts"), "utf8")).toBe("attempt\n"); + }, +); + +test.skipIf(process.platform === "win32")( + "explicit global disabling prevents automatic executable installation", + async () => { + const f = await fixture(); + await mkdir(join(f.root, ".stepcode")); + await writeFile(join(f.root, ".stepcode", "config.toml"), "[mcp_servers.steppage__steppage]\nenabled = false\n"); + expect(await prepareStepInstallation(f.input)).toEqual([]); + await expect(readFile(join(f.root, "installation-attempts"))).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +test("an already cancelled readiness check never starts installation", async () => { + const f = await fixture(); + const controller = new AbortController(); + controller.abort(new Error("cancelled session")); + await expect(ensureStepPageReady({ env: f.env, signal: controller.signal })).rejects.toThrow("cancelled session"); +}); + +test.skipIf(process.platform === "win32")( + "legacy global default commands are repaired without rewriting their configuration", + async () => { + const f = await fixture(); + await mkdir(join(f.root, ".stepcode")); + const configPath = join(f.root, ".stepcode", "config.toml"); + const config = '[mcp_servers.steppage__steppage]\ncommand = "steppage-mcp"\nstartup_timeout_sec = 30\n'; + await writeFile(configPath, config); + expect(await prepareStepInstallation(f.input)).toEqual([]); + expect(await readFile(join(f.root, "installation-attempts"), "utf8")).toBe("attempt\n"); + expect(await readFile(configPath, "utf8")).toBe(config); + }, +); + +test + .skipIf(process.platform === "win32") + .each([ + 'command = "/custom/steppage-mcp"\n', + 'command = "steppage-mcp"\nargs = ["--custom"]\n', + 'url = "https://example.test/mcp"\n', + ])("custom global declarations stay self-managed: %s", async (declaration) => { + const f = await fixture(); + await mkdir(join(f.root, ".stepcode")); + const configPath = join(f.root, ".stepcode", "config.toml"); + const config = `[mcp_servers.steppage__steppage]\n${declaration}`; + await writeFile(configPath, config); + expect(await prepareStepInstallation(f.input)).toEqual([]); + await expect(readFile(join(f.root, "installation-attempts"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(configPath, "utf8")).toBe(config); +}); + +test.skipIf(process.platform === "win32")( + "cancelling setup stops installer children and leaves the lock reusable", + async () => { + const f = await fixture(); + await writeFile( + f.installer, + `#!/bin/sh +(sleep 0.3; echo late > "$HOME/late-write") & +echo started > "$HOME/started" +wait +`, + ); + const controller = new AbortController(); + const readiness = ensureStepPageReady({ env: f.env, signal: controller.signal }); + // Register rejection handling before cancellation. + const rejected = expect(readiness).rejects.toThrow("cancelled session"); + await vi.waitFor(async () => expect(await readFile(join(f.root, "started"), "utf8")).toBe("started\n")); + controller.abort(new Error("cancelled session")); + await rejected; + await delay(400); + await expect(readFile(join(f.root, "late-write"))).rejects.toMatchObject({ code: "ENOENT" }); + await writeFile(f.installer, installerSource); + expect((await ensureStepPageReady({ env: f.env })).installed).toBe(true); + }, +); diff --git a/packages/coding-agent/test/step-plugins.test.ts b/packages/coding-agent/test/step-plugins.test.ts index 1f20d411..8d244d60 100644 --- a/packages/coding-agent/test/step-plugins.test.ts +++ b/packages/coding-agent/test/step-plugins.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -497,6 +497,32 @@ describe("Step plugin marketplace facade", () => { expect(listed.warnings.join(" ")).toContain("project plugin has precedence"); }); + test("lists a shared user and project plugin root once as user plugins", async () => { + const root = await mkdtemp(join(tmpdir(), "step-plugins-shared-root-")); + roots.push(root); + const userDir = join(root, ".stepcode", "plugins"); + await writePlugin(userDir, "steppage", "steppage", "StepPage"); + + const listed = await listInstalledStepPlugins({ userDir, projectDir: userDir }); + expect(listed.plugins).toHaveLength(1); + expect(listed.plugins[0]).toMatchObject({ id: "steppage", source: "user" }); + expect(listed.warnings).toEqual([]); + }); + + test("recognizes a symlink to the user plugin root as the same installation", async () => { + const root = await mkdtemp(join(tmpdir(), "step-plugins-aliased-root-")); + roots.push(root); + const userDir = join(root, "user"); + const projectDir = join(root, "project-alias"); + await writePlugin(userDir, "steppage", "steppage", "StepPage"); + await symlink(userDir, projectDir, process.platform === "win32" ? "junction" : "dir"); + + const listed = await listInstalledStepPlugins({ userDir, projectDir }); + expect(listed.plugins).toHaveLength(1); + expect(listed.plugins[0]).toMatchObject({ id: "steppage", source: "user", rootPath: join(userDir, "steppage") }); + expect(listed.warnings).toEqual([]); + }); + test("accepts a local file URL and labels it as a local marketplace", async () => { const root = await mkdtemp(join(tmpdir(), "step-plugins-local-")); roots.push(root); diff --git a/packages/coding-agent/test/step-shell-path.test.ts b/packages/coding-agent/test/step-shell-path.test.ts new file mode 100644 index 00000000..ace3e02c --- /dev/null +++ b/packages/coding-agent/test/step-shell-path.test.ts @@ -0,0 +1,82 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; +import { ensureStepShellPath } from "../src/step/shell-path.ts"; + +const execFileAsync = promisify(execFile); +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "step-shell-path-")); + roots.push(root); + const bin = join(root, "custom bin"); + const env = { HOME: root, SHELL: "/bin/zsh", PATH: `${bin}:/usr/bin:/bin` }; + return { root, bin, env, input: { env, executablePath: join(bin, "step"), platform: "darwin" as const } }; +} + +test.skipIf(process.platform === "win32")( + "persists an inherited PATH for fresh shells and stays idempotent", + async () => { + const f = await fixture(); + const profile = join(f.root, ".zshrc"); + await writeFile(profile, "export UNRELATED=preserve\n"); + expect(await ensureStepShellPath(f.input)).toEqual([]); + const first = await readFile(profile, "utf8"); + expect(await ensureStepShellPath(f.input)).toEqual([]); + expect(await readFile(profile, "utf8")).toBe(first); + expect(first).toContain("export UNRELATED=preserve"); + const result = await execFileAsync("/bin/sh", ["-c", '. "$1"; . "$1"; printf "%s" "$PATH"', "test", profile], { + env: { HOME: f.root, PATH: "/usr/bin:/bin" }, + }); + expect(result.stdout.split(":").filter((entry) => entry === f.bin)).toHaveLength(1); + }, +); + +test("respects explicit PATH opt-out", async () => { + const f = await fixture(); + expect(await ensureStepShellPath({ ...f.input, env: { ...f.env, STEP_NO_MODIFY_PATH: "1" } })).toEqual([]); + await expect(readFile(join(f.root, ".zshrc"))).rejects.toMatchObject({ code: "ENOENT" }); +}); + +test("updates only managed blocks when moving an existing installation", async () => { + const f = await fixture(); + const profile = join(f.root, ".zshrc"); + await writeFile(profile, "keep-before\n# stepcode\nexport PATH=old\n# stepcode end\nkeep-after\n"); + expect(await ensureStepShellPath(f.input)).toEqual([]); + const updated = await readFile(profile, "utf8"); + expect(updated).toContain("keep-before\nkeep-after\n"); + expect(updated).not.toContain("PATH=old"); + expect(updated.match(/^# stepcode$/gm)).toHaveLength(1); +}); + +test.each([ + "# stepcode\nexport IMPORTANT=preserve\nalias custom=keep\n", + "# stepcode\n# stepcode\nexport IMPORTANT=preserve\n# stepcode end\n# stepcode end\n", + "# stepcode end\nexport IMPORTANT=preserve\n# stepcode\n", +])("leaves malformed user-edited markers unchanged: %s", async (original) => { + const f = await fixture(); + const profile = join(f.root, ".zshrc"); + await writeFile(profile, original); + expect((await ensureStepShellPath(f.input)).join(" ")).toContain("incomplete"); + expect(await readFile(profile, "utf8")).toBe(original); +}); + +test("honors ZDOTDIR and a custom agent directory", async () => { + const f = await fixture(); + const zdot = join(f.root, "zsh-config"); + await mkdir(zdot); + expect( + await ensureStepShellPath({ + ...f.input, + env: { ...f.env, ZDOTDIR: zdot, STEP_CODING_AGENT_DIR: join(f.root, "agent") }, + }), + ).toEqual([]); + const profile = await readFile(join(zdot, ".zshrc"), "utf8"); + expect(profile).toContain(join(f.root, "agent", "bin")); + await expect(readFile(join(f.root, ".zshrc"))).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/packages/coding-agent/test/step-update-command.test.ts b/packages/coding-agent/test/step-update-command.test.ts index 28c9a428..e801ff8c 100644 --- a/packages/coding-agent/test/step-update-command.test.ts +++ b/packages/coding-agent/test/step-update-command.test.ts @@ -31,4 +31,67 @@ describe("Step update command", () => { expect(exitCode).toBe(1); expect(fetchImpl).not.toHaveBeenCalled(); }); + + test.skipIf(process.platform === "win32").each([0, 7])( + "runs the new binary's preparation after upgrade and keeps Step when optional setup exits %s", + async (setupExitCode) => { + const root = await mkdtemp(join(tmpdir(), "step-upgrade-prepare-")); + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + try { + const release = join(root, "release"); + const installed = join(root, "installed"); + await mkdir(release); + await mkdir(installed); + const executablePath = join(installed, "step"); + await writeFile(executablePath, "#!/bin/sh\nexit 99\n"); + const binary = join(release, "step"); + await writeFile( + binary, + `#!/bin/sh +case "$*" in + --version) printf '0.4.0\\n' ;; + "mcp prepare") printf '%s\\n' "$*" > "$HOME/prepared"; exit ${setupExitCode} ;; + *) exit 99 ;; +esac +`, + ); + await chmod(binary, 0o755); + const archivePath = join(root, "release.tar.gz"); + expect(spawnSync("tar", ["-czf", archivePath, "-C", root, "release"]).status).toBe(0); + const archive = await readFile(archivePath); + const target = `${process.platform}-${process.arch}`; + const url = "https://releases.example.test/release.tar.gz"; + const manifest = { + version: "0.4.0", + packages: { [target]: url }, + checksums: { [target]: createHash("sha256").update(archive).digest("hex") }, + }; + const fetchImpl = vi + .fn() + .mockImplementation(async (input) => + String(input) === url ? new Response(archive) : Response.json(manifest), + ); + expect( + await runStepUpdateCommand({ + version: "0.4.0", + executablePath, + env: { HOME: root, PATH: "/usr/bin:/bin" }, + fetchImpl, + }), + ).toBe(0); + expect(await readFile(join(root, "prepared"), "utf8")).toBe("mcp prepare\n"); + expect(await readFile(executablePath, "utf8")).toContain("0.4.0"); + if (setupExitCode) expect(stderr).toHaveBeenCalledWith(expect.stringContaining("Step remains usable")); + } finally { + stderr.mockRestore(); + await rm(root, { recursive: true, force: true }); + } + }, + ); }); + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; diff --git a/scripts/release-bundle.test.mjs b/scripts/release-bundle.test.mjs index c192afa8..67e7f9c3 100644 --- a/scripts/release-bundle.test.mjs +++ b/scripts/release-bundle.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -251,7 +251,7 @@ test("install.sh persists PATH via a guarded, env-var-honoring # stepcode block "written block must self-guard so sourcing it twice does not double-prepend", ); assert.ok( - rendered.includes('export PATH="%s:%s/bin:$PATH"'), + rendered.includes('export PATH="%s:$PATH"') && rendered.includes('export PATH="%s/bin:$PATH"'), "export must use the resolved INSTALL_DIR/AGENT_DIR (honors STEP_INSTALL_DIR/STEP_CODING_AGENT_DIR)", ); assert.ok(rendered.includes('fish_add_path "%s" "%s/bin"'), "fish must be configured via fish_add_path"); @@ -265,6 +265,61 @@ const powershellShell = commandAvailable("pwsh", "--version") ? "powershell" : null; +test( + "install.sh persists inherited PATH and preserves malformed user blocks", + { skip: commandAvailable("bash", "--version") ? false : "bash unavailable" }, + async () => { + const source = await readFile(shPath, "utf8"); + const functions = ["path_hint", "profile_targets", "strip_step_block", "configure_shell_path"].map((name) => { + const match = source.match(new RegExp(name + String.raw`\(\) \{[\s\S]*?\n\}`)); + assert.ok(match, `missing ${name}`); + return match[0]; + }); + const root = await mkdtemp(join(tmpdir(), "step-installer-path-")); + try { + const installDir = join(root, "custom-bin"); + await mkdir(installDir); + await writeFile(join(installDir, "step"), "#!/bin/sh\nexit 0\n"); + await chmod(join(installDir, "step"), 0o755); + await writeFile(join(root, ".zshrc"), "export USER_SETTING=keep\n"); + const script = join(root, "test.sh"); + await writeFile(script, `set -euo pipefail\n${functions.join("\n")}\nconfigure_shell_path\nconfigure_shell_path\n`); + const env = { + HOME: root, SHELL: "/bin/zsh", PATH: `${installDir}:/usr/bin:/bin`, + INSTALL_DIR: installDir, AGENT_DIR: join(root, "agent"), + }; + const result = spawnSync("bash", [script], { + encoding: "utf8", + env, + }); + assert.equal(result.status, 0, result.stderr); + const profile = await readFile(join(root, ".zshrc"), "utf8"); + assert.equal(profile.match(/^# stepcode$/gm)?.length, 1); + assert.match(profile, /USER_SETTING=keep/); + const fresh = spawnSync("/bin/sh", ["-c", '. "$HOME/.zshrc"; . "$HOME/.zshrc"; command -v step'], { + encoding: "utf8", env: { HOME: root, PATH: "/usr/bin:/bin" }, + }); + assert.equal(fresh.status, 0, fresh.stderr); + assert.equal(fresh.stdout.trim(), join(installDir, "step")); + const malformed = "# stepcode\nexport USER_SETTING=keep\n"; + await writeFile(join(root, ".zshrc"), malformed); + const preserved = spawnSync("bash", [script], { encoding: "utf8", env }); + assert.equal(preserved.status, 0, preserved.stderr); + assert.equal(await readFile(join(root, ".zshrc"), "utf8"), malformed); + assert.match(preserved.stderr, /left it unchanged/); + await rm(join(root, ".zshrc")); + const unsafe = spawnSync("bash", [script], { + encoding: "utf8", env: { ...env, INSTALL_DIR: `${root}/unsafe\\` }, + }); + assert.equal(unsafe.status, 0, unsafe.stderr); + assert.match(unsafe.stderr, /unsafe to write/); + await assert.rejects(readFile(join(root, ".zshrc")), { code: "ENOENT" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); + test( "Get-MissingPathEntries is idempotent, null-safe, and normalizes case/trailing-backslash (pwsh)", { skip: powershellShell ? false : "powershell/pwsh unavailable" }, @@ -387,7 +442,7 @@ ${match[0]} printf 'keep1\n\n# stepcode\nexport PATH=x\n# stepcode end\nkeep2\n' > "$1" strip_step_block "$1" printf '# stepcode\nexport IMPORTANT=keepme\nalias a=b\n' > "$2" -strip_step_block "$2" +if strip_step_block "$2"; then exit 1; fi printf 'WELL:%s\n' "$(tr '\n' '|' < "$1")" printf 'UNTERM_KEEP:%s\n' "$(grep -c 'IMPORTANT=keepme' "$2")" `; From 44f694e8cf8023c3bd14cf3dfc5cf08560983c6c Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Thu, 8 Oct 2026 20:07:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E9=9A=94=E7=A6=BB=E7=A6=BB=E7=BA=BF=20CLI?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E7=9A=84=E5=86=85=E7=BD=AE=20MCP=20?= =?UTF-8?q?=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/coding-agent/test/completion-check-cli.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/coding-agent/test/completion-check-cli.test.ts b/packages/coding-agent/test/completion-check-cli.test.ts index 17f84f6c..ea902e81 100644 --- a/packages/coding-agent/test/completion-check-cli.test.ts +++ b/packages/coding-agent/test/completion-check-cli.test.ts @@ -59,6 +59,10 @@ function runCli(flags: string[], repository: boolean) { for (const name of ["PATH", "SystemRoot", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"]) { if (process.env[name] !== undefined) env[name] = process.env[name]; } + // --no-tools leaves MCP initialization enabled. Keep this provider fixture + // offline by explicitly disabling the built-in server in its isolated config. + mkdirSync(join(root, "config"), { recursive: true }); + writeFileSync(join(root, "config", "config.toml"), "[mcp_servers.steppage__steppage]\nenabled = false\n"); const result = spawnSync( process.execPath, [ @@ -99,6 +103,7 @@ describe("real CLI completion-check dispatch with an offline provider", () => { expect(result.calls).toBe(2); expect(result.stdout).toBe("CLI offline final\n"); expect(result.stderr).toContain("Completion check incomplete after 1 follow-up(s)."); + expect(result.stderr).not.toContain("steppage"); }); it("records both tree and commit checks in JSON with the default two follow-ups", () => {