diff --git a/frontend/src/lib/skill-package-registry.ts b/frontend/src/lib/skill-package-registry.ts index c37a5183..dd89cc01 100644 --- a/frontend/src/lib/skill-package-registry.ts +++ b/frontend/src/lib/skill-package-registry.ts @@ -2,18 +2,24 @@ import { createHash } from "node:crypto"; import { existsSync, lstatSync, + mkdirSync, + mkdtempSync, readdirSync, readFileSync, realpathSync, statSync, + symlinkSync, } from "node:fs"; import { + basename, isAbsolute, + join, relative, resolve, sep, win32, } from "node:path"; +import { tmpdir } from "node:os"; export type SkillPackageStatus = "active" | "deprecated" | "blocked"; @@ -65,6 +71,7 @@ const SOURCE_TREE_PROJECT_ROOT = process.env.JYOTISHA_PROJECT_ROOT ? process.cwd() : resolve(process.cwd(), "..")); const PACKAGE_HASH_DOMAIN = "jyotisha-skill-package-v1\0"; +const RUNTIME_SKILL_ROOT = mkdtempSync(join(tmpdir(), "jyotisha-skill-runtime-")); export class SkillPackageRegistryError extends Error { constructor(message: string) { @@ -582,6 +589,73 @@ export class SkillPackageRegistry { } } +/** + * Return a Mastra-compatible read-only alias for a verified package. + * + * Mastra validates that the configured skill directory basename matches the + * frontmatter name. Versioned registry packages intentionally live under a + * numeric directory (for example, `versions/6.9.14`), so loading that + * directory directly fails validation. The alias keeps the verified package + * bytes and identity unchanged while exposing the canonical skill name at the + * loader boundary. + */ +export function resolveSkillPackageRuntimePath( + skillPackage: ResolvedSkillPackageIdentity, +): string { + const packageDirectory = resolveExistingPath( + skillPackage.resolvedPath, + `Skill package directory ${skillPackage.packagePath}`, + ); + assertDirectory( + packageDirectory, + `Skill package directory ${skillPackage.packagePath}`, + ); + const actualHash = computeSkillPackageSha256(packageDirectory); + if (actualHash !== skillPackage.sha256) { + return fail( + `SHA-256 mismatch before runtime loading for ${skillPackage.name}@${skillPackage.version}: expected ${skillPackage.sha256}, got ${actualHash}`, + ); + } + + const runtimeParent = join(RUNTIME_SKILL_ROOT, skillPackage.sha256); + mkdirSync(runtimeParent, { recursive: true }); + const runtimePath = join(runtimeParent, skillPackage.name); + if (basename(runtimePath) !== skillPackage.name) { + return fail( + `Runtime skill path must end with the canonical skill name ${skillPackage.name}`, + ); + } + + try { + const runtimeEntry = lstatSync(runtimePath); + if (!runtimeEntry.isSymbolicLink()) { + return fail(`Runtime skill path is not a symbolic-link alias: ${runtimePath}`); + } + const linkedTarget = resolveExistingPath( + runtimePath, + `Runtime skill alias for ${skillPackage.name}@${skillPackage.version}`, + ); + if (linkedTarget !== packageDirectory) { + return fail( + `Runtime skill alias for ${skillPackage.name} points to an unexpected package`, + ); + } + } catch (error) { + if (error instanceof SkillPackageRegistryError) throw error; + try { + symlinkSync(packageDirectory, runtimePath, "dir"); + } catch (linkError) { + return fail( + `Runtime skill alias for ${skillPackage.name}@${skillPackage.version} could not be created: ${ + linkError instanceof Error ? linkError.message : "unknown filesystem error" + }`, + ); + } + } + + return runtimePath; +} + export function createSkillPackageRegistry( options: SkillPackageRegistryOptions = {}, ): SkillPackageRegistry { diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index cdeda70a..b68523c2 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -2,6 +2,7 @@ import { Agent } from "@mastra/core/agent"; import type { ResolvedLanguageModel } from "./model"; import { resolveActiveSkillPackage, + resolveSkillPackageRuntimePath, type ResolvedSkillPackageIdentity, } from "../lib/skill-package-registry.ts"; import { @@ -88,7 +89,7 @@ export function getRectificationV9Agent( name: "Birth Time Rectification V9", model: model.model, instructions: agenticRectificationInstructions, - skills: [skillPackage.resolvedPath], + skills: [resolveSkillPackageRuntimePath(skillPackage)], tools: createRectificationV9Tools(ctx), }); } @@ -116,7 +117,7 @@ export function getRectificationV9RegenerationAgent( name: "Jyotisha Rectification Reply Regenerator", model: model.model, instructions: regenerationInstructions, - skills: [skillPackage.resolvedPath], + skills: [resolveSkillPackageRuntimePath(skillPackage)], tools: createRectificationV9ReadOnlyTools(ctx), }); } diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index f95fb404..eb90fc28 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -4,13 +4,16 @@ import { createConsultationTools, type ConsultationAgentContext } from "./consul import { toAgentConsultationContext } from "./consultation-workflow.ts"; import { evidenceDraftModelOutputSchema } from "../lib/birth-time-guide-agent.ts"; import type { ResolvedLanguageModel } from "./model"; -import { resolveActiveSkillPackage } from "../lib/skill-package-registry.ts"; +import { + resolveActiveSkillPackage, + resolveSkillPackageRuntimePath, +} from "../lib/skill-package-registry.ts"; export { consultationInputSchema, consultationWorkflowReceipt, consultationWorkflowResponseSchema, runConsultationWorkflow, toAgentConsultationContext } from "./consultation-workflow.ts"; export type { ConsultationInput } from "./consultation-workflow.ts"; const jyotishSkillPackage = resolveActiveSkillPackage("jyotish-vedic-astrology"); -const jyotishSkillPath = jyotishSkillPackage.resolvedPath; +const jyotishSkillPath = resolveSkillPackageRuntimePath(jyotishSkillPackage); const jyotishInstructions = `You are the guide for a conversational Vedic astrology product. Write in concise Simplified Chinese as a natural conversation, not a report or fixed template. Use Markdown only when it improves scanning; tables are allowed only for genuinely comparative information. diff --git a/frontend/tests/skill-registry.test.ts b/frontend/tests/skill-registry.test.ts index baec3e2c..81db2e15 100644 --- a/frontend/tests/skill-registry.test.ts +++ b/frontend/tests/skill-registry.test.ts @@ -9,7 +9,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { test, type TestContext } from "node:test"; import { @@ -17,6 +17,7 @@ import { createSkillPackageRegistry, resolveActiveSkillPackage, resolveExactSkillPackage, + resolveSkillPackageRuntimePath, resolveSkillPackageVersion, verifyAllActiveSkillPackages, type SkillPackageIdentity, @@ -105,6 +106,10 @@ test("checked-in registry verifies both active packages and returns absolute dir projectRoot, }); assert.equal(active.version, "6.9.14"); + const runtimePath = resolveSkillPackageRuntimePath(active); + assert.equal(basename(runtimePath), active.name); + assert.equal(realpathSync(runtimePath), active.resolvedPath); + assert.notEqual(runtimePath, active.resolvedPath); assert.deepEqual( resolveExactSkillPackage(active.name, active.version, active.sha256, { projectRoot,