Files
oh-my-pi/packages/coding-agent/src/tools/bash-skill-urls.ts
T
2026-02-23 01:51:54 +01:00

204 lines
6.7 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Skill } from "../extensibility/skills";
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
import { validateRelativePath } from "../internal-urls/skill-protocol";
import type { InternalResource } from "../internal-urls/types";
import { ToolError } from "./tool-errors";
/** Regex to find skill:// tokens in command text. */
const SKILL_URL_PATTERN = /'skill:\/\/[^'\s")`\\]+'|"skill:\/\/[^"\s')`\\]+"|skill:\/\/[^\s'")`\\]+/g;
/** Regex to find supported internal URL tokens in command text. */
const INTERNAL_URL_PATTERN =
/'(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^\s'")`\\]+/g;
const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "memory", "rule", "local"] as const;
type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
interface InternalUrlResolver {
canHandle(input: string): boolean;
resolve(input: string): Promise<InternalResource>;
}
export interface InternalUrlExpansionOptions {
skills: readonly Skill[];
noEscape?: boolean;
internalRouter?: InternalUrlResolver;
localOptions?: LocalProtocolOptions;
ensureLocalParentDirs?: boolean;
}
/**
* Resolve a single skill:// URL to its absolute filesystem path.
* Does NOT read file content or verify existence.
*/
export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): string {
const parsed = /^skill:\/\/([^/?#]+)(\/[^?#]*)?(?:[?#].*)?$/.exec(url);
if (!parsed) {
throw new ToolError(`Invalid skill:// URL: ${url}`);
}
const skillName = parsed[1];
if (!skillName) {
throw new ToolError(`skill:// URL requires a skill name: ${url}`);
}
const rawPath = parsed[2] ?? "";
const skill = skills.find(s => s.name === skillName);
if (!skill) {
const available = skills.map(s => s.name);
const availableStr = available.length > 0 ? available.join(", ") : "none";
throw new ToolError(`Unknown skill: ${skillName}. Available: ${availableStr}`);
}
const hasRelativePath = rawPath !== "" && rawPath !== "/";
if (!hasRelativePath) {
return path.resolve(skill.filePath);
}
let relativePath: string;
try {
relativePath = decodeURIComponent(rawPath.slice(1));
} catch {
throw new ToolError(`Invalid skill:// URL path encoding: ${url}`);
}
try {
validateRelativePath(relativePath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ToolError(message);
}
const targetPath = path.join(skill.baseDir, relativePath);
const resolvedPath = path.resolve(targetPath);
const resolvedBaseDir = path.resolve(skill.baseDir);
if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
throw new ToolError("Path traversal is not allowed in skill:// URLs");
}
return resolvedPath;
}
function extractScheme(url: string): SupportedInternalScheme | undefined {
const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url);
if (!match) return undefined;
const scheme = match[1].toLowerCase();
if (!SUPPORTED_INTERNAL_SCHEMES.includes(scheme as SupportedInternalScheme)) return undefined;
return scheme as SupportedInternalScheme;
}
function unquoteToken(token: string): string {
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
return token.slice(1, -1);
}
return token;
}
/** Shell-escape a path using single quotes. */
function shellEscape(p: string): string {
return `'${p.replace(/'/g, "'\\''")}'`;
}
async function resolveInternalUrlToPath(
url: string,
skills: readonly Skill[],
internalRouter?: InternalUrlResolver,
localOptions?: LocalProtocolOptions,
ensureLocalParentDirs?: boolean,
): Promise<string> {
const scheme = extractScheme(url);
if (!scheme) {
throw new ToolError(`Unsupported internal URL in bash command: ${url}`);
}
if (scheme === "skill") {
return resolveSkillUrlToPath(url, skills);
}
if (scheme === "local") {
if (!localOptions) {
throw new ToolError(
"Cannot resolve local:// URL in bash command: local protocol options are unavailable for this session.",
);
}
const resolvedLocalPath = resolveLocalUrlToPath(url, localOptions);
if (ensureLocalParentDirs) {
await fs.mkdir(path.dirname(resolvedLocalPath), { recursive: true });
}
return resolvedLocalPath;
}
if (!internalRouter || !internalRouter.canHandle(url)) {
throw new ToolError(
`Cannot resolve ${scheme}:// URL in bash command: ${url}\n` +
"Internal URL router is unavailable for this protocol in the current session.",
);
}
let resource: InternalResource;
try {
resource = await internalRouter.resolve(url);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ToolError(`Failed to resolve ${scheme}:// URL in bash command: ${url}\n${message}`);
}
if (!resource.sourcePath) {
throw new ToolError(`${scheme}:// URL resolved without a filesystem path and cannot be used in bash: ${url}`);
}
return path.resolve(resource.sourcePath);
}
/**
* Expand all skill:// URIs in a bash command string.
* Returns the command with URIs replaced by shell-escaped absolute paths.
* Throws ToolError if any URI cannot be resolved.
*/
export function expandSkillUrls(command: string, skills: readonly Skill[]): string {
if (skills.length === 0 || !command.includes("skill://")) {
return command;
}
return command.replace(SKILL_URL_PATTERN, token => {
const url = unquoteToken(token);
const resolvedPath = resolveSkillUrlToPath(url, skills);
return shellEscape(resolvedPath);
});
}
/**
* Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
* Supported schemes: skill://, agent://, artifact://, memory://, rule://, local://
*/
export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
if (!command.includes("://")) return command;
const matches = Array.from(command.matchAll(INTERNAL_URL_PATTERN));
if (matches.length === 0) return command;
let expanded = command;
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const token = match[0];
const index = match.index;
if (index === undefined) continue;
const url = unquoteToken(token);
const resolvedPath = await resolveInternalUrlToPath(
url,
options.skills,
options.internalRouter,
options.localOptions,
options.ensureLocalParentDirs,
);
const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;
}
return expanded;
}