From f038d04dc58b212eee36686a16c0f358e26bb527 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 04:13:35 -0700 Subject: [PATCH] FN-8986: stage computer-use skill only on Darwin Stage the bundled computer-use discovery skill exclusively for supported macOS runtimes. - Gate Claude and Grok session skill resolution and staging by platform. - Install the Hermes discovery skill on Darwin while preserving user overrides and non-Darwin suppression. - Document runtime discovery behavior, add coverage, and add a minor release changeset. Files changed: .../darwin-only-computer-use-skill-staging.md | 7 + docs/computer-use.md | 10 +- .../custom-provider-routes-hermes-additive.test.ts | 2 +- .../src/__tests__/skill-loader.test.ts | 197 +++++++++++++++++++++ .../src/skill-loader.ts | 145 ++++++++++----- .../src/__tests__/skill-loader.test.ts | 51 +++++- .../fusion-plugin-grok-runtime/src/skill-loader.ts | 145 ++++++++++----- .../src/__tests__/fusion-skill-install.test.ts | 62 ++++++- .../src/__tests__/index.test.ts | 37 +++- .../src/fusion-skill-install.ts | 151 ++++++++-------- plugins/fusion-plugin-hermes-runtime/src/index.ts | 21 ++- 11 files changed, 649 insertions(+), 179 deletions(-) Fusion-Task-Id: FN-8986 Fusion-Task-Lineage: 373d558a-1d0a-4737-99be-f53189a9aeaf Co-authored-by: Fusion (runfusion.ai) --- .../darwin-only-computer-use-skill-staging.md | 7 + docs/computer-use.md | 10 +- ...om-provider-routes-hermes-additive.test.ts | 2 +- .../src/__tests__/skill-loader.test.ts | 197 ++++++++++++++++++ .../src/skill-loader.ts | 147 ++++++++----- .../src/__tests__/skill-loader.test.ts | 51 ++++- .../src/skill-loader.ts | 147 ++++++++----- .../__tests__/fusion-skill-install.test.ts | 62 +++++- .../src/__tests__/index.test.ts | 37 +++- .../src/fusion-skill-install.ts | 153 +++++++------- .../fusion-plugin-hermes-runtime/src/index.ts | 21 +- 11 files changed, 652 insertions(+), 182 deletions(-) create mode 100644 .changeset/darwin-only-computer-use-skill-staging.md create mode 100644 plugins/fusion-plugin-claude-runtime/src/__tests__/skill-loader.test.ts diff --git a/.changeset/darwin-only-computer-use-skill-staging.md b/.changeset/darwin-only-computer-use-skill-staging.md new file mode 100644 index 0000000000..cee6597a2d --- /dev/null +++ b/.changeset/darwin-only-computer-use-skill-staging.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Let macOS runtime sessions automatically discover the computer-use skill. +category: feature +dev: Adds installer-boundary Darwin gating in Hermes and suppression-only staging in Claude, Grok, and Hermes loaders. diff --git a/docs/computer-use.md b/docs/computer-use.md index 70da8bfb3e..0c20eae9ae 100644 --- a/docs/computer-use.md +++ b/docs/computer-use.md @@ -151,13 +151,19 @@ No user timeout flag is provided. Default seam timeouts are: permission probe 5 ## Orca prior art and Fusion differences -This surface was informed by [Orca](https://github.com/stablyai/orca) and its [computer-use CLI documentation](https://www.onorca.dev/docs/cli/computer-use). Orca uses two layers: a native-backed `orca computer` CLI and a thin skill that loads a version-matched guide. Fusion follows the CLI-plus-skill separation; the skill is deliberately out of scope for this command and is delivered separately. +This surface was informed by [Orca](https://github.com/stablyai/orca) and its [computer-use CLI documentation](https://www.onorca.dev/docs/cli/computer-use). Orca uses two layers: a native-backed `orca computer` CLI and a thin skill that loads a version-matched guide. Fusion follows the CLI-plus-skill separation; the thin discovery skill is shipped separately from this command surface. Fusion deliberately differs by using macOS OS built-ins instead of native helpers; publishing non-mutating preflight permission checks, an `unknown` status, and the outcome matrix; documenting durable on-disk cross-process snapshots with identity-verified window/locator replay; distinguishing stale, missing, and unresolvable indexes; publishing deterministic failure precedence; and using a versioned envelope with a fixed append-only error enum. `paste-text` and `perform-secondary-action` are not implemented in this release; they remain absent from capabilities rather than being stubs. ## Use it from an agent -Fusion ships a thin `computer-use` skill alongside the Fusion skill. When Claude-compatible skill installation is configured, both are reconciled into project `.claude/skills/` directories; `fn init` also copies both into supported home skill directories. + + +### How agents discover this + +On macOS, Claude and Grok runtime sessions stage the shipped `computer-use` discovery skill in their session plugin directory, and Hermes installs it into `/skills/computer-use`. The gate is authoritative: no runtime option, requested skill name, or additional path can stage the bundled skill elsewhere, and the Hermes installer returns `skipped` without touching the Hermes home on other platforms. This avoids spending context or disk on an unsupported CLI capability. + +Fusion ships the thin `computer-use` skill alongside the Fusion skill. When Claude-compatible skill installation is configured, both are reconciled into project `.claude/skills/` directories; `fn init` also copies both into supported home skill directories. The discovery stub intentionally contains no command reference. An agent resolves one `fn` executable for its session and runs `fn skills get computer-use` before automation. That command renders its complete guide in-process from the same binary's command-surface descriptor and reports the same package version as `fn --version`. diff --git a/packages/dashboard/src/routes/__tests__/custom-provider-routes-hermes-additive.test.ts b/packages/dashboard/src/routes/__tests__/custom-provider-routes-hermes-additive.test.ts index 072adcd4c6..fbe524c521 100644 --- a/packages/dashboard/src/routes/__tests__/custom-provider-routes-hermes-additive.test.ts +++ b/packages/dashboard/src/routes/__tests__/custom-provider-routes-hermes-additive.test.ts @@ -53,7 +53,7 @@ vi.mock("@fusion-plugin-examples/hermes-runtime/dist/cli-spawn.js", async () => }); vi.mock("@fusion-plugin-examples/hermes-runtime/dist/fusion-skill-install.js", async () => { const actual = await vi.importActual>("@fusion-plugin-examples/hermes-runtime/dist/fusion-skill-install.js"); - return { ...actual, installFusionSkillIntoHermesHome: mockInstallFusionSkill }; + return { ...actual, installComputerUseSkillIntoHermesHome: mockInstallComputerUseSkill, installFusionSkillIntoHermesHome: mockInstallFusionSkill }; }); function createMockGlobalSettingsStore(settings: GlobalSettings) { diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/skill-loader.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/skill-loader.test.ts new file mode 100644 index 0000000000..b1cecf31eb --- /dev/null +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/skill-loader.test.ts @@ -0,0 +1,197 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildClaudeSkillRules, + extractRequestedSkillNames, + getFusionSkillSourceCandidates, + resolveBundledComputerUseSkillSource, + resolveBundledFusionSkillSource, + stageClaudeSessionSkills, +} from "../skill-loader.js"; + +const disposers: Array<() => void> = []; +const envKeysTouched = new Set(); + +afterEach(() => { + while (disposers.length > 0) disposers.pop()?.(); + for (const key of envKeysTouched) { + delete process.env[key]; + } + envKeysTouched.clear(); +}); + +function setEnv(key: string, value: string): void { + process.env[key] = value; + envKeysTouched.add(key); +} + +describe("skill-loader", () => { + it("resolves the bundled Fusion skill from the monorepo", () => { + const source = resolveBundledFusionSkillSource(); + expect(source).toBeTruthy(); + expect(existsSync(join(source!, "SKILL.md"))).toBe(true); + }); + + it("includes monorepo packages/cli/skill/fusion among candidates", () => { + const candidates = getFusionSkillSourceCandidates(); + expect( + candidates.some((c) => c.endsWith(join("packages", "cli", "skill", "fusion")) || c.includes(`${join("packages", "cli", "skill", "fusion")}`)), + ).toBe(true); + }); + + /* + FNXC:ClaudeAcp 2026-07-12-06:15: + Packaged @runfusion/fusion ships skill/** at package root next to dist/plugins/. + Candidate generation from a synthetic bundled.js URL must reach skill/fusion + without a monorepo tree. + */ + it("generates packaged-install candidates for bundled plugin layout", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "fusion-pkg-")); + const pluginDist = join(packageRoot, "dist", "plugins", "fusion-plugin-claude-runtime"); + mkdirSync(pluginDist, { recursive: true }); + const bundledJs = join(pluginDist, "bundled.js"); + writeFileSync(bundledJs, "// stub\n"); + + const skillDir = join(packageRoot, "skill", "fusion"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# fusion\n"); + + const moduleUrl = pathToFileURL(bundledJs).href; + const candidates = getFusionSkillSourceCandidates(moduleUrl); + const resolvedSkill = resolve(skillDir); + + expect(candidates).toContain(resolvedSkill); + expect(resolveBundledFusionSkillSource(moduleUrl)).toBe(resolvedSkill); + }); + + it("walks ancestors for packages/cli/skill/fusion layout", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "fusion-repo-")); + const pluginSrc = join(repoRoot, "plugins", "fusion-plugin-claude-runtime", "src"); + mkdirSync(pluginSrc, { recursive: true }); + const moduleFile = join(pluginSrc, "skill-loader.js"); + writeFileSync(moduleFile, "// stub\n"); + + const skillDir = join(repoRoot, "packages", "cli", "skill", "fusion"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# fusion\n"); + + const moduleUrl = pathToFileURL(moduleFile).href; + const candidates = getFusionSkillSourceCandidates(moduleUrl); + expect(candidates).toContain(resolve(skillDir)); + expect(resolveBundledFusionSkillSource(moduleUrl)).toBe(resolve(skillDir)); + }); + + it("prefers FUSION_SKILL_SOURCE when set and valid", () => { + const root = mkdtempSync(join(tmpdir(), "fusion-env-skill-")); + const skillDir = join(root, "custom-fusion"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# env\n"); + setEnv("FUSION_SKILL_SOURCE", skillDir); + + const candidates = getFusionSkillSourceCandidates(pathToFileURL(join(root, "missing", "mod.js")).href); + expect(candidates[0]).toBe(resolve(skillDir)); + expect(resolveBundledFusionSkillSource(pathToFileURL(join(root, "missing", "mod.js")).href)).toBe( + resolve(skillDir), + ); + }); + + it("stages fusion skill plus additional skill roots into a plugin dir", () => { + const extraRoot = mkdtempSync(join(tmpdir(), "extra-skills-")); + const skillDir = join(extraRoot, "ce-plan"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: ce-plan\n---\n# plan\n"); + + const staged = stageClaudeSessionSkills({ + requestedSkillNames: ["fusion", "ce-plan"], + additionalSkillPaths: [extraRoot], + }); + disposers.push(staged.dispose); + + expect(existsSync(join(staged.pluginDir, "skills", "fusion", "SKILL.md"))).toBe(true); + expect(existsSync(join(staged.pluginDir, "skills", "ce-plan", "SKILL.md"))).toBe(true); + expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "ce-plan"])); + }); + + it("still lists requested fusion skill in rules when staging cannot copy files", () => { + const staged = stageClaudeSessionSkills({ + requestedSkillNames: ["fusion"], + includeFusionSkill: false, + }); + disposers.push(staged.dispose); + expect(staged.skillNames).toContain("fusion"); + const rules = buildClaudeSkillRules({ skillNames: staged.skillNames, fusionToolCount: 0 }); + expect(rules).toContain("fusion"); + expect(rules).toContain("Use the Fusion skill workflows"); + }); + + it("extracts requested skill names from skills or skillSelection", () => { + expect(extractRequestedSkillNames({ skills: ["a", "b"] })).toEqual(["a", "b"]); + expect( + extractRequestedSkillNames({ skillSelection: { requestedSkillNames: ["fusion", "ce-plan"] } }), + ).toEqual(["fusion", "ce-plan"]); + }); + + it("builds rules mentioning skills and tool counts", () => { + const rules = buildClaudeSkillRules({ + skillNames: ["fusion"], + toolMode: "coding", + fusionToolCount: 3, + operatorMcpCount: 1, + }); + expect(rules).toContain("fusion"); + expect(rules).toContain("fusion-custom-tools"); + expect(rules).toContain("Operator MCP servers"); + }); + + it("stages bundled computer-use only on Darwin", () => { + const staged = stageClaudeSessionSkills({ platform: "darwin" }); + disposers.push(staged.dispose); + expect(existsSync(join(staged.pluginDir, "skills", "fusion", "SKILL.md"))).toBe(true); + expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true); + expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "computer-use"])); + }); + + it("keeps bundled computer-use absent on non-Darwin through every option path", () => { + const source = resolveBundledComputerUseSkillSource(import.meta.url, "darwin")!; + const cases = [ + { includeComputerUseSkill: true }, + { requestedSkillNames: ["computer-use"] }, + { additionalSkillPaths: [source] }, + { includeComputerUseSkill: true, requestedSkillNames: ["computer-use"], additionalSkillPaths: [source] }, + ]; + for (const options of cases) { + const staged = stageClaudeSessionSkills({ ...options, platform: "linux" }); + disposers.push(staged.dispose); + expect(existsSync(join(staged.pluginDir, "skills", "computer-use"))).toBe(false); + expect(staged.skillNames).not.toContain("computer-use"); + } + }); + + it("keeps user-owned computer-use roots on non-Darwin and honors suppression", () => { + const root = mkdtempSync(join(tmpdir(), "user-computer-use-")); + const userSkill = join(root, "computer-use"); + mkdirSync(userSkill, { recursive: true }); + writeFileSync(join(userSkill, "SKILL.md"), "# user skill\n"); + const staged = stageClaudeSessionSkills({ platform: "linux", additionalSkillPaths: [root] }); + disposers.push(staged.dispose); + expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true); + const overridden = stageClaudeSessionSkills({ platform: "darwin", additionalSkillPaths: [root] }); + disposers.push(overridden.dispose); + expect(readFileSync(join(overridden.pluginDir, "skills", "computer-use", "SKILL.md"), "utf8")).toContain("# user skill"); + const suppressed = stageClaudeSessionSkills({ platform: "darwin", includeComputerUseSkill: false }); + disposers.push(suppressed.dispose); + expect(suppressed.skillNames).not.toContain("computer-use"); + }); + + it("gates computer-use at source resolution and keeps rules as discovery-only", () => { + expect(resolveBundledComputerUseSkillSource(import.meta.url, "linux")).toBeNull(); + expect(resolveBundledComputerUseSkillSource(import.meta.url, "darwin")).toBeTruthy(); + const rules = buildClaudeSkillRules({ skillNames: ["fusion", "computer-use"] }); + expect(rules).toContain("computer-use"); + expect(rules).not.toMatch(/fn computer|--/i); + }); +}); diff --git a/plugins/fusion-plugin-claude-runtime/src/skill-loader.ts b/plugins/fusion-plugin-claude-runtime/src/skill-loader.ts index d15c022783..3caae48d76 100644 --- a/plugins/fusion-plugin-claude-runtime/src/skill-loader.ts +++ b/plugins/fusion-plugin-claude-runtime/src/skill-loader.ts @@ -23,6 +23,7 @@ import { mkdirSync, mkdtempSync, readdirSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -33,6 +34,7 @@ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export const FUSION_SKILL_NAME = "fusion"; +export const COMPUTER_USE_SKILL_NAME = "computer-use"; export interface ClaudeSkillStagingResult { pluginDir: string; @@ -50,83 +52,102 @@ function pushUnique(out: string[], candidate: string | null | undefined): void { if (!out.includes(resolved)) out.push(resolved); } -function pushSkillLayoutsAtRoot(out: string[], root: string): void { - pushUnique(out, join(root, "skill", FUSION_SKILL_NAME)); - pushUnique(out, join(root, "packages", "cli", "skill", FUSION_SKILL_NAME)); +function pushSkillLayoutsAtRoot(out: string[], root: string, skillName: string): void { + pushUnique(out, join(root, "skill", skillName)); + pushUnique(out, join(root, "packages", "cli", "skill", skillName)); } -function walkAncestorSkillCandidates(out: string[], startDir: string, maxParents = 8): void { +function walkAncestorSkillCandidates(out: string[], startDir: string, skillName: string, maxParents = 8): void { let dir = startDir; for (let i = 0; i < maxParents; i++) { - pushSkillLayoutsAtRoot(out, dir); + pushSkillLayoutsAtRoot(out, dir, skillName); const parent = dirname(dir); if (parent === dir) break; dir = parent; } } -function pushPackageRequireCandidates(out: string[], from: string): void { +function pushPackageRequireCandidates(out: string[], from: string, skillName: string): void { try { const require = createRequire(from); const pkgJson = require.resolve("@runfusion/fusion/package.json"); - pushUnique(out, join(dirname(pkgJson), "skill", FUSION_SKILL_NAME)); + pushUnique(out, join(dirname(pkgJson), "skill", skillName)); } catch { // Package not resolvable from this origin (plugin-only tree, tests, etc.). } } -/** - * Ordered candidate directories for the bundled Fusion skill (`skill/fusion` with SKILL.md). - * First existing skill dir wins in resolveBundledFusionSkillSource. - */ -export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { +/** Ordered candidate directories for a bundled skill. First existing directory wins. */ +function getSkillSourceCandidates(skillName: string, moduleUrl = import.meta.url): string[] { const candidates: string[] = []; - const envSource = process.env.FUSION_SKILL_SOURCE?.trim(); - if (envSource) { - pushUnique(candidates, envSource); - } - const here = fileURLToPath(moduleUrl); const moduleDir = dirname(here); - // Monorepo source checkout relative to plugins/fusion-plugin-claude-runtime/src - pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME)); - // Relative siblings used in various dist layouts - pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME)); - pushUnique(candidates, resolve(moduleDir, "..", "skill", FUSION_SKILL_NAME)); - pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); - /* - FNXC:ClaudeAcp 2026-07-12-06:15: - Published package layout: dist/plugins/fusion-plugin-claude-runtime/* → ../../../skill/fusion - at the @runfusion/fusion package root (files includes skill/**). - */ - pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); - pushUnique(candidates, resolve(moduleDir, "../../../skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "..", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "../../../skill", skillName)); - walkAncestorSkillCandidates(candidates, moduleDir, 8); - pushPackageRequireCandidates(candidates, moduleUrl); + walkAncestorSkillCandidates(candidates, moduleDir, skillName, 8); + pushPackageRequireCandidates(candidates, moduleUrl, skillName); const argv1 = typeof process.argv[1] === "string" ? process.argv[1].trim() : ""; if (argv1) { try { const argvPath = resolve(argv1); - pushPackageRequireCandidates(candidates, argvPath); - walkAncestorSkillCandidates(candidates, dirname(argvPath), 8); + pushPackageRequireCandidates(candidates, argvPath, skillName); + walkAncestorSkillCandidates(candidates, dirname(argvPath), skillName, 8); } catch { // ignore bad argv paths } } - return candidates; } -export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null { - for (const candidate of getFusionSkillSourceCandidates(moduleUrl)) { - if (isSkillDir(candidate)) return candidate; - } +/** Candidate ordering and FUSION_SKILL_SOURCE precedence remain fusion-only. */ +export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + const candidates: string[] = []; + const envSource = process.env.FUSION_SKILL_SOURCE?.trim(); + if (envSource) pushUnique(candidates, envSource); + for (const candidate of getSkillSourceCandidates(FUSION_SKILL_NAME, moduleUrl)) pushUnique(candidates, candidate); + return candidates; +} + +export function getComputerUseSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + return getSkillSourceCandidates(COMPUTER_USE_SKILL_NAME, moduleUrl); +} + +function resolveSkillSource(candidates: string[]): string | null { + for (const candidate of candidates) if (isSkillDir(candidate)) return candidate; return null; } +export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null { + return resolveSkillSource(getFusionSkillSourceCandidates(moduleUrl)); +} + +export function shouldStageComputerUseSkill(platform: NodeJS.Platform = process.platform): boolean { + return platform === "darwin"; +} + +export function resolveBundledComputerUseSkillSource( + moduleUrl = import.meta.url, + platform: NodeJS.Platform = process.platform, +): string | null { + if (!shouldStageComputerUseSkill(platform)) return null; + return resolveSkillSource(getComputerUseSkillSourceCandidates(moduleUrl)); +} + +function resolvedRealPath(path: string | null): string | null { + if (!path) return null; + try { + return realpathSync(path); + } catch { + return null; + } +} + function installSkillDir(sourceDir: string, targetDir: string): boolean { if (!isSkillDir(sourceDir)) return false; mkdirSync(dirname(targetDir), { recursive: true }); @@ -174,6 +195,12 @@ export interface StageClaudeSkillsOptions { additionalSkillPaths?: string[]; /** Always include the bundled Fusion skill (default true). */ includeFusionSkill?: boolean; + /** Platform injection keeps both platform branches testable on every host. */ + platform?: NodeJS.Platform; + /** Suppression-only: it can disable computer-use, never override its Darwin gate. */ + includeComputerUseSkill?: boolean; + /** Module URL override for packaged-layout resolution tests. */ + moduleUrl?: string; } /** @@ -185,31 +212,52 @@ export function stageClaudeSessionSkills(options: StageClaudeSkillsOptions = {}) mkdirSync(skillsDir, { recursive: true }); const installed = new Map(); + const platform = options.platform ?? process.platform; const includeFusion = options.includeFusionSkill !== false; + const moduleUrl = options.moduleUrl ?? import.meta.url; + // FNXC:ClaudeAcp 2026-08-11-09:23: FN-8984 requires computer-use only on Darwin. + // Gate both source resolution and installation so no requested/additional option can force + // the FN-8961 discovery stub (and never its command flags/body) onto another platform. + const includeComputerUse = options.includeComputerUseSkill !== false && shouldStageComputerUseSkill(platform); + const bundledComputerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, "darwin"); + const bundledComputerUseRealPath = resolvedRealPath(bundledComputerUseSource); + const installedBundledNames = new Set(); if (includeFusion) { - const fusionSource = resolveBundledFusionSkillSource(); + const fusionSource = resolveBundledFusionSkillSource(moduleUrl); if (fusionSource && installSkillDir(fusionSource, join(skillsDir, FUSION_SKILL_NAME))) { installed.set(FUSION_SKILL_NAME, fusionSource); + installedBundledNames.add(FUSION_SKILL_NAME); + } + } + if (includeComputerUse) { + const computerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, platform); + if (computerUseSource && installSkillDir(computerUseSource, join(skillsDir, COMPUTER_USE_SKILL_NAME))) { + installed.set(COMPUTER_USE_SKILL_NAME, computerUseSource); + installedBundledNames.add(COMPUTER_USE_SKILL_NAME); } } for (const root of options.additionalSkillPaths ?? []) { if (typeof root !== "string" || !root.trim()) continue; - collectSkillsFromRoot(root.trim(), installed); + const collected = new Map(); + collectSkillsFromRoot(root.trim(), collected); + for (const [name, source] of collected) { + if (!shouldStageComputerUseSkill(platform) && bundledComputerUseRealPath && resolvedRealPath(source) === bundledComputerUseRealPath) continue; + installed.set(name, source); + } } - // Re-install collected skills (may overwrite with higher-priority roots). + // FNXC:ClaudeAcp 2026-08-11-09:41: Preserve Fusion's existing bundled precedence, but let a + // user-owned computer-use root override the Darwin bundle without reinstalling the same source. for (const [name, source] of installed) { - if (name === FUSION_SKILL_NAME && includeFusion) continue; // already installed + if (name === FUSION_SKILL_NAME && includeFusion) continue; + if (name === COMPUTER_USE_SKILL_NAME && installedBundledNames.has(name) && source === bundledComputerUseSource) continue; installSkillDir(source, join(skillsDir, name)); } - // Second pass: additionalSkillPaths may have added fusion under a different name path. for (const [name, source] of installed) { - if (!existsSync(join(skillsDir, name))) { - installSkillDir(source, join(skillsDir, name)); - } + if (!existsSync(join(skillsDir, name))) installSkillDir(source, join(skillsDir, name)); } writeFileSync( @@ -228,7 +276,10 @@ export function stageClaudeSessionSkills(options: StageClaudeSkillsOptions = {}) const skillNames = Array.from( new Set([ ...installed.keys(), - ...(options.requestedSkillNames ?? []).filter((n) => typeof n === "string" && n.trim().length > 0), + ...(options.requestedSkillNames ?? []).filter( + (n) => typeof n === "string" && n.trim().length > 0 && + (n !== COMPUTER_USE_SKILL_NAME || shouldStageComputerUseSkill(platform) || installed.has(n)), + ), ]), ); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts index 5822892829..fe153c4c72 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/skill-loader.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { mkdtempSync } from "node:fs"; @@ -8,6 +8,7 @@ import { buildGrokSkillRules, extractRequestedSkillNames, getFusionSkillSourceCandidates, + resolveBundledComputerUseSkillSource, resolveBundledFusionSkillSource, stageGrokSessionSkills, } from "../skill-loader.js"; @@ -145,4 +146,52 @@ describe("skill-loader", () => { expect(rules).toContain("fusion-custom-tools"); expect(rules).toContain("Operator MCP servers"); }); + + it("stages bundled computer-use only on Darwin", () => { + const staged = stageGrokSessionSkills({ platform: "darwin" }); + disposers.push(staged.dispose); + expect(existsSync(join(staged.pluginDir, "skills", "fusion", "SKILL.md"))).toBe(true); + expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true); + expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "computer-use"])); + }); + + it("keeps bundled computer-use absent on non-Darwin through every option path", () => { + const source = resolveBundledComputerUseSkillSource(import.meta.url, "darwin")!; + const cases = [ + { includeComputerUseSkill: true }, + { requestedSkillNames: ["computer-use"] }, + { additionalSkillPaths: [source] }, + { includeComputerUseSkill: true, requestedSkillNames: ["computer-use"], additionalSkillPaths: [source] }, + ]; + for (const options of cases) { + const staged = stageGrokSessionSkills({ ...options, platform: "linux" }); + disposers.push(staged.dispose); + expect(existsSync(join(staged.pluginDir, "skills", "computer-use"))).toBe(false); + expect(staged.skillNames).not.toContain("computer-use"); + } + }); + + it("keeps user-owned computer-use roots on non-Darwin and honors suppression", () => { + const root = mkdtempSync(join(tmpdir(), "user-computer-use-")); + const userSkill = join(root, "computer-use"); + mkdirSync(userSkill, { recursive: true }); + writeFileSync(join(userSkill, "SKILL.md"), "# user skill\n"); + const staged = stageGrokSessionSkills({ platform: "linux", additionalSkillPaths: [root] }); + disposers.push(staged.dispose); + expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true); + const overridden = stageGrokSessionSkills({ platform: "darwin", additionalSkillPaths: [root] }); + disposers.push(overridden.dispose); + expect(readFileSync(join(overridden.pluginDir, "skills", "computer-use", "SKILL.md"), "utf8")).toContain("# user skill"); + const suppressed = stageGrokSessionSkills({ platform: "darwin", includeComputerUseSkill: false }); + disposers.push(suppressed.dispose); + expect(suppressed.skillNames).not.toContain("computer-use"); + }); + + it("gates computer-use at source resolution and keeps rules as discovery-only", () => { + expect(resolveBundledComputerUseSkillSource(import.meta.url, "linux")).toBeNull(); + expect(resolveBundledComputerUseSkillSource(import.meta.url, "darwin")).toBeTruthy(); + const rules = buildGrokSkillRules({ skillNames: ["fusion", "computer-use"] }); + expect(rules).toContain("computer-use"); + expect(rules).not.toMatch(/fn computer|--/i); + }); }); diff --git a/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts b/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts index 19dbeec518..e6eeb6dc5c 100644 --- a/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts +++ b/plugins/fusion-plugin-grok-runtime/src/skill-loader.ts @@ -23,6 +23,7 @@ import { mkdirSync, mkdtempSync, readdirSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -33,6 +34,7 @@ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export const FUSION_SKILL_NAME = "fusion"; +export const COMPUTER_USE_SKILL_NAME = "computer-use"; export interface GrokSkillStagingResult { pluginDir: string; @@ -50,83 +52,102 @@ function pushUnique(out: string[], candidate: string | null | undefined): void { if (!out.includes(resolved)) out.push(resolved); } -function pushSkillLayoutsAtRoot(out: string[], root: string): void { - pushUnique(out, join(root, "skill", FUSION_SKILL_NAME)); - pushUnique(out, join(root, "packages", "cli", "skill", FUSION_SKILL_NAME)); +function pushSkillLayoutsAtRoot(out: string[], root: string, skillName: string): void { + pushUnique(out, join(root, "skill", skillName)); + pushUnique(out, join(root, "packages", "cli", "skill", skillName)); } -function walkAncestorSkillCandidates(out: string[], startDir: string, maxParents = 8): void { +function walkAncestorSkillCandidates(out: string[], startDir: string, skillName: string, maxParents = 8): void { let dir = startDir; for (let i = 0; i < maxParents; i++) { - pushSkillLayoutsAtRoot(out, dir); + pushSkillLayoutsAtRoot(out, dir, skillName); const parent = dirname(dir); if (parent === dir) break; dir = parent; } } -function pushPackageRequireCandidates(out: string[], from: string): void { +function pushPackageRequireCandidates(out: string[], from: string, skillName: string): void { try { const require = createRequire(from); const pkgJson = require.resolve("@runfusion/fusion/package.json"); - pushUnique(out, join(dirname(pkgJson), "skill", FUSION_SKILL_NAME)); + pushUnique(out, join(dirname(pkgJson), "skill", skillName)); } catch { // Package not resolvable from this origin (plugin-only tree, tests, etc.). } } -/** - * Ordered candidate directories for the bundled Fusion skill (`skill/fusion` with SKILL.md). - * First existing skill dir wins in resolveBundledFusionSkillSource. - */ -export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { +/** Ordered candidate directories for a bundled skill. First existing directory wins. */ +function getSkillSourceCandidates(skillName: string, moduleUrl = import.meta.url): string[] { const candidates: string[] = []; - const envSource = process.env.FUSION_SKILL_SOURCE?.trim(); - if (envSource) { - pushUnique(candidates, envSource); - } - const here = fileURLToPath(moduleUrl); const moduleDir = dirname(here); - // Monorepo source checkout relative to plugins/fusion-plugin-grok-runtime/src - pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME)); - // Relative siblings used in various dist layouts - pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME)); - pushUnique(candidates, resolve(moduleDir, "..", "skill", FUSION_SKILL_NAME)); - pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); - /* - FNXC:GrokAcp 2026-07-12-06:15: - Published package layout: dist/plugins/fusion-plugin-grok-runtime/* → ../../../skill/fusion - at the @runfusion/fusion package root (files includes skill/**). - */ - pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME)); - pushUnique(candidates, resolve(moduleDir, "../../../skill", FUSION_SKILL_NAME)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "..", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", skillName)); + pushUnique(candidates, resolve(moduleDir, "../../../skill", skillName)); - walkAncestorSkillCandidates(candidates, moduleDir, 8); - pushPackageRequireCandidates(candidates, moduleUrl); + walkAncestorSkillCandidates(candidates, moduleDir, skillName, 8); + pushPackageRequireCandidates(candidates, moduleUrl, skillName); const argv1 = typeof process.argv[1] === "string" ? process.argv[1].trim() : ""; if (argv1) { try { const argvPath = resolve(argv1); - pushPackageRequireCandidates(candidates, argvPath); - walkAncestorSkillCandidates(candidates, dirname(argvPath), 8); + pushPackageRequireCandidates(candidates, argvPath, skillName); + walkAncestorSkillCandidates(candidates, dirname(argvPath), skillName, 8); } catch { // ignore bad argv paths } } - return candidates; } -export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null { - for (const candidate of getFusionSkillSourceCandidates(moduleUrl)) { - if (isSkillDir(candidate)) return candidate; - } +/** Candidate ordering and FUSION_SKILL_SOURCE precedence remain fusion-only. */ +export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + const candidates: string[] = []; + const envSource = process.env.FUSION_SKILL_SOURCE?.trim(); + if (envSource) pushUnique(candidates, envSource); + for (const candidate of getSkillSourceCandidates(FUSION_SKILL_NAME, moduleUrl)) pushUnique(candidates, candidate); + return candidates; +} + +export function getComputerUseSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + return getSkillSourceCandidates(COMPUTER_USE_SKILL_NAME, moduleUrl); +} + +function resolveSkillSource(candidates: string[]): string | null { + for (const candidate of candidates) if (isSkillDir(candidate)) return candidate; return null; } +export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null { + return resolveSkillSource(getFusionSkillSourceCandidates(moduleUrl)); +} + +export function shouldStageComputerUseSkill(platform: NodeJS.Platform = process.platform): boolean { + return platform === "darwin"; +} + +export function resolveBundledComputerUseSkillSource( + moduleUrl = import.meta.url, + platform: NodeJS.Platform = process.platform, +): string | null { + if (!shouldStageComputerUseSkill(platform)) return null; + return resolveSkillSource(getComputerUseSkillSourceCandidates(moduleUrl)); +} + +function resolvedRealPath(path: string | null): string | null { + if (!path) return null; + try { + return realpathSync(path); + } catch { + return null; + } +} + function installSkillDir(sourceDir: string, targetDir: string): boolean { if (!isSkillDir(sourceDir)) return false; mkdirSync(dirname(targetDir), { recursive: true }); @@ -174,6 +195,12 @@ export interface StageGrokSkillsOptions { additionalSkillPaths?: string[]; /** Always include the bundled Fusion skill (default true). */ includeFusionSkill?: boolean; + /** Platform injection keeps both platform branches testable on every host. */ + platform?: NodeJS.Platform; + /** Suppression-only: it can disable computer-use, never override its Darwin gate. */ + includeComputerUseSkill?: boolean; + /** Module URL override for packaged-layout resolution tests. */ + moduleUrl?: string; } /** @@ -185,31 +212,52 @@ export function stageGrokSessionSkills(options: StageGrokSkillsOptions = {}): Gr mkdirSync(skillsDir, { recursive: true }); const installed = new Map(); + const platform = options.platform ?? process.platform; const includeFusion = options.includeFusionSkill !== false; + const moduleUrl = options.moduleUrl ?? import.meta.url; + // FNXC:GrokAcp 2026-08-11-09:23: FN-8984 requires computer-use only on Darwin. + // Gate both source resolution and installation so no requested/additional option can force + // the FN-8961 discovery stub (and never its command flags/body) onto another platform. + const includeComputerUse = options.includeComputerUseSkill !== false && shouldStageComputerUseSkill(platform); + const bundledComputerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, "darwin"); + const bundledComputerUseRealPath = resolvedRealPath(bundledComputerUseSource); + const installedBundledNames = new Set(); if (includeFusion) { - const fusionSource = resolveBundledFusionSkillSource(); + const fusionSource = resolveBundledFusionSkillSource(moduleUrl); if (fusionSource && installSkillDir(fusionSource, join(skillsDir, FUSION_SKILL_NAME))) { installed.set(FUSION_SKILL_NAME, fusionSource); + installedBundledNames.add(FUSION_SKILL_NAME); + } + } + if (includeComputerUse) { + const computerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, platform); + if (computerUseSource && installSkillDir(computerUseSource, join(skillsDir, COMPUTER_USE_SKILL_NAME))) { + installed.set(COMPUTER_USE_SKILL_NAME, computerUseSource); + installedBundledNames.add(COMPUTER_USE_SKILL_NAME); } } for (const root of options.additionalSkillPaths ?? []) { if (typeof root !== "string" || !root.trim()) continue; - collectSkillsFromRoot(root.trim(), installed); + const collected = new Map(); + collectSkillsFromRoot(root.trim(), collected); + for (const [name, source] of collected) { + if (!shouldStageComputerUseSkill(platform) && bundledComputerUseRealPath && resolvedRealPath(source) === bundledComputerUseRealPath) continue; + installed.set(name, source); + } } - // Re-install collected skills (may overwrite with higher-priority roots). + // FNXC:GrokAcp 2026-08-11-09:41: Preserve Fusion's existing bundled precedence, but let a + // user-owned computer-use root override the Darwin bundle without reinstalling the same source. for (const [name, source] of installed) { - if (name === FUSION_SKILL_NAME && includeFusion) continue; // already installed + if (name === FUSION_SKILL_NAME && includeFusion) continue; + if (name === COMPUTER_USE_SKILL_NAME && installedBundledNames.has(name) && source === bundledComputerUseSource) continue; installSkillDir(source, join(skillsDir, name)); } - // Second pass: additionalSkillPaths may have added fusion under a different name path. for (const [name, source] of installed) { - if (!existsSync(join(skillsDir, name))) { - installSkillDir(source, join(skillsDir, name)); - } + if (!existsSync(join(skillsDir, name))) installSkillDir(source, join(skillsDir, name)); } writeFileSync( @@ -228,7 +276,10 @@ export function stageGrokSessionSkills(options: StageGrokSkillsOptions = {}): Gr const skillNames = Array.from( new Set([ ...installed.keys(), - ...(options.requestedSkillNames ?? []).filter((n) => typeof n === "string" && n.trim().length > 0), + ...(options.requestedSkillNames ?? []).filter( + (n) => typeof n === "string" && n.trim().length > 0 && + (n !== COMPUTER_USE_SKILL_NAME || shouldStageComputerUseSkill(platform) || installed.has(n)), + ), ]), ); diff --git a/plugins/fusion-plugin-hermes-runtime/src/__tests__/fusion-skill-install.test.ts b/plugins/fusion-plugin-hermes-runtime/src/__tests__/fusion-skill-install.test.ts index 440d145f7f..45c36e65d2 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/__tests__/fusion-skill-install.test.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/__tests__/fusion-skill-install.test.ts @@ -1,11 +1,13 @@ -import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { getFusionSkillSourceCandidates, + installComputerUseSkillIntoHermesHome, installFusionSkillIntoHermesHome, resolveBundledFusionSkillSourceFromCandidates, + shouldInstallComputerUseSkill, } from "../fusion-skill-install.js"; const tempDirs: string[] = []; @@ -16,9 +18,9 @@ function tempDir(prefix: string): string { return dir; } -function makeSkillSource(dir: string): void { +function makeSkillSource(dir: string, body = "# Fusion Skill\n"): void { mkdirSync(dir, { recursive: true }); - writeFileSync(path.join(dir, "SKILL.md"), "# Fusion Skill\n"); + writeFileSync(path.join(dir, "SKILL.md"), body); } afterEach(() => { @@ -116,4 +118,58 @@ describe("fusion skill installer", () => { const result = installFusionSkillIntoHermesHome({ sourceDir: source }); expect(result.outcome).toBe("replaced"); }); + + it("installs computer-use on Darwin and preserves repeat/profile outcomes", () => { + const home = tempDir("hermes-home-"); + const source = path.join(tempDir("computer-source-"), "computer-use"); + makeSkillSource(source, "# computer-use skill\n"); + process.env.HERMES_HOME = home; + expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("installed"); + expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("already-installed"); + const profiled = installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin", profile: "work" }); + expect(profiled.targetDir).toBe(path.join(home, "profiles", "work", "skills", "computer-use")); + }); + + it("replaces stale computer-use installs but protects user targets", () => { + const home = tempDir("hermes-home-"); + const source = path.join(tempDir("computer-source-"), "computer-use"); + const old = path.join(tempDir("computer-old-"), "computer-use"); + makeSkillSource(source, "# computer-use skill\n"); + makeSkillSource(old, "# computer-use skill\n"); + process.env.HERMES_HOME = home; + const target = path.join(home, "skills", "computer-use"); + makeSkillSource(target, "# computer-use skill\n"); + expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("replaced"); + rmSync(target, { recursive: true, force: true }); + symlinkSync(old, target, "dir"); + expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("replaced"); + rmSync(target, { recursive: true, force: true }); + mkdirSync(target, { recursive: true }); + writeFileSync(path.join(target, "README.md"), "mine"); + expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("skipped"); + }); + + it("enforces the platform gate before touching Hermes home", () => { + const home = tempDir("hermes-home-"); + const source = path.join(tempDir("computer-source-"), "computer-use"); + makeSkillSource(source, "# computer-use skill\n"); + process.env.HERMES_HOME = home; + expect(shouldInstallComputerUseSkill("darwin")).toBe(true); + expect(shouldInstallComputerUseSkill("linux")).toBe(false); + expect(shouldInstallComputerUseSkill("win32")).toBe(false); + for (const platform of ["linux", "win32"] as const) { + expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform }).outcome).toBe("skipped"); + expect(existsSync(path.join(home, "skills"))).toBe(false); + } + const stale = path.join(home, "skills", "computer-use"); + makeSkillSource(stale, "# computer-use skill\nold"); + expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "linux" }).outcome).toBe("skipped"); + expect(readFileSync(path.join(stale, "SKILL.md"), "utf8")).toContain("old"); + expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("installed"); + }); + + it("warns for a missing computer-use source on Darwin", () => { + process.env.HERMES_HOME = tempDir("hermes-home-"); + expect(installComputerUseSkillIntoHermesHome({ sourceDir: null, platform: "darwin" }).outcome).toBe("warning"); + }); }); diff --git a/plugins/fusion-plugin-hermes-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-hermes-runtime/src/__tests__/index.test.ts index 64ea3d3f1d..14e630e795 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/__tests__/index.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({ +const { mockResolveCli, mockInstallComputerUseSkill, mockInstallFusionSkill, mockShouldInstallComputerUseSkill } = vi.hoisted(() => ({ mockResolveCli: vi.fn().mockReturnValue({ binaryPath: "hermes", model: undefined, @@ -9,6 +9,12 @@ const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({ yolo: false, cliTimeoutMs: 300_000, }), + mockInstallComputerUseSkill: vi.fn().mockReturnValue({ + outcome: "installed", + sourceDir: "/tmp/source", + targetDir: "/tmp/computer-use", + }), + mockShouldInstallComputerUseSkill: vi.fn().mockReturnValue(false), mockInstallFusionSkill: vi.fn().mockReturnValue({ outcome: "installed", sourceDir: "/tmp/source", @@ -30,7 +36,9 @@ vi.mock("../fusion-skill-install.js", async () => { ); return { ...actual, + installComputerUseSkillIntoHermesHome: mockInstallComputerUseSkill, installFusionSkillIntoHermesHome: mockInstallFusionSkill, + shouldInstallComputerUseSkill: mockShouldInstallComputerUseSkill, }; }); @@ -59,6 +67,12 @@ describe("hermes-runtime plugin", () => { cliTimeoutMs: 300_000, profile: undefined, }); + mockShouldInstallComputerUseSkill.mockReturnValue(false); + mockInstallComputerUseSkill.mockReturnValue({ + outcome: "installed", + sourceDir: "/tmp/source", + targetDir: "/tmp/computer-use", + }); mockInstallFusionSkill.mockReturnValue({ outcome: "installed", sourceDir: "/tmp/source", @@ -104,6 +118,12 @@ describe("hermes-runtime plugin", () => { }); it("onLoad warns but continues when skill install warns", async () => { + mockShouldInstallComputerUseSkill.mockReturnValue(false); + mockInstallComputerUseSkill.mockReturnValue({ + outcome: "installed", + sourceDir: "/tmp/source", + targetDir: "/tmp/computer-use", + }); mockInstallFusionSkill.mockReturnValue({ outcome: "warning", sourceDir: null, @@ -121,6 +141,21 @@ describe("hermes-runtime plugin", () => { }); }); + it("installs computer-use only when the caller platform gate permits it", async () => { + mockShouldInstallComputerUseSkill.mockReturnValue(true); + const ctx = createMockContext(); + await plugin.hooks!.onLoad!(ctx as any); + expect(mockInstallComputerUseSkill).toHaveBeenCalledWith({ profile: undefined }); + expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("computerUseSkill=installed")); + + vi.clearAllMocks(); + mockShouldInstallComputerUseSkill.mockReturnValue(false); + await plugin.hooks!.onLoad!(ctx as any); + expect(mockInstallFusionSkill).toHaveBeenCalledWith({ profile: undefined }); + expect(mockInstallComputerUseSkill).not.toHaveBeenCalled(); + expect(ctx.logger.info).toHaveBeenCalledWith(expect.not.stringContaining("computerUseSkill=")); + }); + it("factory returns a HermesRuntimeAdapter", async () => { const ctx = createMockContext({ binaryPath: "hermes" }); const runtime = (await hermesRuntimeFactory(ctx as any)) as HermesRuntimeAdapter; diff --git a/plugins/fusion-plugin-hermes-runtime/src/fusion-skill-install.ts b/plugins/fusion-plugin-hermes-runtime/src/fusion-skill-install.ts index eb7549f4ee..1fb5a73299 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/fusion-skill-install.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/fusion-skill-install.ts @@ -14,6 +14,7 @@ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export const FUSION_SKILL_NAME = "fusion"; +export const COMPUTER_USE_SKILL_NAME = "computer-use"; export type HermesFusionSkillInstallOutcome = | "installed" @@ -35,55 +36,66 @@ export function resolveHermesHome(profile?: string): string { return join(base, "profiles", profile); } -export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { +function getSkillSourceCandidates(skillName: string, moduleUrl = import.meta.url): string[] { const here = fileURLToPath(moduleUrl); const moduleDir = dirname(here); - return [ - resolve(moduleDir, "..", "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME), - resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME), - resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME), - resolve(moduleDir, "..", "..", "..", "..", "skill", FUSION_SKILL_NAME), + resolve(moduleDir, "..", "..", "..", "..", "packages", "cli", "skill", skillName), + resolve(moduleDir, "..", "..", "..", "skill", skillName), + resolve(moduleDir, "..", "..", "skill", skillName), + resolve(moduleDir, "..", "..", "..", "..", "skill", skillName), ]; } -export function resolveBundledFusionSkillSource(): string | null { - const candidates = getFusionSkillSourceCandidates(); +export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + return getSkillSourceCandidates(FUSION_SKILL_NAME, moduleUrl); +} +export function getComputerUseSkillSourceCandidates(moduleUrl = import.meta.url): string[] { + return getSkillSourceCandidates(COMPUTER_USE_SKILL_NAME, moduleUrl); +} + +function resolveBundledSkillSourceFromCandidates(candidates: string[]): string | null { for (const candidate of candidates) { if (existsSync(join(candidate, "SKILL.md"))) return candidate; } - return null; } +export function resolveBundledFusionSkillSource(): string | null { + return resolveBundledSkillSourceFromCandidates(getFusionSkillSourceCandidates()); +} + +export function resolveBundledComputerUseSkillSource(): string | null { + return resolveBundledSkillSourceFromCandidates(getComputerUseSkillSourceCandidates()); +} + export function resolveBundledFusionSkillSourceFromCandidates(candidates: string[]): string | null { - for (const candidate of candidates) { - if (existsSync(join(candidate, "SKILL.md"))) return candidate; - } - - return null; + return resolveBundledSkillSourceFromCandidates(candidates); } -export function installFusionSkillIntoHermesHome(options: { +export function shouldInstallComputerUseSkill(platform: NodeJS.Platform = process.platform): boolean { + return platform === "darwin"; +} + +interface InstallSkillOptions { profile?: string; sourceDir?: string | null; -} = {}): HermesFusionSkillInstallResult { - const sourceDir = options.sourceDir ?? resolveBundledFusionSkillSource(); - const targetDir = join(resolveHermesHome(options.profile), "skills", FUSION_SKILL_NAME); +} +function installSkillIntoHermesHome( + skillName: string, + options: InstallSkillOptions, + resolveSource: () => string | null, +): HermesFusionSkillInstallResult { + const sourceDir = options.sourceDir ?? resolveSource(); + const targetDir = join(resolveHermesHome(options.profile), "skills", skillName); if (!sourceDir) { - return { - outcome: "warning", - sourceDir, - targetDir, - reason: "bundled Fusion skill source directory not found", - }; + return { outcome: "warning", sourceDir, targetDir, reason: `bundled ${skillName} skill source directory not found` }; } try { mkdirSync(dirname(targetDir), { recursive: true }); - let replaced = false; if (existsSync(targetDir) || isBrokenSymlink(targetDir)) { const stat = lstatSync(targetDir); @@ -92,93 +104,82 @@ export function installFusionSkillIntoHermesHome(options: { if (currentTarget && resolve(dirname(targetDir), currentTarget) === resolve(sourceDir)) { return { outcome: "already-installed", sourceDir, targetDir }; } - if (!looksLikeFusionSkillTarget(resolve(dirname(targetDir), currentTarget ?? ""))) { - return { - outcome: "skipped", - sourceDir, - targetDir, - reason: "existing symlink does not look like a Fusion skill install", - }; + if (!looksLikeFusionSkillTarget(resolve(dirname(targetDir), currentTarget ?? ""), skillName)) { + return { outcome: "skipped", sourceDir, targetDir, reason: "existing symlink does not look like a Fusion skill install" }; } unlinkSync(targetDir); replaced = true; } else { - if (!looksLikePriorFusionInstall(targetDir)) { - return { - outcome: "skipped", - sourceDir, - targetDir, - reason: "existing directory does not look like a Fusion skill install", - }; + if (!looksLikePriorFusionInstall(targetDir, skillName)) { + return { outcome: "skipped", sourceDir, targetDir, reason: "existing directory does not look like a Fusion skill install" }; } rmSync(targetDir, { recursive: true, force: true }); replaced = true; } } - try { symlinkSync(sourceDir, targetDir, "dir"); } catch (error) { const symlinkReason = error instanceof Error ? error.message : String(error); try { cpSync(sourceDir, targetDir, { recursive: true }); - return { - outcome: replaced ? "replaced" : "installed", - sourceDir, - targetDir, - reason: `symlink failed (${symlinkReason}); copied files instead`, - }; + return { outcome: replaced ? "replaced" : "installed", sourceDir, targetDir, reason: `symlink failed (${symlinkReason}); copied files instead` }; } catch (copyError) { - return { - outcome: "warning", - sourceDir, - targetDir, - reason: copyError instanceof Error ? copyError.message : String(copyError), - }; + return { outcome: "warning", sourceDir, targetDir, reason: copyError instanceof Error ? copyError.message : String(copyError) }; } } - return { outcome: replaced ? "replaced" : "installed", sourceDir, targetDir }; } catch (error) { + return { outcome: "warning", sourceDir, targetDir, reason: error instanceof Error ? error.message : String(error) }; + } +} + +export function installFusionSkillIntoHermesHome(options: InstallSkillOptions = {}): HermesFusionSkillInstallResult { + return installSkillIntoHermesHome(FUSION_SKILL_NAME, options, resolveBundledFusionSkillSource); +} + +/* +FNXC:HermesRuntime 2026-08-11-09:23: +FN-8984 makes computer-use Darwin-only because the CLI capability is macOS-only. This persistent, +public installer self-defends before any filesystem access: callers cannot override its platform gate +and it must never clobber a user-owned ~/.hermes skill target. +*/ +export function installComputerUseSkillIntoHermesHome(options: InstallSkillOptions & { + platform?: NodeJS.Platform; +} = {}): HermesFusionSkillInstallResult { + const platform = options.platform ?? process.platform; + if (!shouldInstallComputerUseSkill(platform)) { return { - outcome: "warning", - sourceDir, - targetDir, - reason: error instanceof Error ? error.message : String(error), + outcome: "skipped", + sourceDir: options.sourceDir ?? null, + // Do not resolve Hermes home here: the gate must perform no filesystem reads. + targetDir: "", + reason: `computer-use installation is gated to darwin (current platform: ${platform})`, }; } + return installSkillIntoHermesHome(COMPUTER_USE_SKILL_NAME, options, resolveBundledComputerUseSkillSource); } function safeReadlink(path: string): string | null { - try { - return readlinkSync(path); - } catch { - return null; - } + try { return readlinkSync(path); } catch { return null; } } function isBrokenSymlink(path: string): boolean { - try { - const stat = lstatSync(path); - return stat.isSymbolicLink() && !existsSync(path); - } catch { - return false; - } + try { const stat = lstatSync(path); return stat.isSymbolicLink() && !existsSync(path); } catch { return false; } } -function looksLikePriorFusionInstall(path: string): boolean { +function looksLikePriorFusionInstall(path: string, skillName: string): boolean { const skillMd = join(path, "SKILL.md"); if (!existsSync(skillMd)) return false; try { const body = readFileSync(skillMd, "utf-8"); - return /\bfusion\b/i.test(body) && /\bskill\b/i.test(body); - } catch { - return false; - } + return skillName === FUSION_SKILL_NAME + ? /\bfusion\b/i.test(body) && /\bskill\b/i.test(body) + : /computer[- ]use|fn computer/i.test(body); + } catch { return false; } } -function looksLikeFusionSkillTarget(path: string): boolean { +function looksLikeFusionSkillTarget(path: string, skillName: string): boolean { if (!path) return false; - if (basename(path).toLowerCase() === FUSION_SKILL_NAME) return true; - return existsSync(join(path, "SKILL.md")); + return basename(path).toLowerCase() === skillName || existsSync(join(path, "SKILL.md")); } diff --git a/plugins/fusion-plugin-hermes-runtime/src/index.ts b/plugins/fusion-plugin-hermes-runtime/src/index.ts index 3cae621004..f3dca7717c 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/index.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/index.ts @@ -9,7 +9,11 @@ import { definePlugin } from "@fusion/plugin-sdk"; import { resolveCliSettings } from "./cli-spawn.js"; -import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js"; +import { + installComputerUseSkillIntoHermesHome, + installFusionSkillIntoHermesHome, + shouldInstallComputerUseSkill, +} from "./fusion-skill-install.js"; import { HermesRuntimeAdapter } from "./runtime-adapter.js"; import type { FusionPlugin, @@ -66,6 +70,9 @@ const plugin: FusionPlugin = definePlugin({ onLoad: (ctx: PluginContext) => { const settings = resolveCliSettings(ctx.settings); const skillInstall = installFusionSkillIntoHermesHome({ profile: settings.profile }); + const computerUseInstall = shouldInstallComputerUseSkill() + ? installComputerUseSkillIntoHermesHome({ profile: settings.profile }) + : null; if (skillInstall.outcome === "warning") { ctx.logger.warn( @@ -77,8 +84,13 @@ const plugin: FusionPlugin = definePlugin({ ); } + if (computerUseInstall?.outcome === "warning" || computerUseInstall?.outcome === "skipped") { + ctx.logger.warn( + `Hermes Runtime Plugin: computer-use skill auto-install ${computerUseInstall.outcome}: ${computerUseInstall.reason ?? "unknown"}`, + ); + } ctx.logger.info( - `Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}`, + `Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}${computerUseInstall ? ` computerUseSkill=${computerUseInstall.outcome}` : ""}`, ); ctx.emitEvent("hermes-runtime:loaded", { runtimeId: HERMES_RUNTIME_ID, @@ -109,9 +121,14 @@ export { listHermesProfiles, } from "./cli-spawn.js"; export { + COMPUTER_USE_SKILL_NAME, + getComputerUseSkillSourceCandidates, + installComputerUseSkillIntoHermesHome, installFusionSkillIntoHermesHome, + resolveBundledComputerUseSkillSource, resolveBundledFusionSkillSource, resolveHermesHome, + shouldInstallComputerUseSkill, } from "./fusion-skill-install.js"; export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";