From b8306c76060f479542b4e2cd6139b68ae095cd2e Mon Sep 17 00:00:00 2001 From: mathias-heide Date: Thu, 10 Sep 2026 21:20:12 -0700 Subject: [PATCH] fix(release): close the adversarial review findings before 3.0.0 ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reviewers (Claude + Codex) attacked main; no P0, seven P1s. All fixed here with tests: navigation (summer_open / summer open) - refuse to open a browser on any origin that is not summerengine.com, a subdomain, or loopback — a gateway.url / SUMMER_GATEWAY_URL pointing at summerengine.com.evil.com opened there before (blocked_origin) - refuse res:// paths that escape the project (.., backslashes, percent escapes, leading slash) both as targets and as path/scene params - a failed browser launch is a structured open_failed result carrying the url, not a thrown error out of the MCP tool - `summer open /pricing` reaches navigation (known web paths), an exact map id beats a same-named directory in cwd (`./billing` still means the folder) - unmapped paths: returnUrl/redirect/next-style query params must be relative (no open redirect through the login page); long targets are truncated in hints upgrade path - plugin manifests (.mcp.json, gemini-extension.json, plugin.json for Claude/Codex/Cursor) launch `npx -y summer-engine@latest mcp` — the bare `npx summer-engine mcp` hung on non-TTY hosts and served stale caches - `summer setup --force` prunes the skills 2.8.x installed that v3 retired (summer-cloud, un-prefixed vfx recipes, ...) so no host keeps a skill that calls removed tools; only dirs with a SKILL.md, only names absent from the current registry (src/installer/legacy-skills.ts) - refresh hints follow the channel: a CLI ahead of npm latest (installed from `next`) defaults `summer setup` to `--channel next` and doctor recommends `@next --channel next`, instead of downgrading the MCP server to latest docs: CHANGELOG folds the stray "Unreleased" item into 3.0.0 and fixes the skill count (94); RELEASE-3.0.0.md and using-summer describe the channel rule. Co-Authored-By: Claude Fable 5.1 --- .mcp.json | 3 +- CHANGELOG.md | 7 +- docs/RELEASE-3.0.0.md | 2 +- gemini-extension.json | 3 +- library/skills/navigate-summer/SKILL.md | 4 +- library/skills/using-summer/SKILL.md | 4 +- registry/generated/gemini-extension.json | 3 +- registry/generated/index.json | 4 +- registry/generated/mcp.json | 3 +- .../generate-registry.test.ts | 2 +- scripts/generate-registry/manifests.ts | 4 +- src/cli/commands/open.navigation.test.ts | 23 ++++ src/cli/commands/open.ts | 34 ++++- src/cli/commands/setup.test.ts | 7 ++ src/cli/commands/setup.ts | 7 +- src/cli/commands/skills.ts | 9 ++ src/core/capabilities/doctor.ts | 2 + .../navigation/navigation.test.ts | 38 ++++++ src/core/capabilities/navigation/open.ts | 116 ++++++++++++++++-- src/installer/legacy-skills.test.ts | 35 ++++++ src/installer/legacy-skills.ts | 60 +++++++++ src/installer/version-check.test.ts | 16 +++ src/installer/version-check.ts | 34 ++++- src/mcp/tools/navigation-tools.ts | 4 +- 24 files changed, 391 insertions(+), 33 deletions(-) create mode 100644 src/installer/legacy-skills.test.ts create mode 100644 src/installer/legacy-skills.ts diff --git a/.mcp.json b/.mcp.json index cc2e1745..880c777d 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,7 +4,8 @@ "summer-engine": { "command": "npx", "args": [ - "summer-engine", + "-y", + "summer-engine@latest", "mcp" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ffbac5..7133414a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog -## Unreleased - -- Add optional image background removal to MCP and `summer tool generate-image`, with a shared validated schema and searchable descriptor. - All notable changes to summer-engine will be documented here. Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/). ## [3.0.0] (2026-09-09): "The Library" @@ -18,7 +14,7 @@ v3 rebuilds the package around one idea: every resource is described once (`libr ### Breaking changes vs 2.8.x - **Removed commands and tools.** `summer cloud` (and the seven `summer_cloud_*` MCP tools + the `summer-cloud` skill), `summer agent`, `summer logs` / `summer_creator_logs`. Tool count 62 → 86: 54 names unchanged, 8 removed (listed), 32 new. No MCP tool was renamed; no tool argument was renamed. - **Skill layout.** `skills///` became flat `library/skills//`; `references/` became `library/references//`; `_persona/` is gone. Anything that read skills from the package path (`node_modules/summer-engine/skills/…`) must read `library/skills/`. Plugin-marketplace installs expose `/summer:` instead of `/summer:/`. Cross-references in prompts using the v2 `summer:/` form do not resolve; the old names are recorded in `registry/generated/aliases.json` but nothing resolves them at runtime yet (only legacy `template-` names in `summer create` do). **Installed skill snapshots are not refreshed automatically**: run `npx -y summer-engine@latest setup --yes --force` once (`summer doctor` flags the stale snapshot as `skills-version-stale`). -- **`summer setup ` installs every skill** (95, preview ones labelled) instead of the recommended subset; `--recommended` restores the 2.8.x behaviour, `--stable-only` skips preview skills. +- **`summer setup ` installs every skill** (94, preview ones labelled) instead of the recommended subset; `--recommended` restores the 2.8.x behaviour, `--stable-only` skips preview skills. - **Templates are pinned.** `summer create ` fetches an exact commit and verifies a tree digest instead of cloning a repository's default branch, and writes `.summer/project.json`; `summer list templates` reads the compiled registry, never a GitHub org listing. Legacy `template-` names still resolve. - **Launch and play are quiet by default when an agent drives.** `summer run` launches the engine in the background (no focus steal) whenever stdout is not a TTY and the engine supports it (0.5.66+; older engines launch with focus and say so); `--focus` restores the old behaviour; a human in a terminal still gets focus. `summer_play` no longer switches the editor to the Game tab or grabs focus (`PlayGame agent:true`); `focus: true` restores the toolbar-Play behaviour. `summer run` with no path needs `--no-project` to open a bare editor. - **Exit codes.** `summer ` exits 1 instead of printing the intro. `summer tool ` exits 1 on every result the MCP face marks `isError` (including `engine_lacks_op`). @@ -27,6 +23,7 @@ v3 rebuilds the package around one idea: every resource is described once (`libr - `summer mcp setup ` still works as a deprecated alias of `summer setup `. ### Added +- Optional image background removal (`removeBackground`) on `summer_generate_image` / `summer tool generate-image`, with a shared validated schema and searchable descriptor. - **The librarian**: `summer_search_library` (BM25 over the compiled index, optional semantic fusion when an embeddings sidecar exists, lexical-only offline, never throws) returns ranked entries of every kind for a plain-words task description; `summer_read_library` loads one entry by id (a skill's body, a tool's call recipe, a template's pin, a reference's text), ending in the feedback footer (`entry_id@hash`) that `summer_library_feedback` reports against. Both engine-free, both faces (`summer tool search-library` / `read-library`). Preview entries never outrank stable ones on comparable evidence. - **Navigation**: `summer open ` / `summer_open` / `summer tool open` opens the exact summerengine.com page or editor surface by intent (a product-map id such as `billing`, `my-games`, `mcp-guide`, `scene`, `inspector`; an intent phrase; a `res://` path; or a site path), in the browser (through `/login?returnUrl=` when needed) or in the running editor; `--print` resolves without opening, `--list` prints the map. Web rows come from summerengine.com's route catalog (vendored snapshot `assets/navigation/web-routes.json`); editor rows forward to the engine's `Navigate` op (0.5.66+) and fall back to the original ops (`OpenScene`, `SelectNode`, `OpenResource`, `FocusDock`, `RevealInFileSystem`) on 0.5.65. `summer open ` is unchanged. Design: `docs/design/NAVIGATION-DESIGN.md`. - **Launch posture** (`docs/TESTING.md` "Working in the background"): `summer run [--background|--focus]`: background is the default when stdout is not a TTY. The positive gate is a ` --help` probe for `--summer-background` (cached per binary path + mtime in `~/.summer/launch-probe-cache.json`), never a version pre-check, so dev builds still stamped 0.5.65 are detected correctly; once up, `/api/health capabilities.launchPostures` is the authoritative advert and `summer_get_project_context` surfaces it. `summer_play` is quiet by default (`focus: true` opts in) and its result echoes `agent_quiet` or a `posture_note` when the engine predates quiet play. diff --git a/docs/RELEASE-3.0.0.md b/docs/RELEASE-3.0.0.md index 8126ff33..bc28aaa7 100644 --- a/docs/RELEASE-3.0.0.md +++ b/docs/RELEASE-3.0.0.md @@ -31,7 +31,7 @@ npx clear-npx-cache && npx -y summer-engine@latest setup --yes --force npm view summer-engine dist-tags # expect: latest: 2.8.2, next: 3.0.0 ``` -4. **Soak.** Dogfood with the real published tarball. The MCP entry has to point at `@next`, otherwise the agent runs 2.8.2's server with 3.0.0's skills: +4. **Soak.** Dogfood with the real published tarball. The MCP entry has to point at `@next`, otherwise the agent runs 2.8.2's server with 3.0.0's skills. `summer setup` from a 3.0.0 CLI does this by default while 3.0.0 is ahead of npm `latest` (it prints a note; `--channel latest` overrides), and `summer doctor` recommends the matching `@next --channel next` refresh: ```bash npx clear-npx-cache diff --git a/gemini-extension.json b/gemini-extension.json index 95a795e3..367809f5 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -8,7 +8,8 @@ "summer-engine": { "command": "npx", "args": [ - "summer-engine", + "-y", + "summer-engine@latest", "mcp" ], "cwd": "${extensionPath}" diff --git a/library/skills/navigate-summer/SKILL.md b/library/skills/navigate-summer/SKILL.md index eaec5222..fcb4151c 100644 --- a/library/skills/navigate-summer/SKILL.md +++ b/library/skills/navigate-summer/SKILL.md @@ -41,7 +41,9 @@ Never open a browser or switch the editor's tab as a side effect of building. Op - `action: "engine_not_running"` — nothing opened. Tell the user to start Summer Engine (`summer run `) or open the project in the desktop app, then offer to retry. Do not fall back to editing files. - `action: "unsupported"` (`failure_reason: engine_lacks_op`) — this Summer Engine build cannot open that surface (it predates the `Navigate` op, or does not advertise that id). Say so plainly, tell the user to update Summer Engine, and describe what to open by hand. Never claim it opened. - `action: "ambiguous"` — show the top matches by title and ask, or pick the obvious one and say which you picked. -- `action: "not_found"` — the intent is not a Summer destination. Do not invent a URL; the tool only opens summerengine.com and docs.summerengine.com. +- `action: "not_found"` — the intent is not a Summer destination (or a `res://` path tried to escape the project). Do not invent a URL; the tool only opens summerengine.com and its subdomains. +- `action: "open_failed"` — this machine could not launch a browser (headless, no display). Nothing opened; paste the `url` for the user. +- `action: "blocked_origin"` — the configured gateway is not a Summer origin; nothing opened. Tell the user to check `gateway.url` / `SUMMER_GATEWAY_URL`. ## When to hand over a link instead diff --git a/library/skills/using-summer/SKILL.md b/library/skills/using-summer/SKILL.md index 494c1f42..b86fac8c 100644 --- a/library/skills/using-summer/SKILL.md +++ b/library/skills/using-summer/SKILL.md @@ -139,7 +139,7 @@ Do NOT fall back to editing `.tscn` files directly. The engine reads them on dis If skills aren't found or the MCP server fails to start: 1. Check whether `summer` is on PATH: `which summer` / `where summer`. -2. If not, point them at: `npx -y summer-engine@latest setup --yes --force`. +2. If not, point them at: `npx -y summer-engine@latest setup --yes --force` (use `@next` and `--channel next` while a release is soaking on the `next` tag; `summer doctor` prints the right command). 3. If `summer doctor` is available, run it: `summer doctor` reports auth, engine, port, project memory, and skill state. ## When Summer Is Stale @@ -150,7 +150,7 @@ Run `summer doctor` early in a fresh Summer session when setup, MCP tools, slash npx clear-npx-cache && npx -y summer-engine@latest setup --yes --force ``` -Use the real agent slug from doctor or the current environment (`claude-code`, `codex`, `cursor`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, etc.). +Use the dist-tag `summer doctor` recommends: `@latest` normally, `@next` (with `--channel next`) when this CLI was installed from the soaking release, otherwise the MCP server silently drops back to the older `latest`. Use the real agent slug from doctor or the current environment (`claude-code`, `codex`, `cursor`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, etc.). Why this exact command matters: diff --git a/registry/generated/gemini-extension.json b/registry/generated/gemini-extension.json index 95a795e3..367809f5 100644 --- a/registry/generated/gemini-extension.json +++ b/registry/generated/gemini-extension.json @@ -8,7 +8,8 @@ "summer-engine": { "command": "npx", "args": [ - "summer-engine", + "-y", + "summer-engine@latest", "mcp" ], "cwd": "${extensionPath}" diff --git a/registry/generated/index.json b/registry/generated/index.json index 67bc1352..0bb598da 100644 --- a/registry/generated/index.json +++ b/registry/generated/index.json @@ -1945,7 +1945,7 @@ "id": "skill/navigate-summer", "kind": "skill", "version": "1.0.0", - "content_hash": "eec416519ba3c0aa6338a9f75a0287880f5d251d87335c6a4b6acd93229b1dc1", + "content_hash": "aec360ff8c87b41b565457a6464b1dfbee3a1e241d84e1fc67d9a93a42842d5e", "summary": "When to open a Summer web page or editor surface FOR the user (billing, their games, the scene just built) versus acting through the API, using summer_open.", "use_when": [ "the user asks to see, check, or decide something — \"show me my billing\", \"where do I change my plan\", \"open my games\", \"let me look at the scene\", \"take me to the MCP guide for Cursor\"", @@ -3178,7 +3178,7 @@ "id": "skill/using-summer", "kind": "skill", "version": "1.0.4", - "content_hash": "a14fd976977d7cb4b806a5262988979f17a86129fe55375bd9696c67a4bb243c", + "content_hash": "4df2bb87e809a70e8cc165c27997e0060993444d054182ca654fa6380511752d", "summary": "Session bootstrap for Summer projects — establishes how to find and use Summer skills and the summer-engine MCP before any response.", "use_when": [ "starting any conversation in a Summer Engine project", diff --git a/registry/generated/mcp.json b/registry/generated/mcp.json index cc2e1745..880c777d 100644 --- a/registry/generated/mcp.json +++ b/registry/generated/mcp.json @@ -4,7 +4,8 @@ "summer-engine": { "command": "npx", "args": [ - "summer-engine", + "-y", + "summer-engine@latest", "mcp" ] } diff --git a/scripts/generate-registry/generate-registry.test.ts b/scripts/generate-registry/generate-registry.test.ts index 7893b443..5bf478b6 100644 --- a/scripts/generate-registry/generate-registry.test.ts +++ b/scripts/generate-registry/generate-registry.test.ts @@ -285,7 +285,7 @@ describe("generateRegistry: manifests (golden shapes)", () => { expect(gemini.contextFileName).toBe("GEMINI.md"); expect((gemini.mcpServers as Record)["summer-engine"]).toEqual({ command: "npx", - args: ["summer-engine", "mcp"], + args: ["-y", "summer-engine@latest", "mcp"], cwd: "${extensionPath}", }); diff --git a/scripts/generate-registry/manifests.ts b/scripts/generate-registry/manifests.ts index e68e3355..73fefd0d 100644 --- a/scripts/generate-registry/manifests.ts +++ b/scripts/generate-registry/manifests.ts @@ -34,7 +34,9 @@ function skillPaths(slugs: string[]): string[] { * from the generated root `.mcp.json`. */ export function bundledMcpServer(): { command: string; args: string[] } { - return { command: "npx", args: ["summer-engine", "mcp"] }; + // `-y` so a non-TTY host never hangs on npx's install prompt, `@latest` so a + // stale npx cache never serves an old server (README "Troubleshooting"). + return { command: "npx", args: ["-y", "summer-engine@latest", "mcp"] }; } function buildMcpJson(): string { diff --git a/src/cli/commands/open.navigation.test.ts b/src/cli/commands/open.navigation.test.ts index a4ec6167..8574575e 100644 --- a/src/cli/commands/open.navigation.test.ts +++ b/src/cli/commands/open.navigation.test.ts @@ -155,3 +155,26 @@ describe("summer open — navigation results", () => { expect(text).toMatch(/Update Summer Engine/); }); }); + +describe("summer open — branch precedence (release review 2026-09-11)", () => { + it("a known web path is navigation, an unknown absolute path is a project path", () => { + expect(looksLikeProjectPath("/pricing")).toBe(false); + expect(looksLikeProjectPath("/studio?tab=billing")).toBe(false); + expect(looksLikeProjectPath("/nonexistent/dir")).toBe(true); + expect(looksLikeProjectPath(root)).toBe(true); + }); + + it("an exact map id wins over a same-named directory in cwd; ./name still means the directory", async () => { + const { mkdir } = await import("node:fs/promises"); + const prev = process.cwd(); + await mkdir(join(root, "billing")); + process.chdir(root); + try { + expect(looksLikeProjectPath("billing")).toBe(false); + expect(looksLikeProjectPath("./billing")).toBe(true); + expect(looksLikeProjectPath("somefolder")).toBe(false); + } finally { + process.chdir(prev); + } + }); +}); diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index f75bd5ee..c3f2bff9 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -7,11 +7,13 @@ import { getAuthToken } from "../../core/auth.js"; import { resolveGatewayUrl } from "../../core/config.js"; import { EngineApiClient } from "../../core/api-client.js"; import { + resolveTarget, runOpen, type OpenDeps, type OpenResult, type OpenSurface, } from "../../core/capabilities/navigation/open.js"; +import { getNavTarget } from "../../core/capabilities/navigation/targets.js"; import { c, sym } from "../../core/format.js"; /** @@ -39,11 +41,32 @@ export interface OpenNavigationOptions { param?: string[]; } -/** Path-shaped (absolute, relative, home, Windows drive) or an existing directory. */ +/** + * Which branch does `summer open ` take? + * - An exact product-map id or alias (`billing`, `inspector`) is ALWAYS a + * navigation target, even if a directory of that name exists in cwd — use + * `./billing` to open a project folder that happens to share a name. + * - `./x`, `../x`, `~/x`, `.`, `..`, Windows drive paths: project directory. + * - `/x`: a project directory when it exists on disk; otherwise a + * summerengine.com path (`/pricing`) when the map knows it; otherwise the + * project-directory branch, which reports "Directory not found". + * - Anything else: a project directory only when it exists as one. + */ export function looksLikeProjectPath(arg: string): boolean { - if (/^(\/|\.\/|\.\.\/|~)/.test(arg) || /^[A-Za-z]:[\\/]/.test(arg) || arg === "." || arg === "..") return true; + const trimmed = arg.trim(); + if (getNavTarget(trimmed.toLowerCase().replace(/\s+/g, "-"))) return false; + if (/^(\.\/|\.\.\/|~)/.test(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed) || trimmed === "." || trimmed === "..") return true; + if (trimmed.startsWith("/")) { + if (isDirectory(trimmed)) return true; + const resolution = resolveTarget(trimmed, {}, "web"); + return !(resolution.kind === "target"); + } + return isDirectory(trimmed); +} + +function isDirectory(path: string): boolean { try { - return statSync(resolve(arg)).isDirectory(); + return statSync(resolve(path)).isDirectory(); } catch { return false; } @@ -115,6 +138,11 @@ export function formatOpenResult(result: OpenResult): string { if (result.hint) lines.push(` ${result.hint}`); if (result.op) lines.push(` would send: ${JSON.stringify(result.op)}`); break; + case "open_failed": + case "blocked_origin": + lines.push(c.red(result.hint ?? result.action)); + if (result.url) lines.push(` ${result.url}`); + break; case "engine_not_running": lines.push(c.red("Summer Engine is not running (or no project is open) — nothing was opened.")); if (result.op) lines.push(` would send: ${JSON.stringify(result.op)}`); diff --git a/src/cli/commands/setup.test.ts b/src/cli/commands/setup.test.ts index 59c7e09e..e7dc1b55 100644 --- a/src/cli/commands/setup.test.ts +++ b/src/cli/commands/setup.test.ts @@ -5,6 +5,13 @@ import { setupCommand } from "./setup.js"; // `summer setup` always ends with a doctor pass (network + engine probes); // stub it so the command runs offline. `--print` already keeps the skills // step in dry-run mode, so nothing else touches the machine. +// The default MCP channel consults npm latest; tests must not touch the network +// and must not depend on whether this checkout is ahead of the published tag. +vi.mock("../../installer/version-check.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveDefaultChannel: vi.fn(async () => ({ channel: "latest" })) }; +}); + vi.mock("../../core/capabilities/doctor.js", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 9587ba07..541d881e 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -11,6 +11,7 @@ import { DoctorResult, printDoctorResult, runDoctor } from "../../core/capabilit import { brandLine, c, sym, tildeify } from "../../core/format.js"; import { TOOLKIT_VERSION as cliVersion } from "../../core/version.js"; +import { resolveDefaultChannel } from "../../installer/version-check.js"; const AGENT_LABEL: Record = { "claude-code": "Claude Code", @@ -78,14 +79,18 @@ export const setupCommand = new Command("setup") const agent = resolveAgentSelection(agentArg, opts.agent); const scope = resolveConfigScope(opts.scope); + const defaultChannel = await resolveDefaultChannel(cliVersion); const config = await configureAgentMcp({ agent, scope, dryRun: opts.dryRun, print: opts.print, localDev: Boolean(opts.localDev) || process.env.SUMMER_DEV === "1", - channel: opts.channel ?? process.env.SUMMER_CHANNEL, + channel: opts.channel ?? process.env.SUMMER_CHANNEL ?? defaultChannel.channel, }); + if (!opts.channel && !process.env.SUMMER_CHANNEL && defaultChannel.note && !opts.print) { + console.log(` ${c.dim(defaultChannel.note)}`); + } const skills = setupSkills(agent, { dryRun: Boolean(opts.dryRun || opts.print), diff --git a/src/cli/commands/skills.ts b/src/cli/commands/skills.ts index 95c2db33..fb1ec7f7 100644 --- a/src/cli/commands/skills.ts +++ b/src/cli/commands/skills.ts @@ -21,6 +21,7 @@ import { } from "../../core/skills-registry.js"; import { tildeify } from "../../core/format.js"; import { writeSkillMarker } from "../../installer/version-check.js"; +import { pruneRetiredSkills } from "../../installer/legacy-skills.js"; import { resolveInstallLocation, resolveSkillAgent, @@ -410,6 +411,14 @@ skillsCommand // Keep this line's shape: setup tallies it (Installed|Updated|Generated -> ). console.log(` ${result.action} ${skill.name} -> ${result.path}`); } + // Upgrades: with --force, remove the skills Summer installed in 2.8.x that + // v3 retired or renamed (summer-cloud, the un-prefixed vfx recipes, ...), + // so no host keeps a skill that points at removed tools. + if (opts.force && !name && (location.kind === "skill-dir" || location.kind === "opencode-skill-dir")) { + for (const pruned of pruneRetiredSkills(location.path, getSkillRegistry().map((entry) => entry.name))) { + console.log(` Removed ${pruned.name} -> ${pruned.path} (retired in 3.0.0)`); + } + } if (name && skills[0]?.status === "preview") { console.log( ` Note: ${name} is a preview skill — not yet exercised in-engine by the Summer team; its guidance says so.` diff --git a/src/core/capabilities/doctor.ts b/src/core/capabilities/doctor.ts index 1204543a..ec9c0c1d 100644 --- a/src/core/capabilities/doctor.ts +++ b/src/core/capabilities/doctor.ts @@ -125,10 +125,12 @@ async function checkCliVersionCurrent(): Promise { * recorded by `summer setup`: a `--local-dev` link gets the local-dev form * so the fix does not replace the checkout with the published package. */ async function checkSkillsVersion(): Promise { + const registry = await fetchLatestRegistryVersion(); const result = await buildSkillsVersionCheck({ installedCliVersion: version, candidates: defaultSkillMarkerCandidates(), recordedInstall: detectRecordedInstall, + ...(registry.ok ? { latestRegistryVersion: registry.version } : {}), }); return { id: "skills-version-stale", diff --git a/src/core/capabilities/navigation/navigation.test.ts b/src/core/capabilities/navigation/navigation.test.ts index 0d59b94e..36a1c1f2 100644 --- a/src/core/capabilities/navigation/navigation.test.ts +++ b/src/core/capabilities/navigation/navigation.test.ts @@ -408,3 +408,41 @@ describe("runOpen (editor, engine off)", () => { expect(engine).not.toHaveBeenCalled(); }); }); + +describe("safety rails (release review 2026-09-11)", () => { + it("refuses to open a browser on a non-Summer gateway origin", async () => { + const { d, openUrl } = deps({ gatewayUrl: async () => "https://summerengine.com.evil.com" }); + const res = await runOpen({ target: "billing" }, d); + expect(res).toMatchObject({ ok: false, action: "blocked_origin" }); + expect(res.hint).toMatch(/evil/); + expect(openUrl).not.toHaveBeenCalled(); + const local = await runOpen({ target: "pricing", open: false }, deps({ gatewayUrl: async () => "http://localhost:3000" }).d); + expect(local).toMatchObject({ ok: true, url: "http://localhost:3000/pricing" }); + }); + + it("refuses resource paths that escape the project", async () => { + const { d, engine } = deps(); + for (const bad of ["res://../../etc/passwd", "res://a/../b.tscn", "res://", "res:///abs.tscn", "res://x\\y.gd", "res://%2e%2e/x"]) { + const res = await runOpen({ target: bad, open: false }, d); + expect(res.action, bad).toBe("not_found"); + expect(res.hint, bad).toMatch(/Refused resource path/); + } + const viaParam = await runOpen({ target: "scene", params: { path: "res://../main.tscn" }, open: false }, d); + expect(viaParam).toMatchObject({ ok: false, action: "invalid_params" }); + const viaScene = await runOpen({ target: "node", params: { node: "Player", scene: "res://../x.tscn" }, open: false }, d); + expect(viaScene).toMatchObject({ ok: false, action: "invalid_params" }); + expect(engine).not.toHaveBeenCalled(); + const good = await runOpen({ target: "res://levels/one.tscn", open: false }, d); + expect(good.ok).toBe(true); + }); + + it("a failed browser launch is a structured result, not a thrown error", async () => { + const { d } = deps({ openUrl: async () => { throw new Error("spawn ENOENT open"); } }); + const res = await runOpen({ target: "pricing" }, d); + expect(res).toMatchObject({ ok: false, action: "open_failed", url: `${GATEWAY}/pricing` }); + expect(res.hint).toMatch(/ENOENT/); + const gated = await runOpen({ target: "billing" }, deps({ loggedIn: false, openUrl: async () => { throw new Error("no display"); } }).d); + expect(gated).toMatchObject({ ok: false, action: "open_failed", logged_in: false }); + expect(gated.url).toMatch(/login\?returnUrl=/); + }); +}); diff --git a/src/core/capabilities/navigation/open.ts b/src/core/capabilities/navigation/open.ts index 85a1dd6c..135c4b84 100644 --- a/src/core/capabilities/navigation/open.ts +++ b/src/core/capabilities/navigation/open.ts @@ -71,7 +71,9 @@ export type OpenAction = | "engine_not_running" | "engine_error" | "not_found" - | "invalid_params"; + | "invalid_params" + | "open_failed" + | "blocked_origin"; export interface OpenMatch { id: string; @@ -138,6 +140,67 @@ export interface OpenDeps { docsOrigin?: string; } +// --------------------------------------------------------------------------- +// Safety rails +// --------------------------------------------------------------------------- + +/** The only origins this tool ever opens a browser on: summerengine.com and its + * subdomains, plus loopback for local web development. A configured gateway + * (SUMMER_GATEWAY_URL / gateway.url) that points anywhere else is refused — + * the API calls may go there, a browser launch never does. */ +export function isAllowedNavigationOrigin(origin: string): boolean { + let host: string; + try { + host = new URL(origin).hostname.toLowerCase(); + } catch { + return false; + } + return ( + host === "summerengine.com" || + host.endsWith(".summerengine.com") || + host === "localhost" || + host === "127.0.0.1" || + host === "[::1]" || + host === "::1" + ); +} + +/** A project resource path the engine may be asked to open: `res://` plus a + * plain relative path — no parent segments, no backslashes, no percent + * escapes, nothing that could name a file outside the project. Returns the + * problem, or null when the path is acceptable. */ +export function resPathProblem(value: string): string | null { + if (!value.startsWith("res://")) return `must start with res:// (got "${value}")`; + const rest = value.slice("res://".length); + if (rest.length === 0) return "must name a file or folder under res://"; + if (rest.includes("\\")) return "must use forward slashes"; + if (rest.includes("%")) return "must not contain percent escapes"; + if (rest.startsWith("/")) return "must be relative to res:// (no leading slash)"; + if (rest.split("/").some((segment) => segment === ".." || segment === ".")) return "must not contain . or .. segments"; + if (/[\u0000-\u001f]/.test(rest)) return "must not contain control characters"; + return null; +} + +/** Launch the browser; a failed launch is a result, not a crash. */ +async function launch(deps: OpenDeps, url: string): Promise { + try { + await deps.openUrl(url); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +function openFailed(url: string, error: string, extra: Partial = {}): OpenResult { + return { + ok: false, + action: "open_failed", + url, + ...extra, + hint: `Could not launch a browser on this machine (${error}). Nothing opened — hand the user the url instead.`, + }; +} + // --------------------------------------------------------------------------- // Matching // --------------------------------------------------------------------------- @@ -206,12 +269,34 @@ type Resolution = | { kind: "not_found"; matches: OpenMatch[]; hint: string }; function resolveResourcePath(path: string, params: Record): Resolution { + const problem = resPathProblem(path); + if (problem) { + return { kind: "not_found", matches: [], hint: `Refused resource path: ${problem}.` }; + } const lower = path.toLowerCase(); const id = lower.endsWith(".tscn") || lower.endsWith(".scn") ? "scene" : lower.endsWith(".gd") || lower.endsWith(".cs") ? "script" : "file"; return { kind: "target", target: getNavTarget(id)!, params: { ...params, path } }; } +/** Redirect-style query params on an unmapped path must be relative same-origin + * paths; a full URL there would turn the login page into an open redirect. */ +const REDIRECT_PARAMS = new Set(["returnurl", "redirect", "next", "callbackurl", "redirect_uri", "return_to"]); + +export function unsafeRedirectParam(pathWithQuery: string): string | null { + const q = pathWithQuery.indexOf("?"); + if (q === -1) return null; + for (const [key, value] of new URLSearchParams(pathWithQuery.slice(q + 1))) { + if (!REDIRECT_PARAMS.has(key.toLowerCase())) continue; + if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return key; + } + return null; +} + function resolveWebPath(path: string, params: Record): Resolution { + const badParam = unsafeRedirectParam(path); + if (badParam) { + return { kind: "not_found", matches: [], hint: `Refused: query parameter "${badParam}" must be a relative summerengine.com path (it is used as a redirect target).` }; + } const wanted = path.replace(/\/+$/, "") || "/"; for (const target of NAV_TARGETS) { if (!target.web || target.web.origin !== "gateway") continue; @@ -239,7 +324,7 @@ export function resolveTarget(rawTarget: string, params: Record, return { kind: "not_found", matches, - hint: `No destination matches "${raw}". Run 'summer open --list' (or call summer_open with no target) to see every target, or pass a res:// path or a summerengine.com path.`, + hint: `No destination matches "${raw.length > 80 ? raw.slice(0, 77) + "..." : raw}". Run 'summer open --list' (or call summer_open with no target) to see every target, or pass a res:// path or a summerengine.com path.`, }; } const [top, second] = matches; @@ -305,9 +390,16 @@ export function renderWebPath(template: string, params: Record, function requireParams(meta: EditorTargetMeta, params: Record): void { for (const param of meta.params ?? []) { - if (param.required && (params[param.name] === undefined || params[param.name] === "")) { + const value = params[param.name]; + if (param.required && (value === undefined || value === "")) { throw new ParamError(`Missing required param: ${param.name}`); } + // Resource paths are forwarded to the engine as-is; refuse anything that + // does not name a file under the project. + if ((param.name === "path" || param.name === "scene") && meta.id !== "assistant" && value !== undefined && value !== "") { + const problem = resPathProblem(value); + if (problem) throw new ParamError(`${param.name} ${problem}`); + } } } @@ -405,6 +497,13 @@ export async function runOpen(args: OpenArgs, deps: OpenDeps): Promise 80 ? args.target.slice(0, 77) + "..." : args.target}" matches several destinations. Call again with one of the ids listed in matches.`, }; } if (resolution.kind === "url") { @@ -456,7 +556,8 @@ export async function runOpen(args: OpenArgs, deps: OpenDeps): Promise { if (dir) rmSync(dir, { recursive: true, force: true }); }); + +describe("retired 2.8.x skills are pruned on --force upgrades (release review P1-2)", () => { + it("summer-cloud and the un-prefixed vfx recipes are retired against the current registry", () => { + const current = ["play", "vfx-fire", "vfx-smoke", "using-summer"]; + const retired = retiredSkillNames(current); + expect(retired).toContain("summer-cloud"); + expect(retired).toContain("fire"); + expect(retired).not.toContain("play"); + expect(retired).not.toContain("using-summer"); + expect(LEGACY_SKILL_SLUGS_2_8_2.length).toBe(79); + }); + + it("removes only retired dirs that hold a SKILL.md; keeps current skills and foreign folders", () => { + dir = mkdtempSync(join(tmpdir(), "summer-prune-")); + for (const name of ["summer-cloud", "fire", "play"]) { + mkdirSync(join(dir, name)); + writeFileSync(join(dir, name, "SKILL.md"), "---\nname: x\n---\n"); + } + mkdirSync(join(dir, "smoke")); // same name as a retired skill, but not a skill Summer wrote + writeFileSync(join(dir, "smoke", "notes.txt"), "mine"); + const pruned = pruneRetiredSkills(dir, ["play", "vfx-fire", "vfx-smoke"]); + expect(pruned.map((p) => p.name).sort()).toEqual(["fire", "summer-cloud"]); + expect(existsSync(join(dir, "play", "SKILL.md"))).toBe(true); + expect(existsSync(join(dir, "smoke", "notes.txt"))).toBe(true); + expect(existsSync(join(dir, "summer-cloud"))).toBe(false); + }); +}); diff --git a/src/installer/legacy-skills.ts b/src/installer/legacy-skills.ts new file mode 100644 index 00000000..dd3072e7 --- /dev/null +++ b/src/installer/legacy-skills.ts @@ -0,0 +1,60 @@ +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Skill directory names the 2.8.x package installed into hosts' skill folders + * (`~/.claude/skills/` and the equivalents). v3 flattened and renamed + * the library, so a `summer setup --force` on an upgraded machine must remove + * the entries Summer itself installed that no longer exist — otherwise a + * retired skill such as `summer-cloud` stays behind and keeps telling agents + * to call tools that were removed (release review P1-2, 2026-09-11). + * + * The list is exactly the SKILL.md basenames shipped in summer-engine@2.8.2. + * Pruning removes a name ONLY when it is absent from the current registry, so + * a slug that returns to the library is never touched, and never removes a + * directory that has no SKILL.md (not a skill Summer wrote). + */ +export const LEGACY_SKILL_SLUGS_2_8_2: readonly string[] = [ + "character-portrait", "concept-art", "create-asset-sheet", "instantiate-asset-pack", "pixel-art", + "skybox-panorama", "sprite-sheet", "tileable-texture", "ui-graphics", "use-widget-asset", + "character-model", "environment-kit", "organic-model", "prop-model", "vehicle-model", + "design-npc", "animation-tree", "facial-and-lipsync", "generate-motion", "procedural-animation", + "retarget", "asset-strategy", "adaptive-music", "ambient-bed", "audio-direction", "music-track", + "sound-effect", "voice-line", "fps-controller", "debug", "export-and-ship", "remote-deploy", + "auto-fire-targeting", "design-mechanic", "design-level", "scene-to-level", + "host-authoritative-state", "peer-to-peer-multiplayer", "setup-multiplayer", "tune-performance", + "3d-lighting", "art-direction", "brainstorm-game", "browse-templates", "make-game", "new-project", + "play", "scene-composition", "summer-cloud", "gdscript-patterns", "ui-basics", "animated-loop", + "cinematic-cutscene", "trailer-shot", "game-feel", "dissolve", "fire", "hit-spark", "lightning", + "magic-glow", "muzzle-flash", "smoke", "water-ripple", "brainstorming", "debugging-game-feel", + "diagnosing-perf-regressions", "dispatching-parallel-agents", "gameskill", "headless-scripting", + "investigating-bugs", "mcpupdate", "playtesting-a-feature", "skill-create", "skill-improve", + "skill-test", "using-summer", "verification-before-completion", "writing-plans", "writing-skills", +]; + +export interface PrunedSkill { + name: string; + path: string; +} + +/** Names Summer installed in 2.8.x that the current registry no longer ships. */ +export function retiredSkillNames(currentSkillNames: Iterable): string[] { + const current = new Set(currentSkillNames); + return LEGACY_SKILL_SLUGS_2_8_2.filter((name) => !current.has(name)); +} + +/** + * Remove retired Summer skill directories from one host skill folder. Only + * directories that still contain a SKILL.md are removed (that is what Summer + * wrote); anything else with a colliding name is left alone. + */ +export function pruneRetiredSkills(skillsDir: string, currentSkillNames: Iterable): PrunedSkill[] { + const pruned: PrunedSkill[] = []; + for (const name of retiredSkillNames(currentSkillNames)) { + const dir = join(skillsDir, name); + if (!existsSync(join(dir, "SKILL.md"))) continue; + rmSync(dir, { recursive: true, force: true }); + pruned.push({ name, path: dir }); + } + return pruned; +} diff --git a/src/installer/version-check.test.ts b/src/installer/version-check.test.ts index 37d43439..0a002704 100644 --- a/src/installer/version-check.test.ts +++ b/src/installer/version-check.test.ts @@ -465,3 +465,19 @@ describe("buildBootDriftNotice", () => { expect(notice?.text).toContain("setup claude-code"); }); }); + +describe("refresh channel follows where this CLI came from (release review P1-3)", () => { + it("a CLI ahead of npm latest refreshes from next; behind or equal refreshes from latest", async () => { + const { refreshChannel, skillsRefreshCommand } = await import("./version-check.js"); + expect(refreshChannel("3.0.0", "2.8.2")).toBe("next"); + expect(refreshChannel("2.8.2", "2.8.2")).toBe("latest"); + expect(refreshChannel("2.8.0", "2.8.2")).toBe("latest"); + expect(refreshChannel("3.0.0", undefined)).toBe("latest"); + expect(skillsRefreshCommand("claude-code", null, "next")).toBe( + "npx clear-npx-cache && npx -y summer-engine@next setup claude-code --yes --force --channel next" + ); + expect(skillsRefreshCommand("claude-code", null)).toBe( + "npx clear-npx-cache && npx -y summer-engine@latest setup claude-code --yes --force" + ); + }); +}); diff --git a/src/installer/version-check.ts b/src/installer/version-check.ts index 7fd6b611..0bab3885 100644 --- a/src/installer/version-check.ts +++ b/src/installer/version-check.ts @@ -347,9 +347,36 @@ export interface RecordedInstall { source: "agent-config" | "running-cli"; } +export type RefreshChannel = "latest" | "next"; + +/** The npm dist-tag a refresh command should name. A CLI that is AHEAD of + * npm `latest` was installed from `next` (a soaking release); telling that + * user to run `summer-engine@latest` would silently downgrade the MCP server + * while leaving the newer skills in place (release review P1-3). */ +export function refreshChannel(installedVersion: string, latestVersion: string | undefined): RefreshChannel { + const installed = parseSemver(installedVersion); + const latest = latestVersion ? parseSemver(latestVersion) : null; + if (!installed || !latest) return "latest"; + return classifyDrift(installed, latest).reason === "ahead" ? "next" : "latest"; +} + +/** Default `summer setup` channel when neither --channel nor SUMMER_CHANNEL is + * given: `next` while this CLI is ahead of npm latest, else `latest`. Network + * failure means `latest` (never blocks setup). */ +export async function resolveDefaultChannel(installedVersion: string): Promise<{ channel: RefreshChannel; note?: string }> { + const registry = await fetchLatestRegistryVersion(); + if (!registry.ok) return { channel: "latest" }; + const channel = refreshChannel(installedVersion, registry.version); + return channel === "next" + ? { channel, note: `This CLI (${installedVersion}) is ahead of npm latest (${registry.version}); the MCP entry is pinned to summer-engine@next so the agent keeps running this release. Pass --channel latest to override.` } + : { channel }; +} + export interface SkillsVersionCheckInput { installedCliVersion: string; candidates: SkillMarkerCandidate[]; + /** npm latest, when known, so the refresh command names the right dist-tag. */ + latestRegistryVersion?: string; /** Resolves how the stale agent's MCP entry was recorded; defaults to * detectRecordedInstall. Seam for tests. */ recordedInstall?: (agent: string) => Promise | RecordedInstall | null; @@ -447,7 +474,7 @@ export async function buildSkillsVersionCheck( ...baseDetails, drift: worst.drift.reason, ...(install ? { install } : {}), - recommendedAction: skillsRefreshCommand(worst.agent, install), + recommendedAction: skillsRefreshCommand(worst.agent, install, refreshChannel(input.installedCliVersion, input.latestRegistryVersion)), }, }; } @@ -471,11 +498,12 @@ async function resolveRecordedInstall( * (`setup --local-dev --force` re-copies the checkout's skills); the npx form * is for installs that already run the published package. */ -export function skillsRefreshCommand(agent: string, install: RecordedInstall | null): string { +export function skillsRefreshCommand(agent: string, install: RecordedInstall | null, channel: RefreshChannel = "latest"): string { if (install?.localDev && install.cliPath) { return `node ${install.cliPath} setup ${agent} --local-dev --yes --force`; } - return `npx clear-npx-cache && npx -y summer-engine@latest setup ${agent} --yes --force`; + const tag = channel === "next" ? "next" : "latest"; + return `npx clear-npx-cache && npx -y summer-engine@${tag} setup ${agent} --yes --force${channel === "next" ? " --channel next" : ""}`; } /** The shape `summer setup --local-dev` writes: `node <…>/dist/bin/summer.js mcp` diff --git a/src/mcp/tools/navigation-tools.ts b/src/mcp/tools/navigation-tools.ts index 2b525706..24b292a4 100644 --- a/src/mcp/tools/navigation-tools.ts +++ b/src/mcp/tools/navigation-tools.ts @@ -21,12 +21,12 @@ target: an id (billing, usage, account, settings, team, my-games, game, pricing, params: slot values — gameId + section (builds, releases, store-page, analytics, …) for game; guide (agent name: cursor, claude-code, codex, gemini, …) for mcp-guide; username; version; path / node / scene / line / col / tab for editor targets. open: false resolves only and returns url (+ login_url when the page needs login) or op; nothing opens, no engine needed. -Result: { ok, action: opened | printed | listed | ambiguous | unsupported | engine_not_running | engine_error | not_found | invalid_params, target (with availability for editor ids), url, login_url, logged_in, opened_url, op, engine, failure_reason, matches, hint }. +Result: { ok, action: opened | printed | listed | ambiguous | unsupported | engine_not_running | engine_error | not_found | invalid_params | open_failed | blocked_origin, target (with availability for editor ids), url, login_url, logged_in, opened_url, op, engine, failure_reason, matches, hint }. - Web targets that require login open through /login?returnUrl= when this machine holds no Summer login token (logged_in:false) — the destination loads after sign-in. - Editor targets need Summer Engine running with the project open; otherwise action engine_not_running with the op that would have been sent and a 'summer run' hint. Nothing was opened. - Editor destinations are forwarded to the engine's own navigation table (op Navigate; ids advertised in /api/health capabilities.navigation). On an engine that predates it, scene/node/script/file and the three docks still work through their original ops; anything else answers action unsupported with failure_reason engine_lacks_op and an update hint. Never claim those opened. Listing (no target) shows each editor id's availability from the connected engine. - ambiguous: several destinations match; call again with one of matches[].id. -Only summerengine.com and docs.summerengine.com are ever opened.`, +Only summerengine.com (and subdomains) are ever opened — a gateway configured elsewhere is refused (blocked_origin); res:// paths with .. or other escapes are refused; a machine with no browser gets open_failed with the url to hand over.`, openArgsShape, async (args) => { const result = await runOpen(args, {